Skip to content

Commit a34787d

Browse files
devversionjelbourn
authored andcommitted
chore: screenshots task code cleanup (#5416)
Attempt to make the screenshot task more readable and easier to understand. The screenshot task includes a lot of magic (due to the Firebase functions JWT solution) and we should try our best to make it as readable as possible.
1 parent bb316cb commit a34787d

File tree

1 file changed

+133
-110
lines changed

1 file changed

+133
-110
lines changed

tools/gulp/tasks/screenshots.ts

Lines changed: 133 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,26 @@
11
import {task} from 'gulp';
22
import {readdirSync, statSync, existsSync, mkdirp, readFileSync, writeFileSync} from 'fs-extra';
3+
import {openScreenshotsBucket, connectFirebaseScreenshots} from '../util/firebase';
4+
import {isTravisMasterBuild} from '../util/travis-ci';
5+
36
import * as path from 'path';
47
import * as firebaseAdmin from 'firebase-admin';
5-
import * as firebase from 'firebase';
6-
import {
7-
openScreenshotsBucket,
8-
connectFirebaseScreenshots} from '../util/firebase';
9-
import {isTravisMasterBuild} from '../util/travis-ci';
108

9+
// Firebase provides TypeScript definitions that are only accessible from specific namespaces.
10+
// This means that those types are really long and it's nearly impossible to write a function that
11+
// doesn't exceed the maximum columns. Import the types from the namespace so they are shorter.
12+
import Database = firebaseAdmin.database.Database;
13+
import DataSnapshot = firebaseAdmin.database.DataSnapshot;
14+
15+
// This import lacks of type definitions.
1116
const imageDiff = require('image-diff');
1217

18+
/** Travis secure token that will be used by the Screenshot functions to verify the identity. */
19+
const travisSecureToken = getSecureToken();
20+
21+
/** Git SHA of the current Pull Request being checked by Travis. */
22+
const pullRequestSha = process.env['TRAVIS_PULL_REQUEST_SHA'];
23+
1324
const SCREENSHOT_DIR = './screenshots';
1425
const LOCAL_GOLDENS = path.join(SCREENSHOT_DIR, `golds`);
1526
const LOCAL_DIFFS = path.join(SCREENSHOT_DIR, `diff`);
@@ -29,157 +40,169 @@ task('screenshots', () => {
2940

3041
if (isTravisMasterBuild()) {
3142
// Only update goldens for master build
32-
return uploadScreenshots();
43+
return uploadGoldenScreenshots();
3344
} else if (prNumber) {
3445
const firebaseApp = connectFirebaseScreenshots();
3546
const database = firebaseApp.database();
3647

37-
return updateTravis(database, prNumber)
38-
.then(() => getScreenshotFiles(database))
39-
.then(() => downloadAllGoldsAndCompare(database, prNumber))
40-
.then((results: boolean) => updateResult(database, prNumber, results))
48+
return uploadTravisJobInfo(database, prNumber)
49+
.then(() => downloadGoldScreenshotFiles(database))
50+
.then(() => compareScreenshotFiles(database, prNumber))
51+
.then(passedAll => setPullRequestResult(database, prNumber, passedAll))
4152
.then(() => uploadScreenshotsData(database, 'diff', prNumber))
4253
.then(() => uploadScreenshotsData(database, 'test', prNumber))
4354
.catch((err: any) => console.error(err))
4455
.then(() => firebaseApp.delete());
4556
}
4657
});
4758

48-
function updateFileResult(database: firebase.database.Database, prNumber: string,
49-
filenameKey: string, result: boolean) {
50-
return getPullRequestRef(database, prNumber).child('results').child(filenameKey).set(result);
59+
/** Sets the screenshot diff result for a given file of a Pull Request. */
60+
function setFileResult(database: Database, prNumber: string, fileName: string, result: boolean) {
61+
return getPullRequestRef(database, prNumber).child('results').child(fileName).set(result);
5162
}
5263

53-
function updateResult(database: firebase.database.Database, prNumber: string, result: boolean) {
54-
return getPullRequestRef(database, prNumber).child('result')
55-
.child(process.env['TRAVIS_PULL_REQUEST_SHA']).set(result).then(() => result);
64+
/** Sets the full diff result for the current Pull Request that runs inside of Travis. */
65+
function setPullRequestResult(database: Database, prNumber: string, result: boolean) {
66+
return getPullRequestRef(database, prNumber).child('result').child(pullRequestSha).set(result);
5667
}
5768

58-
function getPullRequestRef(database: firebase.database.Database | firebaseAdmin.database.Database,
59-
prNumber: string) {
60-
let secureToken = getSecureToken();
61-
return database.ref(FIREBASE_REPORT).child(prNumber).child(secureToken);
69+
/** Returns the Firebase Reference that contains all data related to the specified PR. */
70+
function getPullRequestRef(database: Database, prNumber: string) {
71+
return database.ref(FIREBASE_REPORT).child(prNumber).child(travisSecureToken);
6272
}
6373

64-
function updateTravis(database: firebase.database.Database,
65-
prNumber: string) {
74+
/** Uploads necessary Travis CI job variables that will be used in the Screenshot Panel. */
75+
function uploadTravisJobInfo(database: Database, prNumber: string) {
6676
return getPullRequestRef(database, prNumber).update({
6777
sha: process.env['TRAVIS_PULL_REQUEST_SHA'],
6878
travis: process.env['TRAVIS_JOB_ID'],
6979
});
7080
}
7181

72-
/** Get a list of filenames from firebase database. */
73-
function getScreenshotFiles(database: firebase.database.Database) {
82+
/** Downloads all golden screenshot files and stores them in the local file system. */
83+
function downloadGoldScreenshotFiles(database: Database) {
84+
// Create the directory that will contain all goldens if it's not present yet.
7485
mkdirp(LOCAL_GOLDENS);
75-
mkdirp(LOCAL_DIFFS);
76-
77-
return database.ref(FIREBASE_DATA_GOLDENS).once('value')
78-
.then((snapshot: firebase.database.DataSnapshot) => {
79-
let counter = 0;
80-
snapshot.forEach((childSnapshot: firebase.database.DataSnapshot) => {
81-
let key = childSnapshot.key;
82-
let binaryData = new Buffer(childSnapshot.val(), 'base64').toString('binary');
83-
writeFileSync(`${LOCAL_GOLDENS}/${key}.screenshot.png`, binaryData, 'binary');
84-
counter++;
85-
return counter == snapshot.numChildren();
86+
87+
return database.ref(FIREBASE_DATA_GOLDENS).once('value').then(snapshot => {
88+
snapshot.forEach((childSnapshot: DataSnapshot) => {
89+
const screenshotName = childSnapshot.key;
90+
const binaryData = new Buffer(childSnapshot.val(), 'base64').toString('binary');
91+
92+
writeFileSync(`${LOCAL_GOLDENS}/${screenshotName}.screenshot.png`, binaryData, 'binary');
8693
});
87-
}).catch((error: any) => console.log(error));
94+
});
8895
}
8996

97+
/** Extracts the name of a given screenshot file by removing the file extension. */
9098
function extractScreenshotName(fileName: string) {
9199
return path.basename(fileName, '.screenshot.png');
92100
}
93101

94-
function getLocalScreenshotFiles(dir: string): string[] {
95-
return readdirSync(dir)
96-
.filter((fileName: string) => !statSync(path.join(dir, fileName)).isDirectory())
102+
/** Gets a list of files inside of a directory that end with `.screenshot.png`. */
103+
function getLocalScreenshotFiles(directory: string): string[] {
104+
return readdirSync(directory)
105+
.filter((fileName: string) => !statSync(path.join(directory, fileName)).isDirectory())
97106
.filter((fileName: string) => fileName.endsWith('.screenshot.png'));
98107
}
99108

100109
/**
101-
* Get processed secure token. The jwt token has 3 parts: header, payload, signature and has format
102-
* {jwtHeader}.{jwtPayload}.{jwtSignature}
103-
* The three parts is connected by '.', while '.' is not a valid path in firebase database.
104-
* Replace all '.' to '/' to make the path valid
105-
* Output is {jwtHeader}/{jwtPayload}/{jwtSignature}.
106-
* This secure token is used to validate the write access is from our TravisCI under our repo.
107-
* All data is written to /$path/$secureToken/$data and after validated the
108-
* secure token, the data is moved to /$path/$data in database.
110+
* Upload screenshots to a Firebase Database path that will then upload the file to a Google
111+
* Cloud Storage bucket if the Auth token is valid.
112+
* @param database Firebase database instance.
113+
* @param prNumber The key used in firebase. Here it is the PR number.
114+
* @param mode Upload mode. This can be either 'test' or 'diff'.
115+
* - If the images are the test results, mode should be 'test'.
116+
* - If the images are the diff images generated, mode should be 'diff'.
109117
*/
110-
function getSecureToken() {
111-
return process.env['FIREBASE_ACCESS_TOKEN'].replace(/[.]/g, '/');
112-
}
113-
114-
/**
115-
* Upload screenshots to google cloud storage.
116-
* @param prNumber - The key used in firebase. Here it is the PR number.
117-
* @param mode - Can be 'test' or 'diff' .
118-
* If the images are the test results, mode should be 'test'.
119-
* If the images are the diff images generated, mode should be 'diff'.
120-
*/
121-
function uploadScreenshotsData(database: firebase.database.Database,
122-
mode: 'test' | 'diff', prNumber: string) {
123-
let localDir = mode == 'diff' ? path.join(SCREENSHOT_DIR, 'diff') : SCREENSHOT_DIR;
124-
let promises = getLocalScreenshotFiles(localDir).map((file: string) => {
125-
let fileName = path.join(localDir, file);
126-
let filenameKey = extractScreenshotName(fileName);
127-
let secureToken = getSecureToken();
128-
let data = readFileSync(fileName);
129-
return database.ref(FIREBASE_IMAGE).child(prNumber)
130-
.child(secureToken).child(mode).child(filenameKey).set(data);
131-
});
132-
return Promise.all(promises);
118+
function uploadScreenshotsData(database: Database, mode: 'test' | 'diff', prNumber: string) {
119+
const localDir = mode == 'diff' ? path.join(SCREENSHOT_DIR, 'diff') : SCREENSHOT_DIR;
120+
121+
return Promise.all(getLocalScreenshotFiles(localDir).map(file => {
122+
const filePath = path.join(localDir, file);
123+
const fileName = extractScreenshotName(filePath);
124+
const binaryContent = readFileSync(filePath);
125+
126+
// Upload the Buffer of the screenshot image to a Firebase Database reference that will
127+
// then upload the screenshot file to a Google Cloud Storage bucket if the JWT token is valid.
128+
return database.ref(FIREBASE_IMAGE)
129+
.child(prNumber).child(travisSecureToken).child(mode).child(fileName)
130+
.set(binaryContent);
131+
}));
133132
}
134133

134+
/** Concurrently compares every golden screenshot with the newly taken screenshots. */
135+
function compareScreenshotFiles(database: Database, prNumber: string) {
136+
const fileNames = getLocalScreenshotFiles(LOCAL_GOLDENS);
137+
const compares = fileNames.map(fileName => compareScreenshotFile(fileName, database, prNumber));
135138

136-
/** Download golds screenshots. */
137-
function downloadAllGoldsAndCompare(database: firebase.database.Database, prNumber: string) {
139+
// Wait for all compares to finish and then return a Promise that resolves with a boolean that
140+
// shows whether the tests passed or not.
141+
return Promise.all(compares).then((results: boolean[]) => results.every(Boolean));
142+
}
138143

139-
let filenames = getLocalScreenshotFiles(LOCAL_GOLDENS);
144+
/** Compare the specified screenshot file with the golden file from Firebase. */
145+
function compareScreenshotFile(fileName: string, database: Database, prNumber: string) {
146+
const goldScreenshotPath = path.join(LOCAL_GOLDENS, fileName);
147+
const localScreenshotPath = path.join(SCREENSHOT_DIR, fileName);
148+
const diffScreenshotPath = path.join(LOCAL_DIFFS, fileName);
140149

141-
return Promise.all(filenames.map((filename: string) => {
142-
return diffScreenshot(filename, database, prNumber);
143-
})).then((results: boolean[]) => results.every((value: boolean) => value == true));
144-
}
150+
const screenshotName = extractScreenshotName(fileName);
145151

146-
function diffScreenshot(filename: string, database: firebase.database.Database,
147-
prNumber: string) {
148-
// TODO(tinayuangao): Run the downloads and diffs in parallel.
149-
filename = path.basename(filename);
150-
let goldUrl = path.join(LOCAL_GOLDENS, filename);
151-
let pullRequestUrl = path.join(SCREENSHOT_DIR, filename);
152-
let diffUrl = path.join(LOCAL_DIFFS, filename);
153-
let filenameKey = extractScreenshotName(filename);
154-
155-
if (existsSync(goldUrl) && existsSync(pullRequestUrl)) {
156-
return new Promise((resolve: any, reject: any) => {
157-
imageDiff({
158-
actualImage: pullRequestUrl,
159-
expectedImage: goldUrl,
160-
diffImage: diffUrl,
161-
}, (err: any, imagesAreSame: boolean) => {
162-
if (err) {
163-
console.log(err);
164-
imagesAreSame = false;
165-
reject(err);
166-
}
167-
resolve(imagesAreSame);
168-
return updateFileResult(database, prNumber, filenameKey, imagesAreSame);
152+
if (existsSync(goldScreenshotPath) && existsSync(localScreenshotPath)) {
153+
return compareImage(localScreenshotPath, goldScreenshotPath, diffScreenshotPath)
154+
.then(result => {
155+
// Set the screenshot diff result in Firebase and afterwards pass the result boolean
156+
// to the Promise chain again.
157+
return setFileResult(database, prNumber, screenshotName, result).then(() => result);
169158
});
170-
});
171159
} else {
172-
return updateFileResult(database, prNumber, filenameKey, false).then(() => false);
160+
return setFileResult(database, prNumber, screenshotName, false).then(() => false);
173161
}
174162
}
175163

176-
/** Upload screenshots to google cloud storage. */
177-
function uploadScreenshots() {
178-
let bucket = openScreenshotsBucket();
179-
let promises = getLocalScreenshotFiles(SCREENSHOT_DIR).map((file: string) => {
180-
let fileName = path.join(SCREENSHOT_DIR, file);
181-
let destination = `${FIREBASE_STORAGE_GOLDENS}/${file}`;
182-
return bucket.upload(fileName, { destination: destination });
164+
/** Uploads golden screenshots to the Google Cloud Storage bucket for the screenshots. */
165+
function uploadGoldenScreenshots() {
166+
const bucket = openScreenshotsBucket();
167+
168+
return Promise.all(getLocalScreenshotFiles(SCREENSHOT_DIR).map(fileName => {
169+
const filePath = path.join(SCREENSHOT_DIR, fileName);
170+
const storageDestination = `${FIREBASE_STORAGE_GOLDENS}/${filePath}`;
171+
172+
return bucket.upload(fileName, { destination: storageDestination });
173+
}));
174+
}
175+
176+
/**
177+
* Compares two images using the Node package image-diff. A difference screenshot will be created.
178+
* The returned promise will resolve with a boolean that will be true if the images are equal.
179+
*/
180+
function compareImage(actualPath: string, goldenPath: string, diffPath: string): Promise<boolean> {
181+
return new Promise(resolve => {
182+
imageDiff({
183+
actualImage: actualPath,
184+
expectedImage: goldenPath,
185+
diffImage: diffPath,
186+
}, (err: any, imagesAreEqual: boolean) => {
187+
if (err) {
188+
throw err;
189+
}
190+
191+
resolve(imagesAreEqual);
192+
});
183193
});
184-
return Promise.all(promises);
194+
}
195+
196+
/**
197+
* Get processed secure token. The jwt token has 3 parts: header, payload, signature and has format
198+
* {jwtHeader}.{jwtPayload}.{jwtSignature}
199+
* The three parts is connected by '.', while '.' is not a valid path in firebase database.
200+
* Replace all '.' to '/' to make the path valid
201+
* Output is {jwtHeader}/{jwtPayload}/{jwtSignature}.
202+
* This secure token is used to validate the write access is from our TravisCI under our repo.
203+
* All data is written to /$path/$secureToken/$data and after validated the
204+
* secure token, the data is moved to /$path/$data in database.
205+
*/
206+
function getSecureToken() {
207+
return (process.env['FIREBASE_ACCESS_TOKEN'] || '').replace(/[.]/g, '/');
185208
}

0 commit comments

Comments
 (0)