├── .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 | ![build](https://github.com/tacoss/testcafe-blink-diff/workflows/build/badge.svg) 2 | [![NPM version](https://badge.fury.io/js/testcafe-blink-diff.png)](http://badge.fury.io/js/testcafe-blink-diff) 3 | [![Known Vulnerabilities](https://snyk.io/test/npm/testcafe-blink-diff/badge.svg)](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 | 15 | 18 | 21 | 22 | 23 | 26 | 29 | 32 | 33 | 34 | 37 | 40 | 43 | 44 |
13 |
La
14 |
16 |
Si
17 |
19 |
20 |
24 |
Do
25 |
27 |
Re
28 |
30 |
Mi
31 |
35 |
36 |
38 |
Fa
39 |
41 |
Sol
42 |
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 | 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 | --------------------------------------------------------------------------------