├── .babelrc ├── .editorconfig ├── .gitignore ├── .npmrc ├── .travis.yml ├── LICENSE.md ├── README.md ├── compare.js ├── index.js ├── launcher.js ├── package.json ├── src ├── VisualRegressionLauncher.js ├── compare.js ├── index.js ├── methods │ ├── BaseCompare.js │ ├── LocalCompare.js │ ├── SaveScreenshot.js │ └── Spectre.js ├── modules │ └── mapViewports.js └── scripts │ └── getUserAgent.js └── test ├── fixture ├── ignoreComparison │ ├── 100x100-red.png │ └── 100x100-red2.png ├── image │ ├── 100x100-diff.png │ ├── 100x100-rotated.png │ └── 100x100.png └── misMatchTolerance │ ├── base.png │ ├── custom-outside.png │ ├── custom-within.png │ ├── default-outside.png │ └── default-within.png ├── helper ├── compareImages.js └── compareMethod.js ├── unit └── methods │ ├── LocalCompare.test.js │ ├── SaveScreenshot.test.js │ └── Spectre.test.js └── wdio ├── VisualRegressionLauncher.test.js └── wdio.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"], 3 | "plugins": ["syntax-async-functions", "transform-regenerator", "transform-runtime", "transform-object-rest-spread"] 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # idea 2 | .idea 3 | 4 | # Node 5 | node_modules 6 | npm-debug.log 7 | 8 | # project 9 | .tmp 10 | lib 11 | logs 12 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | message = "Version %s" 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | - "8" 5 | 6 | before_script: 7 | - npm run phantom 2>&1 >/dev/null & 8 | 9 | script: 10 | - npm run test 11 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2018 Jan-André Zinser 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wdio-visual-regression-service [![Build Status][build-badge]][build] [![npm package][npm-badge]][npm] 2 | 3 | > Visual regression testing with WebdriverIO. 4 | 5 | 6 | ## Installation 7 | 8 | wdio-visual-regression-service uses [wdio-screenshot](https://github.com/zinserjan/wdio-screenshot) for capturing screenhots. 9 | 10 | You can install wdio-visual-regression-service via NPM as usual: 11 | 12 | ```sh 13 | $ npm install wdio-visual-regression-service --save-dev 14 | ``` 15 | 16 | Instructions on how to install `WebdriverIO` can be found [here.](http://webdriver.io/guide/getstarted/install.html) 17 | 18 | An example repository using the wdio-visual-regression service can be found [here.](https://github.com/zinserjan/webdriverio-example) 19 | 20 | ## Configuration 21 | Setup wdio-visual-regression-service by adding `visual-regression` to the service section of your WebdriverIO config and define your desired comparison strategy in `visualRegression`. 22 | 23 | ```js 24 | // wdio.conf.js 25 | 26 | var path = require('path'); 27 | var VisualRegressionCompare = require('wdio-visual-regression-service/compare'); 28 | 29 | function getScreenshotName(basePath) { 30 | return function(context) { 31 | var type = context.type; 32 | var testName = context.test.title; 33 | var browserVersion = parseInt(context.browser.version, 10); 34 | var browserName = context.browser.name; 35 | var browserViewport = context.meta.viewport; 36 | var browserWidth = browserViewport.width; 37 | var browserHeight = browserViewport.height; 38 | 39 | return path.join(basePath, `${testName}_${type}_${browserName}_v${browserVersion}_${browserWidth}x${browserHeight}.png`); 40 | }; 41 | } 42 | 43 | exports.config = { 44 | // ... 45 | services: [ 46 | 'visual-regression', 47 | ], 48 | visualRegression: { 49 | compare: new VisualRegressionCompare.LocalCompare({ 50 | referenceName: getScreenshotName(path.join(process.cwd(), 'screenshots/reference')), 51 | screenshotName: getScreenshotName(path.join(process.cwd(), 'screenshots/screen')), 52 | diffName: getScreenshotName(path.join(process.cwd(), 'screenshots/diff')), 53 | misMatchTolerance: 0.01, 54 | }), 55 | viewportChangePause: 300, 56 | viewports: [{ width: 320, height: 480 }, { width: 480, height: 320 }, { width: 1024, height: 768 }], 57 | orientations: ['landscape', 'portrait'], 58 | }, 59 | // ... 60 | }; 61 | ``` 62 | 63 | ### Options 64 | Under the key `visualRegression` in your wdio.config.js you can pass a configuration object with the following structure: 65 | 66 | * **compare** `Object`
67 | screenshot compare method, see [Compare Methods](#compare-methods) 68 | 69 | * **viewportChangePause** `Number` ( default: 100 )
70 | wait x milliseconds after viewport change. It can take a while for the browser to re-paint. This could lead to rendering issues and produces inconsistent results between runs. 71 | 72 | * **viewports** `Object[{ width: Number, height: Number }]` ( default: *[current-viewport]* ) (**desktop only**)
73 | all screenshots will be taken in different viewport dimensions (e.g. for responsive design tests) 74 | 75 | * **orientations** `String[] {landscape, portrait}` ( default: *[current-orientation]* ) (**mobile only**)
76 | all screenshots will be taken in different screen orientations (e.g. for responsive design tests) 77 | 78 | ### Compare Methods 79 | wdio-visual-regression-service allows the usage of different screenshot comparison methods. 80 | 81 | #### VisualRegressionCompare.LocalCompare 82 | As it's name suggests *LocalCompare* captures screenshots locally on your computer and compares them against previous runs. 83 | 84 | You can pass the following options to it's constructor as object: 85 | 86 | * **referenceName** `Function`
87 | pass in a function that returns the filename for the reference screenshot. Function receives a *context* object as first parameter with all relevant information about the command. 88 | 89 | * **screenshotName** `Function`
90 | pass in a function that returns the filename for the current screenshot. Function receives a *context* object as first parameter with all relevant information about the command. 91 | 92 | * **diffName** `Function`
93 | pass in a function that returns the filename for the diff screenshot. Function receives a *context* object as first parameter with all relevant information about the command. 94 | 95 | * **misMatchTolerance** `Number` ( default: 0.01 )
96 | number between 0 and 100 that defines the degree of mismatch to consider two images as identical, increasing this value will decrease test coverage. 97 | 98 | * **ignoreComparison** `String` ( default: nothing )
99 | pass in a string with value of `nothing` , `colors` or `antialiasing` to adjust the comparison method. 100 | 101 | For an example of generating screenshot filesnames dependent on the current test name, have a look at the sample code of [Configuration](#configuration). 102 | 103 | #### VisualRegressionCompare.SaveScreenshot 104 | This method is a stripped variant of `VisualRegressionCompare.LocalCompare` to capture only screenshots. This is quite useful when you just want to create reference screenshots and overwrite the previous one without diffing. 105 | 106 | You can pass the following options to it's constructor as object: 107 | 108 | * **screenshotName** `Function`
109 | pass in a function that returns the filename for the current screenshot. Function receives a *context* object as first parameter with all relevant information about the command. 110 | 111 | #### VisualRegressionCompare.Spectre 112 | This method is used for uploading screenshots to the web application [Spectre](https://github.com/wearefriday/spectre). 113 | Spectre is a UI for visual regression testing. It stores the screenshots and compares them which is quite useful for Continuous Integration. 114 | 115 | You can pass the following options to it's constructor as object: 116 | 117 | * **url** `String`
118 | pass in a spectre webservice url. 119 | 120 | * **project** `String`
121 | pass in a name for your project. 122 | 123 | * **suite** `String`
124 | pass in a name for your testsuite. One project can contain several suites. 125 | 126 | * **test** `Function`
127 | pass in a function that returns the test name for the screenshot. Function receives a *context* object as first parameter with all relevant information about the command. 128 | 129 | * **browser** `Function`
130 | pass in a function that returns the browser for the screenshot. Function receives a *context* object as first parameter with all relevant information about the command. 131 | 132 | * **size** `Function`
133 | pass in a function that returns the size for the screenshot. Function receives a *context* object as first parameter with all relevant information about the command. 134 | 135 | * **fuzzLevel** `Number` ( default: 30 )
136 | number between 0 and 100 that defines the fuzz factor of Spectre's image comparison method. For more details please have a look at [Spectre documentation](https://github.com/wearefriday/spectre). 137 | 138 | **Example** 139 | ```js 140 | // wdio.conf.js 141 | 142 | var path = require('path'); 143 | var VisualRegressionCompare = require('wdio-visual-regression-service/compare'); 144 | 145 | exports.config = { 146 | // ... 147 | services: [ 148 | 'visual-regression', 149 | ], 150 | visualRegression: { 151 | compare: new VisualRegressionCompare.Spectre({ 152 | url: 'http://localhost:3000', 153 | project: 'my project', 154 | suite: 'my test suite', 155 | test: function getTest(context) { 156 | return context.test.title; 157 | }, 158 | browser: function getBrowser(context) { 159 | return context.browser.name; 160 | }, 161 | size: function getSize(context) { 162 | return context.meta.viewport != null ? context.meta.viewport.width : context.meta.orientation; 163 | }, 164 | fuzzLevel: 30 165 | }), 166 | viewportChangePause: 300, 167 | viewports: [{ width: 320, height: 480 }, { width: 480, height: 320 }, { width: 1024, height: 768 }], 168 | orientations: ['landscape', 'portrait'], 169 | }, 170 | // ... 171 | }; 172 | ``` 173 | 174 | ## Usage 175 | wdio-visual-regression-service enhances an WebdriverIO instance with the following commands: 176 | * `browser.checkViewport([{options}]);` 177 | * `browser.checkDocument([{options}]);` 178 | * `browser.checkElement(elementSelector, [{options}]);` 179 | 180 | 181 | All of these provide options that will help you to capture screenshots in different dimensions or to exclude unrelevant parts (e.g. content). The following options are 182 | available: 183 | 184 | 185 | * **exclude** `String[]|Object[]` (**not yet implemented**)
186 | exclude frequently changing parts of your screenshot, you can either pass all kinds of different [WebdriverIO selector strategies](http://webdriver.io/guide/usage/selectors.html) 187 | that queries one or multiple elements or you can define x and y values which stretch a rectangle or polygon 188 | 189 | * **hide** `String[]`
190 | hides all elements queried by all kinds of different [WebdriverIO selector strategies](http://webdriver.io/guide/usage/selectors.html) (via `visibility: hidden`) 191 | 192 | * **remove** `String[]`
193 | removes all elements queried by all kinds of different [WebdriverIO selector strategies](http://webdriver.io/guide/usage/selectors.html) (via `display: none`) 194 | 195 | * **viewports** `Object[{ width: Number, height: Number }]` (**desktop only**)
196 | Overrides the global *viewports* value for this command. All screenshots will be taken in different viewport dimensions (e.g. for responsive design tests) 197 | 198 | * **orientations** `String[] {landscape, portrait}` (**mobile only**)
199 | Overrides the global *orientations* value for this command. All screenshots will be taken in different screen orientations (e.g. for responsive design tests) 200 | 201 | * **misMatchTolerance** `Number`
202 | Overrides the global *misMatchTolerance* value for this command. Pass in a number between 0 and 100 that defines the degree of mismatch to consider two images as identical. 203 | 204 | * **fuzzLevel** `Number`
205 | Overrides the global *fuzzLevel* value for this command. Pass in a number between 0 and 100 that defines the fuzz factor of Spectre's image comparison method. 206 | 207 | * **ignoreComparison** `String`
208 | Overrides the global *ignoreComparison* value for this command. Pass in a string with value of `nothing` , `colors` or `antialiasing` to adjust the comparison method. 209 | 210 | * **viewportChangePause** `Number`
211 | Overrides the global *viewportChangePause* value for this command. Wait x milliseconds after viewport change. 212 | 213 | ### License 214 | 215 | MIT 216 | 217 | 218 | [build-badge]: https://travis-ci.org/zinserjan/wdio-visual-regression-service.svg?branch=master 219 | [build]: https://travis-ci.org/zinserjan/wdio-visual-regression-service 220 | [npm-badge]: https://img.shields.io/npm/v/wdio-visual-regression-service.svg?style=flat-square 221 | [npm]: https://www.npmjs.org/package/wdio-visual-regression-service 222 | -------------------------------------------------------------------------------- /compare.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/compare'); 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./launcher'); 2 | -------------------------------------------------------------------------------- /launcher.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib'); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wdio-visual-regression-service", 3 | "version": "0.9.0", 4 | "description": "Visual regression testing for WebdriverIO", 5 | "main": "index.js", 6 | "files": [ 7 | "index.js", 8 | "compare.js", 9 | "launcher.js", 10 | "*.md", 11 | "src", 12 | "lib" 13 | ], 14 | "scripts": { 15 | "clean": "rimraf lib .tmp", 16 | "phantom": "phantomjs --webdriver=4444", 17 | "build": "npm run clean && babel ./src -d lib", 18 | "test": "npm run test:unit && npm run test:wdio", 19 | "test:unit": "npm run clean && mocha --compilers js:babel-register test/unit/**/*.test.js", 20 | "test:wdio": "npm run clean && wdio ./test/wdio/wdio.config.js", 21 | "prepublish": "npm run build", 22 | "release": "np" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/zinserjan/wdio-visual-regression-service" 27 | }, 28 | "bugs": { 29 | "url": "https://github.com/zinserjan/wdio-visual-regression-service/issues" 30 | }, 31 | "author": "Jan-André Zinser", 32 | "license": "MIT", 33 | "peerDependencies": { 34 | "webdriverio": "^4.0.7" 35 | }, 36 | "dependencies": { 37 | "babel-runtime": "^6.9.0", 38 | "debug": "^2.2.0", 39 | "fs-extra": "^3.0.1", 40 | "lodash": "^4.13.1", 41 | "node-resemble-js": "0.0.5", 42 | "nodeclient-spectre": "^1.0.3", 43 | "platform": "^1.3.1", 44 | "wdio-screenshot": "^0.6.0" 45 | }, 46 | "devDependencies": { 47 | "babel-cli": "^6.9.0", 48 | "babel-plugin-syntax-async-functions": "^6.8.0", 49 | "babel-plugin-transform-object-rest-spread": "^6.8.0", 50 | "babel-plugin-transform-regenerator": "^6.9.0", 51 | "babel-plugin-transform-runtime": "^6.9.0", 52 | "babel-preset-es2015": "^6.9.0", 53 | "babel-register": "^6.9.0", 54 | "chai": "^3.5.0", 55 | "mocha": "^2.4.5", 56 | "nock": "^9.2.3", 57 | "np": "^2.10.0", 58 | "phantomjs": "^1.9.20", 59 | "rimraf": "^2.5.2", 60 | "sinon": "^1.17.4", 61 | "wdio-mocha-framework": "^0.3.1", 62 | "wdio-selenium-standalone-service": "0.0.7", 63 | "webdriverio": "^4.0.9" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/VisualRegressionLauncher.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { parse as parsePlatform } from 'platform'; 3 | import { makeElementScreenshot, makeDocumentScreenshot, makeViewportScreenshot } from 'wdio-screenshot'; 4 | 5 | import getUserAgent from './scripts/getUserAgent'; 6 | import { mapViewports, mapOrientations } from './modules/mapViewports'; 7 | 8 | 9 | export default class VisualRegressionLauncher { 10 | 11 | constructor() { 12 | this.currentSuite = null; 13 | this.currentTest = null; 14 | this.currentFeature = null; 15 | this.currentScenario = null; 16 | this.currentStep = null; 17 | } 18 | 19 | /** 20 | * Gets executed once before all workers get launched. 21 | * @param {Object} config wdio configuration object 22 | * @param {Array.} capabilities list of capabilities details 23 | */ 24 | async onPrepare(config) { 25 | this.validateConfig(config); 26 | this.compare = config.visualRegression.compare; 27 | await this.runHook('onPrepare'); 28 | } 29 | 30 | /** 31 | * Gets executed before test execution begins. At this point you can access 32 | * all global variables, such as `browser`. 33 | * It is the perfect place to define custom commands. 34 | * @param {object} capabilities desiredCapabilities 35 | * @param {[type]} specs [description] 36 | * @return {Promise} 37 | */ 38 | async before(capabilities, specs) { 39 | this.validateConfig(browser.options); 40 | 41 | this.compare = browser.options.visualRegression.compare; 42 | this.viewportChangePause = _.get(browser.options, 'visualRegression.viewportChangePause', 100); 43 | this.viewports = _.get(browser.options, 'visualRegression.viewports'); 44 | this.orientations = _.get(browser.options, 'visualRegression.orientations'); 45 | const userAgent = (await browser.execute(getUserAgent)).value; 46 | const { name, version, ua } = parsePlatform(userAgent); 47 | 48 | this.context = { 49 | browser: { 50 | name, 51 | version, 52 | userAgent: ua 53 | }, 54 | desiredCapabilities: capabilities, 55 | specs: specs 56 | }; 57 | 58 | browser.addCommand('checkElement', this.wrapCommand(browser, 'element', makeElementScreenshot)); 59 | browser.addCommand('checkDocument', this.wrapCommand(browser, 'document', makeDocumentScreenshot)); 60 | browser.addCommand('checkViewport', this.wrapCommand(browser, 'viewport', makeViewportScreenshot)); 61 | 62 | await this.runHook('before', this.context); 63 | } 64 | 65 | /** 66 | * Hook that gets executed before the suite starts 67 | * @param {Object} suite suite details 68 | */ 69 | beforeSuite (suite) { 70 | this.currentSuite = suite; 71 | } 72 | 73 | /** 74 | * Hook that gets executed after the suite has ended 75 | * @param {Object} suite suite details 76 | */ 77 | afterSuite(suite) { 78 | this.currentSuite = null; 79 | } 80 | 81 | /** 82 | * Function to be executed before a test (in Mocha/Jasmine) or a step (in Cucumber) starts. 83 | * @param {Object} test test details 84 | */ 85 | beforeTest(test) { 86 | this.currentTest = test; 87 | } 88 | 89 | /** 90 | * Function to be executed after a test (in Mocha/Jasmine) or a step (in Cucumber) ends. 91 | * @param {Object} test test details 92 | */ 93 | afterTest(test) { 94 | this.currentTest = null; 95 | } 96 | 97 | /** 98 | * Function to be executed before a feature starts in Cucumber. 99 | * @param {Object} feature feature details 100 | */ 101 | beforeFeature(feature) { 102 | this.currentFeature = feature; 103 | } 104 | 105 | /** 106 | * Function to be executed after a feature ends in Cucumber. 107 | * @param {Object} feature feature details 108 | */ 109 | afterFeature(feature) { 110 | this.currentFeature = null; 111 | } 112 | 113 | /** 114 | * Function to be executed before a scenario starts in Cucumber. 115 | * @param {Object} scenario scenario details 116 | */ 117 | beforeScenario(scenario) { 118 | this.currentScenario = scenario; 119 | } 120 | 121 | /** 122 | * Function to be executed after a scenario ends in Cucumber. 123 | * @param {Object} scenario scenario details 124 | */ 125 | afterScenario(scenario) { 126 | this.currentScenario = null; 127 | } 128 | 129 | /** 130 | * Function to be executed before a step starts in Cucumber. 131 | * @param {Object} step step details 132 | */ 133 | beforeStep(step) { 134 | this.currentStep = step; 135 | } 136 | 137 | /** 138 | * Function to be executed after a step ends in Cucumber. 139 | * @param {Object} stepResult stepResult details 140 | */ 141 | afterStep(stepResult) { 142 | this.currentStep = null; 143 | } 144 | 145 | 146 | /** 147 | * Gets executed after all tests are done. You still have access to all global 148 | * variables from the test. 149 | * @param {object} capabilities desiredCapabilities 150 | * @param {[type]} specs [description] 151 | * @return {Promise} 152 | */ 153 | async after(capabilities, specs) { 154 | await this.runHook('after', capabilities, specs); 155 | } 156 | 157 | /** 158 | * Gets executed after all workers got shut down and the process is about to exit. 159 | * @param {Object} exitCode 0 - success, 1 - fail 160 | * @param {Object} config wdio configuration object 161 | * @param {Array.} capabilities list of capabilities details 162 | */ 163 | async onComplete(exitCode, config, capabilities) { 164 | await this.runHook('onComplete'); 165 | } 166 | 167 | async runHook(hookName, ...args) { 168 | if (typeof this.compare[hookName] === 'function') { 169 | return await this.compare[hookName](...args) 170 | } 171 | } 172 | 173 | validateConfig(config) { 174 | if(!_.isPlainObject(config.visualRegression) || !_.has(config.visualRegression, 'compare')) { 175 | throw new Error('Please provide a visualRegression configuration with a compare method in your wdio-conf.js!'); 176 | } 177 | } 178 | 179 | wrapCommand(browser, type, command) { 180 | const baseContext = { 181 | type, 182 | browser: this.context.browser, 183 | desiredCapabilities: this.context.desiredCapabilities, 184 | }; 185 | 186 | const runHook = this.runHook.bind(this); 187 | 188 | const getTestDetails = () => this.getTestDetails(); 189 | 190 | const resolutionKeySingle = browser.isMobile ? 'orientation' : 'viewport'; 191 | const resolutionKeyPlural = browser.isMobile ? 'orientations' : 'viewports'; 192 | const resolutionMap = browser.isMobile ? mapOrientations : mapViewports; 193 | 194 | const viewportChangePauseDefault = this.viewportChangePause; 195 | const resolutionDefault = browser.isMobile ? this.orientations : this.viewports; 196 | 197 | return async function async(...args) { 198 | const url = await browser.getUrl(); 199 | 200 | const elementSelector = type === 'element' ? args[0] : undefined; 201 | const options = _.isPlainObject(args[args.length - 1]) ? args[args.length - 1] : {}; 202 | 203 | const { 204 | exclude, 205 | hide, 206 | remove, 207 | } = options; 208 | 209 | const resolutions = _.get(options, resolutionKeyPlural, resolutionDefault); 210 | const viewportChangePause = _.get(options, 'viewportChangePause', viewportChangePauseDefault); 211 | 212 | const results = await resolutionMap( 213 | browser, 214 | viewportChangePause, 215 | resolutions, 216 | async function takeScreenshot(resolution) { 217 | const meta = _.pickBy({ 218 | url, 219 | element: elementSelector, 220 | exclude, 221 | hide, 222 | remove, 223 | [resolutionKeySingle]: resolution 224 | }, _.identity); 225 | 226 | const screenshotContext = { 227 | ...baseContext, 228 | ...getTestDetails(), 229 | meta, 230 | options 231 | }; 232 | 233 | const screenshotContextCleaned = _.pickBy(screenshotContext, _.identity); 234 | 235 | await runHook('beforeScreenshot', screenshotContextCleaned); 236 | 237 | const base64Screenshot = await command(browser, ...args); 238 | 239 | await runHook('afterScreenshot', screenshotContextCleaned, base64Screenshot); 240 | 241 | // pass the following params to next iteratee function 242 | return [screenshotContextCleaned, base64Screenshot]; 243 | }, 244 | async function processScreenshot(screenshotContextCleaned, base64Screenshot) { 245 | return await runHook('processScreenshot', screenshotContextCleaned, base64Screenshot); 246 | } 247 | ); 248 | return results; 249 | 250 | } 251 | } 252 | 253 | getTestDetails() { 254 | return _.pickBy({ 255 | // mocha 256 | suite: this.currentSuite, 257 | test: this.currentTest, 258 | // cucumber 259 | feature: this.currentFeature, 260 | scenario: this.currentScenario, 261 | step: this.currentStep, 262 | }, _.identity); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/compare.js: -------------------------------------------------------------------------------- 1 | export { default as LocalCompare } from './methods/LocalCompare'; 2 | export { default as SaveScreenshot } from './methods/SaveScreenshot'; 3 | export { default as Spectre} from './methods/Spectre'; 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import VisualRegressionLauncher from './VisualRegressionLauncher'; 2 | 3 | module.exports = new VisualRegressionLauncher(); 4 | -------------------------------------------------------------------------------- /src/methods/BaseCompare.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | import debug from 'debug'; 4 | 5 | const log = debug('wdio-visual-regression-service:BaseCompare'); 6 | const runtimeConfigPath = __dirname; 7 | 8 | export default class BaseCompare { 9 | 10 | constructor() { 11 | this.configs = new Map(); 12 | } 13 | 14 | /** 15 | * Gets executed once before all workers get launched. 16 | */ 17 | async onPrepare() { 18 | return Promise.resolve(); 19 | } 20 | 21 | /** 22 | * Gets executed before the tests starts. 23 | */ 24 | async before(context) { 25 | return Promise.resolve(); 26 | } 27 | 28 | /** 29 | * Gets executed immediately before the screenshot is taken. 30 | */ 31 | async beforeScreenshot(context) { 32 | return Promise.resolve(); 33 | } 34 | 35 | /** 36 | * Gets executed after the screenshot is taken. 37 | */ 38 | async afterScreenshot(context, base64Screenshot) { 39 | return Promise.resolve(); 40 | } 41 | 42 | /** 43 | * You can do here your image comparison magic. 44 | */ 45 | async processScreenshot(context, base64Screenshot) { 46 | return Promise.resolve(); 47 | } 48 | 49 | /** 50 | * Gets executed after all tests are done. You still have access to all global 51 | * variables from the test. 52 | */ 53 | async after() { 54 | return Promise.resolve(); 55 | } 56 | 57 | /** 58 | * Gets executed after all workers got shut down and the process is about to exit. 59 | */ 60 | async onComplete() { 61 | return Promise.resolve(); 62 | } 63 | 64 | /** 65 | * Prepare runtime config for worker process 66 | */ 67 | async saveRuntimeConfig(name, config) { 68 | const file = this.getRuntimeConfigFileName(name); 69 | log(`Save runtime config ${name} to file ${file}`); 70 | await fs.writeJson(file, config, { 71 | spaces: 2 72 | }); 73 | this.configs.set(name, config); 74 | } 75 | 76 | /** 77 | * Read prepared runtime config from launcher process 78 | */ 79 | async getRuntimeConfig(name) { 80 | // try to read from cache first 81 | if (this.configs.has(name)) { 82 | log(`Read runtime config ${name} from cache`); 83 | return this.configs.get(name); 84 | } 85 | // otherwise read from fs 86 | const file = this.getRuntimeConfigFileName(name); 87 | log(`Read runtime config ${name} from file ${file}`); 88 | const config = await fs.readJson(file); 89 | // and cache the result 90 | this.configs.set(name, config); 91 | return config; 92 | } 93 | 94 | /** 95 | * Remove runtime config 96 | */ 97 | async removeRuntimeConfig(name) { 98 | // delete from fs 99 | const file = this.getRuntimeConfigFileName(name); 100 | log(`Remove runtime config ${name} file ${file}`); 101 | await fs.remove(file); 102 | // delete from cache 103 | this.configs.delete(name); 104 | } 105 | 106 | /** 107 | * Removes all created runtime configs 108 | */ 109 | async cleanUpRuntimeConfigs() { 110 | // clean up all saved config files 111 | const names = [...this.configs.keys()]; 112 | await Promise.all(names.map((n) => this.removeRuntimeConfig(n))); 113 | } 114 | 115 | /** 116 | * Builds a non-conflicting file name for this webdriverio run 117 | */ 118 | getRuntimeConfigFileName(name) { 119 | // launcher and runner gets the same arguments, so let's use them to build a hash to determine different calls 120 | const hash = require("crypto").createHash('md5').update(process.argv.slice(2).join("")).digest('hex').substring(0, 4); 121 | // try to use process id to generate a unique file name for each webdriverio instance 122 | const runner = global.browser != null; 123 | const pid = !process.hasOwnProperty("ppid") ? null : (runner ? process.ppid : process.pid); 124 | // generate file name suffix 125 | const suffix = [hash, pid].filter(Boolean).join("-"); 126 | return path.join(runtimeConfigPath, `${name}-${suffix}.json`); 127 | } 128 | 129 | createResultReport(misMatchPercentage, isWithinMisMatchTolerance, isSameDimensions) { 130 | return { 131 | misMatchPercentage, 132 | isWithinMisMatchTolerance, 133 | isSameDimensions, 134 | isExactSameImage: misMatchPercentage === 0 135 | }; 136 | } 137 | 138 | } 139 | -------------------------------------------------------------------------------- /src/methods/LocalCompare.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import resemble from 'node-resemble-js'; 3 | import BaseCompare from './BaseCompare'; 4 | import debug from 'debug'; 5 | import _ from 'lodash'; 6 | 7 | const log = debug('wdio-visual-regression-service:LocalCompare'); 8 | 9 | export default class LocalCompare extends BaseCompare { 10 | 11 | constructor(options = {}) { 12 | super(); 13 | this.getScreenshotFile = options.screenshotName; 14 | this.getReferencefile = options.referenceName; 15 | this.getDiffFile = options.diffName; 16 | this.misMatchTolerance = _.get(options, 'misMatchTolerance', 0.01); 17 | this.ignoreComparison = _.get(options, 'ignoreComparison', 'nothing'); 18 | } 19 | 20 | async processScreenshot(context, base64Screenshot) { 21 | const screenshotPath = this.getScreenshotFile(context); 22 | const referencePath = this.getReferencefile(context); 23 | 24 | await fs.outputFile(screenshotPath, base64Screenshot, 'base64'); 25 | 26 | const referenceExists = await fs.exists(referencePath); 27 | 28 | if (referenceExists) { 29 | log('reference exists, compare it with the taken now'); 30 | const captured = new Buffer(base64Screenshot, 'base64'); 31 | const ignoreComparison = _.get(context, 'options.ignoreComparison', this.ignoreComparison); 32 | 33 | const compareData = await this.compareImages(referencePath, captured, ignoreComparison); 34 | 35 | const { isSameDimensions } = compareData; 36 | const misMatchPercentage = Number(compareData.misMatchPercentage); 37 | const misMatchTolerance = _.get(context, 'options.misMatchTolerance', this.misMatchTolerance); 38 | 39 | const diffPath = this.getDiffFile(context); 40 | 41 | if (misMatchPercentage > misMatchTolerance) { 42 | log(`Image is different! ${misMatchPercentage}%`); 43 | const png = compareData.getDiffImage().pack(); 44 | await this.writeDiff(png, diffPath); 45 | 46 | return this.createResultReport(misMatchPercentage, false, isSameDimensions); 47 | } else { 48 | log(`Image is within tolerance or the same`); 49 | await fs.remove(diffPath); 50 | 51 | return this.createResultReport(misMatchPercentage, true, isSameDimensions); 52 | } 53 | 54 | } else { 55 | log('first run - create reference file'); 56 | await fs.outputFile(referencePath, base64Screenshot, 'base64'); 57 | return this.createResultReport(0, true, true); 58 | } 59 | } 60 | 61 | /** 62 | * Compares two images with resemble 63 | * @param {Buffer|string} reference path to reference file or buffer 64 | * @param {Buffer|string} screenshot path to file or buffer to compare with reference 65 | * @return {{misMatchPercentage: Number, isSameDimensions:Boolean, getImageDataUrl: function}} 66 | */ 67 | async compareImages(reference, screenshot, ignore = '') { 68 | return await new Promise((resolve) => { 69 | const image = resemble(reference).compareTo(screenshot); 70 | 71 | switch(ignore) { 72 | case 'colors': 73 | image.ignoreColors(); 74 | break; 75 | case 'antialiasing': 76 | image.ignoreAntialiasing(); 77 | break; 78 | } 79 | 80 | image.onComplete((data) => { 81 | resolve(data); 82 | }); 83 | }); 84 | } 85 | 86 | 87 | /** 88 | * Writes provided diff by resemble as png 89 | * @param {Stream} png node-png file Stream. 90 | * @return {Promise} 91 | */ 92 | async writeDiff(png, filepath) { 93 | await new Promise((resolve, reject) => { 94 | const chunks = []; 95 | png.on('data', function(chunk) { 96 | chunks.push(chunk); 97 | }); 98 | png.on('end', () => { 99 | const buffer = Buffer.concat(chunks); 100 | 101 | Promise 102 | .resolve() 103 | .then(() => fs.outputFile(filepath, buffer.toString('base64'), 'base64')) 104 | .then(() => resolve()) 105 | .catch(reject); 106 | }); 107 | png.on('error', (err) => reject(err)); 108 | }); 109 | } 110 | 111 | 112 | } 113 | -------------------------------------------------------------------------------- /src/methods/SaveScreenshot.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import BaseCompare from './BaseCompare'; 3 | import debug from 'debug'; 4 | 5 | const log = debug('wdio-visual-regression-service:SaveScreenshot'); 6 | 7 | export default class SaveScreenshot extends BaseCompare { 8 | 9 | constructor(options = {}) { 10 | super(); 11 | this.getScreenshotFile = options.screenshotName; 12 | } 13 | 14 | async processScreenshot(context, base64Screenshot) { 15 | const screenshotPath = this.getScreenshotFile(context); 16 | 17 | log(`create screenshot file at ${screenshotPath}`); 18 | await fs.outputFile(screenshotPath, base64Screenshot, 'base64'); 19 | return this.createResultReport(0, true, true); 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/methods/Spectre.js: -------------------------------------------------------------------------------- 1 | import BaseCompare from './BaseCompare'; 2 | import debug from 'debug'; 3 | import _ from 'lodash'; 4 | 5 | import SpectreClient from "nodeclient-spectre"; 6 | 7 | const log = debug('wdio-visual-regression-service:Spectre'); 8 | const runtimeConfigName = 'spectre-run'; 9 | 10 | export default class Spectre extends BaseCompare { 11 | 12 | constructor(options = {}) { 13 | super(); 14 | this.fuzzLevel = _.get(options, 'fuzzLevel', 30); 15 | this.spectreURL = options.url; 16 | this.project = options.project; 17 | this.suite = options.suite; 18 | this.test = options.test; 19 | this.browser = options.browser; 20 | this.size = options.size; 21 | this.spectreClient = new SpectreClient(this.spectreURL); 22 | } 23 | 24 | async onPrepare() { 25 | const creationOptions = `Api-Url: ${this.spectreURL}, Project: ${this.project}, Suite: ${this.suite}`; 26 | log(`${creationOptions} - Creating testrun`); 27 | const result = await this.spectreClient.createTestrun(this.project, this.suite); 28 | log(`${creationOptions} - Testrun created - Run-Id: #${result.id}`); 29 | this.saveRuntimeConfig(runtimeConfigName, result); 30 | } 31 | 32 | async processScreenshot(context, base64Screenshot) { 33 | const runDetails = await this.getRuntimeConfig(runtimeConfigName); 34 | const testrunID = runDetails.id; 35 | const test = this.test(context); 36 | const browser = this.browser(context); 37 | const size = this.size(context); 38 | const fuzzLevel = `${_.get(context, 'options.fuzzLevel', this.fuzzLevel)}%`; 39 | const url = _.get(context, 'meta.url', undefined); 40 | 41 | const uploadName = `Run-Id: #${testrunID}, Test: ${test}, Url: ${url}, Browser: ${browser}, Size: ${size}, Fuzz-Level: ${fuzzLevel}`; 42 | log(`${uploadName} - Starting upload`); 43 | 44 | const result = await this.spectreClient.submitScreenshot(test, browser, size, base64Screenshot, testrunID, '', url, fuzzLevel); 45 | log(`${uploadName} - Upload successful`); 46 | 47 | if (result.pass) { 48 | log(`${uploadName} - Image is within tolerance or the same`); 49 | return this.createResultReport(result.diff, result.pass, true); 50 | } else { 51 | log(`${uploadName} - Image is different! ${result.diff}%`); 52 | return this.createResultReport(result.diff, result.pass, true); 53 | } 54 | } 55 | 56 | async onComplete() { 57 | await this.cleanUpRuntimeConfigs(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/modules/mapViewports.js: -------------------------------------------------------------------------------- 1 | export async function mapViewports(browser, delay, viewports = [], iterateeScreenshot, iterateeProcess) { 2 | const results = []; 3 | 4 | if (!viewports.length) { 5 | const viewport = await browser.getViewportSize(); 6 | const params = await iterateeScreenshot(viewport); 7 | results.push(iterateeProcess(...params)); 8 | } else { 9 | for (let viewport of viewports) { 10 | await browser.setViewportSize(viewport); 11 | await browser.pause(delay); 12 | const params = await iterateeScreenshot(viewport); 13 | results.push(iterateeProcess(...params)); 14 | } 15 | } 16 | 17 | return Promise.all(results); 18 | } 19 | 20 | export async function mapOrientations(browser, delay, orientations = [], iterateeScreenshot, iterateeProcess) { 21 | const results = []; 22 | 23 | if (!orientations.length) { 24 | const orientation = await browser.getOrientation(); 25 | const params = await iterateeScreenshot(orientation); 26 | results.push(iterateeProcess(...params)); 27 | } else { 28 | for (let orientation of orientations) { 29 | await browser.setOrientation(orientation); 30 | await browser.pause(delay); 31 | const params = await iterateeScreenshot(orientation); 32 | results.push(iterateeProcess(...params)); 33 | } 34 | } 35 | 36 | return Promise.all(results); 37 | } 38 | -------------------------------------------------------------------------------- /src/scripts/getUserAgent.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Reads the userAgent sent by your browser. 4 | * @return {string} userAgent string 5 | */ 6 | export default function getUserAgent() { 7 | return window.navigator.userAgent; 8 | } 9 | -------------------------------------------------------------------------------- /test/fixture/ignoreComparison/100x100-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zinserjan/wdio-visual-regression-service/b2e67ffc7f11cb5597e5c10e35ddac37858662f1/test/fixture/ignoreComparison/100x100-red.png -------------------------------------------------------------------------------- /test/fixture/ignoreComparison/100x100-red2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zinserjan/wdio-visual-regression-service/b2e67ffc7f11cb5597e5c10e35ddac37858662f1/test/fixture/ignoreComparison/100x100-red2.png -------------------------------------------------------------------------------- /test/fixture/image/100x100-diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zinserjan/wdio-visual-regression-service/b2e67ffc7f11cb5597e5c10e35ddac37858662f1/test/fixture/image/100x100-diff.png -------------------------------------------------------------------------------- /test/fixture/image/100x100-rotated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zinserjan/wdio-visual-regression-service/b2e67ffc7f11cb5597e5c10e35ddac37858662f1/test/fixture/image/100x100-rotated.png -------------------------------------------------------------------------------- /test/fixture/image/100x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zinserjan/wdio-visual-regression-service/b2e67ffc7f11cb5597e5c10e35ddac37858662f1/test/fixture/image/100x100.png -------------------------------------------------------------------------------- /test/fixture/misMatchTolerance/base.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zinserjan/wdio-visual-regression-service/b2e67ffc7f11cb5597e5c10e35ddac37858662f1/test/fixture/misMatchTolerance/base.png -------------------------------------------------------------------------------- /test/fixture/misMatchTolerance/custom-outside.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zinserjan/wdio-visual-regression-service/b2e67ffc7f11cb5597e5c10e35ddac37858662f1/test/fixture/misMatchTolerance/custom-outside.png -------------------------------------------------------------------------------- /test/fixture/misMatchTolerance/custom-within.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zinserjan/wdio-visual-regression-service/b2e67ffc7f11cb5597e5c10e35ddac37858662f1/test/fixture/misMatchTolerance/custom-within.png -------------------------------------------------------------------------------- /test/fixture/misMatchTolerance/default-outside.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zinserjan/wdio-visual-regression-service/b2e67ffc7f11cb5597e5c10e35ddac37858662f1/test/fixture/misMatchTolerance/default-outside.png -------------------------------------------------------------------------------- /test/fixture/misMatchTolerance/default-within.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zinserjan/wdio-visual-regression-service/b2e67ffc7f11cb5597e5c10e35ddac37858662f1/test/fixture/misMatchTolerance/default-within.png -------------------------------------------------------------------------------- /test/helper/compareImages.js: -------------------------------------------------------------------------------- 1 | import { 2 | assert 3 | } from 'chai'; 4 | import resemble from 'node-resemble-js'; 5 | 6 | export default function compareImages(image1, image2) { 7 | return new Promise((resolve) => { 8 | const image = resemble(image1).compareTo(image2); 9 | image.onComplete((data) => { 10 | assert.isTrue(data.isSameDimensions); 11 | assert.closeTo(Number(data.misMatchPercentage), 0, 0.0001); 12 | resolve(); 13 | }); 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /test/helper/compareMethod.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import { stub } from 'sinon'; 3 | 4 | export const before = stub(); 5 | export const beforeScreenshot = stub(); 6 | export const afterScreenshot = stub(); 7 | export const processScreenshot = stub(); 8 | export const after = stub(); 9 | 10 | /** 11 | * Creates a wrapper around the compare method to simulate webdriverio's parent/child execution 12 | * - onPrepare & onComplete hooks are only executed on launcher process 13 | * - before, beforeScreenshot, afterScreenshot & after are only executed on worker processes 14 | */ 15 | export function createTestMethodInstance(Clazz, ...options) { 16 | const launcher = new Clazz(...options); 17 | const worker = new Clazz(...options); 18 | 19 | return { 20 | onPrepare: () => launcher.onPrepare(), 21 | before: (...args) => worker.before(...args), 22 | beforeScreenshot: (...args) => worker.beforeScreenshot(...args), 23 | afterScreenshot: (...args) => worker.afterScreenshot(...args), 24 | processScreenshot: (...args) => worker.processScreenshot(...args), 25 | after: (...args) => worker.after(...args), 26 | onComplete: () => launcher.onComplete() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/unit/methods/LocalCompare.test.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { assert } from 'chai'; 3 | import { stub } from 'sinon'; 4 | import fs from 'fs-extra'; 5 | 6 | import compareImages from '../../helper/compareImages'; 7 | 8 | import BaseCompare from '../../../src/methods/BaseCompare'; 9 | import LocalCompare from '../../../src/methods/LocalCompare'; 10 | 11 | const dirTmp = path.join(process.cwd(), '.tmp'); 12 | const dirFixture = path.join(__dirname, '../../fixture/'); 13 | 14 | 15 | async function readAsBase64(file) { 16 | // read binary data 17 | const content = await fs.readFile(file); 18 | // convert binary data to base64 encoded string 19 | return new Buffer(content).toString('base64'); 20 | } 21 | 22 | 23 | 24 | describe('LocalCompare', function () { 25 | beforeEach(async function () { 26 | await fs.remove(dirTmp); 27 | }); 28 | 29 | after(async function () { 30 | await fs.remove(dirTmp); 31 | }); 32 | 33 | it('creates a instance of BaseCompare', async function () { 34 | const localCompare = new LocalCompare(); 35 | assert.instanceOf(localCompare, BaseCompare, 'LocalCompare should extend BaseCompare'); 36 | }); 37 | 38 | context('processScreenshot', function () { 39 | beforeEach(async function() { 40 | this.screenshotFile = path.join(dirTmp, 'screenshot.png'); 41 | this.referencFile = path.join(dirTmp, 'reference.png'); 42 | this.diffFile = path.join(dirTmp, 'diff.png'); 43 | 44 | this.getScreenshotFile = stub().returns(this.screenshotFile); 45 | this.getReferenceFile = stub().returns(this.referencFile); 46 | this.getDiffFile = stub().returns(this.diffFile); 47 | 48 | this.localCompare = new LocalCompare({ 49 | screenshotName: this.getScreenshotFile, 50 | referenceName: this.getReferenceFile, 51 | diffName: this.getDiffFile, 52 | }); 53 | 54 | this.resultIdentical = { 55 | misMatchPercentage: 0, 56 | isWithinMisMatchTolerance: true, 57 | isSameDimensions: true, 58 | isExactSameImage: true 59 | }; 60 | }); 61 | 62 | it('creates the captured screenshot', async function () { 63 | const context = {}; 64 | const base64Screenshot = await readAsBase64(path.join(dirFixture, 'image/100x100.png')); 65 | 66 | await this.localCompare.processScreenshot(context, base64Screenshot); 67 | 68 | // check screenshot getter 69 | assert.strictEqual(this.getScreenshotFile.callCount, 1, 'Screenshot getter should be called once'); 70 | assert.isTrue(this.getScreenshotFile.calledWithExactly(context), 'Screenshot getter should receive context as arg'); 71 | 72 | // check if screenshot was created 73 | const existsScreenshot = await fs.exists(this.screenshotFile); 74 | assert.isTrue(existsScreenshot, 'Captured screenshot should exist'); 75 | }); 76 | 77 | it('creates a reference file for the first run', async function () { 78 | const context = {}; 79 | const base64Screenshot = await readAsBase64(path.join(dirFixture, 'image/100x100.png')); 80 | 81 | const results = await this.localCompare.processScreenshot(context, base64Screenshot); 82 | 83 | // check reference getter 84 | assert.strictEqual(this.getReferenceFile.callCount, 1, 'Reference getter should be called once'); 85 | assert.isTrue(this.getReferenceFile.calledWithExactly(context), 'Reference getter should receive context as arg'); 86 | 87 | // check image results 88 | assert.deepEqual(results, this.resultIdentical, 'Result should be reported'); 89 | 90 | // check if reference image was created 91 | const existsReference = await fs.exists(this.referencFile); 92 | assert.isTrue(existsReference, 'Reference screenshot should exist'); 93 | 94 | }); 95 | 96 | it('does not update the reference image when changes are in tolerance', async function () { 97 | const context = {}; 98 | const base64Screenshot = await readAsBase64(path.join(dirFixture, 'image/100x100.png')); 99 | 100 | // 1st run -> create reference 101 | const resultFirst = await this.localCompare.processScreenshot(context, base64Screenshot); 102 | 103 | // check reference getter 104 | assert.strictEqual(this.getReferenceFile.callCount, 1, 'Reference getter should be called once'); 105 | assert.isTrue(this.getReferenceFile.calledWithExactly(context), 'Reference getter should receive context as arg'); 106 | 107 | // check image results 108 | assert.deepEqual(resultFirst, this.resultIdentical, 'Result should be reported'); 109 | 110 | // check if reference was created 111 | const existsReference = await fs.exists(this.screenshotFile); 112 | assert.isTrue(existsReference, 'Captured screenshot should exist'); 113 | 114 | // check last modified 115 | const statsFirst = await fs.stat(this.referencFile); 116 | assert.isAbove(statsFirst.mtime.getTime(), 0); 117 | 118 | // 2nd run --> go against reference image 119 | const resultSecond = await this.localCompare.processScreenshot(context, base64Screenshot); 120 | 121 | // check reference getter 122 | assert.strictEqual(this.getReferenceFile.callCount, 2, 'Reference getter should be called once'); 123 | assert.isTrue(this.getReferenceFile.alwaysCalledWithExactly(context), 'Reference getter should receive context as arg'); 124 | 125 | // check if image is reported as same 126 | assert.deepEqual(resultSecond, this.resultIdentical, 'Result should be reported'); 127 | 128 | // check that reference was not updated 129 | const statsSecond = await fs.stat(this.referencFile); 130 | assert.strictEqual(statsSecond.mtime.getTime(), statsFirst.mtime.getTime(), 'File should not be modified'); 131 | }); 132 | 133 | it('creates a diff image when changes are not in tolerance', async function () { 134 | const context = {}; 135 | const base64ScreenshotReference = await readAsBase64(path.join(dirFixture, 'image/100x100.png')); 136 | const base64ScreenshotNew = await readAsBase64(path.join(dirFixture, 'image/100x100-rotated.png')); 137 | 138 | // 1st run -> create reference 139 | const resultFirst = await this.localCompare.processScreenshot(context, base64ScreenshotReference); 140 | 141 | // check reference getter 142 | assert.strictEqual(this.getReferenceFile.callCount, 1, 'Reference getter should be called once'); 143 | assert.isTrue(this.getReferenceFile.calledWithExactly(context), 'Reference getter should receive context as arg'); 144 | 145 | // check image results 146 | assert.deepEqual(resultFirst, this.resultIdentical, 'Result should be reported'); 147 | 148 | // check if reference was created 149 | const existsReference = await fs.exists(this.screenshotFile); 150 | assert.isTrue(existsReference, 'Captured screenshot should exist'); 151 | 152 | // check last modified 153 | const statsFirst = await fs.stat(this.referencFile); 154 | assert.isAbove(statsFirst.mtime.getTime(), 0); 155 | 156 | // 2nd run --> create diff image 157 | const resultSecond = await this.localCompare.processScreenshot(context, base64ScreenshotNew); 158 | 159 | // check diff getter 160 | assert.strictEqual(this.getDiffFile.callCount, 1, 'Diff getter should be called once'); 161 | assert.isTrue(this.getDiffFile.calledWithExactly(context), 'Diff getter should receive context as arg'); 162 | 163 | // check diff results 164 | assert.isAbove(resultSecond.misMatchPercentage, resultFirst.misMatchPercentage, 'Images should diff'); 165 | assert.isFalse(resultSecond.isExactSameImage, 'Images should diff'); 166 | assert.isFalse(resultSecond.isWithinMisMatchTolerance, 'Images should be marked as diff'); 167 | assert.isTrue(resultSecond.isSameDimensions, 'Image dimensioms should be the same'); 168 | 169 | // check if reference is still the same 170 | const statsSecond = await fs.stat(this.referencFile); 171 | assert.strictEqual(statsSecond.mtime.getTime(), statsFirst.mtime.getTime(), 'Reference file should be the same'); 172 | 173 | // check if diff image was created 174 | const existsDiff = await fs.exists(this.diffFile); 175 | assert.isTrue(existsDiff, 'Diff screenshot should exists'); 176 | 177 | // check if diff is correct 178 | await compareImages(this.diffFile, path.join(dirFixture, 'image/100x100-diff.png')) 179 | }); 180 | 181 | it('deletes existing diff image when image is in tolerance now', async function () { 182 | const base64ScreenshotReference = await readAsBase64(path.join(dirFixture, 'image/100x100.png')); 183 | const base64ScreenshotNew = await readAsBase64(path.join(dirFixture, 'image/100x100-rotated.png')); 184 | 185 | // 1st run -> create reference 186 | await this.localCompare.processScreenshot({}, base64ScreenshotReference); 187 | 188 | // 2nd run --> create diff image 189 | await this.localCompare.processScreenshot({}, base64ScreenshotNew); 190 | 191 | // check if diff image was created 192 | let existsDiff = await fs.exists(this.diffFile); 193 | assert.isTrue(existsDiff, 'Diff screenshot should exist'); 194 | 195 | // 3rd run --> delete existing diff 196 | const context = { 197 | options: { 198 | misMatchTolerance: 100, 199 | } 200 | }; 201 | await this.localCompare.processScreenshot(context, base64ScreenshotNew); 202 | 203 | // check if diff image was deleted 204 | existsDiff = await fs.exists(this.diffFile); 205 | assert.isFalse(existsDiff, 'Diff screenshot should no longer exist'); 206 | }); 207 | 208 | }); 209 | 210 | 211 | context('misMatchTolerance', function () { 212 | before(async function () { 213 | this.screenshotBase = await readAsBase64(path.join(dirFixture, 'misMatchTolerance/base.png')); 214 | 215 | this.screenshotToleranceDefaultWithin = await readAsBase64(path.join(dirFixture, 'misMatchTolerance/default-within.png')); 216 | this.screenshotToleranceDefaultOutside = await readAsBase64(path.join(dirFixture, 'misMatchTolerance/default-outside.png')); 217 | 218 | this.screenshotToleranceCustomWithin = await readAsBase64(path.join(dirFixture, 'misMatchTolerance/custom-within.png')); 219 | this.screenshotToleranceCustomOutside = await readAsBase64(path.join(dirFixture, 'misMatchTolerance/custom-outside.png')); 220 | 221 | }); 222 | 223 | beforeEach(async function () { 224 | this.screenshotFile = path.join(dirTmp, 'screenshot.png'); 225 | this.referencFile = path.join(dirTmp, 'reference.png'); 226 | this.diffFile = path.join(dirTmp, 'diff.png'); 227 | 228 | this.getScreenshotFile = stub().returns(this.screenshotFile); 229 | this.getReferenceFile = stub().returns(this.referencFile); 230 | this.getDiffFile = stub().returns(this.diffFile); 231 | }); 232 | 233 | 234 | context('uses default misMatchTolerance', function () { 235 | beforeEach(async function () { 236 | this.misMatchTolerance = 0.01; 237 | this.context = {}; 238 | 239 | this.localCompare = new LocalCompare({ 240 | screenshotName: this.getScreenshotFile, 241 | referenceName: this.getReferenceFile, 242 | diffName: this.getDiffFile, 243 | }); 244 | 245 | // 1st run -> create reference 246 | const resultFirst = await this.localCompare.processScreenshot({}, this.screenshotBase); 247 | 248 | // check if reference was created 249 | const existsReference = await fs.exists(this.screenshotFile); 250 | assert.isTrue(existsReference, 'Captured screenshot should exist'); 251 | }); 252 | 253 | it('reports equal when in tolerance', async function () { 254 | // compare screenshots 255 | const result = await this.localCompare.processScreenshot(this.context, this.screenshotToleranceDefaultWithin); 256 | 257 | // check diff results 258 | assert.isAtMost(result.misMatchPercentage, this.misMatchTolerance, 'Images should diff'); 259 | assert.isFalse(result.isExactSameImage, 'Images should diff'); 260 | assert.isTrue(result.isWithinMisMatchTolerance, 'Diff should be in tolerance'); 261 | 262 | // check if diff image was not created 263 | const existsDiff = await fs.exists(this.diffFile); 264 | assert.isFalse(existsDiff, 'Diff screenshot should not exist'); 265 | }); 266 | 267 | it('reports diff when NOT in tolerance', async function () { 268 | // compare screenshots 269 | const result = await this.localCompare.processScreenshot(this.context, this.screenshotToleranceDefaultOutside); 270 | 271 | // check diff results 272 | assert.isAbove(result.misMatchPercentage, this.misMatchTolerance, 'Images should diff'); 273 | assert.isFalse(result.isExactSameImage, 'Images should diff'); 274 | assert.isFalse(result.isWithinMisMatchTolerance, 'Images should be marked as diff'); 275 | 276 | // check if diff image was created 277 | const existsDiff = await fs.exists(this.diffFile); 278 | assert.isTrue(existsDiff, 'Diff screenshot should exist'); 279 | }); 280 | }); 281 | 282 | context('uses custom misMatchTolerance passed in constructor option', function () { 283 | beforeEach(async function () { 284 | this.misMatchTolerance = 0.25; 285 | this.context = {}; 286 | 287 | this.localCompare = new LocalCompare({ 288 | screenshotName: this.getScreenshotFile, 289 | referenceName: this.getReferenceFile, 290 | diffName: this.getDiffFile, 291 | misMatchTolerance: this.misMatchTolerance, 292 | }); 293 | 294 | // 1st run -> create reference 295 | const resultFirst = await this.localCompare.processScreenshot({}, this.screenshotBase); 296 | 297 | // check if reference was created 298 | const existsReference = await fs.exists(this.screenshotFile); 299 | assert.isTrue(existsReference, 'Captured screenshot should exist'); 300 | }); 301 | 302 | it('reports equal when in tolerance', async function () { 303 | // compare screenshots 304 | const result = await this.localCompare.processScreenshot(this.context, this.screenshotToleranceCustomWithin); 305 | 306 | // check diff results 307 | assert.isAtMost(result.misMatchPercentage, this.misMatchTolerance, 'Images should diff'); 308 | assert.isFalse(result.isExactSameImage, 'Images should diff'); 309 | assert.isTrue(result.isWithinMisMatchTolerance, 'Diff should be in tolerance'); 310 | 311 | // check if diff image was not created 312 | const existsDiff = await fs.exists(this.diffFile); 313 | assert.isFalse(existsDiff, 'Diff screenshot should not exist'); 314 | }); 315 | 316 | it('reports diff when NOT in tolerance', async function () { 317 | // compare screenshots 318 | const result = await this.localCompare.processScreenshot(this.context, this.screenshotToleranceCustomOutside); 319 | 320 | // check diff results 321 | assert.isAbove(result.misMatchPercentage, this.misMatchTolerance, 'Images should diff'); 322 | assert.isFalse(result.isExactSameImage, 'Images should diff'); 323 | assert.isFalse(result.isWithinMisMatchTolerance, 'Images should be marked as diff'); 324 | 325 | // check if diff image was created 326 | const existsDiff = await fs.exists(this.diffFile); 327 | assert.isTrue(existsDiff, 'Diff screenshot should exist'); 328 | }); 329 | }); 330 | 331 | context('uses custom misMatchTolerance passed in command options', function () { 332 | beforeEach(async function () { 333 | this.misMatchTolerance = 0.25; 334 | this.context = { 335 | options: { 336 | misMatchTolerance: this.misMatchTolerance, 337 | } 338 | }; 339 | 340 | this.localCompare = new LocalCompare({ 341 | screenshotName: this.getScreenshotFile, 342 | referenceName: this.getReferenceFile, 343 | diffName: this.getDiffFile, 344 | }); 345 | 346 | // 1st run -> create reference 347 | const resultFirst = await this.localCompare.processScreenshot({}, this.screenshotBase); 348 | 349 | // check if reference was created 350 | const existsReference = await fs.exists(this.screenshotFile); 351 | assert.isTrue(existsReference, 'Captured screenshot should exist'); 352 | }); 353 | 354 | it('reports equal when in tolerance', async function () { 355 | // compare screenshots 356 | const result = await this.localCompare.processScreenshot(this.context, this.screenshotToleranceCustomWithin); 357 | 358 | // check diff results 359 | assert.isAtMost(result.misMatchPercentage, this.misMatchTolerance, 'Images should diff'); 360 | assert.isFalse(result.isExactSameImage, 'Images should diff'); 361 | assert.isTrue(result.isWithinMisMatchTolerance, 'Diff should be in tolerance'); 362 | 363 | // check if diff image was not created 364 | const existsDiff = await fs.exists(this.diffFile); 365 | assert.isFalse(existsDiff, 'Diff screenshot should not exist'); 366 | }); 367 | 368 | it('reports diff when NOT in tolerance', async function () { 369 | // compare screenshots 370 | const result = await this.localCompare.processScreenshot(this.context, this.screenshotToleranceCustomOutside); 371 | 372 | // check diff results 373 | assert.isAbove(result.misMatchPercentage, this.misMatchTolerance, 'Images should diff'); 374 | assert.isFalse(result.isExactSameImage, 'Images should diff'); 375 | assert.isFalse(result.isWithinMisMatchTolerance, 'Images should be marked as diff'); 376 | 377 | // check if diff image was created 378 | const existsDiff = await fs.exists(this.diffFile); 379 | assert.isTrue(existsDiff, 'Diff screenshot should exist'); 380 | }); 381 | }); 382 | }); 383 | 384 | context('ignoreComparison', function () { 385 | before(async function () { 386 | this.screenshotRed = await readAsBase64(path.join(dirFixture, 'ignoreComparison/100x100-red.png')); 387 | this.screenshotRed2 = await readAsBase64(path.join(dirFixture, 'ignoreComparison/100x100-red2.png')); 388 | }); 389 | 390 | beforeEach(async function () { 391 | this.screenshotFile = path.join(dirTmp, 'screenshot.png'); 392 | this.referencFile = path.join(dirTmp, 'reference.png'); 393 | this.diffFile = path.join(dirTmp, 'diff.png'); 394 | 395 | this.getScreenshotFile = stub().returns(this.screenshotFile); 396 | this.getReferenceFile = stub().returns(this.referencFile); 397 | this.getDiffFile = stub().returns(this.diffFile); 398 | }); 399 | 400 | 401 | context('uses default ignoreComparison', function () { 402 | beforeEach(async function () { 403 | this.context = {}; 404 | 405 | this.localCompare = new LocalCompare({ 406 | screenshotName: this.getScreenshotFile, 407 | referenceName: this.getReferenceFile, 408 | diffName: this.getDiffFile, 409 | }); 410 | 411 | // 1st run -> create reference 412 | const resultFirst = await this.localCompare.processScreenshot({}, this.screenshotRed); 413 | 414 | // check if reference was created 415 | const existsReference = await fs.exists(this.screenshotFile); 416 | assert.isTrue(existsReference, 'Captured screenshot should exist'); 417 | }); 418 | 419 | it('reports diff when colors differs', async function () { 420 | // compare screenshots 421 | const result = await this.localCompare.processScreenshot(this.context, this.screenshotRed2); 422 | 423 | // check diff results 424 | assert.isAbove(result.misMatchPercentage, 0, 'Images should diff'); 425 | assert.isFalse(result.isExactSameImage, 'Images should diff'); 426 | assert.isFalse(result.isWithinMisMatchTolerance, 'Diff should not be in tolerance'); 427 | 428 | // check if diff image was created 429 | const existsDiff = await fs.exists(this.diffFile); 430 | assert.isTrue(existsDiff, 'Diff screenshot should exist'); 431 | }); 432 | }); 433 | 434 | context('uses custom ignoreComparison passed in constructor option', function () { 435 | beforeEach(async function () { 436 | this.ignoreComparison = 'colors'; 437 | this.context = {}; 438 | 439 | this.localCompare = new LocalCompare({ 440 | screenshotName: this.getScreenshotFile, 441 | referenceName: this.getReferenceFile, 442 | diffName: this.getDiffFile, 443 | ignoreComparison: this.ignoreComparison, 444 | }); 445 | 446 | // 1st run -> create reference 447 | const resultFirst = await this.localCompare.processScreenshot({}, this.screenshotRed); 448 | 449 | // check if reference was created 450 | const existsReference = await fs.exists(this.screenshotFile); 451 | assert.isTrue(existsReference, 'Captured screenshot should exist'); 452 | }); 453 | 454 | it('reports equal with ignoreComparison=colors when colors differs', async function () { 455 | // compare screenshots 456 | const result = await this.localCompare.processScreenshot(this.context, this.screenshotRed2); 457 | // check diff results 458 | assert.isTrue(result.isExactSameImage, 'Images should not diff'); 459 | assert.isTrue(result.isWithinMisMatchTolerance, 'Diff should be in tolerance'); 460 | 461 | // check if diff image was not created 462 | const existsDiff = await fs.exists(this.diffFile); 463 | assert.isFalse(existsDiff, 'Diff screenshot should not exist'); 464 | }); 465 | }); 466 | 467 | context('uses custom ignoreComparison passed in command options', function () { 468 | beforeEach(async function () { 469 | this.ignoreComparison = 'colors'; 470 | this.context = { 471 | options: { 472 | ignoreComparison: this.ignoreComparison, 473 | } 474 | }; 475 | 476 | this.localCompare = new LocalCompare({ 477 | screenshotName: this.getScreenshotFile, 478 | referenceName: this.getReferenceFile, 479 | diffName: this.getDiffFile, 480 | }); 481 | 482 | // 1st run -> create reference 483 | const resultFirst = await this.localCompare.processScreenshot({}, this.screenshotRed); 484 | 485 | // check if reference was created 486 | const existsReference = await fs.exists(this.screenshotFile); 487 | assert.isTrue(existsReference, 'Captured screenshot should exist'); 488 | }); 489 | 490 | it('reports equal with ignoreComparison=colors when colors differs', async function () { 491 | // compare screenshots 492 | const result = await this.localCompare.processScreenshot(this.context, this.screenshotRed2); 493 | 494 | // check diff results 495 | assert.isTrue(result.isExactSameImage, 'Images should not diff'); 496 | assert.isTrue(result.isWithinMisMatchTolerance, 'Diff should be in tolerance'); 497 | 498 | // check if diff image was not created 499 | const existsDiff = await fs.exists(this.diffFile); 500 | assert.isFalse(existsDiff, 'Diff screenshot should not exist'); 501 | }); 502 | }); 503 | }); 504 | }); 505 | -------------------------------------------------------------------------------- /test/unit/methods/SaveScreenshot.test.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { assert } from 'chai'; 3 | import { stub } from 'sinon'; 4 | import fs from 'fs-extra'; 5 | 6 | import BaseCompare from '../../../src/methods/BaseCompare'; 7 | import SaveScreenshot from '../../../src/methods/SaveScreenshot'; 8 | 9 | const dirTmp = path.join(process.cwd(), '.tmp'); 10 | const dirFixture = path.join(__dirname, '../../fixture/'); 11 | 12 | 13 | async function readAsBase64(file) { 14 | // read binary data 15 | const content = await fs.readFile(file); 16 | // convert binary data to base64 encoded string 17 | return new Buffer(content).toString('base64'); 18 | } 19 | 20 | 21 | function pause(ms) { 22 | return new Promise((resolve) => { 23 | setTimeout(resolve, ms); 24 | }); 25 | } 26 | 27 | describe('SaveScreenshot', function () { 28 | beforeEach(async function () { 29 | await fs.remove(dirTmp); 30 | }); 31 | 32 | after(async function () { 33 | await fs.remove(dirTmp); 34 | }); 35 | 36 | it('creates a instance of BaseCompare', async function () { 37 | const saveScreenshot = new SaveScreenshot(); 38 | assert.instanceOf(saveScreenshot, BaseCompare, 'SaveScreenshot should extend BaseCompare'); 39 | }); 40 | 41 | context('processScreenshot', function () { 42 | beforeEach(async function() { 43 | this.referencFile = path.join(dirTmp, 'reference.png'); 44 | this.getReferenceFile = stub().returns(this.referencFile); 45 | 46 | this.saveScreenshot = new SaveScreenshot({ 47 | screenshotName: this.getReferenceFile, 48 | }); 49 | 50 | this.resultIdentical = { 51 | misMatchPercentage: 0, 52 | isWithinMisMatchTolerance: true, 53 | isSameDimensions: true, 54 | isExactSameImage: true 55 | }; 56 | }); 57 | 58 | it('creates a reference file for the first run', async function () { 59 | const context = {}; 60 | const base64Screenshot = await readAsBase64(path.join(dirFixture, 'image/100x100.png')); 61 | 62 | const results = await this.saveScreenshot.processScreenshot(context, base64Screenshot); 63 | 64 | // check reference getter 65 | assert.strictEqual(this.getReferenceFile.callCount, 1, 'Reference getter should be called once'); 66 | assert.isTrue(this.getReferenceFile.calledWithExactly(context), 'Reference getter should receive context as arg'); 67 | 68 | // check image results 69 | assert.deepEqual(results, this.resultIdentical, 'Result should be reported'); 70 | 71 | // check if reference image was created 72 | const existsReference = await fs.exists(this.referencFile); 73 | assert.isTrue(existsReference, 'Reference screenshot should exist'); 74 | 75 | }); 76 | 77 | it('updates the reference image when changes are in tolerance', async function () { 78 | const context = {}; 79 | const base64Screenshot = await readAsBase64(path.join(dirFixture, 'image/100x100.png')); 80 | 81 | // 1st run -> create reference 82 | const resultFirst = await this.saveScreenshot.processScreenshot(context, base64Screenshot); 83 | 84 | // check reference getter 85 | assert.strictEqual(this.getReferenceFile.callCount, 1, 'Reference getter should be called once'); 86 | assert.isTrue(this.getReferenceFile.calledWithExactly(context), 'Reference getter should receive context as arg'); 87 | 88 | // check image results 89 | assert.deepEqual(resultFirst, this.resultIdentical, 'Result should be reported'); 90 | 91 | // check if reference was created 92 | const existsReference = await fs.exists(this.referencFile); 93 | assert.isTrue(existsReference, 'Captured screenshot should exist'); 94 | 95 | // check last modified 96 | const statsFirst = await fs.stat(this.referencFile); 97 | assert.isAbove(statsFirst.mtime.getTime(), 0); 98 | 99 | // wait to get a different last modified time 100 | await pause(1000); 101 | 102 | // 2nd run --> update reference image 103 | const resultSecond = await this.saveScreenshot.processScreenshot(context, base64Screenshot); 104 | 105 | // check reference getter 106 | assert.strictEqual(this.getReferenceFile.callCount, 2, 'Reference getter should be called once'); 107 | assert.isTrue(this.getReferenceFile.alwaysCalledWithExactly(context), 'Reference getter should receive context as arg'); 108 | 109 | // check if image is reported as same 110 | assert.deepEqual(resultSecond, this.resultIdentical, 'Result should be reported'); 111 | 112 | // check if reference was updated 113 | const statsSecond = await fs.stat(this.referencFile); 114 | assert.isAbove(statsSecond.mtime.getTime(), statsFirst.mtime.getTime(), 'File should be modified'); 115 | }); 116 | 117 | it('updates the reference image when changes are not in tolerance', async function () { 118 | const context = {}; 119 | const base64ScreenshotReference = await readAsBase64(path.join(dirFixture, 'image/100x100.png')); 120 | const base64ScreenshotNew = await readAsBase64(path.join(dirFixture, 'image/100x100-rotated.png')); 121 | 122 | // 1st run -> create reference 123 | const resultFirst = await this.saveScreenshot.processScreenshot(context, base64ScreenshotReference); 124 | 125 | // check reference getter 126 | assert.strictEqual(this.getReferenceFile.callCount, 1, 'Reference getter should be called once'); 127 | assert.isTrue(this.getReferenceFile.calledWithExactly(context), 'Reference getter should receive context as arg'); 128 | 129 | // check image results 130 | assert.deepEqual(resultFirst, this.resultIdentical, 'Result should be reported'); 131 | 132 | // check if reference was created 133 | const existsReference = await fs.exists(this.referencFile); 134 | assert.isTrue(existsReference, 'Captured screenshot should exist'); 135 | 136 | // check last modified 137 | const statsFirst = await fs.stat(this.referencFile); 138 | assert.isAbove(statsFirst.mtime.getTime(), 0); 139 | 140 | // wait to get a different last modified time 141 | await pause(1000); 142 | 143 | // 2nd run --> update refernece with diff image 144 | const resultSecond = await this.saveScreenshot.processScreenshot(context, base64ScreenshotNew); 145 | 146 | // check reference getter 147 | assert.strictEqual(this.getReferenceFile.callCount, 2, 'Reference getter should be called once'); 148 | assert.isTrue(this.getReferenceFile.alwaysCalledWithExactly(context), 'Reference getter should receive context as arg'); 149 | 150 | // check if image is reported as same (we don't care about the results here, as we are just overwriting all reference screenshots) 151 | assert.deepEqual(resultSecond, this.resultIdentical, 'Result should be reported'); 152 | 153 | // check if reference was updated 154 | const statsSecond = await fs.stat(this.referencFile); 155 | assert.isAbove(statsSecond.mtime.getTime(), statsFirst.mtime.getTime(), 'File should be modified'); 156 | }); 157 | 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /test/unit/methods/Spectre.test.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { assert } from 'chai'; 3 | import { stub } from 'sinon'; 4 | import fs from 'fs-extra'; 5 | import nock from 'nock'; 6 | import BaseCompare from '../../../src/methods/BaseCompare'; 7 | import { Spectre } from '../../../src/compare'; 8 | import { createTestMethodInstance } from '../../helper/compareMethod'; 9 | 10 | 11 | const dirFixture = path.join(__dirname, '../../fixture/'); 12 | 13 | 14 | async function readAsBase64(file) { 15 | // read binary data 16 | const content = await fs.readFile(file); 17 | // convert binary data to base64 encoded string 18 | return new Buffer(content).toString('base64'); 19 | } 20 | 21 | describe('Spectre', function () { 22 | beforeEach(function () { 23 | nock.disableNetConnect(); 24 | 25 | this.url = 'http://localhost:3000'; 26 | this.project = 'test-project'; 27 | this.suite = 'test-suite'; 28 | this.test = 'MyTest'; 29 | this.browser = 'Chrome'; 30 | this.size = '100px'; 31 | this.getTest = stub().returns(this.test); 32 | this.getBrowser = stub().returns(this.browser); 33 | this.getSize = stub().returns(this.size); 34 | }); 35 | 36 | afterEach(function () { 37 | nock.enableNetConnect(); 38 | nock.cleanAll(); 39 | }); 40 | 41 | it('creates a instance of BaseCompare', async function () { 42 | const instance = new Spectre({ 43 | url: this.url, 44 | project: this.project, 45 | suite: this.suite, 46 | test: this.getTest, 47 | browser: this.getBrowser, 48 | size: this.getSize, 49 | }); 50 | assert.instanceOf(instance, BaseCompare, 'Spectre should extend BaseCompare'); 51 | }); 52 | 53 | it('creates test run and uploads screenshots', async function () { 54 | const base64Screenshot1 = await readAsBase64(path.join(dirFixture, 'image/100x100.png')); 55 | const base64Screenshot2 = await readAsBase64(path.join(dirFixture, 'image/100x100-rotated.png')); 56 | let requestBody = null; 57 | 58 | const instance = createTestMethodInstance(Spectre, { 59 | url: this.url, 60 | project: this.project, 61 | suite: this.suite, 62 | test: this.getTest, 63 | browser: this.getBrowser, 64 | size: this.getSize, 65 | }); 66 | 67 | nock(this.url) 68 | .post('/runs', function (body) { 69 | requestBody = body; 70 | return true; 71 | }) 72 | .once() 73 | .reply(200, { 74 | id: 112, 75 | suite_id: 44, 76 | created_at: '2018-03-22T15:43:11.720Z', 77 | updated_at: '2018-03-22T15:43:11.720Z', 78 | sequential_id: 1, 79 | url: `/projects/${this.project}/suites/${this.suite}/runs/1` 80 | }); 81 | 82 | await instance.onPrepare(); 83 | 84 | assert.isNotNull(requestBody, "Request should be finished!"); 85 | assert.include(requestBody, 'name="project"'); 86 | assert.include(requestBody, this.project); 87 | assert.include(requestBody, 'name="suite"'); 88 | assert.include(requestBody, this.suite); 89 | 90 | nock(this.url) 91 | .post('/tests') 92 | .once() 93 | .reply(200, { 94 | id: 1128, 95 | name: this.test, 96 | browser: this.browser, 97 | size: this.size, 98 | run_id: 111, 99 | diff: 0, 100 | created_at: '2018-03-22T15:34:15.036Z', 101 | updated_at: '2018-03-22T15:34:15.206Z', 102 | screenshot_uid: '2018/03/22/6qiv4yz8n9_1128_test.png', 103 | screenshot_baseline_uid: '2018/03/22/9c4qcvtiyf_1128_baseline.png', 104 | screenshot_diff_uid: '2018/03/22/34jop3ovj7_1128_diff.png', 105 | key: 'test-project-test-suite-mytest-chrome-100px', 106 | pass: true, 107 | source_url: '', 108 | fuzz_level: '30%', 109 | highlight_colour: 'ff00ff', 110 | crop_area: '', 111 | url: `/projects/${this.project}/suites/${this.suite}/runs/2#test_1128` 112 | }); 113 | 114 | const context = {}; 115 | 116 | const resultIdentitical = await instance.processScreenshot(context, base64Screenshot1); 117 | 118 | assert.strictEqual(this.getTest.callCount, 1, 'test getter should be called once'); 119 | assert.isTrue(this.getTest.calledWithExactly(context), 'test getter should receive context as arg'); 120 | assert.strictEqual(this.getBrowser.callCount, 1, 'browser getter should be called once'); 121 | assert.isTrue(this.getBrowser.calledWithExactly(context), 'browser getter should receive context as arg'); 122 | assert.strictEqual(this.getSize.callCount, 1, 'size getter should be called once'); 123 | assert.isTrue(this.getSize.calledWithExactly(context), 'size getter should receive context as arg'); 124 | 125 | assert.deepEqual(resultIdentitical, { 126 | misMatchPercentage: 0, 127 | isWithinMisMatchTolerance: true, 128 | isSameDimensions: true, 129 | isExactSameImage: true 130 | }, 'Result should be reported'); 131 | 132 | nock(this.url) 133 | .post('/tests') 134 | .once() 135 | .reply(200, { 136 | id: 1131, 137 | name: this.test, 138 | browser: this.browser, 139 | size: this.size, 140 | run_id: 112, 141 | diff: 4.56, 142 | created_at: "2018-03-22T15:43:12.792Z", 143 | updated_at: "2018-03-22T15:43:12.996Z", 144 | screenshot_uid: "2018/03/22/396hsmk5pv_1131_test.png", 145 | screenshot_baseline_uid: "2018/03/22/81gnw0lg50_1131_baseline.png", 146 | screenshot_diff_uid: "2018/03/22/17alxnmrin_1131_diff.png", 147 | key: "test-project-test-suite-mytest-chrome-100px", 148 | pass: false, 149 | source_url: "", 150 | fuzz_level: "30%", 151 | highlight_colour: "ff00ff", 152 | crop_area: "", 153 | url: `/projects/${this.project}/suites/${this.suite}/runs/1#test_1131` 154 | }); 155 | 156 | const resultDifferent = await instance.processScreenshot(context, base64Screenshot2); 157 | 158 | assert.strictEqual(this.getTest.callCount, 2, 'test getter should be called again'); 159 | assert.isTrue(this.getTest.calledWithExactly(context), 'test getter should receive context as arg'); 160 | assert.strictEqual(this.getBrowser.callCount, 2, 'browser getter should be called again'); 161 | assert.isTrue(this.getBrowser.calledWithExactly(context), 'browser getter should receive context as arg'); 162 | assert.strictEqual(this.getSize.callCount, 2, 'size getter should be called again'); 163 | assert.isTrue(this.getSize.calledWithExactly(context), 'size getter should receive context as arg'); 164 | 165 | assert.deepEqual(resultDifferent, { 166 | misMatchPercentage: 4.56, 167 | isWithinMisMatchTolerance: false, 168 | isSameDimensions: true, 169 | isExactSameImage: false, 170 | }, 'Result should be reported'); 171 | 172 | await instance.onComplete(); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /test/wdio/VisualRegressionLauncher.test.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { assert } from 'chai'; 3 | import { stub, spy } from 'sinon'; 4 | 5 | import { config } from './wdio.config'; 6 | import * as compareStubs from '../helper/compareMethod'; 7 | 8 | const TYPE_ELEMENT = 'element'; 9 | const TYPE_DOCUMENT = 'document'; 10 | const TYPE_VIEWPORT = 'viewport'; 11 | 12 | function assertScreenshotContext(options, type, screenshotContext) { 13 | assert.isObject(screenshotContext); 14 | 15 | // check type 16 | assert.property(screenshotContext, 'type'); 17 | assert.strictEqual(screenshotContext.type, type); 18 | 19 | // check browser 20 | const { browser } = screenshotContext; 21 | assertBrowser(browser); 22 | 23 | // check desiredCapabilities 24 | const { desiredCapabilities } = screenshotContext; 25 | assertDesiredCapabilities(desiredCapabilities); 26 | 27 | // check test 28 | const { test } = screenshotContext; 29 | assertTest(test); 30 | 31 | // check suite 32 | const { suite } = screenshotContext; 33 | assertTest(suite); 34 | 35 | // check meta 36 | const { meta } = screenshotContext; 37 | assertMeta(meta, type, options); 38 | 39 | // check options 40 | assert.property(screenshotContext, 'options'); 41 | assert.deepEqual(screenshotContext.options, options); 42 | } 43 | 44 | 45 | function assertBrowser(browser) { 46 | assert.isObject(browser, 'browser must be an object'); 47 | // test browser property 48 | assert.property(browser, 'name'); 49 | assert.isString(browser.name); 50 | 51 | assert.property(browser, 'version'); 52 | assert.isString(browser.version); 53 | 54 | assert.property(browser, 'userAgent'); 55 | assert.isString(browser.userAgent); 56 | } 57 | 58 | 59 | function assertDesiredCapabilities(desiredCapabilities) { 60 | assert.isObject(desiredCapabilities, 'desiredCapabilities must be an object'); 61 | assert.deepEqual(desiredCapabilities, config.capabilities[0]); 62 | } 63 | 64 | function assertTest(test) { 65 | assert.isObject(test, 'test must be an object'); 66 | assert.property(test, 'title'); 67 | assert.isString(test.title); 68 | 69 | assert.property(test, 'parent'); 70 | assert.isString(test.parent); 71 | 72 | assert.property(test, 'file'); 73 | assert.isString(test.file); 74 | } 75 | 76 | 77 | function assertMeta(meta, type, options) { 78 | assert.isObject(meta, 'meta must be an object'); 79 | 80 | assert.property(meta, 'url'); 81 | assert.isString(meta.url); 82 | 83 | if (type === TYPE_ELEMENT) { 84 | assert.property(meta, 'element'); 85 | assert.isTrue(typeof meta.element === 'string' || Array.isArray(meta.element), 'element should be a string or array'); 86 | } 87 | 88 | if (typeof options.exclude !== 'undefined') { 89 | assert.property(meta, 'exclude'); 90 | assert.strictEqual(meta.exclude, options.exclude); 91 | } 92 | 93 | if (typeof options.hide !== 'undefined') { 94 | assert.property(meta, 'hide'); 95 | assert.strictEqual(meta.hide, options.hide); 96 | } 97 | 98 | if (typeof options.remove !== 'undefined') { 99 | assert.property(meta, 'remove'); 100 | assert.strictEqual(meta.remove, options.remove); 101 | } 102 | 103 | if (typeof options.viewports !== 'undefined') { 104 | assert.property(meta, 'viewport'); 105 | assert.oneOf(meta.viewport, options.viewports); 106 | } 107 | 108 | if (typeof options.orientations !== 'undefined') { 109 | assert.property(meta, 'orientation'); 110 | assert.oneOf(options.orientation, options.orientations); 111 | } 112 | } 113 | 114 | 115 | describe('VisualRegressionLauncher - custom compare method & hooks', function () { 116 | 117 | beforeEach(function () { 118 | const { before, beforeScreenshot, afterScreenshot, processScreenshot, after } = compareStubs; 119 | 120 | this.beforeStub = before; 121 | this.beforeScreenshotStub = beforeScreenshot; 122 | this.afterScreenshotStub = afterScreenshot; 123 | this.processScreenshotStub = processScreenshot; 124 | this.afterStub = after; 125 | 126 | this.beforeScreenshotStub.reset(); 127 | this.beforeScreenshotStub.resetBehavior(); 128 | this.afterScreenshotStub.reset(); 129 | this.afterScreenshotStub.resetBehavior(); 130 | this.processScreenshotStub.reset(); 131 | this.processScreenshotStub.resetBehavior(); 132 | 133 | this.expectedResult = { 134 | misMatchPercentage: 10.5, 135 | isWithinMisMatchTolerance: false, 136 | isSameDimensions: true, 137 | isExactSameImage: false 138 | }; 139 | 140 | }); 141 | 142 | it.skip('calls onPrepare hook', async function () { 143 | // untestable cause this will be executed before tests on launcher process :D 144 | }); 145 | 146 | it('calls before hook', async function () { 147 | assert.isTrue(this.beforeStub.calledOnce, 'before hook should be called once'); 148 | const beforeArgs = this.beforeStub.args[0]; 149 | assert.lengthOf(beforeArgs, 1, 'before hook should receive 1 argument'); 150 | const [context] = beforeArgs; 151 | assert.isObject(context, 'befores hook argument context should be an object'); 152 | 153 | // test browser 154 | const { browser } = context; 155 | assertBrowser(browser); 156 | 157 | // test desiredCapabilities 158 | const { desiredCapabilities } = context; 159 | assertDesiredCapabilities(desiredCapabilities); 160 | 161 | // test specs 162 | const { specs } = context; 163 | assert.isArray(specs); 164 | }); 165 | 166 | 167 | it('calls beforeScreenshot hook', async function () { 168 | assert.isTrue(this.beforeScreenshotStub.notCalled); 169 | 170 | const options = {}; 171 | await browser.checkDocument(); 172 | 173 | assert.isTrue(this.beforeScreenshotStub.calledBefore(this.afterScreenshotStub)); 174 | 175 | // each hook should be called once for this command 176 | assert.isTrue(this.beforeScreenshotStub.calledOnce, 'beforeScreenshot hook should be called once'); 177 | 178 | const beforeScreenshotArgs = this.beforeScreenshotStub.args[0]; 179 | 180 | assert.lengthOf(beforeScreenshotArgs, 1); 181 | 182 | const [screenshotContext] = beforeScreenshotArgs; 183 | 184 | assertScreenshotContext(options, TYPE_DOCUMENT, screenshotContext); 185 | }); 186 | 187 | 188 | it('calls afterScreenshot hook', async function () { 189 | assert.isTrue(this.afterScreenshotStub.notCalled); 190 | 191 | const options = {}; 192 | const results = await browser.checkDocument(); 193 | 194 | assert.isTrue(this.afterScreenshotStub.calledAfter(this.beforeScreenshotStub)); 195 | 196 | // each hook should be called once for this command 197 | assert.isTrue(this.afterScreenshotStub.calledOnce, 'afterScreenshot hook should be called once'); 198 | 199 | const afterScreenshotArgs = this.afterScreenshotStub.args[0]; 200 | 201 | assert.lengthOf(afterScreenshotArgs, 2); 202 | 203 | const [screenshotContext, base64Screenshot] = afterScreenshotArgs; 204 | assertScreenshotContext(options, TYPE_DOCUMENT, screenshotContext); 205 | assert.isString(base64Screenshot, 'Screenshot should be a base64 string'); 206 | }); 207 | 208 | it('calls processScreenshot hook', async function () { 209 | assert.isTrue(this.processScreenshotStub.notCalled); 210 | 211 | const options = {}; 212 | const results = await browser.checkDocument(); 213 | 214 | assert.isTrue(this.processScreenshotStub.calledAfter(this.afterScreenshotStub)); 215 | 216 | // each hook should be called once for this command 217 | assert.isTrue(this.processScreenshotStub.calledOnce, 'processScreenshot hook should be called once'); 218 | 219 | const processScreenshotArgs = this.processScreenshotStub.args[0]; 220 | 221 | assert.lengthOf(processScreenshotArgs, 2); 222 | 223 | const [screenshotContext, base64Screenshot] = processScreenshotArgs; 224 | assertScreenshotContext(options, TYPE_DOCUMENT, screenshotContext); 225 | assert.isString(base64Screenshot, 'Screenshot should be a base64 string'); 226 | }); 227 | 228 | it.skip('calls onComplete hook', function () { 229 | // untestable cause this will be executed after tests on launcher process :D 230 | }); 231 | 232 | it('returns result from processScreenshot hook', async function () { 233 | const expectedResult = { 234 | misMatchPercentage: 10.05, 235 | isWithinMisMatchTolerance: false, 236 | isSameDimensions: true, 237 | isExactSameImage: true, 238 | }; 239 | 240 | this.processScreenshotStub.returns(expectedResult); 241 | 242 | const options = {}; 243 | const results = await browser.checkDocument(); 244 | 245 | assert.isArray(results, 'results should be an array of results'); 246 | assert.lengthOf(results, 1); 247 | 248 | const [result] = results; 249 | 250 | assert.strictEqual(result, expectedResult); 251 | }); 252 | 253 | context('viewportChangePause', function () { 254 | 255 | beforeEach(function () { 256 | spy(browser, 'pause'); 257 | spy(browser, 'setViewportSize'); 258 | }); 259 | 260 | afterEach(function () { 261 | browser.pause.restore(); 262 | browser.setViewportSize.restore(); 263 | }); 264 | 265 | it('uses a custom delay passed in constructor options', async function () { 266 | await browser.checkViewport({ 267 | viewports: [{ width: 500, height: 1000 }], 268 | }); 269 | 270 | assert.isTrue(browser.pause.calledAfter(browser.setViewportSize), 'browser.pause should have been after setViewportSize'); 271 | assert.isTrue(browser.pause.calledWith(250), 'browser.pause should have been called called with 250'); 272 | }); 273 | 274 | it('uses a custom delay passed in command options', async function () { 275 | await browser.checkViewport({ 276 | viewports: [{ width: 500, height: 1000 }], 277 | viewportChangePause: 1500, 278 | }); 279 | 280 | assert.isTrue(browser.pause.calledAfter(browser.setViewportSize), 'browser.pause should have been called after setViewportSize'); 281 | assert.isTrue(browser.pause.calledWith(1500), 'browser.pause should have been called with 1500'); 282 | }); 283 | }); 284 | 285 | context('viewports', function () { 286 | 287 | beforeEach(function () { 288 | spy(browser, 'setViewportSize'); 289 | }); 290 | 291 | afterEach(function () { 292 | browser.setViewportSize.restore(); 293 | }); 294 | 295 | it('uses viewport passed in constructor options', async function () { 296 | const expectedViewport = browser.options.visualRegression.viewports[0]; 297 | await browser.checkViewport(); 298 | 299 | const viewport = browser.setViewportSize.args[0][0]; 300 | assert.deepEqual(viewport, expectedViewport, 'browser.setViewportSize should have been called with global value'); 301 | }); 302 | 303 | it('uses a custom viewport passed in command options', async function () { 304 | const expectedViewport = { width: 500, height: 600 }; 305 | 306 | await browser.checkViewport({ 307 | viewports: [expectedViewport], 308 | }); 309 | 310 | const viewport = browser.setViewportSize.args[0][0]; 311 | assert.deepEqual(viewport, expectedViewport, 'browser.setViewportSize should have been called with custom value'); 312 | }); 313 | }); 314 | 315 | // it('calls after hook', async function () { 316 | // // NOTE: can not test this, cause this will be executed after tests.. 317 | // assert.isTrue(this.afterStub.calledOnce, 'after hook should be called once'); 318 | // const afterArgs = this.afterStub.args[0]; 319 | // assert.lengthOf(afterArgs, 0, 'after hook shouldnt receive arguments'); 320 | // }); 321 | 322 | 323 | }); 324 | -------------------------------------------------------------------------------- /test/wdio/wdio.config.js: -------------------------------------------------------------------------------- 1 | require("babel-register"); 2 | 3 | var path = require('path'); 4 | 5 | var compareMethod = require('../helper/compareMethod'); 6 | 7 | exports.config = { 8 | specs: [ 9 | path.join(__dirname, '*.test.js') 10 | ], 11 | capabilities: [ 12 | { 13 | browserName: 'phantomjs', 14 | 'phantomjs.binary.path': require('phantomjs').path, 15 | } 16 | ], 17 | sync: false, 18 | logLevel: 'silent', 19 | coloredLogs: true, 20 | 21 | baseUrl: 'http://webdriver.io', 22 | 23 | waitforTimeout: 10000, 24 | connectionRetryTimeout: 90000, 25 | connectionRetryCount: 3, 26 | 27 | framework: 'mocha', 28 | mochaOpts: { 29 | ui: 'bdd', 30 | timeout: 60000, 31 | compilers: [ 32 | 'js:babel-register' 33 | ], 34 | }, 35 | services: [ 36 | 'selenium-standalone', 37 | require('../../src') 38 | ], 39 | visualRegression: { 40 | compare: compareMethod, 41 | viewportChangePause: 250, 42 | viewports: [{ width: 600, height: 1000 }], 43 | }, 44 | // Options for selenium-standalone 45 | // Path where all logs from the Selenium server should be stored. 46 | seleniumLogs: './logs/', 47 | } 48 | --------------------------------------------------------------------------------