├── .prettierrc
├── .gitignore
├── example-output.png
├── src
├── drivers
│ ├── index.js
│ └── headless-chrome.js
├── reporters
│ ├── index.js
│ ├── json.js
│ └── gallery.js
├── identifier.js
├── index.js
├── log.js
├── test-permutations.js
├── capture.js
├── test.js
├── compare.js
├── cli.js
└── config.js
├── Dockerfile
├── spec
├── capture-spec.js
├── compare-spec.js
├── identifier-spec.js
├── support
│ ├── jasmine.json
│ ├── test-config.yml
│ ├── invalid-config.yml
│ └── test-pages
│ │ ├── 1.html
│ │ └── 2.html
├── helpers
│ └── reporter.js
├── test-permutations-spec.js
├── log-spec.js
├── reporters
│ └── gallery-spec.js
└── config-spec.js
├── bin
└── whoopsie.js
├── .eslintrc.js
├── .snyk
├── LICENSE
├── package.json
├── config
└── sample.yml
├── README.md
└── templates
└── gallery.html
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | npm-debug.log
2 | package-lock.json
3 | .nyc_output/
4 | node_modules/
5 | results/
6 |
--------------------------------------------------------------------------------
/example-output.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wildlyinaccurate/whoopsie/HEAD/example-output.png
--------------------------------------------------------------------------------
/src/drivers/index.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | HeadlessChrome: require("./headless-chrome"),
3 | };
4 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:6
2 | MAINTAINER joseph@wildlyinaccurate.com
3 |
4 | RUN npm install --global whoopsie
5 |
--------------------------------------------------------------------------------
/spec/capture-spec.js:
--------------------------------------------------------------------------------
1 | describe("capture()", () => {
2 | it("???", () => {
3 | // :)
4 | });
5 | });
6 |
--------------------------------------------------------------------------------
/spec/compare-spec.js:
--------------------------------------------------------------------------------
1 | describe("compare()", () => {
2 | it("???", () => {
3 | // :)
4 | });
5 | });
6 |
--------------------------------------------------------------------------------
/src/reporters/index.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | json: require("./json"),
3 | gallery: require("./gallery"),
4 | };
5 |
--------------------------------------------------------------------------------
/bin/whoopsie.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const cli = require("../src/cli");
4 | const argv = require("minimist")(process.argv.slice(2));
5 |
6 | cli(argv);
7 |
--------------------------------------------------------------------------------
/spec/identifier-spec.js:
--------------------------------------------------------------------------------
1 | const identifier = require("../src/identifier");
2 |
3 | describe("identifier()", () => {
4 | it("should prefix identifiers", () => {
5 | expect(identifier("prefix").startsWith("prefix-")).toBe(true);
6 | });
7 | });
8 |
--------------------------------------------------------------------------------
/spec/support/jasmine.json:
--------------------------------------------------------------------------------
1 | {
2 | "spec_dir": "spec",
3 | "spec_files": [
4 | "**/*-spec.js"
5 | ],
6 | "helpers": [
7 | "helpers/**/*.js"
8 | ],
9 | "stopSpecOnExpectationFailure": false,
10 | "random": false
11 | }
12 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | node: true,
4 | browser: true,
5 | es2021: true,
6 | jasmine: true
7 | },
8 | plugins: ["prettier"],
9 | extends: ["eslint:recommended", "plugin:prettier/recommended"]
10 | };
11 |
--------------------------------------------------------------------------------
/src/reporters/json.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs-extra");
2 | const log = require("../log");
3 |
4 | module.exports = async function jsonReporter(output, config) {
5 | return fs.outputJSON(config.inFile, output).then(() => log.notice(`Wrote results to ${config.inFile}`));
6 | };
7 |
--------------------------------------------------------------------------------
/spec/helpers/reporter.js:
--------------------------------------------------------------------------------
1 | const SpecReporter = require("jasmine-spec-reporter").SpecReporter;
2 |
3 | jasmine.getEnv().clearReporters();
4 | jasmine.getEnv().addReporter(
5 | new SpecReporter({
6 | spec: {
7 | displayPending: true,
8 | },
9 | })
10 | );
11 |
--------------------------------------------------------------------------------
/spec/support/test-config.yml:
--------------------------------------------------------------------------------
1 | sites:
2 | - http://localhost:8888/1
3 | - http://localhost:8888/2
4 |
5 | pages:
6 | - path: .html
7 |
8 | viewports:
9 | - width: 240
10 | - width: 320
11 |
12 | networkIdleTimeout: 0
13 | outDir: results/
14 | failureThreshold: 10
15 | fuzz: 5
16 |
--------------------------------------------------------------------------------
/src/identifier.js:
--------------------------------------------------------------------------------
1 | const crypto = require("crypto");
2 |
3 | module.exports = function identifier(prefix = "") {
4 | return `${prefix}-${hash().substr(0, 8)}`;
5 | };
6 |
7 | function hash() {
8 | return crypto.createHash("sha1").update(Math.random().toString()).digest("hex");
9 | }
10 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | // capture :: Url -> Width -> CaptureResult
3 | capture: require("./capture"),
4 |
5 | // compare :: CaptureResult -> CaptureResult -> Diff
6 | compare: require("./compare"),
7 |
8 | // gallery :: CaptureResult -> CaptureResult -> Diff -> HTML
9 | gallery: require("./gallery"),
10 | };
11 |
--------------------------------------------------------------------------------
/spec/support/invalid-config.yml:
--------------------------------------------------------------------------------
1 | sites:
2 | - http://www.bbc.co.uk/news
3 |
4 | pages:
5 | - path: /
6 | - path: /technology
7 | - path: /technology-36387563
8 |
9 | widths:
10 | - 320
11 | - 600
12 |
13 | ignoreSelectors:
14 |
15 | renderWaitTime: 3000
16 |
17 | outDir: results/
18 |
19 | failureThreshold: 10
20 |
21 | fuzz: 5
22 |
--------------------------------------------------------------------------------
/.snyk:
--------------------------------------------------------------------------------
1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
2 | version: v1.13.5
3 | ignore: {}
4 | # patches apply the minimum changes required to fix a vulnerability
5 | patch:
6 | 'npm:ms:20170412':
7 | - image-diff > gm > debug > ms:
8 | patched: '2017-05-25T04:33:03.154Z'
9 | SNYK-JS-HTTPSPROXYAGENT-469131:
10 | - puppeteer > https-proxy-agent:
11 | patched: '2019-10-10T05:30:53.987Z'
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2016, Joseph Wynn
2 |
3 | Permission to use, copy, modify, and/or distribute this software for any
4 | purpose with or without fee is hereby granted, provided that the above
5 | copyright notice and this permission notice appear in all copies.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
14 |
--------------------------------------------------------------------------------
/src/log.js:
--------------------------------------------------------------------------------
1 | const format = require("util").format;
2 | const console = require("console");
3 | const Logger = require("log");
4 |
5 | const log = new Logger();
6 | log.level = Logger.NOTICE;
7 |
8 | log.log = function (level, args) {
9 | if (Logger[level] <= this.level) {
10 | this.stream.write(`${format.apply(null, args)}\n`);
11 | }
12 | };
13 |
14 | log.error = function (message) {
15 | this.log("ERROR", [`ERROR: ${message}`]);
16 | };
17 |
18 | log.time = function (label) {
19 | if (this.level >= Logger.DEBUG) {
20 | console.time(label);
21 | }
22 | };
23 |
24 | log.timeEnd = function (label) {
25 | if (this.level >= Logger.DEBUG) {
26 | console.timeEnd(label);
27 | }
28 | };
29 |
30 | log.EMERGENCY = Logger.EMERGENCY;
31 | log.ALERT = Logger.ALERT;
32 | log.CRITICAL = Logger.CRITICAL;
33 | log.ERROR = Logger.ERROR;
34 | log.WARNING = Logger.WARNING;
35 | log.NOTICE = Logger.NOTICE;
36 | log.INFO = Logger.INFO;
37 | log.DEBUG = Logger.DEBUG;
38 |
39 | module.exports = log;
40 |
--------------------------------------------------------------------------------
/spec/support/test-pages/1.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Whoopsie Test Page
5 |
13 |
14 |
15 |
16 |
17 | interfere miniature scintillating lewd destruction sick rampant bore giants wave childlike practise maid chalk cover multiply flesh force terrific squash gaping story acoustics whispering nod tempt aberrant polite tasteless waste transport snatch icky literate blush aquatic toe eye lavish agree subdued cushion abiding annoy raise wicked skate rate ethereal meaty wholesale chin use excited march sense joke first reach acid unusual nest solid wobble realise organic powerful natural trail race standing wrestle reward descriptive sore flowery seal womanly thunder accurate digestion push like lunch rule hill swift nervous provide expect books discover wren tedious foot things alcoholic hammer marble rabbits amuck drop
18 |
19 |
20 |
--------------------------------------------------------------------------------
/spec/support/test-pages/2.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Whoopsie Test Page
5 |
13 |
14 |
15 |
16 |
17 | interfere miniature scintillating lewd destruction sick rampant bore giants wave childlike practise maid chalk cover multiply flesh force terrific squash gaping story acoustics whispering nod tempt aberrant polite tasteless waste transport snatch icky literate blush aquatic toe eye lavish agree subdued cushion abiding annoy raise wicked skate rate ethereal meaty wholesale chin use excited march sense joke first reach acid unusual nest solid wobble realise organic powerful natural trail race standing wrestle reward descriptive sore flowery seal womanly thunder accurate digestion push like lunch rule hill swift nervous provide expect books discover wren tedious foot things alcoholic hammer marble rabbits amuck drop
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/test-permutations.js:
--------------------------------------------------------------------------------
1 | const { format, parse } = require("url");
2 | const { chunk, merge } = require("lodash/fp");
3 | const product = require("cartesian-product");
4 |
5 | // Generate a list of pairs which contain (url, viewport) tuples representing all
6 | // permutations of the sites, paths, and viewports provided.
7 | //
8 | // The permutations will be ordered such that each pair contains the same url
9 | // and viewport for each site.
10 | module.exports = function testPermutations(sites, pages, viewports) {
11 | const pairs = chunk(2);
12 |
13 | return pairs(product([pages, viewports, sites]).map(makeTuple));
14 | };
15 |
16 | function makeTuple([page, viewport, site]) {
17 | const siteUrl = parse(site, true);
18 | const pathUrl = parse(page.path, true);
19 |
20 | siteUrl.pathname = mergePathnames(siteUrl.pathname, pathUrl.pathname);
21 | siteUrl.query = merge(siteUrl.query, pathUrl.query);
22 | siteUrl.search = undefined;
23 |
24 | return [{ ...page, url: format(siteUrl), site }, viewport];
25 | }
26 |
27 | function mergePathnames(path1, path2) {
28 | return (path1 === "/" ? "" : path1) + path2;
29 | }
30 |
--------------------------------------------------------------------------------
/spec/test-permutations-spec.js:
--------------------------------------------------------------------------------
1 | const testPermutations = require("../src/test-permutations");
2 |
3 | describe("testPermutations()", () => {
4 | it("should generate (url, width) tuples", () => {
5 | const sites = ["http://site-1", "http://site-2"];
6 | const pages = [{ path: "/test-page" }];
7 | const widths = [100, 200];
8 |
9 | expect(testPermutations(sites, pages, widths)).toEqual([
10 | [
11 | [{ path: "/test-page", url: "http://site-1/test-page" }, 100],
12 | [{ path: "/test-page", url: "http://site-2/test-page" }, 100],
13 | ],
14 | [
15 | [{ path: "/test-page", url: "http://site-1/test-page" }, 200],
16 | [{ path: "/test-page", url: "http://site-2/test-page" }, 200],
17 | ],
18 | ]);
19 | });
20 |
21 | it("should allow query parameters and ports in both sites and pages", () => {
22 | const sites = ["http://site?env=test", "http://site?env=live"];
23 | const pages = [{ path: "/☃️?p=1" }];
24 | const widths = [100, 200];
25 |
26 | expect(testPermutations(sites, pages, widths)).toEqual([
27 | [
28 | [{ path: "/☃️?p=1", url: "http://site/☃️?env=test&p=1" }, 100],
29 | [{ path: "/☃️?p=1", url: "http://site/☃️?env=live&p=1" }, 100],
30 | ],
31 | [
32 | [{ path: "/☃️?p=1", url: "http://site/☃️?env=test&p=1" }, 200],
33 | [{ path: "/☃️?p=1", url: "http://site/☃️?env=live&p=1" }, 200],
34 | ],
35 | ]);
36 | });
37 | });
38 |
--------------------------------------------------------------------------------
/spec/log-spec.js:
--------------------------------------------------------------------------------
1 | const proxyquire = require("proxyquire");
2 | const Log = require("log");
3 |
4 | const consoleSpy = jasmine.createSpyObj("console", ["time", "timeEnd"]);
5 | const log = proxyquire("../src/log", {
6 | console: consoleSpy,
7 | });
8 |
9 | describe("log", () => {
10 | beforeEach(() => {
11 | consoleSpy.time.calls.reset();
12 | consoleSpy.timeEnd.calls.reset();
13 | });
14 |
15 | it("should log only the message", () => {
16 | spyOn(log.stream, "write");
17 |
18 | log.critical("野菜はおいしいです!");
19 |
20 | expect(log.stream.write).toHaveBeenCalledWith("野菜はおいしいです!\n");
21 | });
22 |
23 | it('should prefix errors with "ERROR:"', () => {
24 | spyOn(log.stream, "write");
25 |
26 | log.error("Everything broke!");
27 |
28 | expect(log.stream.write).toHaveBeenCalledWith("ERROR: Everything broke!\n");
29 | });
30 |
31 | it("should not use time and timeEnd when the level is higher than DEBUG", () => {
32 | log.level = Log.INFO;
33 | log.time("test");
34 | log.timeEnd("test");
35 |
36 | expect(consoleSpy.time).not.toHaveBeenCalled();
37 | expect(consoleSpy.timeEnd).not.toHaveBeenCalled();
38 | });
39 |
40 | it("should use time and timeEnd when the level is DEBUG", () => {
41 | log.level = Log.DEBUG;
42 | log.time("test");
43 | log.timeEnd("test");
44 |
45 | expect(consoleSpy.time).toHaveBeenCalledWith("test");
46 | expect(consoleSpy.timeEnd).toHaveBeenCalledWith("test");
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/src/capture.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const os = require("os");
3 | const log = require("./log");
4 | const identifier = require("./identifier");
5 |
6 | module.exports = async function capture(driver, page, viewport, config) {
7 | const captureId = identifier("capture");
8 |
9 | log.debug(`Capture identifier is ${captureId}`);
10 | log.time(captureId);
11 | const results = await doCapture(driver, captureId, page, viewport, config);
12 | log.timeEnd(captureId);
13 |
14 | return results;
15 | };
16 |
17 | async function doCapture(driver, captureId, page, viewport, config) {
18 | if (page.selectors) {
19 | const results = page.selectors.map(
20 | (selector, index) => new SelectorCaptureResult(selector, captureId, page, makeImagePath(`${captureId}-${index}`))
21 | );
22 |
23 | await driver.captureSelectors(results, page.url, viewport, config);
24 |
25 | return results;
26 | } else {
27 | const imagePath = makeImagePath(captureId);
28 | await driver.capturePage(imagePath, page.url, viewport, config);
29 |
30 | return [new PageCaptureResult(captureId, page, imagePath)];
31 | }
32 | }
33 |
34 | function makeImagePath(suffix) {
35 | return path.join(os.tmpdir(), `whoopsie-${suffix}.png`);
36 | }
37 |
38 | function PageCaptureResult(id, page, imagePath) {
39 | this.type = "page";
40 | this.id = id;
41 | this.page = page;
42 | this.imagePath = imagePath;
43 | }
44 |
45 | function SelectorCaptureResult(selector, id, page, imagePath) {
46 | this.type = "selector";
47 | this.selector = selector;
48 | this.id = id;
49 | this.page = page;
50 | this.imagePath = imagePath;
51 | }
52 |
--------------------------------------------------------------------------------
/spec/reporters/gallery-spec.js:
--------------------------------------------------------------------------------
1 | /* eslint handle-callback-err: "off" */
2 | const proxyquire = require("proxyquire");
3 |
4 | const templateSpy = jasmine.createSpy("template").and.returnValue("MOCK HTML");
5 |
6 | const galleryReporter = proxyquire("../../src/reporters/gallery", {
7 | "fs-extra": jasmine.createSpyObj("fs", ["readFile", "writeFile", "copy"]),
8 | "lodash/fp": {
9 | template: () => templateSpy,
10 | },
11 | });
12 |
13 | const mockDiff = (percentage) => {
14 | return {
15 | diff: {
16 | percentage,
17 | imagePath: "/tmp/diff.png",
18 | },
19 | base: { imagePath: "/tmp/base.png" },
20 | test: { imagePath: "/tmp/test.png" },
21 | };
22 | };
23 |
24 | const mockConfig = {
25 | outDir: "/tmp/whoopsie-test",
26 | failureThreshold: 10,
27 | };
28 |
29 | describe("galleryReporter()", () => {
30 | beforeEach(() => {
31 | templateSpy.calls.reset();
32 | });
33 |
34 | it("should order results by highest difference", (done) => {
35 | const diff1 = mockDiff(8);
36 | const diff2 = mockDiff(10);
37 | const mockOutput = {
38 | results: [diff1, diff2],
39 | };
40 |
41 | galleryReporter(mockOutput, mockConfig).then(() => {
42 | const results = templateSpy.calls.argsFor(0)[0].results;
43 |
44 | expect(results[0].diff.percentage).toEqual(10);
45 | expect(results[1].diff.percentage).toEqual(8);
46 |
47 | done();
48 | });
49 | });
50 |
51 | it("should correctly identify failures based on the threshold", (done) => {
52 | const mockOutput = {
53 | results: [mockDiff(8), mockDiff(10)],
54 | };
55 |
56 | galleryReporter(mockOutput, mockConfig).then(() => {
57 | const results = templateSpy.calls.argsFor(0)[0].results;
58 |
59 | expect(results[0].failed).toBe(true);
60 | expect(results[1].failed).toBe(false);
61 |
62 | done();
63 | });
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "whoopsie",
3 | "version": "1.3.1",
4 | "description": "Whoopsie is a visual regression tool for testing responsive web sites",
5 | "main": "src/index.js",
6 | "bin": "./bin/whoopsie.js",
7 | "scripts": {
8 | "test": "npm run lint && npm run jasmine",
9 | "lint": "eslint --fix \"src/**/*.js\" \"spec/**/*.js\"",
10 | "jasmine": "if [ -z $TRAVIS_JOB_ID ]; then nyc --all --exclude \"spec/**\" jasmine; else npm run jasmine-coveralls; fi",
11 | "jasmine-coveralls": "nyc --all --exclude \"spec/**\" --reporter=lcov jasmine && cat ./coverage/lcov.info | coveralls",
12 | "snyk-protect": "snyk protect",
13 | "prepublish": "npm run snyk-protect"
14 | },
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/wildlyinaccurate/whoopsie.git"
18 | },
19 | "keywords": [
20 | "visual",
21 | "regression",
22 | "wraith"
23 | ],
24 | "author": "Joseph Wynn ",
25 | "license": "ISC",
26 | "bugs": {
27 | "url": "https://github.com/wildlyinaccurate/whoopsie/issues"
28 | },
29 | "homepage": "https://github.com/wildlyinaccurate/whoopsie#readme",
30 | "dependencies": {
31 | "cartesian-product": "^2.1.2",
32 | "fs-extra": "^11.1.1",
33 | "image-diff": "^2.0.0",
34 | "js-yaml": "^3.6.1",
35 | "lodash": "^4.13.1",
36 | "log": "^1.4.0",
37 | "minimist": "^1.2.0",
38 | "mkdirp": "^3.0.1",
39 | "pixelmatch": "^5.3.0",
40 | "pngjs": "^7.0.0",
41 | "puppeteer": "^20.7.3",
42 | "queue": "^4.0.0",
43 | "snyk": "^1.234.0",
44 | "validate": "^3.0.1"
45 | },
46 | "devDependencies": {
47 | "coveralls": "^2.11.9",
48 | "eslint": "^8.43.0",
49 | "eslint-config-prettier": "^8.8.0",
50 | "eslint-plugin-prettier": "^4.2.1",
51 | "jasmine": "^2.8.0",
52 | "jasmine-spec-reporter": "^4.2.1",
53 | "nyc": "^11.1.0",
54 | "prettier": "^2.8.8",
55 | "proxyquire": "^1.7.9"
56 | },
57 | "snyk": true
58 | }
59 |
--------------------------------------------------------------------------------
/spec/config-spec.js:
--------------------------------------------------------------------------------
1 | const config = require("../src/config");
2 |
3 | const minimumValidConfig = {
4 | sites: ["site1", "site2"],
5 | viewports: [{ width: 200 }, { width: 300 }],
6 | pages: [{ path: "/" }],
7 | };
8 |
9 | describe("config.processFile()", () => {
10 | it("should accept a valid config file", (done) => {
11 | config.processFile("config/sample.yml").then(done);
12 | });
13 |
14 | it("should reject an invalid config file", (done) => {
15 | config.processFile("spec/support/invalid-config.yml").catch(done);
16 | });
17 |
18 | it("should reject when the file does not exist", (done) => {
19 | config.processFile("-").catch(done);
20 | });
21 | });
22 |
23 | describe("config.process()", () => {
24 | it("should accept a minimum valid config", (done) => {
25 | config.process(minimumValidConfig).then((actualConfig) => {
26 | expect(actualConfig).toEqual(jasmine.objectContaining(minimumValidConfig));
27 | done();
28 | });
29 | });
30 |
31 | it("should reject config with no sites", (done) => {
32 | config.process(modifyConfig({ sites: [] })).catch(done);
33 | });
34 |
35 | it("should reject config with one site", (done) => {
36 | config.process(modifyConfig({ sites: ["site1"] })).catch(done);
37 | });
38 |
39 | it("should reject config with more than two sites", (done) => {
40 | config.process(modifyConfig({ sites: ["site1", "site2", "site3"] })).catch(done);
41 | });
42 |
43 | it("should reject config with no viewports", (done) => {
44 | config.process(modifyConfig({ viewports: [] })).catch(done);
45 | });
46 |
47 | it("should reject config with no pages", (done) => {
48 | config.process(modifyConfig({ pages: [] })).catch(done);
49 | });
50 |
51 | it("should accept a config with optional values", (done) => {
52 | config
53 | .process(
54 | modifyConfig({
55 | headless: true,
56 | fuzz: 5,
57 | })
58 | )
59 | .then(done);
60 | });
61 | });
62 |
63 | function modifyConfig(changes) {
64 | return { ...minimumValidConfig, ...changes };
65 | }
66 |
--------------------------------------------------------------------------------
/src/test.js:
--------------------------------------------------------------------------------
1 | const { compose, filter, map, set } = require("lodash/fp");
2 | const Queue = require("queue");
3 | const testPermutations = require("./test-permutations");
4 | const capture = require("./capture");
5 | const compare = require("./compare");
6 | const drivers = require("./drivers");
7 | const log = require("./log");
8 |
9 | module.exports = async function test(config) {
10 | const driver = drivers[config.browser];
11 | const concurrency = Math.ceil(config.concurrency / 2);
12 |
13 | if (!driver) {
14 | throw new Error(`Unsupported browser "${config.browser}"`);
15 | }
16 |
17 | log.info(`Running tests in ${config.browser} with concurrency = ${config.concurrency}`);
18 |
19 | const results = [];
20 | const testPairs = testPermutations(config.sites.slice(0, 2), config.pages, config.viewports);
21 | const q = new Queue({ concurrency });
22 |
23 | q.on("success", (result) => results.push(...result));
24 |
25 | await driver.initialise(config);
26 |
27 | testPairs.forEach((pair) => {
28 | // Viewport is the same for both tuples
29 | const [page, viewport] = pair[0];
30 |
31 | q.push((cb) => {
32 | log.notice(`Testing ${page.name || page.path} at ${viewport.width}px`);
33 |
34 | capturePair(driver, pair, config)
35 | .then(diffCaptures)
36 | .then(map(set("viewport", viewport)))
37 | .then(map(setPassedAndFailed(config.failureThreshold)))
38 | .then((results) => cb(null, results));
39 | });
40 | });
41 |
42 | return new Promise((resolve) => {
43 | q.start(() => {
44 | driver.cleanUp();
45 |
46 | resolve(new TestResult(results));
47 | });
48 | });
49 | };
50 |
51 | async function capturePair(driver, pair, config) {
52 | const makeCapture = ([page, viewport]) => capture(driver, page, viewport, config);
53 |
54 | return Promise.all(pair.map(makeCapture));
55 | }
56 |
57 | async function diffCaptures([base, test]) {
58 | return compare(base, test);
59 | }
60 |
61 | function setPassedAndFailed(failureThreshold) {
62 | return (result) =>
63 | compose(
64 | set("failed", result.diff.percentage >= failureThreshold),
65 | set("passed", result.diff.percentage < failureThreshold)
66 | )(result);
67 | }
68 |
69 | function TestResult(results) {
70 | this.summary = makeSummary(results);
71 | this.results = results;
72 | }
73 |
74 | function makeSummary(results) {
75 | const total = results.length;
76 | const failures = filter("failed", results).length;
77 | const passes = total - failures;
78 |
79 | return { total, failures, passes };
80 | }
81 |
--------------------------------------------------------------------------------
/config/sample.yml:
--------------------------------------------------------------------------------
1 | # Two URLs to run tests against. The first is considered to be the baseline
2 | sites:
3 | - http://www.bbc.com
4 | - http://www.test.bbc.com
5 |
6 | # List of pages to test on each site
7 | pages:
8 | # Each page must have a path (relative to the site URLs).
9 | # By default, the whole page will be captured.
10 | - path: /news
11 |
12 | # Pages can also have a list of selectors. Rather than capturing the whole
13 | # page, an image of each selector will be captured.
14 | - path: /news
15 | selectors:
16 | - .nw-c-top-stories
17 | - .nw-c-must-see
18 | - .nw-c-full-story
19 | - .nw-c-most-read
20 |
21 | # Pages can be named to make test results easier to read
22 | - path: /news
23 | name: News Index
24 |
25 | # Viewport configurations to run tests with
26 | # Each viewport can have the following properties:
27 | # - width (required)
28 | # - height: (default: 1000)
29 | # - isMobile (default: false)
30 | # - javascriptDisabled (default: false)
31 | # - name
32 | viewports:
33 | - width: 640
34 | isMobile: true
35 |
36 | - width: 320
37 | height: 480
38 | isMobile: true
39 | javascriptDisabled: true
40 | name: Core Experience
41 |
42 | # Which browser to run the tests with. Available browsers:
43 | # - HeadlessChrome
44 | browser: HeadlessChrome
45 |
46 | # List of requests to block.
47 | # Request URLs are partially matched against each value
48 | blockRequests:
49 | - static.bbc.co.uk/bbcdotcom
50 | - bbc.co.uk/wwscripts
51 |
52 | # List of CSS selectors to hide (display: none) before capturing a screenshot
53 | ignoreSelectors:
54 | - '#blq-global'
55 | - '#orb-header'
56 | - '#orb-footer'
57 | - '.nw-c-breaking-news-banner'
58 | - '#breaking-news-container'
59 |
60 | # Where to store Whoopsie output
61 | outDir: whoopsie/
62 |
63 | # Input file for the generate-gallery command
64 | inFile: whoopsie/results.json
65 |
66 | # How different two pages should be (as a percentage) for a test to fail
67 | failureThreshold: 10
68 |
69 | # How many milliseconds the network should be idle for before capturing the page
70 | networkIdleTimeout: 3000
71 |
72 | # The maximum amount of milliseconds to wait for the network to be idle
73 | maxNetworkIdleWait: 10000
74 |
75 | # How much fuzz should be applied when calculating differences
76 | # See http://www.imagemagick.org/Usage/color_basics/#fuzz for a full explanation
77 | fuzz: 5
78 |
79 | # Whether to scroll to the end of the page before capturing a screenshot
80 | scroll: true
81 |
82 | # Whether to run the capture process in headless mode
83 | headless: true
84 |
--------------------------------------------------------------------------------
/src/reporters/gallery.js:
--------------------------------------------------------------------------------
1 | const { map, orderBy, set, template } = require("lodash/fp");
2 | const fs = require("fs-extra");
3 | const { mkdirp } = require("mkdirp");
4 | const path = require("path");
5 | const log = require("../log");
6 | const identifier = require("../identifier");
7 |
8 | module.exports = function galleryReporter(output, config) {
9 | const date = new Date().toISOString().split("T")[0];
10 | const galleryId = identifier(`gallery-${date}`);
11 | const galleryDir = path.resolve(path.join(config.outDir, galleryId));
12 | const galleryIndexPath = path.join(galleryDir, "index.html");
13 |
14 | log.info(`Generating gallery for ${output.results.length} results`);
15 | log.time(galleryId);
16 |
17 | return mkdirp(galleryDir)
18 | .then(() => fs.readFile(templatePath(), "utf8"))
19 | .then((view) => Promise.all([template(view), processOutput(galleryDir, output, config.failureThreshold)]))
20 | .then(([compiledTmpl, processedOutput]) => {
21 | return compiledTmpl({
22 | failureThreshold: config.failureThreshold,
23 | time: new Date(),
24 | ...processedOutput,
25 | });
26 | })
27 | .then((html) => fs.writeFile(galleryIndexPath, html))
28 | .then(() => {
29 | log.notice(`Gallery written to ${galleryIndexPath}`);
30 | log.timeEnd(galleryId);
31 | });
32 | };
33 |
34 | function templatePath() {
35 | return path.join(__dirname, "../../templates/gallery.html");
36 | }
37 |
38 | function processOutput(galleryDir, output, failureThreshold) {
39 | return Promise.resolve(output.results)
40 | .then(orderBy("diff.percentage", "desc"))
41 | .then(map((result) => setFailed(failureThreshold, result)))
42 | .then(map((result) => copyImages(galleryDir, result)))
43 | .then((results) => Promise.all(results))
44 | .then((results) => set("results", results, output));
45 | }
46 |
47 | function copyImages(galleryDir, result) {
48 | const baseImagePath = `${result.base.id}.png`;
49 | const testImagePath = `${result.test.id}.png`;
50 | const diffImagePath = `${result.diff.id}.png`;
51 |
52 | return Promise.all([
53 | fs.copy(result.base.imagePath, path.join(galleryDir, baseImagePath)),
54 | fs.copy(result.test.imagePath, path.join(galleryDir, testImagePath)),
55 | fs.copy(result.diff.imagePath, path.join(galleryDir, diffImagePath)),
56 | ]).then(() => {
57 | result.base.imagePath = baseImagePath;
58 | result.test.imagePath = testImagePath;
59 | result.diff.imagePath = diffImagePath;
60 |
61 | return result;
62 | });
63 | }
64 |
65 | function setFailed(failureThreshold, result) {
66 | return set("failed", result.diff.percentage >= failureThreshold, result);
67 | }
68 |
--------------------------------------------------------------------------------
/src/compare.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs-extra");
2 | const path = require("path");
3 | const PNG = require("pngjs").PNG;
4 | const pixelmatch = require("pixelmatch");
5 | const log = require("./log");
6 | const identifier = require("./identifier");
7 |
8 | // compareCaptures :: [CaptureResult] -> [CaptureResult] -> [CompareResult]
9 | module.exports = function compareCaptures(baseCaptures, testCaptures) {
10 | return Promise.all(baseCaptures.map((base, index) => compare(base, testCaptures[index])));
11 | };
12 |
13 | // compare :: CaptureResult -> CaptureResult -> CompareResult
14 | async function compare(baseCapture, testCapture) {
15 | const compareId = identifier("compare");
16 |
17 | log.info(`Comparing captures of ${baseCapture.page.url} and ${testCapture.page.url}`);
18 | log.debug(`Compare identifier is ${compareId}`);
19 | log.time(compareId);
20 |
21 | const [baseImageData, testImageData] = await Promise.all([
22 | fs.readFile(baseCapture.imagePath),
23 | fs.readFile(testCapture.imagePath),
24 | ]);
25 |
26 | let baseImage = PNG.sync.read(baseImageData);
27 | let testImage = PNG.sync.read(testImageData);
28 |
29 | const maxWidth = Math.max(baseImage.width, testImage.width);
30 | const maxHeight = Math.max(baseImage.height, testImage.height);
31 |
32 | if (baseImage.width !== testImage.width) {
33 | log.warning("Captured images are not the same width. Cannot proceed.");
34 | }
35 |
36 | if (baseImage.height < maxHeight) {
37 | log.debug(`Growing base image height from ${baseImage.height}px to ${maxHeight}px`);
38 | baseImage = { ...baseImage, height: maxHeight, data: growImageHeight(baseImage, maxHeight) };
39 | }
40 |
41 | if (testImage.height < maxHeight) {
42 | log.debug(`Growing test image height from ${testImage.height}px to ${maxHeight}px`);
43 | testImage = { ...testImage, height: maxHeight, data: growImageHeight(testImage, maxHeight) };
44 | }
45 |
46 | const diffImage = new PNG({ width: maxWidth, height: maxHeight });
47 | const diffPixels = pixelmatch(baseImage.data, testImage.data, diffImage.data, maxWidth, maxHeight);
48 | const diffImagePath = path.join(path.dirname(baseCapture.imagePath), `whoopsie-${compareId}.png`);
49 | fs.writeFileSync(diffImagePath, PNG.sync.write(diffImage));
50 |
51 | try {
52 | log.timeEnd(compareId);
53 |
54 | const totalPixels = maxWidth * maxHeight;
55 | const diffPercentage = (diffPixels / totalPixels) * 100;
56 |
57 | const result = {
58 | id: compareId,
59 | imagePath: diffImagePath,
60 | percentage: diffPercentage,
61 | total: diffPixels,
62 | };
63 |
64 | return new CompareResult(result, baseCapture, testCapture);
65 | } catch (error) {
66 | log.error("Unable to compare screenshots. The driver was probably unable to load one of the pages.");
67 | log.debug(`${compareId} diff generation failed: ${error}`);
68 | }
69 | }
70 |
71 | function CompareResult(diff, baseCapture, testCapture) {
72 | this.base = baseCapture;
73 | this.test = testCapture;
74 | this.diff = diff;
75 | }
76 |
77 | function growImageHeight(image, targetHeight) {
78 | const heightDiff = targetHeight - image.height;
79 | const diffBuffer = new Uint8Array(heightDiff * image.width * 4);
80 |
81 | for (let y = 0; y < heightDiff; y++) {
82 | for (let x = 0; x < image.width; x++) {
83 | const yi = (x + y) * 4;
84 |
85 | // Add a transparent pixel for each missing pixel
86 | diffBuffer[yi + 0] = 0;
87 | diffBuffer[yi + 1] = 0;
88 | diffBuffer[yi + 2] = 0;
89 | diffBuffer[yi + 3] = 0;
90 | }
91 | }
92 |
93 | return Buffer.concat([image.data, diffBuffer], targetHeight * image.width * 4);
94 | }
95 |
--------------------------------------------------------------------------------
/src/cli.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs-extra");
2 | const pkg = require("../package.json");
3 | const log = require("./log");
4 | const test = require("./test");
5 | const { processFile } = require("./config");
6 | const identifier = require("./identifier");
7 | const reporters = require("./reporters");
8 | const { getOr } = require("lodash/fp");
9 |
10 | const DEFAULT_REPORTERS = ["json"];
11 |
12 | module.exports = function cli(argv) {
13 | if (argv.verbose || argv.v) {
14 | log.level = log.INFO;
15 | } else if (argv.debug || argv.vv) {
16 | log.level = log.DEBUG;
17 | } else if (argv.quiet || argv.q) {
18 | log.level = log.ERROR;
19 | }
20 |
21 | const command = argv._[0];
22 | const commandIdentifier = identifier(`command$${command}`);
23 |
24 | log.debug(`Command identifier is ${commandIdentifier}`);
25 |
26 | log.time(commandIdentifier);
27 |
28 | runCommand(command, argv)
29 | .then(() => {
30 | log.timeEnd(commandIdentifier);
31 | process.exit(0);
32 | })
33 | .catch((error) => {
34 | log.error(error.message);
35 | log.debug(error);
36 | process.exit(1);
37 | });
38 | };
39 |
40 | async function runCommand(command, argv) {
41 | const getConfig = async () => {
42 | const configPath = getOr(getOr(".whoopsie-config.yml", "c", argv), "config", argv);
43 | log.debug(`Reading config from ${configPath}`);
44 | const config = await processFile(configPath);
45 |
46 | if (argv.concurrency) {
47 | config.concurrency = argv.concurrency;
48 | }
49 |
50 | return config;
51 | };
52 |
53 | switch (command) {
54 | case "gallery":
55 | return runTestCommand(await getConfig(), ["gallery"]);
56 |
57 | case "test":
58 | return runTestCommand(await getConfig(), getReporters(argv.reporter));
59 |
60 | case "generate-gallery":
61 | return generateGallery(await getConfig());
62 |
63 | case "validate-config":
64 | return getConfig().then(() => console.log("Configuration is valid."));
65 |
66 | case "version":
67 | return console.log(pkg.version);
68 |
69 | case "help":
70 | default:
71 | return usage();
72 | }
73 | }
74 |
75 | async function runTestCommand(config, reporters) {
76 | const output = await test(config);
77 |
78 | return reportOutput(output, config, reporters);
79 | }
80 |
81 | async function generateGallery(config) {
82 | return fs.readJSON(config.inFile).then((output) => reporters.gallery(output, config));
83 | }
84 |
85 | function getReporters(reporterNames = DEFAULT_REPORTERS) {
86 | return [].concat(reporterNames);
87 | }
88 |
89 | function reportOutput(output, config, useReporters) {
90 | const reporterPromises = [];
91 |
92 | useReporters.forEach((reporterName) => {
93 | const reporter = reporters[reporterName];
94 |
95 | if (reporter) {
96 | reporterPromises.push(reporter(output, config));
97 | } else {
98 | log.warning(`Reporter "${reporterName}" does not exist`);
99 | }
100 | });
101 |
102 | return Promise.all(reporterPromises);
103 | }
104 |
105 | function usage() {
106 | console.log(`
107 | Whoopsie v${pkg.version}
108 |
109 | Usage:
110 |
111 | whoopsie test Run visual regression tests and output raw JSON results using the configuration at
112 | whoopsie gallery Run visual regression tests and generate an HTML comparison gallery using the configuration at
113 | whoopsie validate-config Validate configuration at
114 | whoopsie generate-gallery Generate a gallery from JSON using the configuration at
115 | whoopsie version Show the program version
116 | whoopsie help Show this message
117 |
118 | Extra flags:
119 |
120 | --concurrency Number of tests to run concurrently (default: one per CPU core)
121 | --verbose Print test information while running (default: off)
122 | --debug Print extra debugging information while running (default: off)
123 | --quiet Only print errors and reporter output while running (default: off)
124 |
125 | `);
126 | }
127 |
--------------------------------------------------------------------------------
/src/config.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const schema = require("validate");
3 | const yaml = require("js-yaml");
4 |
5 | const DEFAULT_CONFIG = {
6 | blockRequests: [],
7 | browser: "HeadlessChrome",
8 | concurrency: 4,
9 | failureThreshold: 10,
10 | fuzz: 5,
11 | headless: true,
12 | ignoreSelectors: [],
13 | inFile: "whoopsie/results.json",
14 | maxNetworkIdleWait: 10000,
15 | networkIdleTimeout: 500,
16 | outDir: "whoopsie/",
17 | scroll: true,
18 | };
19 |
20 | // process :: Object -> Promise