Test and Automate Features behind the Flags using Cypress Tests (#206)

* nodejs cypress test launchdarkly

* Delete README.md

* refactor code

* refactor code

* code cleanup

Co-authored-by: Arpendu Kumar Garai <Arpendu.KumarGarai@microfocus.com>
This commit is contained in:
Arpendu Kumar Garai
2022-11-07 14:21:48 +05:30
committed by GitHub
parent cd3e68cc66
commit 3930e1cb03
20 changed files with 591 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
],
"plugins": [
"@babel/plugin-proposal-class-properties",
"@babel/plugin-transform-runtime",
"babel-plugin-styled-components"
]
}

View File

@@ -0,0 +1,2 @@
LAUNCH_DARKLY_PROJECT_KEY="default"
LAUNCH_DARKLY_AUTH_TOKEN="api-********-****-****-****-************"

View File

@@ -0,0 +1,67 @@
{
"parser": "babel-eslint",
"parserOptions": {
"allowImportExportEverywhere": true
},
"extends": [
"airbnb",
"eslint:recommended",
"plugin:react/recommended"
],
"plugins": [
"babel"
],
"rules": {
"arrow-parens": 0,
"eol-last": 0,
"global-require": 0,
"arrow-body-style": 0,
"consistent-return": 0,
"no-unneeded-ternary": 0,
"max-len": 0,
"no-param-reassign": 2,
"new-cap": 0,
"no-console": 0,
"object-curly-spacing": 0,
"spaced-comment": 0,
"import/no-extraneous-dependencies": 0,
"import/first": 0,
"import/prefer-default-export": 0,
"import/no-mutable-exports": 0,
"import/no-named-as-default": 0,
"react/display-name": 0,
"react/jsx-filename-extension": 0,
"react/jsx-indent": 0,
"react/jsx-indent-props": 0,
"react/jsx-space-before-closing": 0,
"react/jsx-first-prop-new-line": 0,
"react/prefer-stateless-function": 0,
"react/jsx-closing-bracket-location": 0,
"react/require-extension": 0,
"react/sort-comp": 0,
"react/jsx-wrap-multilines": 0,
"react/jsx-no-bind": 0,
"react/jsx-users-react": 0,
"react/jsx-tag-spacing": 0,
"jsx-a11y/anchor-is-valid": 0,
"jsx-a11y/img-has-alt": 0,
"no-trailing-spaces": 0,
"no-underscore-dangle": 0,
"no-use-before-define": 0,
"no-duplicate-imports": 0,
"import/no-duplicates": 1,
"no-useless-escape": 0,
"no-unused-expressions": [1 , {"allowTernary": true}],
"react/forbid-prop-types": 0
},
"env": {
"browser": true,
"jest": true,
"node": true
},
"globals": {
"React": true,
"fetch": true,
"jest": true
}
}

View File

@@ -0,0 +1,17 @@
name: ci
on: push
jobs:
test:
runs-on: ubuntu-20.04
steps:
- name: Checkout 🛎
uses: actions/checkout@v2
- name: Run tests 🧪
# https://github.com/cypress-io/github-action
uses: cypress-io/github-action@v3
with:
start: 'yarn start'
env:
LAUNCH_DARKLY_PROJECT_KEY: ${{ secrets.LAUNCH_DARKLY_PROJECT_KEY }}
LAUNCH_DARKLY_AUTH_TOKEN: ${{ secrets.LAUNCH_DARKLY_AUTH_TOKEN }}

View File

@@ -0,0 +1,6 @@
node_modules
.idea
npm-debug.log
dist
.eslintcache
.as-a.ini

View File

@@ -0,0 +1,6 @@
{
"trailingComma": "all",
"tabWidth": 2,
"semi": false,
"singleQuote": true
}

View File

@@ -0,0 +1,3 @@
# Related Blog Posts
* [Automated Tests with Feature Flags and Cypress](https://reflectoring.io/nodejs-feature-flag-launchdarkly-react-cypress/)

View File

@@ -0,0 +1,6 @@
{
"fixturesFolder": false,
"supportFile": false,
"baseUrl": "http://localhost:3000",
"viewportHeight": 300
}

View File

@@ -0,0 +1,94 @@
/// <reference types="cypress" />
before(() => {
expect(Cypress.env('launchDarklyApiAvailable'), 'LaunchDarkly').to.be.true
});
const featureFlagKey = 'test-greeting-from-cypress';
const userId = 'CYPRESS_TEST_1234';
it('shows a casual greeting', () => {
// target the given user to receive the first variation of the feature flag
cy.task('cypress-ld-control:setFeatureFlagForUser', {
featureFlagKey,
userId,
variationIndex: 0,
})
cy.visit('/')
cy.contains('h1', 'Hello, World !!').should('be.visible')
});
it('shows a formal greeting', () => {
cy.task('cypress-ld-control:setFeatureFlagForUser', {
featureFlagKey,
userId,
variationIndex: 1,
})
cy.visit('/')
cy.contains('h1', 'Good Morning, World !!').should('be.visible')
});
it('shows a vacation greeting', () => {
cy.task('cypress-ld-control:setFeatureFlagForUser', {
featureFlagKey,
userId,
variationIndex: 2,
})
cy.visit('/')
cy.contains('h1', 'Hurrayyyyy, World').should('be.visible')
// print the current state of the feature flag and its variations
cy.task('cypress-ld-control:getFeatureFlag', featureFlagKey)
.then(console.log)
// let's print the variations to the Command Log side panel
.its('variations')
.then((variations) => {
variations.forEach((v, k) => {
cy.log(`${k}: ${v.name} is ${v.value}`)
})
})
});
it('shows all greetings', () => {
cy.visit('/')
cy.task('cypress-ld-control:setFeatureFlagForUser', {
featureFlagKey,
userId,
variationIndex: 0,
})
cy.contains('h1', 'Hello, World !!')
.should('be.visible')
.wait(1000)
cy.task('cypress-ld-control:setFeatureFlagForUser', {
featureFlagKey,
userId,
variationIndex: 1,
})
cy.contains('h1', 'Good Morning, World !!').should('be.visible').wait(1000)
cy.task('cypress-ld-control:setFeatureFlagForUser', {
featureFlagKey,
userId,
variationIndex: 2,
})
cy.contains('h1', 'Hurrayyyyy, World !!').should('be.visible')
});
it('click a button', () => {
cy.task('cypress-ld-control:setFeatureFlagForUser', {
featureFlagKey: 'show-shiny-new-feature',
userId: 'john_doe',
variationIndex: 0,
})
cy.visit('/');
var alerted = false;
cy.on('window:alert', msg => alerted = msg);
cy.get('#shiny-button').should('be.visible').click().then(
() => expect(alerted).to.match(/A new shiny feature pops up!/));
});
after(() => {
cy.task('cypress-ld-control:removeUserTarget', { featureFlagKey, userId })
});

View File

@@ -0,0 +1,31 @@
const { initLaunchDarklyApiTasks } = require('cypress-ld-control');
require('dotenv').config();
module.exports = (on, config) => {
const tasks = {
// add your other Cypress tasks if any
}
if (
process.env.LAUNCH_DARKLY_PROJECT_KEY &&
process.env.LAUNCH_DARKLY_AUTH_TOKEN
) {
const ldApiTasks = initLaunchDarklyApiTasks({
projectKey: process.env.LAUNCH_DARKLY_PROJECT_KEY,
authToken: process.env.LAUNCH_DARKLY_AUTH_TOKEN,
environment: 'production', // the name of your environment to use
})
// copy all LaunchDarkly methods as individual tasks
Object.assign(tasks, ldApiTasks)
// set an environment variable for specs to use
// to check if the LaunchDarkly can be controlled
config.env.launchDarklyApiAvailable = true
} else {
console.log('Skipping cypress-ld-control plugin')
}
// register all tasks with Cypress
on('task', tasks)
// IMPORTANT: return the updated config object
return config
}

View File

@@ -0,0 +1,66 @@
{
"name": "react-cypress-launchdarkly-feature-flag-test",
"version": "1.1.0",
"description": "Example usage of launchdarkly-react-client-sdk",
"main": "src/server/index.js",
"scripts": {
"start": "node src/server/index.js",
"lint": "eslint ./src",
"serve": "webpack-serve webpack.config.server",
"test": "start-test 3000 'cypress open'"
},
"repository": {
"type": "git",
"url": "git://github.com/launchdarkly/js-client-sdk.git"
},
"keywords": [
"launchdarkly",
"launch",
"darkly",
"react",
"sdk",
"bindings"
],
"author": "LaunchDarkly <team@launchdarkly.com>",
"license": "Apache-2.0",
"homepage": "https://github.com/launchdarkly/js-client-sdk/tree/master/packages/launchdarkly-react-client-sdk",
"dependencies": {
"@babel/polyfill": "^7.2.5",
"dotenv": "^16.0.1",
"express": "^4.16.4",
"launchdarkly-react-client-sdk": "^2.22.1",
"lodash": "^4.17.21",
"prop-types": "^15.7.2",
"react": "^16.11.0",
"react-dom": "^16.11.0",
"react-router-dom": "^5.1.2",
"styled-components": "^4.1.3"
},
"devDependencies": {
"@babel/cli": "^7.2.3",
"@babel/core": "^7.2.2",
"@babel/plugin-proposal-class-properties": "^7.2.3",
"@babel/plugin-transform-runtime": "^7.2.0",
"@babel/preset-env": "^7.2.3",
"@babel/preset-react": "^7.0.0",
"babel-eslint": "^10.0.1",
"babel-loader": "^8.0.4",
"babel-plugin-styled-components": "^1.10.0",
"cypress": "^9.5.1",
"cypress-ld-control": "^1.1.2",
"eslint": "^5.10.0",
"eslint-config-airbnb": "^17.1.0",
"eslint-config-prettier": "^3.3.0",
"eslint-plugin-babel": "^5.3.0",
"eslint-plugin-import": "^2.14.0",
"eslint-plugin-jsx-a11y": "^6.1.2",
"eslint-plugin-react": "^7.11.1",
"prettier": "^2.5.1",
"start-server-and-test": "^1.14.0",
"universal-hot-reload": "^3.3.4",
"webpack": "^4.27.1",
"webpack-cli": "^3.1.2",
"webpack-node-externals": "^1.7.2",
"webpack-serve": "^2.0.3"
}
}

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { render } from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import App from '../universal/app';
render(
<BrowserRouter>
<App />
</BrowserRouter>,
document.getElementById('reactDiv'),
);

View File

@@ -0,0 +1,9 @@
const UniversalHotReload = require('universal-hot-reload').default;
// supply your own webpack configs
const serverConfig = require('../../webpack.config.server.js');
const clientConfig = require('../../webpack.config.client.js');
// the configs are optional, you can supply either one or both.
// if you omit say the server config, then your server won't hot reload.
UniversalHotReload({ serverConfig, clientConfig });

View File

@@ -0,0 +1,35 @@
import Express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import App from '../universal/app';
const PORT = 3000;
const app = Express();
app.use('/dist', Express.static('dist', { maxAge: '1d' }));
app.use((req, res) => {
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ld-react example</title>
</head>
<body>
<div id="reactDiv">${renderToString(
<StaticRouter location={req.url} context={{}}>
<App />
</StaticRouter>,
)}</div>
<script type="application/javascript" src="http://localhost:3002/dist/bundle.js"></script>
</body>
</html>`;
res.end(html);
});
export default app.listen(PORT, () => {
console.log(`Example app listening at ${PORT}...`);
});

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { Switch, Route, Redirect } from 'react-router-dom';
import { withLDProvider } from 'launchdarkly-react-client-sdk';
import SiteNav from './siteNav';
import Home from './home';
import HooksDemo from './hooksDemo';
const App = () => (
<div>
<SiteNav />
<main>
<Switch>
<Route exact path="/" component={Home} />
<Route path="/home">
<Redirect to="/" />
</Route>
<Route path="/hooks" component={HooksDemo} />
</Switch>
</main>
</div>
);
// Set clientSideID to your own Client-side ID. You can find this in
// your LaunchDarkly portal under Account settings / Projects
// https://docs.launchdarkly.com/sdk/client-side/javascript#initializing-the-client
const user = {
key: 'CYPRESS_TEST_1234'
};
export default withLDProvider({ clientSideID: '63**********************', user })(App);

View File

@@ -0,0 +1,64 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { withLDConsumer } from 'launchdarkly-react-client-sdk';
const Root = styled.div`
color: #001b44;
`;
const Heading = styled.h1`
color: #00449e;
`;
const theme = {
blue: {
default: "#3f51b5",
hover: "#283593"
}
};
const Button = styled.button`
background-color: ${(props) => theme[props.theme].default};
color: white;
padding: 5px 15px;
border-radius: 5px;
outline: 0;
text-transform: uppercase;
margin: 10px 0px;
cursor: pointer;
box-shadow: 0px 2px 2px lightgray;
transition: ease background-color 250ms;
&:hover {
background-color: ${(props) => theme[props.theme].hover};
}
&:disabled {
cursor: default;
opacity: 0.7;
}
`;
const clickMe = () => {
alert("A new shiny feature pops up!");
};
const Home = ({ flags }) => (
<Root>
<Heading>{flags.testGreetingFromCypress}, World !!</Heading>
<div>
This is a LaunchDarkly React example project. The message above changes the greeting,
based on the current feature flag variation.
</div>
<div>
{flags.showShinyNewFeature ?
<Button id='shiny-button' theme='blue' onClick={clickMe}>Shiny New Feature</Button>: ''}
</div>
<div>
{flags.showShinyNewFeature ? 'This button will show new shiny feature in UI on clicking it.': ''}
</div>
</Root>
);
Home.propTypes = {
flags: PropTypes.object.isRequired,
};
export default withLDConsumer()(Home);

View File

@@ -0,0 +1,63 @@
import React from 'react';
import styled from 'styled-components';
import { useFlags } from 'launchdarkly-react-client-sdk';
const Root = styled.div`
color: #001b44;
`;
const Heading = styled.h1`
color: #00449e;
`;
const theme = {
blue: {
default: "#3f51b5",
hover: "#283593"
}
};
const Button = styled.button`
background-color: ${(props) => theme[props.theme].default};
color: white;
padding: 5px 15px;
border-radius: 5px;
outline: 0;
text-transform: uppercase;
margin: 10px 0px;
cursor: pointer;
box-shadow: 0px 2px 2px lightgray;
transition: ease background-color 250ms;
&:hover {
background-color: ${(props) => theme[props.theme].hover};
}
&:disabled {
cursor: default;
opacity: 0.7;
}
`;
const clickMe = () => {
alert("A new shiny feature pops up!");
};
const HooksDemo = () => {
const { testGreetingFromCypress, showShinyNewFeature } = useFlags();
return (
<Root>
<Heading>{testGreetingFromCypress}, World !!</Heading>
<div>
This is the equivalent LaunchDarkly React example project using hooks. The message above changes the greeting,
based on the current feature flag variation.
</div>
<div>
{showShinyNewFeature ?
<Button id='shiny-button' theme='blue' onClick={clickMe}>Shiny New Feature</Button>: ''}
</div>
<div>
{showShinyNewFeature ? 'This button will show new shiny feature in UI on clicking it.': ''}
</div>
</Root>
);
};
export default HooksDemo;

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { css } from 'styled-components';
import { Link } from 'react-router-dom';
export default () => (
<div
css={css`
display: flex;
width: 170px;
justify-content: space-between;
`}
>
<Link to="/">Home</Link>
<Link to="/hooks">Hooks Demo</Link>
</div>
);

View File

@@ -0,0 +1,27 @@
const path = require('path');
const WebpackServeUrl = 'http://localhost:3002';
module.exports = {
mode: 'development',
devtool: 'source-map',
entry: ['@babel/polyfill', './src/client/index'],
output: {
path: path.resolve('dist'),
publicPath: `${WebpackServeUrl}/dist/`, // MUST BE FULL PATH!
filename: 'bundle.js',
},
module: {
rules: [
{
test: /\.jsx?$/,
include: path.resolve('src'),
exclude: /node_modules/,
loader: 'babel-loader',
options: {
cacheDirectory: true,
},
},
],
},
};

View File

@@ -0,0 +1,28 @@
const path = require('path');
const nodeExternals = require('webpack-node-externals');
module.exports = {
mode: 'development',
devtool: 'source-map',
entry: ['@babel/polyfill', './src/server/server.js'], // set this to your server entry point. This should be where you start your express server with .listen()
target: 'node', // tell webpack this bundle will be used in nodejs environment.
externals: [nodeExternals()], // Omit node_modules code from the bundle. You don't want and don't need them in the bundle.
output: {
path: path.resolve('dist'),
filename: 'serverBundle.js',
libraryTarget: 'commonjs2', // IMPORTANT! Add module.exports to the beginning of the bundle, so universal-hot-reload can access your app.
},
// The rest of the config is pretty standard and can contain other webpack stuff you need.
module: {
rules: [
{
test: /\.jsx?$/,
include: path.resolve('src'),
exclude: /node_modules/,
loader: 'babel-loader',
options: {
cacheDirectory: true,
},
}],
},
};