├── .eslintignore
├── .eslintrc
├── .gitattributes
├── .github
└── workflows
│ ├── release.yml
│ └── testing.yml
├── .gitignore
├── Makefile
├── README.md
├── bin
└── cli.js
├── e2e
├── .testcaferc.json
├── cases
│ ├── cli-testcafe-blink-diff.tests.js
│ ├── cli-testcafe.tests.js
│ ├── main.test.js
│ └── setting.test.js
├── file
│ ├── cases
│ │ └── takesnapshot.test.js
│ └── screens
│ │ ├── Testing_cli_testcafe-blink-diff_with
│ │ └── expects
│ │ │ ├── actual.png
│ │ │ ├── base.png
│ │ │ └── thumbnails
│ │ │ ├── actual.png
│ │ │ └── base.png
│ │ └── Testing_cli_testcafe-blink-diff_without
│ │ └── expects
│ │ ├── actual.png
│ │ └── base.png
├── lib.js
└── public
│ └── index.html
├── lib
├── builder.js
├── image-diff.js
├── index.html
└── index.js
├── package.json
├── screenshot.png
├── src
├── index.js
└── modal.js
└── typings
└── index.d.ts
/.eslintignore:
--------------------------------------------------------------------------------
1 | dist
2 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "es6": true,
4 | "node": true,
5 | "browser": true
6 | },
7 | "globals": {
8 | "fixture": "readonly",
9 | "test": "readonly"
10 | },
11 | "extends": "airbnb-base",
12 | "parserOptions": {
13 | "sourceType": "module"
14 | },
15 | "rules" : {
16 | "max-len": ["error", {
17 | "code": 150
18 | }],
19 | "arrow-parens": ["error", "as-needed"],
20 | "no-multi-assign": 0,
21 | "strict": 0,
22 | "no-console": 0,
23 | "prefer-destructuring": 0,
24 | "function-paren-newline": 0,
25 | "global-require": 0,
26 | "prefer-spread": 0,
27 | "prefer-rest-params": 0,
28 | "prefer-arrow-callback": 0,
29 | "arrow-body-style": 0,
30 | "no-restricted-globals": 0,
31 | "consistent-return": 0,
32 | "no-param-reassign": 0,
33 | "no-underscore-dangle": 0,
34 | "import/no-unresolved": 0,
35 | "import/no-dynamic-require": 0,
36 | "import/no-extraneous-dependencies": 0
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | package-lock.json binary
2 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | Release:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v2
13 | with:
14 | persist-credentials: false
15 | fetch-depth: 0
16 | - name: Create version
17 | run: |
18 | git config user.name github-actions
19 | git config user.email github-actions@github.com
20 |
21 | make release
22 | env:
23 | USE_RELEASE_VERSION: ${{ secrets.RELEASE_VERSION }}
24 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
25 | - name: Push changes
26 | uses: ad-m/github-push-action@master
27 | with:
28 | github_token: ${{ secrets.GITHUB_TOKEN }}
29 | branch: ${{ github.ref }}
30 | tags: true
31 | - name: Publish version
32 | run: npm publish
33 |
--------------------------------------------------------------------------------
/.github/workflows/testing.yml:
--------------------------------------------------------------------------------
1 | name: build
2 | on: [push]
3 | jobs:
4 | test:
5 | name: test-${{ matrix.env.name }}-node${{ matrix.node }}-${{ matrix.env.browser }}
6 | strategy:
7 | matrix:
8 | env: [
9 | { name: ubuntu, os: ubuntu-latest, browser: chrome },
10 | { name: windows, os: windows-latest, browser: edge }
11 | ]
12 | node: [18, 20, 22]
13 | runs-on: ${{ matrix.env.os }}
14 | env:
15 | # XXX: adjust to node 18's defaults changes
16 | # https://stackoverflow.com/questions/77142563/nodejs-18-breaks-dns-resolution-of-localhost-from-127-0-0-1-to-1
17 | NODE_OPTIONS: --dns-result-order=ipv4first
18 | BASE_URL: http://localhost:3000
19 | BROWSER: ${{ matrix.env.browser }}
20 | WINDOW_DPI: 1
21 | steps:
22 | - uses: actions/checkout@v4
23 | - uses: actions/setup-node@v4
24 | with:
25 | node-version: ${{ matrix.node }}
26 | - run: npm i
27 | - name: Run test (Ubuntu)
28 | if: ${{ matrix.env.name == 'ubuntu' }}
29 | uses: DevExpress/testcafe-action@latest
30 | with:
31 | # XXX: default is false, and install latest TestCafe,
32 | # since this, ubuntu's tests run with latest TestCafe everytime
33 | skip-install: false
34 | args: "--config-file e2e/.testcaferc.json ${{ matrix.env.browser }}"
35 | - name: Run test (Windows)
36 | if: ${{ matrix.env.name == 'windows' }}
37 | run: npx testcafe --config-file e2e/.testcaferc.json ${{ matrix.env.browser }}
38 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.todo
2 | *.log
3 | .DS_Store
4 | dist
5 | generated
6 | e2e/file/screens
7 | e2e/generated-*
8 | e2e/screens
9 | node_modules
10 | package-lock.json
11 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | help: Makefile
2 | @awk -F':.*?##' '/^[a-z0-9\\%!:-]+:.*##/{gsub("%","*",$$1);gsub("\\\\",":*",$$1);printf "\033[36m%8s\033[0m %s\n",$$1,$$2}' $<
3 |
4 | ci: src deps clean ## Run CI scripts
5 | @npm run test -- --color $(E2E_FLAGS)
6 |
7 | dev: src deps ## Start dev tasks
8 | @npm run dev
9 |
10 | e2e: src deps ## Run E2E tests locally
11 | @npm run test:e2e -- e2e/cases $(E2E_FLAGS)
12 |
13 | deps: package*.json
14 | @(((ls node_modules | grep .) > /dev/null 2>&1) || npm i) || true
15 |
16 | clean:
17 | @npm run clean
18 |
19 | release: deps
20 | ifneq ($(CI),)
21 | @echo '//registry.npmjs.org/:_authToken=$(NODE_AUTH_TOKEN)' > .npmrc
22 | @npm version $(USE_RELEASE_VERSION)
23 | endif
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | [](http://badge.fury.io/js/testcafe-blink-diff)
3 | [](https://snyk.io/test/npm/testcafe-blink-diff)
4 |
5 | # How it works?
6 |
7 | Install this dependency in your project, e.g. `npm i testcafe-blink-diff --save-dev`
8 |
9 | Call the `takeSnapshot()` helper within your tests, e.g.
10 |
11 | ```js
12 | import { takeSnapshot } from 'testcafe-blink-diff';
13 |
14 | fixture('Snapshots')
15 | .page('http://localhost:8080');
16 |
17 | test('check something here', async t => {
18 | // verify anything you want before
19 | await t
20 | .click('...')
21 | .expect('...')
22 | .ok();
23 |
24 | // then pass the `t` reference to invoke the helper
25 | await takeSnapshot(t);
26 | });
27 | ```
28 |
29 | Each time you run tests with `--take-snapshot base` it'll take the **base** screenshots.
30 |
31 | ```bash
32 | $ npx testcafe chrome:headless tests/e2e/cases -s tests/screenshots --take-snapshot
33 | ```
34 |
35 | Now run the same tests `--take-snapshot actual` to take the **actual** screenshots to compare with.
36 |
37 | Finally, invoke the CLI for generating a simple `generated/index.html` report, e.g.
38 |
39 | ```bash
40 | $ npx testcafe-blink-diff tests/screenshots --compare base:actual --open --threshold 0.03
41 | ```
42 |
43 | > Note that v0.4.x will still treat `0.03` as `3%` which may be confusing — since v0.5.x this value is kept as is, so `0.03` will be `0.03%` and nothing else!
44 |
45 | That's all, explore the generated report and enjoy!
46 |
47 |
48 |
49 |
50 |
51 | ## API Options
52 |
53 | `takeSnapshot(t[, label[, options]])`
54 |
55 | > If the given selector does not exists on the DOM, a warning will be raised.
56 |
57 | - `label|options.label` — Readable name for the taken snapshot
58 | - `options.as` — Valid identifier for later comparison
59 | - `options.base` — Custom folder for saving the taken snapshot
60 | - `options.timeout` — Waiting time before taking snapshots
61 | - `options.selector` — String, or `Selector()` to match on the DOM
62 | - `options.blockOut` — List of `Selector()` nodes to "block-out" on the snapshot
63 | - `options.fullPage` — Enable `fullPage: true` as options passed to `takeScreenshot(...)`
64 |
65 | If you set `selector` as an array, then the list of _possible nodes_ will be used to the snapshot.
66 |
67 | If no selectors are given, then it'll take page-screenshot of the visible content, unless `fullPage` is enabled.
68 |
69 | > "Block-out" means matched DOM nodes are covered by a solid-color overlay, helping to reduce unwanted differences if they change often, e.g. ads
70 |
71 | Type `npx testcafe-blink-diff --help` to list all available options.
72 |
73 | ## Contributors
74 |
75 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/bin/cli.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | 'use strict';
4 |
5 | require('../lib/builder');
6 |
--------------------------------------------------------------------------------
/e2e/.testcaferc.json:
--------------------------------------------------------------------------------
1 | {
2 | "src": "e2e/cases",
3 | "screenshots": {
4 | "path": "e2e/screens",
5 | "fullPage": true
6 | },
7 | "appCommand": "npm run serve"
8 | }
9 |
--------------------------------------------------------------------------------
/e2e/cases/cli-testcafe-blink-diff.tests.js:
--------------------------------------------------------------------------------
1 | import { existsSync } from 'node:fs';
2 | import { join } from 'node:path';
3 |
4 | import { rimrafSync } from 'rimraf';
5 |
6 | import {
7 | getBaseUrl, fixedFile, callTBD,
8 | } from '../lib';
9 |
10 | fixture('Testing cli testcafe-blink-diff').page(getBaseUrl());
11 |
12 | test('do compare', async t => {
13 | rimrafSync(join('e2e', 'generated-with'));
14 |
15 | await callTBD([
16 | 'e2e/file/screens/Testing_cli_testcafe-blink-diff_with',
17 | 'e2e/generated-with',
18 | '--compare',
19 | 'base:actual',
20 | '--threshold',
21 | '1.00',
22 | ]);
23 |
24 | await t
25 | .expect(existsSync(join('e2e', 'generated-with', 'index.html')))
26 | .ok();
27 | await t
28 | .expect(existsSync(join('e2e', 'generated-with', 'expects', fixedFile('out.png'))))
29 | .ok();
30 | });
31 |
32 | test('create thumbnails when not exists', async t => {
33 | rimrafSync(join('e2e', 'generated-without'));
34 |
35 | await callTBD([
36 | 'e2e/file/screens/Testing_cli_testcafe-blink-diff_without',
37 | 'e2e/generated-without',
38 | '--compare',
39 | 'base:actual',
40 | '--threshold',
41 | '1.00',
42 | ]);
43 |
44 | await t
45 | .expect(existsSync(join('e2e', 'generated-without', 'expects', 'thumbnails', fixedFile('base.png'))))
46 | .ok();
47 | await t
48 | .expect(existsSync(join('e2e', 'generated-without', 'expects', 'thumbnails', fixedFile('actual.png'))))
49 | .ok();
50 | });
51 |
--------------------------------------------------------------------------------
/e2e/cases/cli-testcafe.tests.js:
--------------------------------------------------------------------------------
1 | import { existsSync } from 'node:fs';
2 | import { join } from 'node:path';
3 |
4 | import sizeOf from 'image-size';
5 | import { rimrafSync } from 'rimraf';
6 |
7 | import {
8 | getBaseUrl, fixedSize, fixedFile, callTC,
9 | } from '../lib';
10 |
11 | fixture('Testing cli testcafe --take-snapshot').page(getBaseUrl());
12 |
13 | test('should care arg without name', async t => {
14 | const basePath = join('e2e', 'screens', 'Testing_cli', 'assert__it');
15 | rimrafSync(basePath);
16 |
17 | await callTC('e2e/file/cases/takesnapshot.test.js', ['--take-snapshot', 'base', '-t', 'takesnapshot']);
18 |
19 | await t.expect(existsSync(join(basePath, fixedFile('base.png')))).ok();
20 | });
21 |
22 | test('should care arg with name', async t => {
23 | const basePath = join('e2e', 'screens', 'Testing_cli', 'assert__it');
24 | rimrafSync(basePath);
25 |
26 | const fpath = 'e2e/file/cases/takesnapshot.test.js';
27 | await callTC(fpath, ['--take-snapshot', 'base', '-t', 'takesnapshot']);
28 | await callTC(fpath, ['--take-snapshot', 'actual', '-t', 'takesnapshot']);
29 | await callTC(fpath, ['--take-snapshot', 'foo', '-t', 'takesnapshot']);
30 |
31 | await t.expect(existsSync(join(basePath, fixedFile('base.png')))).ok();
32 | await t.expect(existsSync(join(basePath, fixedFile('actual.png')))).ok();
33 | await t.expect(existsSync(join(basePath, fixedFile('foo.png')))).ok();
34 | await t.expect(existsSync(join(basePath, fixedFile('bar.png')))).notOk();
35 | });
36 |
37 | fixture('Testing cli testcafe --full-page').page(getBaseUrl());
38 |
39 | test('should care arg', async t => {
40 | const basePath = join('e2e', 'screens', 'Testing_cli');
41 | rimrafSync(basePath);
42 |
43 | const fpath = 'e2e/file/cases/takesnapshot.test.js';
44 | await callTC(fpath, ['--take-snapshot', '--full-page', '-t', 'fullpage']);
45 | await callTC(fpath, ['--take-snapshot', '-t', 'nofullpage']);
46 |
47 | const pngFull = sizeOf(join(basePath, 'assert__it__with__fullpage', fixedFile('base.png')));
48 | await t.expect(pngFull.width).eql(fixedSize(1290));
49 | await t.expect(pngFull.height).gte(fixedSize(1356));
50 |
51 | const pngNoFull = sizeOf(join(basePath, 'assert__it__without__fullpage', fixedFile('base.png')));
52 | await t.expect(pngNoFull.width).gte(fixedSize(623));
53 | await t.expect(pngNoFull.width).lte(fixedSize(640));
54 | await t.expect(pngNoFull.height).gte(fixedSize(463));
55 | await t.expect(pngNoFull.height).lte(fixedSize(480));
56 | });
57 |
--------------------------------------------------------------------------------
/e2e/cases/main.test.js:
--------------------------------------------------------------------------------
1 | import { Selector } from 'testcafe';
2 |
3 | import { getBaseUrl } from '../lib';
4 | import { takeSnapshot } from '../../lib';
5 |
6 | fixture('Testing').page(getBaseUrl());
7 |
8 | test('should render "It works!"', async t => {
9 | process.env.TAKE_SNAPSHOT = '1';
10 |
11 | await t.wait(1000);
12 | await takeSnapshot(t, 'before_assert_it_works');
13 |
14 | await t
15 | .expect(Selector('h1').exists).ok()
16 | .expect(Selector('h1').visible).ok()
17 | .expect(Selector('h1').innerText)
18 | .contains('It works!');
19 |
20 | await takeSnapshot(t, 'after_assert_it_works');
21 | });
22 |
23 | fixture('Testing fullPage').page(getBaseUrl());
24 |
25 | test('should render "It works!"', async t => {
26 | process.env.TAKE_SNAPSHOT = '1';
27 |
28 | await t.wait(1000);
29 | await takeSnapshot(t, { label: 'before_assert_fullpage', fullPage: true });
30 |
31 | await t
32 | .expect(Selector('h1').exists).ok()
33 | .expect(Selector('h1').visible).ok()
34 | .expect(Selector('h1').innerText)
35 | .contains('It works!');
36 |
37 | await takeSnapshot(t, { label: 'after_assert_fullpage', fullPage: true });
38 | });
39 |
40 | fixture('Testing blockOut').page(getBaseUrl());
41 |
42 | test('should render "It works!"', async t => {
43 | process.env.TAKE_SNAPSHOT = '1';
44 |
45 | await t.wait(1000);
46 |
47 | const elements = [
48 | Selector('.animated'),
49 | ];
50 |
51 | await takeSnapshot(t, { label: 'before_assert_blockout', blockOut: elements, fullPage: true });
52 |
53 | await t
54 | .expect(Selector('h1').exists).ok()
55 | .expect(Selector('h1').visible).ok()
56 | .expect(Selector('h1').innerText)
57 | .contains('It works!');
58 |
59 | await takeSnapshot(t, { label: 'after_assert_blockout', blockOut: elements, fullPage: true });
60 | });
61 |
--------------------------------------------------------------------------------
/e2e/cases/setting.test.js:
--------------------------------------------------------------------------------
1 | import { existsSync } from 'node:fs';
2 | import { join } from 'node:path';
3 |
4 | import sizeOf from 'image-size';
5 | import { rimrafSync } from 'rimraf';
6 |
7 | import { getBaseUrl, fixedSize, fixedFile } from '../lib';
8 | import { takeSnapshot } from '../../lib';
9 |
10 | fixture('Testing set FULL_PAGE').page(getBaseUrl());
11 |
12 | test('should care environment variables', async t => {
13 | process.env.TAKE_SNAPSHOT = '1';
14 | // XXX: set snapshot name to fix what without settings
15 | // makes set to commandline's browser name
16 | process.env.SNAPSHOT_NAME = 'base';
17 |
18 | const basePath = join('e2e', 'screens', 'Testing_set_FULL__PAGE');
19 | rimrafSync(basePath);
20 |
21 | process.env.FULL_PAGE = '1';
22 | await t.resizeWindow(640, 480);
23 | await takeSnapshot(t, 'after_set_env');
24 |
25 | delete process.env.FULL_PAGE;
26 | await takeSnapshot(t, 'after_del_env');
27 |
28 | const pngWithEnv = sizeOf(join(basePath, 'after__set__env', fixedFile('base.png')));
29 | await t.expect(pngWithEnv.width).eql(fixedSize(1290));
30 | await t.expect(pngWithEnv.height).gte(fixedSize(1356));
31 |
32 | const pngWithOutEnv = sizeOf(join(basePath, 'after__del__env', fixedFile('base.png')));
33 | await t.expect(pngWithOutEnv.width).gte(fixedSize(623));
34 | await t.expect(pngWithOutEnv.width).lte(fixedSize(640));
35 | await t.expect(pngWithOutEnv.height).gte(fixedSize(463));
36 | await t.expect(pngWithOutEnv.height).lte(fixedSize(480));
37 | });
38 |
39 | fixture('Testing set TAKE_SNAPSHOT').page(getBaseUrl());
40 |
41 | test('should care environment variables', async t => {
42 | // XXX: set snapshot name to fix what without settings
43 | // makes set to commandline's browser name
44 | process.env.SNAPSHOT_NAME = 'base';
45 |
46 | const basePath = join('e2e', 'screens', 'Testing_set_TAKE__SNAPSHOT');
47 | rimrafSync(basePath);
48 |
49 | await t.wait(1000);
50 |
51 | process.env.TAKE_SNAPSHOT = '1';
52 | await takeSnapshot(t, 'after_set_env');
53 |
54 | delete process.env.TAKE_SNAPSHOT;
55 | await takeSnapshot(t, 'after_del_env');
56 |
57 | await t.expect(existsSync(join(basePath, 'after__set__env', fixedFile('base.png')))).ok();
58 | await t.expect(existsSync(join(basePath, 'after__del__env', fixedFile('base.png')))).notOk();
59 | });
60 |
61 | fixture('Testing set SNAPSHOT_NAME').page(getBaseUrl());
62 |
63 | test('should care environment variables', async t => {
64 | process.env.TAKE_SNAPSHOT = '1';
65 |
66 | const basePath = join('e2e', 'screens', 'Testing_set_SNAPSHOT__NAME');
67 | rimrafSync(basePath);
68 |
69 | await t.wait(1000);
70 |
71 | process.env.SNAPSHOT_NAME = 'base';
72 | await takeSnapshot(t, 'after_set_env_base');
73 |
74 | process.env.SNAPSHOT_NAME = 'actual';
75 | await takeSnapshot(t, 'after_set_env_actual');
76 |
77 | process.env.SNAPSHOT_NAME = 'foo';
78 | await takeSnapshot(t, 'after_set_env_foo');
79 |
80 | await t.expect(existsSync(join(basePath, 'after__set__env__base', fixedFile('base.png')))).ok();
81 | await t.expect(existsSync(join(basePath, 'after__set__env__actual', fixedFile('actual.png')))).ok();
82 | await t.expect(existsSync(join(basePath, 'after__set__env__foo', fixedFile('foo.png')))).ok();
83 | });
84 |
--------------------------------------------------------------------------------
/e2e/file/cases/takesnapshot.test.js:
--------------------------------------------------------------------------------
1 | import { getBaseUrl } from '../../lib';
2 | import { takeSnapshot } from '../../../lib';
3 |
4 | fixture('Testing cli').page(getBaseUrl());
5 |
6 | test('takesnapshot', async t => {
7 | await takeSnapshot(t, 'assert_it');
8 | });
9 |
10 | test('fullpage', async t => {
11 | await t.resizeWindow(640, 480);
12 | await takeSnapshot(t, 'assert_it_with_fullpage');
13 | });
14 |
15 | test('nofullpage', async t => {
16 | await t.resizeWindow(640, 480);
17 | await takeSnapshot(t, 'assert_it_without_fullpage');
18 | });
19 |
--------------------------------------------------------------------------------
/e2e/file/screens/Testing_cli_testcafe-blink-diff_with/expects/actual.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tacoss/testcafe-blink-diff/d26826de795d5f88d3fc090e8db0f9c4f9b25c8e/e2e/file/screens/Testing_cli_testcafe-blink-diff_with/expects/actual.png
--------------------------------------------------------------------------------
/e2e/file/screens/Testing_cli_testcafe-blink-diff_with/expects/base.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tacoss/testcafe-blink-diff/d26826de795d5f88d3fc090e8db0f9c4f9b25c8e/e2e/file/screens/Testing_cli_testcafe-blink-diff_with/expects/base.png
--------------------------------------------------------------------------------
/e2e/file/screens/Testing_cli_testcafe-blink-diff_with/expects/thumbnails/actual.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tacoss/testcafe-blink-diff/d26826de795d5f88d3fc090e8db0f9c4f9b25c8e/e2e/file/screens/Testing_cli_testcafe-blink-diff_with/expects/thumbnails/actual.png
--------------------------------------------------------------------------------
/e2e/file/screens/Testing_cli_testcafe-blink-diff_with/expects/thumbnails/base.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tacoss/testcafe-blink-diff/d26826de795d5f88d3fc090e8db0f9c4f9b25c8e/e2e/file/screens/Testing_cli_testcafe-blink-diff_with/expects/thumbnails/base.png
--------------------------------------------------------------------------------
/e2e/file/screens/Testing_cli_testcafe-blink-diff_without/expects/actual.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tacoss/testcafe-blink-diff/d26826de795d5f88d3fc090e8db0f9c4f9b25c8e/e2e/file/screens/Testing_cli_testcafe-blink-diff_without/expects/actual.png
--------------------------------------------------------------------------------
/e2e/file/screens/Testing_cli_testcafe-blink-diff_without/expects/base.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tacoss/testcafe-blink-diff/d26826de795d5f88d3fc090e8db0f9c4f9b25c8e/e2e/file/screens/Testing_cli_testcafe-blink-diff_without/expects/base.png
--------------------------------------------------------------------------------
/e2e/lib.js:
--------------------------------------------------------------------------------
1 | import { spawn } from 'node:child_process';
2 | import process from 'node:process';
3 |
4 | // this enforces a particular DPI to allow
5 | // tests to behave in a deterministic way!!
6 | process.env.WINDOW_DPI = process.env.WINDOW_DPI || '2';
7 |
8 | function getBaseUrl() {
9 | return `${process.env.BASE_URL}/`;
10 | }
11 |
12 | function fixedSize(value) {
13 | return value * process.env.WINDOW_DPI;
14 | }
15 |
16 | function fixedFile(name) {
17 | if (process.env.WINDOW_DPI > 1) {
18 | return name.replace('.', `@${process.env.WINDOW_DPI}.`);
19 | }
20 | return name;
21 | }
22 |
23 | function callNpx(args) {
24 | const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
25 |
26 | return new Promise(function fn(resolve) {
27 | const cmd = spawn(npx, args, { shell: true, stdio: 'inherit' });
28 |
29 | cmd.on('close', function onClose() {
30 | resolve();
31 | });
32 | });
33 | }
34 |
35 | function callTC(targetTest, additionalArgs) {
36 | const browser = process.env.BROWSER != null ? process.env.BROWSER : 'chrome:headless';
37 | const baseArgs = ['testcafe', browser, targetTest, '-s', 'path=e2e/screens', '-q'];
38 | return callNpx(baseArgs.concat(additionalArgs));
39 | }
40 |
41 | function callTBD(argArray) {
42 | return callNpx(['testcafe-blink-diff', ...argArray]);
43 | }
44 |
45 | export {
46 | getBaseUrl, fixedSize, fixedFile, callTC, callTBD,
47 | };
48 |
--------------------------------------------------------------------------------
/e2e/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | It works!
10 |
11 |
12 | |
13 | La
14 | |
15 |
16 | Si
17 | |
18 |
19 |
20 | |
21 |
22 |
23 | |
24 | Do
25 | |
26 |
27 | Re
28 | |
29 |
30 | Mi
31 | |
32 |
33 |
34 | |
35 |
36 | |
37 |
38 | Fa
39 | |
40 |
41 | Sol
42 | |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/lib/builder.js:
--------------------------------------------------------------------------------
1 | const wargs = require('wargs');
2 | const path = require('path');
3 | const fs = require('fs');
4 |
5 | const { generateThumbnail } = require('testcafe-browser-tools');
6 |
7 | const pkgInfo = require('../package.json');
8 | const ImageDiff = require('./image-diff');
9 |
10 | const USAGE_INFO = `
11 | Usage:
12 | ${pkgInfo.name} [SCREENSHOTS_DIRECTORY] [OUTPUT_DIRECTORY]
13 |
14 | Options:
15 | -o, --open Browse generated report
16 | -y, --only Filter images to process
17 | -f, --force Ignore missing screenshots
18 | -x, --filter Filter out specific screenshots
19 | -c, --compare Custom snapshots, e.g. \`-c base:actual\`
20 | -t, --threshold Percentage of difference expected
21 |
22 | Snapshots:
23 |
24 | - Import and call \`await takeSnapshot(t)\` within your tests,
25 | e.g. \`import { takeSnapshot } from '${pkgInfo.name}';\`
26 |
27 | - Run testcafe with \`--take-snapshot base\` to take the base screenshots,
28 | run again with \`--take-snapshot actual\` to take actual screenshots
29 |
30 | - Run ${pkgInfo.name} to generate a report from the taken screenshots,
31 | e.g. \`npx ${pkgInfo.name} tests/screenshots --open --threshold 0.03\`
32 |
33 | You can name your snapshots, e.g. \`--take-snapshot latest\` and compare with \`--compare v1:latest \`
34 | `;
35 |
36 | const cwd = process.cwd();
37 | const argv = wargs(process.argv.slice(2), {
38 | alias: {
39 | t: 'threshold',
40 | c: 'compare',
41 | v: 'version',
42 | x: 'filter',
43 | f: 'force',
44 | y: 'only',
45 | o: 'open',
46 | },
47 | boolean: 'vfo',
48 | });
49 |
50 | if (argv.flags.version) {
51 | console.log(`${pkgInfo.name} v${pkgInfo.version}`);
52 | process.exit();
53 | }
54 |
55 | if (!argv._.length) {
56 | console.log(USAGE_INFO);
57 | process.exit();
58 | }
59 |
60 | const filterRegExp = new RegExp(argv.flags.filter || '.*', 'i');
61 | const imagesPath = path.resolve(argv._[0] || 'screenshots');
62 | const destPath = path.resolve(argv._[1] || 'generated');
63 | const destFile = `${destPath}/index.html`;
64 |
65 | const ratio = parseFloat(argv.flags.threshold || '0.01');
66 | const only = [].concat(argv.flags.only || []);
67 |
68 | console.log('Collecting screenshots...');
69 |
70 | const sources = require('glob') // eslint-disable-line
71 | .sync('**/*.png', { cwd: imagesPath })
72 | .filter(x => {
73 | if (x.indexOf('out.png') === -1) {
74 | return filterRegExp.test(x);
75 | }
76 |
77 | return false;
78 | });
79 |
80 | const images = sources
81 | .filter(x => x.indexOf('thumbnails') === -1)
82 | .reduce((prev, cur) => {
83 | if (only.length && !only.some(x => cur.includes(x))) return prev;
84 |
85 | const groupedName = path.basename(cur, '.png').replace(/@[\d.]+$/, '');
86 | const fixedName = `${path.dirname(cur) === '.' ? groupedName : path.dirname(cur)}`
87 | .replace(/__/g, '§')
88 | .replace('-or-', '/')
89 | .replace(/_/g, ' ')
90 | .replace(/§/g, '_');
91 |
92 | if (!prev[fixedName]) {
93 | prev[fixedName] = {};
94 | }
95 |
96 | prev[fixedName][groupedName] = path.join(imagesPath, cur);
97 |
98 | return prev;
99 | }, {});
100 |
101 | function readFile(filename) {
102 | return fs.readFileSync(path.resolve(__dirname, `../${filename}`)).toString();
103 | }
104 |
105 | function copyFile(source, target) {
106 | const rd = fs.createReadStream(source);
107 | const wr = fs.createWriteStream(target);
108 |
109 | return new Promise((resolve, reject) => {
110 | rd.on('error', reject);
111 | wr.on('error', reject);
112 | wr.on('finish', resolve);
113 | rd.pipe(wr);
114 | }).catch(e => {
115 | rd.destroy();
116 | wr.end();
117 | throw e;
118 | });
119 | }
120 |
121 | function mkdirp(filepath) {
122 | const rel = path.relative(cwd, filepath);
123 | const parts = rel.split('/').reduce((prev, cur, i, v) => {
124 | prev.push(path.join(cwd, v.slice(0, i + 1).join('/')));
125 | return prev;
126 | }, []);
127 |
128 | parts.forEach(dir => {
129 | if (!fs.existsSync(dir)) {
130 | fs.mkdirSync(dir, { recursive: true });
131 | }
132 | });
133 | }
134 |
135 | function generateThumbnailWhenNotExists(imgDest) {
136 | const tnFileName = path.basename(imgDest);
137 | const tnDir = path.join(path.dirname(imgDest), 'thumbnails');
138 | if (!fs.existsSync(path.join(tnDir, tnFileName))) {
139 | return generateThumbnail(imgDest);
140 | }
141 | }
142 |
143 | function build() {
144 | const tasks = [];
145 | const check = (argv.flags.compare || '').split(':');
146 | const left = check[0] || 'base';
147 | const right = check[1] || 'actual';
148 |
149 | let failed;
150 | Object.keys(images).forEach(groupedName => {
151 | if (!(images[groupedName][left] && images[groupedName][right])) {
152 | const errorMessage = `Missing snapshots for '${groupedName}'`;
153 |
154 | if (!argv.flags.force) {
155 | console.error(`${errorMessage}, given ${left}:${right}`);
156 | process.exit(1);
157 | }
158 |
159 | console.warn(` ${errorMessage}`); // eslint-disable-line
160 | return;
161 | }
162 |
163 | const actualImage = path.relative(imagesPath, images[groupedName][right]);
164 | const baseImage = path.relative(imagesPath, images[groupedName][left]);
165 | const baseDir = path.dirname(images[groupedName][left]);
166 | const legacyBlockFile = path.join(baseDir, 'blockOut.json');
167 | const blockFileA = path.join(baseDir, `blockOut-${left}.json`);
168 | const blockFileB = path.join(baseDir, `blockOut-${right}.json`);
169 | const outFile = path.join(baseDir, 'out.png');
170 |
171 | tasks.push(new Promise(resolve => {
172 | const legacyBlockOut = fs.existsSync(legacyBlockFile) ? require(legacyBlockFile) : [];
173 | const blockOutA = fs.existsSync(blockFileA) ? require(blockFileA) : null;
174 | const blockOutB = fs.existsSync(blockFileB) ? require(blockFileB) : null;
175 | const nameA = path.basename(images[groupedName][left], '.png');
176 | const nameB = path.basename(images[groupedName][right], '.png');
177 |
178 | const diff = new ImageDiff({
179 | imageOutputPath: outFile,
180 | imageAPath: images[groupedName][left],
181 | imageBPath: images[groupedName][right],
182 | resolutionA: nameA.split('@')[1] || 1,
183 | resolutionB: nameB.split('@')[1] || 1,
184 | threshold: ratio,
185 | blockOutA: blockOutA || legacyBlockOut,
186 | blockOutB: blockOutB || legacyBlockOut,
187 | });
188 |
189 | return diff.run(() => {
190 | const fixed = diff.getDifference();
191 | const errorMessage = `Failed with ${fixed}% of differences`;
192 | const fixedName = groupedName.replace(/\//g, ' / ');
193 | const ok = diff.hasPassed();
194 |
195 | process.stdout.write(` ${fixedName} (${ok ? '' : 'NOT '}PASS)\n`);
196 |
197 | if (!ok) {
198 | console.warn(` ${errorMessage}`); // eslint-disable-line
199 | failed = true;
200 | }
201 |
202 | sources.push(path.relative(imagesPath, diff.getImageOutput()));
203 |
204 | const prefix = baseDir === '.' ? '' : path.relative(imagesPath, baseDir);
205 |
206 | resolve({
207 | thumbnails: {
208 | actual: path.join(prefix, `thumbnails/${path.basename(actualImage)}`),
209 | base: path.join(prefix, `thumbnails/${path.basename(baseImage)}`),
210 | },
211 | images: {
212 | actual: actualImage,
213 | base: baseImage,
214 | out: path.relative(imagesPath, diff.getImageOutput()),
215 | },
216 | height: diff.height,
217 | width: diff.width,
218 | label: fixedName,
219 | diff: fixed,
220 | dpi: diff.options.resolutionA,
221 | dpiActual: diff.options.resolutionB,
222 | dpiBase: diff.options.resolutionA,
223 | ok,
224 | });
225 | });
226 | }));
227 | });
228 |
229 | process.stdout.write(`\rProcessing ${tasks.length} file${tasks.length === 1 ? '' : 's'}...\n`); // eslint-disable-line
230 |
231 | return Promise.all(tasks).then(results => ({ failed, results }));
232 | }
233 |
234 | function render(reportInfo) {
235 | return readFile('lib/index.html')
236 | .replace('/* json */', JSON.stringify(reportInfo))
237 | .replace('/* code */', readFile('dist/reporter.umd.js'));
238 | }
239 |
240 | let exitCode = 0;
241 |
242 | Promise.resolve()
243 | .then(() => build())
244 | .then(({ failed, results }) => {
245 | if (failed) {
246 | exitCode = 1;
247 | }
248 |
249 | mkdirp(destPath);
250 |
251 | const tasks = [];
252 |
253 | if (destPath !== imagesPath) {
254 | sources.forEach(img => {
255 | const imgDest = path.join(destPath, img);
256 | const imgSrc = path.join(imagesPath, img);
257 |
258 | mkdirp(path.dirname(imgDest));
259 |
260 | tasks.push(copyFile(imgSrc, imgDest));
261 |
262 | const parts = path.basename(imgDest).split('.');
263 | const ext = parts[parts.length - 1];
264 | if (['png'].includes(ext) && parts[0] !== 'out') {
265 | const p = generateThumbnailWhenNotExists(imgDest);
266 | if (typeof p !== 'undefined') {
267 | tasks.push(p);
268 | }
269 | }
270 | });
271 | }
272 |
273 | fs.writeFileSync(destFile, render(results));
274 |
275 | console.log(`Write ${path.relative(cwd, destFile)}`); // eslint-disable-line
276 |
277 | return Promise.all(tasks);
278 | })
279 | .then(() => {
280 | if (argv.flags.open) {
281 | require('open')(destFile, typeof argv.flags.open === 'string' ? argv.flags.open : undefined);
282 | }
283 | })
284 | .catch(e => {
285 | console.error(`[ERR] ${e.message}`); // eslint-disable-line
286 | process.exit(1);
287 | })
288 | .then(() => {
289 | process.exit(exitCode);
290 | });
291 |
--------------------------------------------------------------------------------
/lib/image-diff.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const { PNG } = require('pngjs');
3 | const pixelmatch = require('pixelmatch');
4 |
5 | function readPngImage(image) {
6 | return new Promise((resolve, reject) => {
7 | fs.createReadStream(image).pipe(new PNG())
8 | .on('parsed', function onParse() {
9 | resolve(this);
10 | })
11 | .on('error', reject);
12 | });
13 | }
14 |
15 | function fixPngImage(image, width, height) {
16 | if (image.width !== width || image.height !== height) {
17 | const fixedImage = new PNG({
18 | width,
19 | height,
20 | bitDepth: image.bitDepth,
21 | inputHasAlpha: true,
22 | });
23 |
24 | PNG.bitblt(image, fixedImage, 0, 0, image.width, image.height, 0, 0);
25 | return fixedImage;
26 | }
27 | return image;
28 | }
29 |
30 | function fillPngRect(image, x, y, w, h) {
31 | const fillImage = new PNG({
32 | colorType: 2,
33 | bgColor: {
34 | red: 255,
35 | green: 0,
36 | blue: 0,
37 | },
38 | width: w,
39 | height: h,
40 | });
41 |
42 | fillImage.bitblt(image, 0, 0, w, h, x, y);
43 | }
44 |
45 | // taken screenshots may have been aliased, so
46 | // we move the rectangle a bit to crop around it
47 | function getBlockOutArea(image, area, dpi) {
48 | // if left|top were 0, then we would write outside the image!
49 | const x = Math.max(0, (area.left * dpi) - 1);
50 | const y = Math.max(0, (area.top * dpi) - 1);
51 |
52 | // if x+w|y+h > width|height, we would write outside the image!
53 | const w = Math.min((area.width * dpi) + 2, image.width - x);
54 | const h = Math.min((area.height * dpi) + 2, image.height - y);
55 |
56 | return {
57 | x, y, w, h,
58 | };
59 | }
60 |
61 | class ImageDiff {
62 | constructor(opts) {
63 | this.options = { ...opts };
64 | this.width = 0;
65 | this.height = 0;
66 | this.differences = 0;
67 | }
68 |
69 | async run(callback) {
70 | const aImage = await readPngImage(this.options.imageAPath);
71 | const bImage = await readPngImage(this.options.imageBPath);
72 |
73 | const dstImage = new PNG({
74 | width: Math.max(aImage.width, bImage.width),
75 | height: Math.max(aImage.height, bImage.height),
76 | });
77 |
78 | if (this.options.blockOutA) {
79 | [].concat(this.options.blockOutA).forEach(area => {
80 | const {
81 | x, y, w, h,
82 | } = getBlockOutArea(aImage, area, this.options.resolutionA || 1);
83 | if (w && h && x < aImage.width && y < aImage.height) {
84 | fillPngRect(aImage, x, y, Math.min(w, aImage.width - x), Math.min(h, aImage.height - y));
85 | }
86 | });
87 | }
88 | if (this.options.blockOutB) {
89 | [].concat(this.options.blockOutB).forEach(area => {
90 | const {
91 | x, y, w, h,
92 | } = getBlockOutArea(bImage, area, this.options.resolutionB || 1);
93 | if (w && h && x < bImage.width && y < bImage.height) {
94 | fillPngRect(bImage, x, y, Math.min(w, bImage.width - x), Math.min(h, bImage.height - y));
95 | }
96 | });
97 | }
98 |
99 | const aCanvas = await fixPngImage(aImage, dstImage.width, dstImage.height);
100 | const bCanvas = await fixPngImage(bImage, dstImage.width, dstImage.height);
101 |
102 | const options = { threshold: 0.1, diffMask: true };
103 | const result = pixelmatch(aCanvas.data, bCanvas.data, dstImage.data, dstImage.width, dstImage.height, options);
104 |
105 | fs.writeFileSync(this.getImageOutput(), PNG.sync.write(dstImage));
106 |
107 | callback(Object.assign(this, {
108 | width: dstImage.width,
109 | height: dstImage.height,
110 | differences: result,
111 | }));
112 | }
113 |
114 | getImageOutput() {
115 | return this.options.imageOutputPath;
116 | }
117 |
118 | getDifference() {
119 | // eslint-disable-next-line no-mixed-operators
120 | return Math.round(100 * 100 * this.differences / (this.width * this.height)) / 100;
121 | }
122 |
123 | hasPassed() {
124 | const percentage = this.getDifference();
125 | return percentage <= this.options.threshold;
126 | }
127 | }
128 |
129 | module.exports = ImageDiff;
130 |
--------------------------------------------------------------------------------
/lib/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | testcafe-blink-diff
6 |
42 |
43 |
44 |
45 |
47 |
48 | - ESC — To close open Modal
49 | - SPACE — Toggle difference-overlay image
50 | - TAB or SHIFT + TAB — Go to next and previous image
51 | - LEFT or RIGHT — Move diff-handle by 100px (use SHIFT to move by 10px)
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/lib/index.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-return-assign */
2 | /* eslint-disable prefer-object-spread */
3 |
4 | require('global-or-local').devDependencies(['testcafe']);
5 |
6 | const { Selector, ClientFunction } = require('testcafe');
7 | const path = require('path');
8 | const fs = require('fs');
9 |
10 | function isFullPage() {
11 | return 'FULL_PAGE' in process.env
12 | || (process.argv.slice(2).indexOf('--full-page') !== -1);
13 | }
14 | function getTakeSnapshotPos() {
15 | return process.argv.slice(2).indexOf('--take-snapshot');
16 | }
17 | function isTakeSnapshot() {
18 | return 'TAKE_SNAPSHOT' in process.env || getTakeSnapshotPos() > -1;
19 | }
20 | function snapshotName() {
21 | return process.env.SNAPSHOT_NAME
22 | || (getTakeSnapshotPos() > -1 ? process.argv.slice(2)[getTakeSnapshotPos() + 1] : null);
23 | }
24 |
25 | function getType() {
26 | return (snapshotName() && snapshotName().match(/^[a-z\d_]/) ? snapshotName() : null)
27 | || (getTakeSnapshotPos() > -1 ? 'base' : 'actual');
28 | }
29 |
30 | function getCurrentStack() {
31 | let found;
32 | return (new Error()).stack.split('\n').filter(x => {
33 | if (x.indexOf('anonymous') !== -1) return false;
34 | if (found || x.indexOf(' at ') === -1) return true;
35 | if (x.indexOf('takeSnapshot') !== -1) {
36 | found = true;
37 | return false;
38 | }
39 |
40 | return found;
41 | }).join('\n');
42 | }
43 |
44 | function debug(message, options) {
45 | console.log(options.label);
46 | console.log(` \x1b[33m${message}\x1b[0m`);
47 | }
48 |
49 | function normalize(value) {
50 | return value
51 | .replace(/_/g, '__')
52 | .replace(/[^a-zA-Z/\d%[(@;,.)\]_-]+/g, '_')
53 | .replace(/^_+|_+$/g, '')
54 | .replace(/\//g, '-or-');
55 | }
56 |
57 | function _takeSnapshot(t, opts, extra) {
58 | if (typeof opts === 'string') {
59 | opts = Object.assign({}, extra, { label: opts });
60 | }
61 |
62 | const options = Object.assign({}, opts);
63 | const groupId = options.base || t.testRun.test.fixture.name;
64 | const filename = normalize(options.label || t.testRun.test.name);
65 | const imagePath = `${normalize(groupId)}/${filename}/${options.as || getType()}.png`;
66 | const selectors = !Array.isArray(options.selector) && options.selector ? [options.selector] : options.selector;
67 |
68 | // correctly set up fullPage variable
69 | if (!options.fullPage && !isFullPage()) {
70 | options.fullPage = false;
71 | } else {
72 | options.fullPage = true;
73 | }
74 |
75 | let exists;
76 | let found;
77 | let skip;
78 | function notFound(x) {
79 | skip = true;
80 |
81 | const key = Object.getOwnPropertySymbols(x)[0];
82 |
83 | debug(`Selector('${x[key].fn}') was not found on the DOM`, options);
84 | }
85 |
86 | const getWindowDPI = process.env.WINDOW_DPI
87 | ? (() => Promise.resolve(+process.env.WINDOW_DPI))
88 | : ClientFunction(() => window.devicePixelRatio).with({ boundTestRun: t });
89 |
90 | let dpi;
91 | function fixedName() {
92 | return dpi > 1 ? imagePath.replace('.png', `@${dpi}.png`) : imagePath;
93 | }
94 |
95 | return Promise.resolve()
96 | .then(() => t.wait(options.timeout === false ? 0 : (options.timeout || 500)))
97 | .then(() => getWindowDPI().then(value => { dpi = value || 1; }))
98 | .then(() => {
99 | if (!selectors) {
100 | return Promise.resolve()
101 | // testcafe's api for takeScreenshot has changed: https://devexpress.github.io/testcafe/documentation/test-api/actions/take-screenshot.html
102 | .then(() => !found && t.takeScreenshot({ path: fixedName(), fullPage: options.fullPage }))
103 | .then(() => { found = !!exists; });
104 | }
105 | })
106 | .then(() => {
107 | // stop on first existing node
108 | return (selectors || []).reduce((prev, cur) => {
109 | return prev.then(() => {
110 | if (found || exists) return;
111 |
112 | const sel = typeof cur === 'function'
113 | ? cur.with({ boundTestRun: t })
114 | : Selector(cur);
115 |
116 | return Promise.resolve()
117 | .then(() => sel.exists)
118 | .then(ok => { return exists = ok; })
119 | .then(ok => (ok && !found ? t.takeElementScreenshot(sel, fixedName()) : notFound(sel)))
120 | .then(() => { found = !!exists; });
121 | });
122 | }, Promise.resolve());
123 | })
124 | .then(() => {
125 | if (skip) return;
126 | if (options.blockOut) {
127 | const blocks = Array.isArray(options.blockOut) ? options.blockOut : [options.blockOut];
128 | const results = [];
129 |
130 | return Promise.all(blocks.map(x => {
131 | if (typeof x !== 'function') {
132 | debug(`Expecting a Selector() instance, given '${x}'`, options);
133 | return;
134 | }
135 |
136 | let result;
137 | return Promise.resolve()
138 | .then(() => x.with({ boundTestRun: t }))
139 | .then(_result => Promise.resolve()
140 | .then(() => _result.exists)
141 | .then(() => {
142 | result = _result;
143 | }))
144 | .catch(() => { /* do nothing */ })
145 | .then(() => {
146 | if (!result) {
147 | notFound(x);
148 | }
149 |
150 | return result.count;
151 | })
152 | .then(length => Promise.all(Array.from({ length }).map((_, i) => result.nth(i).boundingClientRect)))
153 | .then(_results => {
154 | results.push(..._results);
155 | });
156 | })).then(() => {
157 | let screenshotsDir = t.testRun.opts.screenshotPath || (t.testRun.opts.screenshots || {}).path;
158 | if (!screenshotsDir) {
159 | debug('Unable to read screenshots.path, using current dir', options);
160 | screenshotsDir = path.dirname(t.testRun.test.testFile.filename);
161 | }
162 |
163 | const baseDir = path.join(screenshotsDir, path.dirname(imagePath));
164 | const blockOutName = `blockOut-${getType()}.json`;
165 | const metaFile = path.join(baseDir, blockOutName);
166 |
167 | fs.writeFileSync(metaFile, JSON.stringify(results.filter(Boolean).filter(a => a.width > 0 && a.height > 0)));
168 | });
169 | }
170 | });
171 | }
172 |
173 | function takeSnapshot(t, opts, extra) {
174 | if (!isTakeSnapshot()) {
175 | return;
176 | }
177 |
178 | const fixedStack = getCurrentStack();
179 |
180 | return _takeSnapshot(t, opts, extra).catch(e => {
181 | const err = e.isTestCafeError ? new Error(`takeSnapshot(${e.apiFnChain ? e.apiFnChain[0] : ''}) failed${e.errMsg ? `; ${e.errMsg}` : ''}`) : e;
182 |
183 | err.stack = fixedStack;
184 |
185 | throw err;
186 | });
187 | }
188 |
189 | module.exports = { takeSnapshot };
190 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "testcafe-blink-diff",
3 | "version": "0.5.8",
4 | "description": "Visual regression for Testcafé through BlinkDiff",
5 | "main": "lib/index.js",
6 | "types": "./typings/index.d.ts",
7 | "bin": {
8 | "testcafe-blink-diff": "./bin/cli.js"
9 | },
10 | "files": [
11 | "bin",
12 | "typings",
13 | "lib",
14 | "dist"
15 | ],
16 | "scripts": {
17 | "clean": "rimraf dist/",
18 | "dist": "npm run clean && mkdirp dist/ && npm run build",
19 | "build": "bili --format umd-min --module-name reporter --minimal --file-name reporter.umd.js",
20 | "serve": "sirv e2e/public --port 3000",
21 | "prepublish": "npm run dist",
22 | "pretest": "eslint src lib e2e",
23 | "test": "npm run test:e2e -- -a 'npm run serve' e2e/cases",
24 | "test:e2e": "BASE_URL=http://localhost:3000 testcafe ${BROWSER:-chrome:headless} --screenshots-full-page -s e2e/screens -q"
25 | },
26 | "author": "Alvaro Cabrera ",
27 | "license": "MIT",
28 | "dependencies": {
29 | "glob": "^8.0.3",
30 | "global-or-local": "^0.1.7",
31 | "open": "^8.4.0",
32 | "pixelmatch": "^5.3.0",
33 | "pngjs": "^6.0.0",
34 | "testcafe-browser-tools": "^2.0.26",
35 | "wargs": "^0.10.0"
36 | },
37 | "devDependencies": {
38 | "bili": "^5.0.5",
39 | "eslint": "^7.11.0",
40 | "eslint-config-airbnb-base": "^14.2.0",
41 | "eslint-plugin-import": "^2.22.1",
42 | "image-size": "^1.1.1",
43 | "is-svg": ">=4.2.2",
44 | "mkdirp": "^3.0.1",
45 | "rimraf": "^6.0.1",
46 | "sirv-cli": "^1.0.14",
47 | "somedom": "^0.4.20",
48 | "testcafe": "^2.0.1"
49 | },
50 | "peerDependencies": {
51 | "testcafe": "*"
52 | },
53 | "engines": {
54 | "node": ">=18.0.0"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tacoss/testcafe-blink-diff/d26826de795d5f88d3fc090e8db0f9c4f9b25c8e/screenshot.png
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import { mount } from 'somedom';
2 | import { tag, openModal } from './modal';
3 |
4 | const appScript = document.getElementById('app');
5 | const images = JSON.parse(appScript.innerHTML);
6 |
7 | appScript.parentNode.removeChild(appScript);
8 |
9 | function ImageItem(props, key) {
10 | return ['li', null, [
11 | ['strong', null, props.label],
12 | ['.flex', null, [
13 | ['img.noop', { src: props.thumbnails.base }],
14 | ['img.noop', { src: props.thumbnails.actual }],
15 | ['.info', { class: props.ok ? 'passed' : 'failed' }, [
16 | ['h3', null, props.ok ? 'It passed.' : 'It did not passed'],
17 | ['h2', null, props.diff > 0 ? `Diff: ${props.diff}%` : 'No differences'],
18 | ['button.noop', { onclick: () => openModal(key, images) }, 'Compare'],
19 | ]],
20 | ]],
21 | ]];
22 | }
23 |
24 | function ImageList() {
25 | return !images.length
26 | ? ['ul', null, [['li', null, 'No differences to report']]]
27 | : ['ul', null, images.map((x, key) => ImageItem(x, key))];
28 | }
29 |
30 | mount([ImageList], tag);
31 |
--------------------------------------------------------------------------------
/src/modal.js:
--------------------------------------------------------------------------------
1 | import {
2 | bind, view, unmount, render, listeners, attributes, classes,
3 | } from 'somedom';
4 |
5 | export const tag = bind(render, listeners(), attributes({
6 | class: classes,
7 | }));
8 |
9 | export function mountOverlay(overlay, slider) {
10 | let container;
11 | let maximum;
12 | let clicked;
13 | function set() {
14 | slider.current.style.transform = `translateY(${scrollY}px)`;
15 | }
16 |
17 | function sync() {
18 | container = overlay.current.parentNode.offsetWidth;
19 | maximum = Math.min(document.body.clientWidth, container);
20 | slider.current.style.left = `${(maximum / 2) - (slider.current.offsetWidth / 2)}px`;
21 | overlay.current.style.width = `${maximum / 2}px`;
22 | }
23 |
24 | function slide(x) {
25 | overlay.current.style.width = `${x}px`;
26 | slider.current.style.left = `${overlay.current.offsetWidth - (slider.current.offsetWidth / 2)}px`;
27 | }
28 |
29 | function getCursorPos(e) {
30 | const a = overlay.current.getBoundingClientRect();
31 |
32 | let x = (e || window.event).pageX - a.left;
33 | x -= window.pageXOffset;
34 |
35 | return x;
36 | }
37 |
38 | let moving;
39 | let t;
40 | function clickUp() {
41 | if (!moving) {
42 | moving = true;
43 | document.body.classList.add('moving');
44 | }
45 |
46 | clearTimeout(t);
47 | t = setTimeout(() => {
48 | if (moving) {
49 | moving = false;
50 | document.body.classList.remove('moving');
51 | }
52 | }, 1260);
53 | }
54 |
55 | function slideMove(e) {
56 | clearTimeout(t);
57 |
58 | let pos;
59 | if (!clicked) {
60 | clickUp();
61 | return false;
62 | }
63 |
64 | pos = getCursorPos(e);
65 |
66 | if (pos < 0) pos = 0;
67 | if (pos > container) pos = container;
68 |
69 | slide(pos);
70 | }
71 |
72 | function slideReady(e) {
73 | e.preventDefault();
74 | clicked = 1;
75 | }
76 |
77 | function slideFinish() {
78 | clickUp();
79 | clicked = 0;
80 | }
81 |
82 | sync();
83 | slider.current.addEventListener('mousedown', slideReady);
84 | slider.current.addEventListener('touchstart', slideReady);
85 | addEventListener('mouseup', slideFinish);
86 | addEventListener('touchstop', slideFinish);
87 | addEventListener('mousemove', slideMove);
88 | addEventListener('touchmove', slideMove);
89 | addEventListener('resize', sync);
90 | addEventListener('scroll', set);
91 |
92 | return {
93 | set width(value) {
94 | slide(value);
95 | },
96 | get width() {
97 | return parseInt(overlay.current.style.width, 10);
98 | },
99 | update() {
100 | clickUp();
101 | },
102 | teardown() {
103 | slider.current.removeEventListener('mousedown', slideReady);
104 | slider.current.removeEventListener('touchstart', slideReady);
105 | removeEventListener('mouseup', slideFinish);
106 | removeEventListener('touchstop', slideFinish);
107 | removeEventListener('mousemove', slideMove);
108 | removeEventListener('touchmove', slideMove);
109 | removeEventListener('resize', sync);
110 | removeEventListener('scroll', set);
111 | unmount(slider.current);
112 | },
113 | };
114 | }
115 |
116 | export function openModal(offsetKey, images) {
117 | let overlay;
118 | let modal;
119 | let top;
120 | function onClose(onKeys) {
121 | window.removeEventListener('keydown', onKeys);
122 |
123 | document.body.style.overflow = '';
124 |
125 | if (overlay) {
126 | overlay.teardown();
127 | overlay = null;
128 | }
129 |
130 | modal.unmount();
131 | scrollTo({ behavior: 'instant', left: 0, top });
132 | }
133 |
134 | function testKeys(e) {
135 | if (e.keyCode === 9) {
136 | e.preventDefault();
137 | if (e.shiftKey) modal.prev(e);
138 | else modal.next(e);
139 | overlay.update();
140 | }
141 |
142 | if (e.keyCode === 37 || e.keyCode === 39) {
143 | e.preventDefault();
144 | if (e.keyCode === 37) modal.left(e);
145 | if (e.keyCode === 39) modal.right(e);
146 | overlay.update();
147 | }
148 |
149 | if (e.keyCode === 32) {
150 | e.preventDefault();
151 | modal.change();
152 | overlay.update();
153 | }
154 |
155 | if (e.keyCode === 27) {
156 | onClose(testKeys);
157 | }
158 | }
159 |
160 | function closeModal(e) {
161 | if (!e || e.target === modal.target) {
162 | onClose(testKeys);
163 | }
164 | }
165 |
166 | function scaleImage(img) {
167 | return { width: img.width / img.dpi, height: img.height / img.dpi };
168 | }
169 |
170 | function scaleOverlay(img) {
171 | return [
172 | `width:${img.width / img.dpi}px;height:${img.height / img.dpi}px`,
173 | (img.width / img.dpi) < document.body.clientWidth ? ';right:0' : '',
174 | (img.height / img.dpi) < document.body.clientHeight ? ';bottom:0' : '',
175 | ].join('');
176 | }
177 |
178 | window.addEventListener('keydown', testKeys);
179 |
180 | const overlayRef = {};
181 | const sliderRef = {};
182 |
183 | const app = view(({ key, diff }) => {
184 | const current = images[key];
185 | const props = scaleImage(current);
186 |
187 | return ['.noop.modal', {
188 | onclick: closeModal,
189 | style: scaleOverlay(current),
190 | oncreate(el) {
191 | requestAnimationFrame(() => el.classList.add('ready'));
192 | },
193 | },
194 | ['.container', null, [
195 | ['.layer', null, [
196 | ['img.a', { src: current.images.actual, ...props }],
197 | ]],
198 | ['.layer.overlay', { ref: overlayRef }, [
199 | ['img.b', { src: current.images.base, ...props }],
200 | ]],
201 | ['.slider', { ref: sliderRef }],
202 | ['span.right', null, [
203 | ['small', null, ['b', null, `${current.label} ${current.width / current.dpi}x${current.height / current.dpi} (${key + 1}/${images.length})`]],
204 | ['button.close', { onclick: () => closeModal() }, '×'],
205 | ]],
206 | diff ? ['.layer.difference', null, [
207 | ['img.c', { src: current.images.out, ...props }],
208 | ]] : null,
209 | ]],
210 | ];
211 | }, {
212 | diff: true,
213 | key: offsetKey,
214 | }, {
215 | next: () => ({ key }) => ({ key: key < images.length - 1 ? Math.min(key + 1, images.length - 1) : 0 }),
216 | prev: () => ({ key }) => ({ key: key > 0 ? Math.max(key - 1, 0) : images.length - 1 }),
217 | left: e => () => {
218 | overlay.width = Math.max(0, overlay.width - (!e.shiftKey ? 100 : 10));
219 | },
220 | right: e => ({ key }) => {
221 | overlay.width = Math.min(images[key].width, overlay.width + (!e.shiftKey ? 100 : 10));
222 | },
223 | change: () => ({ diff }) => ({ diff: !diff }),
224 | });
225 |
226 | top = scrollY;
227 | modal = app(document.body, tag);
228 | overlay = mountOverlay(overlayRef, sliderRef);
229 | scrollTo({ behavior: 'instant', left: 0, top: 0 });
230 | }
231 |
--------------------------------------------------------------------------------
/typings/index.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'testcafe-blink-diff' {
2 | /** Additional options to pass to takeSnapshot */
3 | interface TestcafeBlinkDiffOptions {
4 | /** Readable name for the taken snapshot */
5 | label?: string;
6 | /** Valid identifier for later comparison */
7 | as?: string;
8 | /** Custom folder for saving the taken snapshot */
9 | base?: string;
10 | /** Waiting time before taking snapshots */
11 | timeout?: number;
12 | /**
13 | * String, or `Selector()` to match on the DOM
14 | * @remark If you set `selector` as an array, then the list of _possible nodes_ will be used to the snapshot.
15 | * @remark If no selectors are given, then it'll take page-screenshot of the visible content, unless `fullPage` is enabled.
16 | */
17 | selector?: string | string[] | Selector | Selector[];
18 | /**
19 | * List of `Selector()` nodes to "block-out" on the snapshot
20 | * @remark "Block-out" means matched DOM nodes are covered by a solid-color overlay, helping to reduce unwanted differences if they change often, e.g. ads
21 | */
22 | blockOut?: Selector | Selector[];
23 | /** Enable `fullPage: true` as options passed to `takeScreenshot(...)` */
24 | fullPage?: boolean;
25 | }
26 |
27 | /**
28 | * Takes a snapshot using blink-diff
29 | *
30 | * Step 1: Call the `takeSnapshot()` helper within your tests, e.g.
31 | *
32 | * Step 2: Each time you run tests with `--take-snapshot base` it'll take the **base** screenshots.
33 | *
34 | * ```
35 | * $ npx testcafe chrome:headless tests/e2e/cases -s tests/screenshots --take-snapshot
36 | * ```
37 | *
38 | * Step 3: Now run the same tests `--take-snapshot actual` to take the **actual** screenshots to compare with.
39 | *
40 | * Step 4: Finally, invoke the CLI for generating a simple `generated/index.html` report, e.g.
41 | *
42 | * ```
43 | * $ npx testcafe-blink-diff tests/screenshots --compare base:actual --open --threshold 0.03
44 | * ```
45 | *
46 | * Note that v0.4.x will still treat 0.03 as 3% which may be confusing — since v0.5.x this value is kept as is, so 0.03 will be 0.03% and nothing else!
47 | *
48 | * That's all, explore the generated report and enjoy!
49 | *
50 | * @description If the given selector does not exists on the DOM, a warning will be raised.
51 | * @description Type `npx testcafe-blink-diff --help` to list all available options.
52 | * @param t The TestController instance
53 | * @param labelOrOptions Readable name for the taken snapshot, or the {@link TestcafeBlinkDiffOptions} options
54 | * @param options Options to pass to the the takeSnapshot function
55 | */
56 | export function takeSnapshot(t: TestController, labelOrOptions?: string | TestcafeBlinkDiffOptions, options?: TestcafeBlinkDiffOptions): TestController;
57 | }
58 |
--------------------------------------------------------------------------------