Skip to content

Commit 98bcdfe

Browse files
patrickrodeeMatty Goo
authored andcommitted
feat: remote hosted screenshot testing (material-components#12)
1 parent 5fd6d86 commit 98bcdfe

File tree

10 files changed

+237
-115
lines changed

10 files changed

+237
-115
lines changed

.eslintrc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ rules:
99
require-jsdoc: off
1010
valid-jsdoc: off
1111
switch-colon-spacing: 0
12+
max-len: [error, 120]
13+
indent: [error, 2, {"SwitchCase":1}]

.travis.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ git:
55
dist: trusty
66
sudo: required
77

8+
branches:
9+
only:
10+
- master
11+
812
matrix:
913
include:
1014
- node_js: 8
@@ -24,5 +28,3 @@ matrix:
2428
- ./test/screenshot/start.sh
2529
- sleep 10s
2630
- npm run test:image-diff
27-
after_script:
28-
- COMMIT_HASH=$(git rev-parse --short HEAD) npm run upload:screenshots

package-lock.json

Lines changed: 33 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"scripts": {
66
"start": "webpack-dev-server --config test/screenshot/webpack.config.js --content-base test/screenshot",
77
"stop": "./test/screenshot/stop.sh",
8-
"capture": "./node_modules/.bin/mocha --compilers js:babel-core/register --ui tdd test/screenshot/capture-suite.js",
8+
"capture": "cross-env MDC_COMMIT_HASH=$(git rev-parse --short HEAD) MDC_BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD) mocha --compilers js:babel-core/register --ui tdd --timeout 30000 test/screenshot/capture-suite.js",
99
"commitmsg": "validate-commit-msg",
1010
"fix": "eslint --fix packages test",
1111
"lint": "eslint packages test",
@@ -15,7 +15,7 @@
1515
"test:watch": "karma start karma.local.js --auto-watch",
1616
"test:unit": "karma start karma.local.js --single-run",
1717
"test:unit-ci": "karma start karma.ci.js --single-run",
18-
"test:image-diff": "./node_modules/.bin/mocha --compilers js:babel-core/register --ui tdd --timeout 30000 test/screenshot/diff-suite.js",
18+
"test:image-diff": "cross-env MDC_COMMIT_HASH=$(git rev-parse --short HEAD) MDC_BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD) mocha --compilers js:babel-core/register --ui tdd --timeout 30000 test/screenshot/diff-suite.js",
1919
"upload:screenshots": "node ./test/screenshot/upload-screenshots.js"
2020
},
2121
"config": {
@@ -36,13 +36,12 @@
3636
"babel-preset-react": "^6.24.1",
3737
"capture-chrome": "^2.0.0",
3838
"chai": "^4.1.2",
39+
"cross-env": "^5.1.4",
3940
"css-loader": "^0.28.10",
4041
"eslint": "^3.19.0",
4142
"eslint-config-google": "^0.9.1",
4243
"eslint-plugin-react": "^7.7.0",
4344
"extract-text-webpack-plugin": "^3.0.2",
44-
"fs-readfile-promise": "^3.0.1",
45-
"glob": "^7.1.2",
4645
"husky": "^0.14.3",
4746
"istanbul": "^0.4.5",
4847
"istanbul-instrumenter-loader": "^3.0.0",

test/screenshot/golden.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"temporary-package/index.html": "57de91e42bd22a845074bae80f71eca3902ab7c6719f129960bc9018c79db95a",
3+
"temporary-package/foo.html": "91f0795700eaba345092dd7507e046daa0d33e1136bad7992011ab454ddb1faf"
4+
}

test/screenshot/screenshot.js

Lines changed: 173 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,209 @@
1+
import {Readable} from 'stream';
2+
import {createHash} from 'crypto';
3+
import {readFile, writeFile} from 'fs';
4+
import {promisify} from 'util';
15
import puppeteer from 'puppeteer';
26
import compareImages from 'resemblejs/compareImages';
3-
import {promisify} from 'util';
4-
import {readFile, writeFile} from 'fs';
57
import {assert} from 'chai';
8+
import Storage from '@google-cloud/storage';
69

710
import comparisonOptions from './screenshot-comparison-options';
811

912
const readFilePromise = promisify(readFile);
1013
const writeFilePromise = promisify(writeFile);
1114

15+
const serviceAccountKey = process.env.MDC_GCLOUD_SERVICE_ACCOUNT_KEY;
16+
const branchName = process.env.MDC_BRANCH_NAME;
17+
const commitHash = process.env.MDC_COMMIT_HASH;
18+
const goldenFilePath = './test/screenshot/golden.json';
19+
const bucketName = 'screenshot-uploads';
20+
const defaultMetadata = {
21+
commit: commitHash,
22+
branch: branchName,
23+
};
24+
25+
const storage = new Storage({
26+
credentials: JSON.parse(serviceAccountKey),
27+
});
28+
29+
const bucket = storage.bucket(bucketName);
30+
1231
export default class Screenshot {
32+
/**
33+
* @param {string} urlPath The URL path to test
34+
*/
1335
constructor(urlPath) {
36+
/** @private {string} */
1437
this.urlPath_ = urlPath;
15-
this.imagePath_ = `${urlPath}.golden.png`;
16-
this.snapshotImagePath_ = `${urlPath}.snapshot.png`;
17-
this.diffPath_ = `${urlPath}.diff.png`;
1838
// TODO allow clients to specify capture-chrome options, like viewport size
1939
}
2040

41+
/**
42+
* Captures a screenshot of the test URL and marks it as the new golden image
43+
*/
2144
capture() {
2245
test(this.urlPath_, async () => {
23-
const url = `http://localhost:8080/${this.urlPath_}`;
24-
const imagePath = `./test/screenshot/${this.imagePath_}`;
25-
await this.createScreenshotTask_(url, imagePath);
46+
const golden = await this.takeScreenshot_();
47+
const goldenHash = this.generateImageHash_(golden);
48+
const goldenPath = this.getImagePath_(goldenHash, 'golden');
49+
await Promise.all([
50+
this.saveImage_(goldenPath, golden),
51+
this.saveGoldenHash_(goldenHash),
52+
]);
2653
return;
2754
});
2855
}
2956

57+
/**
58+
* Diffs a screenshot of the test URL with the existing golden screenshot
59+
*/
3060
diff() {
3161
test(this.urlPath_, async () => {
32-
const url = `http://localhost:8080/${this.urlPath_}`;
33-
const imagePath = `./test/screenshot/${this.imagePath_}`;
34-
const snapshotImagePath = `./test/screenshot/${this.snapshotImagePath_}`;
35-
const diffPath = `./test/screenshot/${this.diffPath_}`;
36-
37-
const [newScreenshot, oldScreenshot] = await Promise.all([
38-
this.createScreenshotTask_(url, snapshotImagePath),
39-
readFilePromise(imagePath),
62+
// Get the golden file path from the golden hash
63+
const goldenHash = await this.getGoldenHash_();
64+
const goldenPath = this.getImagePath_(goldenHash, 'golden');
65+
66+
// Take a snapshot and download the golden iamge
67+
const [snapshot, golden] = await Promise.all([
68+
this.takeScreenshot_(),
69+
this.readImage_(goldenPath),
4070
]);
4171

42-
const data = await compareImages(newScreenshot, oldScreenshot,
43-
comparisonOptions);
72+
// Compare the images
73+
const data = await compareImages(snapshot, golden, comparisonOptions);
74+
const diff = data.getBuffer();
75+
76+
// Use the same hash for the snapshot path and diff path so it's easy can associate the two
77+
const snapshotHash = this.generateImageHash_(snapshot);
78+
const snapshotPath = this.getImagePath_(snapshotHash, 'snapshot');
79+
const diffPath = this.getImagePath_(snapshotHash, 'diff');
80+
const metadata = {golden: goldenHash};
81+
82+
// Save the snapshot and the diff
83+
await Promise.all([
84+
this.saveImage_(snapshotPath, snapshot, metadata),
85+
this.saveImage_(diffPath, diff, metadata),
86+
]);
87+
88+
return assert.isBelow(Number(data.misMatchPercentage), 0.01);
89+
});
90+
}
91+
92+
/**
93+
* Generates a unique hash from an image's contents
94+
* @param {!Buffer} imageBuffer The image buffer to hash
95+
* @return {string}
96+
* @private
97+
*/
98+
generateImageHash_(imageBuffer) {
99+
return createHash('sha256').update(imageBuffer).digest('hex');
100+
}
101+
102+
/**
103+
* Returns the golden hash
104+
* @return {string|undefined}
105+
* @private
106+
*/
107+
async getGoldenHash_() {
108+
const goldenFile = await readFilePromise(goldenFilePath);
109+
const goldenJSON = JSON.parse(goldenFile);
110+
return goldenJSON[this.urlPath_];
111+
}
112+
113+
/**
114+
* Returns the correct image path
115+
* @param {string} imageHash The image hash
116+
* @param {string} imageType The image type
117+
* @return {string|undefined}
118+
* @private
119+
*/
120+
getImagePath_(imageHash, imageType) {
121+
if (imageType === 'golden') {
122+
return `${this.urlPath_}/${imageHash}.golden.png`;
123+
}
124+
125+
if (['snapshot', 'diff'].includes(imageType)) {
126+
return `${this.urlPath_}/${commitHash}/${imageHash}.${imageType}.png`;
127+
}
128+
}
129+
130+
/**
131+
* Downloads an image from Google Cloud Storage
132+
* @param {string} gcsFilePath The file path on Google Cloud Storage
133+
* @return {Buffer}
134+
* @private
135+
*/
136+
async readImage_(gcsFilePath) {
137+
const data = await bucket.file(gcsFilePath).download();
138+
return data[0];
139+
}
44140

45-
await writeFilePromise(diffPath, data.getBuffer());
141+
/**
142+
* Saves the golden hash
143+
* @param {string} goldenHash The hash of the golden image
144+
* @private
145+
*/
146+
async saveGoldenHash_(goldenHash) {
147+
const goldenFile = await readFilePromise(goldenFilePath);
148+
const goldenJSON = JSON.parse(goldenFile);
149+
goldenJSON[this.urlPath_] = goldenHash;
150+
const goldenContent = JSON.stringify(goldenJSON, null, ' ');
151+
await writeFilePromise(goldenFilePath, `${goldenContent}\r\n`);
152+
}
46153

47-
assert.isBelow(Number(data.misMatchPercentage), 0.01);
154+
/**
155+
* Saves the given image to Google Cloud Storage with optional metadata
156+
* @param {string} imagePath The path to the image
157+
* @param {!Buffer} imageBuffer The image buffer
158+
* @param {!Object=} customMetadata Optional custom metadata
159+
* @private
160+
*/
161+
async saveImage_(imagePath, imageBuffer, customMetadata={}) {
162+
const metadata = Object.assign({}, defaultMetadata, customMetadata);
163+
const file = bucket.file(imagePath);
164+
165+
// Check if file exists and exit if it does
166+
const [exists] = await file.exists();
167+
if (exists) {
168+
console.log('✔︎ No changes to', imagePath);
48169
return;
170+
}
171+
172+
// Create a new stream from the image buffer
173+
let stream = new Readable();
174+
stream.push(imageBuffer);
175+
stream.push(null);
176+
177+
// The promise is resolved or rejected inside the stream event callbacks
178+
return new Promise((resolve, reject) => {
179+
stream.pipe(file.createWriteStream())
180+
.on('error', (err) => {
181+
reject(err);
182+
}).on('finish', async () => {
183+
try {
184+
// Make the image public and set it's metadata
185+
await file.makePublic();
186+
await file.setMetadata({metadata});
187+
console.log('✔︎ Uploaded', imagePath);
188+
resolve();
189+
} catch (err) {
190+
reject(err);
191+
}
192+
});
49193
});
50194
}
51195

52-
async createScreenshotTask_(url, path) {
196+
/**
197+
* Takes a screenshot of the URL
198+
* @return {!Buffer}
199+
* @private
200+
*/
201+
async takeScreenshot_() {
53202
const browser = await puppeteer.launch();
54203
const page = await browser.newPage();
55-
await page.goto(url);
56-
const image = await page.screenshot({path});
204+
await page.goto(`http://localhost:8080/${this.urlPath_}`);
205+
const imageBuffer = await page.screenshot();
57206
await browser.close();
58-
return image;
207+
return imageBuffer;
59208
}
60209
}
Binary file not shown.
Binary file not shown.

0 commit comments

Comments
 (0)