├── .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 |
--------------------------------------------------------------------------------