├── .gitignore ├── README.md ├── applitools.config.js ├── cypress.js ├── cypress.json ├── index.js ├── manifest.yml ├── package-lock.json ├── package.json └── visual-diff ├── integration └── visualdiff.spec.js ├── plugins └── index.js └── support └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Applitools Visual Diff Netlify Plugin 2 | 3 | Check for visual diffs before deploying your site, in various browsers and responsive widths. 4 | Ensure that your CSS still works in all form factors and browsers before deployment, and record 5 | a visual history of your site. And do this without writing a single line of code using this plugin. 6 | 7 | This plugin runs the Applitools Eyes visual tests using Cypress and checks 8 | whether the new build is visually different from the previous run. 9 | 10 | If you decide that the visual tests should fail your site, then the visual changes 11 | will need to be reviewed and approved in the Applitools Test Manager. 12 | Once they’re approved, rebuilding the site will succeed without errors from this plugin. 13 | 14 | ## Why 15 | 16 | CSS is hard. And so is building sites that work across all form factors. If you want to be sure 17 | that your CSS and HTML changes are not breaking your site, you should check it for visual consistency 18 | by running a visual test. 19 | 20 | Has that change in your CSS broken any of the tens of pages in your site? Only a visual diff can make sure 21 | that it hasn't, because going through all of them manually is certainly not an option. And if it was, 22 | you probably would miss something anyway. Only a visual diff would ensure that 23 | 24 | ## Setup 25 | 26 | 1. If you're not already, register for Applitools Eyes at 27 | 1. Login to the Eyes Test Manager at 28 | 1. Copy your Applitools API key from : 29 | 30 | 1. Create an environment variable in your Netlify site's settings called `APPLITOOLS_API_KEY`: 31 | * Go to your site's page in Netlify 32 | * Click on "Site settings" 33 | * Expand "Build and deploy" in the nav bar on the right 34 | * Click on "Environment" in the same nav bar, and click on "Edit variables" 35 | * Add the `APPLITOOLS_API_KEY` variable along with the value you copied from Eyes. 36 | 1. In your `netlify.toml`, add the plugin: 37 | 38 | ```toml 39 | [[plugins]] 40 | package = "netlify-plugin-visual-diff" 41 | ``` 42 | 43 | 1. If you need to configure the plugin (see [Configuration](#configuration) below), 44 | for example, to ignore date-related elements that should 45 | be ignored in the visual diff, use: 46 | 47 | ```toml 48 | [[plugins]] 49 | package = "netlify-plugin-visual-diff" 50 | [plugins.inputs] 51 | ignoreSelector = "#today,.copyright" 52 | ``` 53 | 54 | 1. Decide which browsers and form factors you want to check your site on 55 | (by default, it will be tested on the latest Chrome, with a 1024x768 viewport), and 56 | define them in the `netlify.toml`: 57 | 58 | ```toml 59 | [[plugins]] 60 | package = "netlify-plugin-visual-diff" 61 | [plugins.inputs] 62 | ignoreSelector = "#today,.copyright" 63 | 64 | [[plugins.inputs.browser]] 65 | name = "chrome" 66 | width = 1024 67 | height = 768 68 | 69 | [[plugins.inputs.browser]] 70 | name = "firefox" 71 | width = 1920 72 | height = 1080 73 | 74 | [[plugins.inputs.browser]] 75 | deviceName = "iPhone X" 76 | ``` 77 | 78 | For a full list of browsers and configurations, 79 | see 80 | 81 | 1. That's it! Your next deploys will be visually checked on all the browsers and form factors you specified, 82 | and if there is a diff, the build will 83 | fail and a link to the Visual test will be added to the build so that you can verify that 84 | there is a problem, or approve the changes so that your next build will succeed. 85 | 86 | > Note: if you want the visual test not to fail the build but to just execute the visual test 87 | > so that you can see the result in the Eyes Test Manager, add `failBuildOnDiff` 88 | > to the configuration, thus: 89 | > 90 | > ```toml 91 | > [[plugins]] 92 | > package = "netlify-plugin-visual-diff" 93 | > [plugins.inputs] 94 | > failBuildOnDiff = false 95 | > ``` 96 | 97 | ## Configuration 98 | 99 | The Netlify Visual Diff plugin is a no-touch solution: you just add it to your `netlify.toml`, 100 | and you're good to go. But it _is_ configurable, using the following configurations: 101 | 102 | * `serverUrl`: if you're using the public Applitools server `eyesapi.applitools.com`, there is no 103 | need to configure this, but if you're an Applitools enterprise user with a dedicated server, 104 | put the server URL here, e.g. `https://acustomerapi.applitools.com`. 105 | Default: `https://eyesapi.applitools.com`. 106 | * `ignoreSelector`: if you wish the diff to ignore certain regions when diffiing, 107 | specify a selector that defines which elements to ignore 108 | (remember you can have a selector with multiple elements using a `,`), 109 | e.g. `ignoreSelector = "#today,.copyright"`. Default: none. 110 | * `failBuildOnDiff`: if you wish to only run the Visual test (to see the result in the Eyes 111 | manager), without it failing the build, set this to `false`, e.g `failBuildOnDiff = false`. 112 | Default: `true`. 113 | * `concurrency`: specify a higher level of concurrency to make the test faster. 114 | For more information, see . 115 | * `browser`: an array of browsers. 116 | For more information, see . 117 | 118 | The default configuration, if none is specified, is: 119 | 120 | ```toml 121 | [[plugins]] 122 | package = "netlify-plugin-visual-diff" 123 | [plugins.inputs] 124 | serverUrl = "https://eyesapi.applitools.com" # The public Eyes server 125 | ignoreSelector = "" # There is no null in TOML, but if there were, then it would be null 126 | failBuildOnDiff = true 127 | concurrency = 1 128 | [[plugins.inputs.browser]] 129 | name = "chrome" 130 | width = 1024 131 | height = 768 132 | ``` 133 | -------------------------------------------------------------------------------- /applitools.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /cypress.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra'); 2 | const cypress = require('cypress'); 3 | 4 | const main = async () => { 5 | const runOptions = await cypress.cli.parseRunArguments(process.argv.slice(2)); 6 | const results = await cypress.run(runOptions); 7 | await fs.writeJSON('results.json', results); 8 | }; 9 | 10 | main(); 11 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "integrationFolder": "visual-diff/integration", 3 | "pluginsFile": "visual-diff/plugins", 4 | "supportFile": "visual-diff/support", 5 | "video": false 6 | } 7 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const path = require('path'); 3 | const ecstatic = require('ecstatic'); 4 | const fs = require('fs-extra'); 5 | const glob = require('glob'); 6 | 7 | const createEnvFile = async ({ inputs, builtPages }) => { 8 | await fs.writeJSON(`${__dirname}/cypress.env.json`, { 9 | SITE_NAME: process.env.SITE_NAME || 'localhost-test', 10 | APPLITOOLS_BROWSERS: JSON.stringify(inputs.browser), 11 | APPLITOOLS_FAIL_BUILD_ON_DIFF: inputs.failBuildOnDiff, 12 | APPLITOOLS_SERVER_URL: inputs.serverUrl, 13 | APPLITOOLS_IGNORE_SELECTOR: inputs.ignoreSelector 14 | ? inputs.ignoreSelector 15 | .split(',') 16 | .map((selector) => ({ selector: selector.trim() })) 17 | : [], 18 | APPLITOOLS_CONCURRENCY: inputs.concurrency, 19 | PAGES_TO_CHECK: builtPages, 20 | CYPRESS_CACHE_FOLDER: './node_modules/CypressBinary', 21 | }); 22 | }; 23 | 24 | const runCypress = async ({ utils, port }) => { 25 | await utils.run( 26 | 'node', 27 | ['cypress.js', 'run', '--config', `baseUrl=http://localhost:${port}`], 28 | { cwd: __dirname }, 29 | ); 30 | 31 | return await fs.readJSON(`${__dirname}/results.json`); 32 | }; 33 | 34 | const shutdownServer = async ({ server }) => { 35 | await new Promise((resolve, reject) => { 36 | server.close((err) => { 37 | if (err) { 38 | return reject(err); 39 | } 40 | 41 | resolve(); 42 | }); 43 | }); 44 | }; 45 | 46 | module.exports = { 47 | onPreBuild: async ({ utils }) => { 48 | // bail immediately if this isn’t a production build 49 | if (process.env.CONTEXT !== 'production') return; 50 | 51 | await utils.run('cypress', ['install'], { 52 | stdio: 'ignore', 53 | cwd: __dirname, 54 | }); 55 | }, 56 | onPostBuild: async ({ constants: { PUBLISH_DIR }, utils, inputs }) => { 57 | // bail immediately if this isn’t a production build 58 | if (process.env.CONTEXT !== 'production') return; 59 | 60 | if (!process.env.APPLITOOLS_API_KEY) { 61 | utils.build.failPlugin( 62 | 'No Applitools API key found! Set APPLITOOLS_API_KEY with your API key from https://eyes.applitools.com', 63 | ); 64 | } 65 | 66 | const port = 9919; 67 | const server = http 68 | .createServer(ecstatic({ root: `${PUBLISH_DIR}` })) 69 | .listen(port); 70 | 71 | const builtPages = glob 72 | .sync(`${PUBLISH_DIR}/**/*.html`) 73 | .map((p) => p.replace(PUBLISH_DIR, '')); 74 | 75 | console.log(`Found ${builtPages.length} pages...`); 76 | builtPages.forEach(p => console.log(`> ${p}`)); 77 | 78 | await createEnvFile({ inputs, builtPages }); 79 | const results = await runCypress({ utils, port }); 80 | await shutdownServer({ server }); 81 | 82 | if (results.failures) { 83 | utils.build.failPlugin(`Cypress had a problem`, { 84 | error: new Error(results.message), 85 | }); 86 | } 87 | 88 | if (inputs.failBuildOnDiff && results.totalFailed) { 89 | // take just the first run 90 | const run = results.runs.find(Boolean); 91 | 92 | // find the failed test 93 | const test = run.tests.find((t) => t.state === 'failed'); 94 | 95 | // pull out the Applitools review URL 96 | const url = test.stack.match(/(https:\/\/eyes\.applitools\.com[\S]*)/)[0]; 97 | 98 | utils.build.failBuild( 99 | 'Visual changes were detected. Confirm the changes in Applitools, then rerun this build.', 100 | { 101 | error: new Error(`Review the detected changes at \n${url}`), 102 | }, 103 | ); 104 | } 105 | }, 106 | onEnd: async () => { 107 | // cleanup transient files 108 | await Promise.all( 109 | [ 110 | `${__dirname}/cypress.env.json`, 111 | `${__dirname}/cypress`, 112 | `${__dirname}/results.json`, 113 | ].map((pathToRemove) => fs.remove(pathToRemove)), 114 | ); 115 | }, 116 | }; 117 | -------------------------------------------------------------------------------- /manifest.yml: -------------------------------------------------------------------------------- 1 | name: netlify-plugin-visual-diff 2 | inputs: 3 | - name: serverUrl 4 | description: The Eyes server to use for your tests 5 | default: https://eyesapi.applitools.com 6 | - name: failBuildOnDiff 7 | description: If a diff is detected, whether or not to prevent the site from deploying. 8 | default: true 9 | - name: ignoreSelector 10 | description: If you want to ignore part(s) of pages in visual diffs, add CSS selectors for them here, separated by commas. 11 | default: '' 12 | - name: browser 13 | description: Configure which browser(s) to test in. See https://www.npmjs.com/package/@applitools/eyes-cypress#configuring-the-browser 14 | default: 15 | - width: 1024 16 | height: 768 17 | name: chrome 18 | - name: concurrency 19 | description: Enable higher concurrency for faster testing. See https://www.npmjs.com/package/@applitools/eyes-cypress#concurrency 20 | default: 1 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "netlify-plugin-visual-diff", 3 | "version": "2.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1" 7 | }, 8 | "keywords": [ 9 | "netlify", 10 | "netlify-plugin" 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/applitools/netlify-plugin-visual-diff" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/applitools/netlify-plugin-visual-diff/issues" 18 | }, 19 | "author": "", 20 | "license": "ISC", 21 | "description": "", 22 | "dependencies": { 23 | "@applitools/eyes-cypress": "^3.22.0", 24 | "cypress": "^7.5.0", 25 | "ecstatic": "^4.1.4", 26 | "fs-extra": "^10.0.0", 27 | "glob": "^7.1.7" 28 | }, 29 | "engines": { 30 | "node": ">=12.0.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /visual-diff/integration/visualdiff.spec.js: -------------------------------------------------------------------------------- 1 | describe('check the site for visual regressions', () => { 2 | beforeEach(() => { 3 | cy.eyesOpen({ 4 | appName: Cypress.env('SITE_NAME'), 5 | batchName: Cypress.env('SITE_NAME'), 6 | browser: JSON.parse(Cypress.env('APPLITOOLS_BROWSERS')), 7 | failBuildOnDiff: Boolean(Cypress.env('APPLITOOLS_FAIL_BUILD_ON_DIFF')), 8 | serverUrl: Cypress.env('APPLITOOLS_SERVER_URL'), 9 | concurrency: Number(Cypress.env('APPLITOOLS_CONCURRENCY')), 10 | }); 11 | }); 12 | 13 | afterEach(() => { 14 | cy.eyesClose(); 15 | }); 16 | 17 | // TODO can we loop through all generated pages and check? 18 | const pagesToCheck = Cypress.env('PAGES_TO_CHECK'); 19 | pagesToCheck.forEach((route) => { 20 | it(`Visual Diff for ${route}`, () => { 21 | cy.visit(route); 22 | cy.eyesCheckWindow({ 23 | tag: route, 24 | ignore: Cypress.env('APPLITOOLS_IGNORE_SELECTOR'), 25 | }); 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /visual-diff/plugins/index.js: -------------------------------------------------------------------------------- 1 | module.exports = () => {}; 2 | 3 | require('@applitools/eyes-cypress')(module); 4 | -------------------------------------------------------------------------------- /visual-diff/support/index.js: -------------------------------------------------------------------------------- 1 | require('@applitools/eyes-cypress/commands'); 2 | --------------------------------------------------------------------------------