├── .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 21 | module.exports.process = function process(config) { 22 | const mergedConfig = { ...DEFAULT_CONFIG, ...config }; 23 | 24 | return new Promise((resolve, reject) => { 25 | const errors = makeSchema().validate(mergedConfig); 26 | 27 | if (errors.length > 0) { 28 | reject(makeError(errors)); 29 | } else { 30 | resolve(mergedConfig); 31 | } 32 | }); 33 | }; 34 | 35 | // processFile :: String -> Promise 36 | module.exports.processFile = async function processFile(path) { 37 | return readConfigFile(path).then(yaml.safeLoad).then(module.exports.process); 38 | }; 39 | 40 | function makeSchema() { 41 | return schema({ 42 | sites: { 43 | type: "array", 44 | required: true, 45 | use: (x) => x.length === 2, 46 | message: 'Exactly 2 "sites" values must be specified', 47 | }, 48 | 49 | viewports: [ 50 | { 51 | width: { 52 | type: "number", 53 | required: true, 54 | message: 'A "width" value must be specified for each "viewports" object', 55 | }, 56 | isMobile: { 57 | type: "boolean", 58 | message: 'The value for "isMobile" must be a boolean', 59 | }, 60 | }, 61 | ], 62 | 63 | pages: { 64 | type: "array", 65 | required: true, 66 | use: (x) => x.length > 0, 67 | message: 'At least one "pages" value must be specified', 68 | }, 69 | 70 | blockRequests: { 71 | type: "array", 72 | required: true, 73 | message: 'The value for "blockRequests" must be an array', 74 | }, 75 | 76 | browser: { 77 | type: "string", 78 | required: true, 79 | message: 'A value is required for "browser"', 80 | }, 81 | 82 | concurrency: { 83 | type: "number", 84 | required: true, 85 | }, 86 | 87 | inFile: { 88 | type: "string", 89 | }, 90 | 91 | outDir: { 92 | type: "string", 93 | required: true, 94 | message: 'A value is required for "outDir"', 95 | }, 96 | 97 | failureThreshold: { 98 | type: "number", 99 | required: true, 100 | message: 'A value is required for "failureThreshold"', 101 | }, 102 | 103 | networkIdleTimeout: { 104 | type: "number", 105 | message: 'The value for for "networkIdleTimeout" must be numeric', 106 | }, 107 | 108 | maxNetworkIdleWait: { 109 | type: "number", 110 | message: 'The value for for "maxNetworkIdleWait" must be numeric', 111 | }, 112 | 113 | ignoreSelectors: { 114 | type: "array", 115 | message: 'The value for "ignoreSelectors" must be an array', 116 | }, 117 | 118 | renderWaitTime: { 119 | type: "number", 120 | message: 'The value for "renderWaitTime" must be numeric', 121 | }, 122 | 123 | fuzz: { 124 | type: "number", 125 | message: 'The value for "fuzz" must be numeric', 126 | }, 127 | 128 | headless: { 129 | type: "boolean", 130 | message: 'The value for "headless" must be a boolean', 131 | }, 132 | 133 | scroll: { 134 | type: "boolean", 135 | message: 'The value for "scroll" must be a boolean', 136 | }, 137 | }); 138 | } 139 | 140 | function readConfigFile(path = "") { 141 | return new Promise((resolve, reject) => { 142 | fs.readFile(path, (err, contents) => { 143 | if (err) { 144 | reject(new Error("Configuration file does not exist or is not readable.")); 145 | } else { 146 | resolve(contents); 147 | } 148 | }); 149 | }); 150 | } 151 | 152 | function makeError(errors) { 153 | const combinedErrors = errors.map((e) => ` * ${e.message}`).join("\n"); 154 | 155 | return new Error(`Configuration validation failed.\n\n${combinedErrors}`); 156 | } 157 | -------------------------------------------------------------------------------- /src/drivers/headless-chrome.js: -------------------------------------------------------------------------------- 1 | const { some } = require("lodash/fp"); 2 | const puppeteer = require("puppeteer"); 3 | const log = require("../log"); 4 | 5 | module.exports = { 6 | initialise, 7 | cleanUp, 8 | capturePage, 9 | captureSelectors, 10 | }; 11 | 12 | const DEFAULT_VIEWPORT_HEIGHT = 1000; 13 | 14 | let browser = null; 15 | 16 | async function initialise(config) { 17 | browser = await puppeteer.launch({ 18 | headless: config.headless ? "new" : false, 19 | }); 20 | 21 | const version = await browser.version(); 22 | 23 | log.debug(`Browser version is ${version}`); 24 | } 25 | 26 | async function cleanUp() { 27 | await browser.close(); 28 | } 29 | 30 | async function capturePage(imagePath, url, viewport, config) { 31 | const page = await loadPage(url, viewport, config); 32 | 33 | try { 34 | await page.screenshot({ 35 | path: imagePath, 36 | fullPage: true, 37 | }); 38 | } catch (e) { 39 | log.error(`Failed to capture full page screenshot for ${url}`); 40 | log.debug(e); 41 | } 42 | 43 | await page.close(); 44 | } 45 | 46 | async function captureSelectors(selectors, url, viewport, config) { 47 | const page = await loadPage(url, viewport, config); 48 | 49 | const capturePromises = selectors.map(async function (selector) { 50 | const boundingClientRect = await page.evaluate((selector) => { 51 | const element = document.querySelector(selector); 52 | const rect = element.getBoundingClientRect(); 53 | 54 | return JSON.stringify({ ...rect, y: rect.y + window.pageYOffset }); 55 | }, selector.selector); 56 | 57 | try { 58 | return page.screenshot({ 59 | clip: JSON.parse(boundingClientRect), 60 | path: selector.imagePath, 61 | }); 62 | } catch (e) { 63 | log.error(`Failed to capture screenshot of selector ${selector.selector} for ${url}`); 64 | log.debug(e); 65 | } 66 | }); 67 | 68 | return Promise.all(capturePromises).then(() => page.close()); 69 | } 70 | 71 | async function loadPage(url, viewport, config) { 72 | const width = viewport.width; 73 | const height = viewport.height || DEFAULT_VIEWPORT_HEIGHT; 74 | 75 | log.debug("Setting up page"); 76 | const page = await browser.newPage(); 77 | await page.setViewport({ width, height }); 78 | 79 | if (viewport.javascriptDisabled) { 80 | await page.setJavaScriptEnabled(false); 81 | } 82 | 83 | if (config.blockRequests && config.blockRequests.length) { 84 | await page.setRequestInterception(true); 85 | 86 | // Request interceptor to block requests that match the "blockRequests" config 87 | page.on("request", (req) => { 88 | const matchesRequest = (pattern) => new RegExp(pattern).test(req.url); 89 | 90 | if (some(matchesRequest, config.blockRequests)) { 91 | log.debug(`Blocking request ${req.url} on ${url}`); 92 | req.abort(); 93 | } else { 94 | req.continue(); 95 | } 96 | }); 97 | } 98 | 99 | try { 100 | log.info(`Loading URL ${url}`); 101 | await page.goto(url); 102 | } catch (e) { 103 | log.warning(`Failed to load ${url} at ${width}px. Reloading page to try again.`); 104 | log.debug(e); 105 | 106 | await page.reload(); 107 | } 108 | 109 | log.debug(`Waiting for network idle (${config.networkIdleTimeout} ms)`); 110 | await waitForNetworkIdle(page, config); 111 | 112 | // Set all "ignoredSelectors" elements to display: none 113 | if (config.ignoreSelectors && config.ignoreSelectors.length) { 114 | log.debug(`Hiding ignored selectors: ${JSON.stringify(config.ignoreSelectors)}`); 115 | await page.evaluate((selectors) => { 116 | selectors.forEach((selector) => { 117 | document.querySelectorAll(selector).forEach((element) => { 118 | element.style.display = "none"; 119 | }); 120 | }); 121 | }, config.ignoreSelectors); 122 | } 123 | 124 | // Scroll to the bottom of the page to trigger any lazy-loading 125 | if (config.scroll) { 126 | log.debug("Scrolling to page end"); 127 | await page.evaluate(() => { 128 | window.scrollTo(0, document.body.clientHeight); 129 | }); 130 | 131 | // Wait for any navigation triggered by lazy-loading to finish 132 | log.debug(`Waiting for network idle in case of lazy-loaded content (${config.networkIdleTimeout} ms)`); 133 | await waitForNetworkIdle(page, config); 134 | } 135 | 136 | return page; 137 | } 138 | 139 | async function waitForNetworkIdle(page, config) { 140 | try { 141 | await page.waitForNetworkIdle({ 142 | idleTime: config.networkIdleTimeout, 143 | timeout: config.maxNetworkIdleWait, 144 | }); 145 | } catch (error) { 146 | log.warning( 147 | `Timed out while waiting ${config.maxNetworkIdleWait}ms for ${config.networkIdleTimeout}ms of network idle time` 148 | ); 149 | log.debug(error); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Whoopsie 2 | 3 | [![Build Status](https://img.shields.io/travis/wildlyinaccurate/whoopsie.svg?style=flat-square)](https://travis-ci.org/wildlyinaccurate/whoopsie) 4 | [![Coverage Status](https://img.shields.io/coveralls/wildlyinaccurate/whoopsie.svg?style=flat-square)](https://coveralls.io/repos/github/wildlyinaccurate/whoopsie/badge.svg?branch=master) 5 | 6 | Whoopsie is a visual regression tool for testing responsive web sites. 7 | 8 | ## Installation 9 | 10 | ``` 11 | $ npm install -g whoopsie 12 | ``` 13 | 14 | ## Configuration 15 | 16 | By default Whoopsie will read configuration from `.whoopsie-config.yml` in the current directory. See [config/sample.yml](./config/sample.yml) for a sample configuration file. 17 | 18 | Configuration can be loaded from another path with the `--config` or `-c` flag: 19 | 20 | ``` 21 | $ whoopsie test -c path/to/config.yml 22 | ``` 23 | 24 | ## Usage 25 | 26 | ### Commands 27 | 28 | #### `gallery` 29 | 30 | Run visual regression tests and generate an HTML gallery containing the results. This command is an alias for `whoopsie test --reporter gallery`. 31 | 32 | ``` 33 | $ whoopsie gallery 34 | ``` 35 | 36 | #### `test` 37 | 38 | Run visual regression tests. Uses the `json` reporter by default. 39 | 40 | ``` 41 | $ whoopsie test 42 | ``` 43 | 44 | #### `generate-gallery` 45 | 46 | Generate a gallery from the JSON output of `whoopsie test`. Useful if you generate JSON results in CI and want to view the results in a gallery locally. 47 | 48 | ``` 49 | $ whoopsie generate-gallery 50 | ``` 51 | 52 | #### `validate-config` 53 | 54 | Check that the configuration file is valid. 55 | 56 | ``` 57 | $ whoopsie validate-config 58 | ``` 59 | 60 | ### Options 61 | 62 | | Name | Default value | Description | 63 | |-------------------------|--------------------|----------------------------------------------------------| 64 | | `--reporter` or `r` | `json` | Test result reporter(s) to use | 65 | | `--concurrency` or `-C` | `4` | Number of tests to run concurrently | 66 | | `--verbose` or `-v` | `` | Print extra information while running | 67 | | `--debug` or `-vv` | `` | Print extra information and debug messages while running | 68 | | `--quiet` or `-q` | `` | Only print errors while running | 69 | 70 | ## Reporters 71 | 72 | Reporters can be specified when running `whoopsie test` by passing the `--reporter` flag. More than one reporter can be specified. The default reporter is `json`. 73 | 74 | ### `gallery` 75 | 76 | Outputs test results as an HTML gallery. 77 | 78 | [![](./example-output.png)](./example-output.png) 79 | 80 | ### `json` 81 | 82 | Outputs test results as JSON. 83 | 84 | ```json 85 | { 86 | "summary": { 87 | "total": 2, 88 | "failures": 0, 89 | "passes": 2 90 | }, 91 | "results": [ 92 | { 93 | "base": { 94 | "type": "selector", 95 | "selector": ".nw-c-top-stories", 96 | "id": "capture$ad367858", 97 | "page": { 98 | "path": "/news", 99 | "selectors": [ 100 | ".nw-c-top-stories", 101 | ".nw-c-must-see" 102 | ], 103 | "url": "http://www.bbc.com/news" 104 | }, 105 | "imagePath": "/tmp/whoopsie-capture$ad367858-0.png" 106 | }, 107 | "test": { 108 | "type": "selector", 109 | "selector": ".nw-c-top-stories", 110 | "id": "capture$c1dbebb0", 111 | "page": { 112 | "path": "/news", 113 | "selectors": [ 114 | ".nw-c-top-stories", 115 | ".nw-c-must-see" 116 | ], 117 | "url": "http://www.test.bbc.com/news" 118 | }, 119 | "imagePath": "/tmp/whoopsie-capture$c1dbebb0-0.png" 120 | }, 121 | "diff": { 122 | "total": 0, 123 | "percentage": 0, 124 | "id": "compare$520b7196", 125 | "imagePath": "/tmp/whoopsie-compare$520b7196.png" 126 | }, 127 | "viewport": { 128 | "width": 640, 129 | "isMobile": true 130 | } 131 | }, 132 | { 133 | "base": { ... }, 134 | "test": { ... }, 135 | "diff": { ... }, 136 | "viewport": { ... } 137 | } 138 | ] 139 | } 140 | ``` 141 | 142 | ## Docker 143 | 144 | If you prefer to run Whoopsie in a container, you can use the official Docker image: 145 | 146 | ``` 147 | $ docker pull wildlyinaccurate/whoopsie 148 | $ docker run --rm --volume $PWD:/whoopsie --workdir /whoopsie \ 149 | wildlyinaccurate/whoopsie \ 150 | whoopsie gallery 151 | ``` 152 | 153 | ## License 154 | 155 | [ISC](./LICENSE) 156 | -------------------------------------------------------------------------------- /templates/gallery.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Whoopsie Results 6 | 7 | 8 | 9 | 56 | 57 | 58 |
59 |
60 |

Test Result

61 | 62 |

63 | <%= summary.passes %> passes / 64 | <%= summary.failures %> failures 65 |

66 | 67 |

68 | <%= summary.total %> tests run at <%= time %> with <%= failureThreshold %>% failure threshold 69 |

70 |
71 |
72 | 73 |
74 |
75 | 76 | 77 |
78 |
79 | 80 | <% results.forEach(function(result) { %> 81 |
82 |
83 |
84 |

<%= result.base.page.name ? result.base.page.name : result.base.page.path %>

85 |
86 | 87 |
88 |

<%= result.base.page.site %> (<%= result.viewport.name ? result.viewport.name : result.viewport.width + 'px' %>)

89 |
90 | 91 |
92 |
93 | 94 |
95 |

<%= result.test.page.site %> (<%= result.viewport.name ? result.viewport.name : result.viewport.width + 'px' %>)

96 |
97 | 98 |
99 |
100 | 101 |
102 |

103 | <%= result.diff.percentage.toFixed(2) %>% difference 104 |

105 |
106 | 107 |
108 |
109 |
110 | 111 |
112 |
113 | 114 |
115 |
116 |
117 | <% }); %> 118 | 119 | 152 | 153 | 154 | --------------------------------------------------------------------------------