diff --git a/nodejs/react-cypress-launchdarkly-feature-flag-test/.babelrc b/nodejs/react-cypress-launchdarkly-feature-flag-test/.babelrc new file mode 100644 index 0000000..3172a1b --- /dev/null +++ b/nodejs/react-cypress-launchdarkly-feature-flag-test/.babelrc @@ -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" + ] +} \ No newline at end of file diff --git a/nodejs/react-cypress-launchdarkly-feature-flag-test/.env b/nodejs/react-cypress-launchdarkly-feature-flag-test/.env new file mode 100644 index 0000000..4a7a080 --- /dev/null +++ b/nodejs/react-cypress-launchdarkly-feature-flag-test/.env @@ -0,0 +1,2 @@ +LAUNCH_DARKLY_PROJECT_KEY="default" +LAUNCH_DARKLY_AUTH_TOKEN="api-********-****-****-****-************" \ No newline at end of file diff --git a/nodejs/react-cypress-launchdarkly-feature-flag-test/.eslintrc b/nodejs/react-cypress-launchdarkly-feature-flag-test/.eslintrc new file mode 100644 index 0000000..b4812b8 --- /dev/null +++ b/nodejs/react-cypress-launchdarkly-feature-flag-test/.eslintrc @@ -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 + } +} diff --git a/nodejs/react-cypress-launchdarkly-feature-flag-test/.github/workflows/ci.yml b/nodejs/react-cypress-launchdarkly-feature-flag-test/.github/workflows/ci.yml new file mode 100644 index 0000000..560fb6a --- /dev/null +++ b/nodejs/react-cypress-launchdarkly-feature-flag-test/.github/workflows/ci.yml @@ -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 }} \ No newline at end of file diff --git a/nodejs/react-cypress-launchdarkly-feature-flag-test/.gitignore b/nodejs/react-cypress-launchdarkly-feature-flag-test/.gitignore new file mode 100644 index 0000000..084beeb --- /dev/null +++ b/nodejs/react-cypress-launchdarkly-feature-flag-test/.gitignore @@ -0,0 +1,6 @@ +node_modules +.idea +npm-debug.log +dist +.eslintcache +.as-a.ini diff --git a/nodejs/react-cypress-launchdarkly-feature-flag-test/.prettierrc.json b/nodejs/react-cypress-launchdarkly-feature-flag-test/.prettierrc.json new file mode 100644 index 0000000..d50a919 --- /dev/null +++ b/nodejs/react-cypress-launchdarkly-feature-flag-test/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "trailingComma": "all", + "tabWidth": 2, + "semi": false, + "singleQuote": true +} diff --git a/nodejs/react-cypress-launchdarkly-feature-flag-test/README.md b/nodejs/react-cypress-launchdarkly-feature-flag-test/README.md new file mode 100644 index 0000000..f4dc335 --- /dev/null +++ b/nodejs/react-cypress-launchdarkly-feature-flag-test/README.md @@ -0,0 +1,3 @@ +# Related Blog Posts + +* [Automated Tests with Feature Flags and Cypress](https://reflectoring.io/nodejs-feature-flag-launchdarkly-react-cypress/) diff --git a/nodejs/react-cypress-launchdarkly-feature-flag-test/cypress.json b/nodejs/react-cypress-launchdarkly-feature-flag-test/cypress.json new file mode 100644 index 0000000..24b5524 --- /dev/null +++ b/nodejs/react-cypress-launchdarkly-feature-flag-test/cypress.json @@ -0,0 +1,6 @@ +{ + "fixturesFolder": false, + "supportFile": false, + "baseUrl": "http://localhost:3000", + "viewportHeight": 300 +} diff --git a/nodejs/react-cypress-launchdarkly-feature-flag-test/cypress/integration/spec.js b/nodejs/react-cypress-launchdarkly-feature-flag-test/cypress/integration/spec.js new file mode 100644 index 0000000..8c86896 --- /dev/null +++ b/nodejs/react-cypress-launchdarkly-feature-flag-test/cypress/integration/spec.js @@ -0,0 +1,94 @@ +/// + +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 }) +}); diff --git a/nodejs/react-cypress-launchdarkly-feature-flag-test/cypress/plugins/index.js b/nodejs/react-cypress-launchdarkly-feature-flag-test/cypress/plugins/index.js new file mode 100644 index 0000000..bba3689 --- /dev/null +++ b/nodejs/react-cypress-launchdarkly-feature-flag-test/cypress/plugins/index.js @@ -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 +} diff --git a/nodejs/react-cypress-launchdarkly-feature-flag-test/package.json b/nodejs/react-cypress-launchdarkly-feature-flag-test/package.json new file mode 100644 index 0000000..0e6daa1 --- /dev/null +++ b/nodejs/react-cypress-launchdarkly-feature-flag-test/package.json @@ -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 ", + "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" + } +} diff --git a/nodejs/react-cypress-launchdarkly-feature-flag-test/src/client/index.js b/nodejs/react-cypress-launchdarkly-feature-flag-test/src/client/index.js new file mode 100644 index 0000000..fddc6c5 --- /dev/null +++ b/nodejs/react-cypress-launchdarkly-feature-flag-test/src/client/index.js @@ -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( + + + , + document.getElementById('reactDiv'), +); diff --git a/nodejs/react-cypress-launchdarkly-feature-flag-test/src/server/index.js b/nodejs/react-cypress-launchdarkly-feature-flag-test/src/server/index.js new file mode 100644 index 0000000..829939c --- /dev/null +++ b/nodejs/react-cypress-launchdarkly-feature-flag-test/src/server/index.js @@ -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 }); diff --git a/nodejs/react-cypress-launchdarkly-feature-flag-test/src/server/server.js b/nodejs/react-cypress-launchdarkly-feature-flag-test/src/server/server.js new file mode 100644 index 0000000..d220546 --- /dev/null +++ b/nodejs/react-cypress-launchdarkly-feature-flag-test/src/server/server.js @@ -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 = ` + + + + + ld-react example + + +
${renderToString( + + + , + )}
+ + + `; + + res.end(html); +}); + +export default app.listen(PORT, () => { + console.log(`Example app listening at ${PORT}...`); +}); diff --git a/nodejs/react-cypress-launchdarkly-feature-flag-test/src/universal/app.js b/nodejs/react-cypress-launchdarkly-feature-flag-test/src/universal/app.js new file mode 100644 index 0000000..ab2e1b1 --- /dev/null +++ b/nodejs/react-cypress-launchdarkly-feature-flag-test/src/universal/app.js @@ -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 = () => ( +
+ +
+ + + + + + + +
+
+); + +// 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); diff --git a/nodejs/react-cypress-launchdarkly-feature-flag-test/src/universal/home.js b/nodejs/react-cypress-launchdarkly-feature-flag-test/src/universal/home.js new file mode 100644 index 0000000..fc2d5c3 --- /dev/null +++ b/nodejs/react-cypress-launchdarkly-feature-flag-test/src/universal/home.js @@ -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 }) => ( + + {flags.testGreetingFromCypress}, World !! +
+ This is a LaunchDarkly React example project. The message above changes the greeting, + based on the current feature flag variation. +
+
+ {flags.showShinyNewFeature ? + : ''} +
+
+ {flags.showShinyNewFeature ? 'This button will show new shiny feature in UI on clicking it.': ''} +
+
+); + +Home.propTypes = { + flags: PropTypes.object.isRequired, +}; + +export default withLDConsumer()(Home); diff --git a/nodejs/react-cypress-launchdarkly-feature-flag-test/src/universal/hooksDemo.js b/nodejs/react-cypress-launchdarkly-feature-flag-test/src/universal/hooksDemo.js new file mode 100644 index 0000000..8858536 --- /dev/null +++ b/nodejs/react-cypress-launchdarkly-feature-flag-test/src/universal/hooksDemo.js @@ -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 ( + + {testGreetingFromCypress}, World !! +
+ This is the equivalent LaunchDarkly React example project using hooks. The message above changes the greeting, + based on the current feature flag variation. +
+
+ {showShinyNewFeature ? + : ''} +
+
+ {showShinyNewFeature ? 'This button will show new shiny feature in UI on clicking it.': ''} +
+
+ ); +}; + +export default HooksDemo; diff --git a/nodejs/react-cypress-launchdarkly-feature-flag-test/src/universal/siteNav.js b/nodejs/react-cypress-launchdarkly-feature-flag-test/src/universal/siteNav.js new file mode 100644 index 0000000..7ac8e51 --- /dev/null +++ b/nodejs/react-cypress-launchdarkly-feature-flag-test/src/universal/siteNav.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { css } from 'styled-components'; +import { Link } from 'react-router-dom'; + +export default () => ( +
+ Home + Hooks Demo +
+); diff --git a/nodejs/react-cypress-launchdarkly-feature-flag-test/webpack.config.client.js b/nodejs/react-cypress-launchdarkly-feature-flag-test/webpack.config.client.js new file mode 100644 index 0000000..284abb9 --- /dev/null +++ b/nodejs/react-cypress-launchdarkly-feature-flag-test/webpack.config.client.js @@ -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, + }, + }, + ], + }, +}; diff --git a/nodejs/react-cypress-launchdarkly-feature-flag-test/webpack.config.server.js b/nodejs/react-cypress-launchdarkly-feature-flag-test/webpack.config.server.js new file mode 100644 index 0000000..7074e60 --- /dev/null +++ b/nodejs/react-cypress-launchdarkly-feature-flag-test/webpack.config.server.js @@ -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, + }, + }], + }, +}; \ No newline at end of file