Skip to content

Commit bd6feb3

Browse files
tinayuangaommalerba
authored andcommitted
feat(screenshot): Add a gulp to compare screenshot diffs, upload results to gcs & firebase (#2774)
* Add google cloud storage * Add screenshots to e2e test * Renamed functions. Add types. Remove firebase, use firebase-admin * dependencies * Save pull request sha to firebase db * Save travis job id * Add function to update github commit status * remove update status code * Add update status * Address comments * Add fs-extra * try using ES6 Promise * . * Fix cannot find name promise * Add back function to save filenames to firebase * Update comments
1 parent bb2392f commit bd6feb3

File tree

7 files changed

+239
-4
lines changed

7 files changed

+239
-4
lines changed

package.json

+3
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@angular/platform-browser-dynamic": "^2.3.0",
4242
"@angular/platform-server": "^2.3.0",
4343
"@angular/router": "^3.3.0",
44+
"@types/fs-extra": "0.0.37",
4445
"@types/glob": "^5.0.30",
4546
"@types/gulp": "^3.8.32",
4647
"@types/hammerjs": "^2.0.34",
@@ -59,6 +60,7 @@
5960
"firebase-tools": "^2.2.1",
6061
"fs-extra": "^2.0.0",
6162
"glob": "^7.1.1",
63+
"google-cloud": "^0.45.1",
6264
"gulp": "^3.9.1",
6365
"gulp-autoprefixer": "^3.1.1",
6466
"gulp-better-rollup": "^1.0.2",
@@ -77,6 +79,7 @@
7779
"gulp-transform": "^1.1.0",
7880
"hammerjs": "^2.0.8",
7981
"highlight.js": "^9.9.0",
82+
"image-diff": "^1.6.3",
8083
"jasmine-core": "^2.5.2",
8184
"karma": "^1.4.1",
8285
"karma-browserstack-launcher": "^1.2.0",

tools/gulp/gulpfile.ts

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import './tasks/docs';
77
import './tasks/e2e';
88
import './tasks/lint';
99
import './tasks/release';
10+
import './tasks/screenshots';
1011
import './tasks/serve';
1112
import './tasks/unit-test';
1213
import './tasks/docs';

tools/gulp/task_helpers.ts

+42-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import * as child_process from 'child_process';
22
import * as fs from 'fs';
33
import * as gulp from 'gulp';
44
import * as path from 'path';
5-
65
import {NPM_VENDOR_FILES, PROJECT_ROOT, DIST_ROOT, SASS_AUTOPREFIXER_OPTIONS} from './constants';
76

87

@@ -16,6 +15,7 @@ const gulpAutoprefixer = require('gulp-autoprefixer');
1615
const gulpConnect = require('gulp-connect');
1716
const resolveBin = require('resolve-bin');
1817
const firebaseAdmin = require('firebase-admin');
18+
const gcloud = require('google-cloud');
1919

2020

2121
/** If the string passed in is a glob, returns it, otherwise append '**\/*' to it. */
@@ -186,7 +186,7 @@ export function sequenceTask(...args: any[]) {
186186
}
187187

188188
/** Opens a connection to the firebase realtime database. */
189-
export function openFirebaseDatabase() {
189+
export function openFirebaseDashboardDatabase() {
190190
// Initialize the Firebase application with admin credentials.
191191
// Credentials need to be for a Service Account, which can be created in the Firebase console.
192192
firebaseAdmin.initializeApp({
@@ -207,3 +207,43 @@ export function openFirebaseDatabase() {
207207
export function isTravisPushBuild() {
208208
return process.env['TRAVIS_PULL_REQUEST'] === 'false';
209209
}
210+
211+
/**
212+
* Open Google Cloud Storage for screenshots.
213+
* The files uploaded to google cloud are also available to firebase storage.
214+
*/
215+
export function openScreenshotsBucket() {
216+
let gcs = gcloud.storage({
217+
projectId: 'material2-screenshots',
218+
credentials: {
219+
client_email: 'firebase-adminsdk-t4209@material2-screenshots.iam.gserviceaccount.com',
220+
private_key: decode(process.env['MATERIAL2_SCREENSHOT_FIREBASE_KEY'])
221+
},
222+
});
223+
224+
// Reference an existing bucket.
225+
return gcs.bucket('material2-screenshots.appspot.com');
226+
}
227+
228+
/** Opens a connection to the firebase realtime database for screenshots. */
229+
export function openFirebaseScreenshotsDatabase() {
230+
// Initialize the Firebase application with admin credentials.
231+
// Credentials need to be for a Service Account, which can be created in the Firebase console.
232+
let screenshotApp = firebaseAdmin.initializeApp({
233+
credential: firebaseAdmin.credential.cert({
234+
project_id: 'material2-screenshots',
235+
client_email: 'firebase-adminsdk-t4209@material2-screenshots.iam.gserviceaccount.com',
236+
private_key: decode(process.env['MATERIAL2_SCREENSHOT_FIREBASE_KEY'])
237+
}),
238+
databaseURL: 'https://material2-screenshots.firebaseio.com'
239+
}, 'material2-screenshots');
240+
241+
return screenshotApp.database();
242+
}
243+
244+
/** Decode the token for Travis to use. */
245+
function decode(str: string): string {
246+
// In Travis CI the private key will be incorrect because the line-breaks are escaped.
247+
// The line-breaks need to persist in the service account private key.
248+
return (str || '').split('\\n').reverse().join('\\n').replace(/\\n/g, '\n');
249+
}

tools/gulp/tasks/e2e.ts

+1
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ task('e2e', (done: (err?: string) => void) => {
6464
'serve:e2eapp',
6565
':test:protractor',
6666
':serve:e2eapp:stop',
67+
'screenshots',
6768
(err: any) => done(err)
6869
);
6970
});

tools/gulp/tasks/payload.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {task} from 'gulp';
22
import {join} from 'path';
33
import {statSync, readFileSync} from 'fs';
44
import {DIST_COMPONENTS_ROOT} from '../constants';
5-
import {openFirebaseDatabase, isTravisPushBuild} from '../task_helpers';
5+
import {openFirebaseDashboardDatabase, isTravisPushBuild} from '../task_helpers';
66
import {spawnSync} from 'child_process';
77

88
// Those imports lack types.
@@ -48,7 +48,7 @@ function getUglifiedSize(filePath: string) {
4848
/** Publishes the given results to the firebase database. */
4949
function publishResults(results: any) {
5050
let latestSha = spawnSync('git', ['rev-parse', 'HEAD']).stdout.toString().trim();
51-
let database = openFirebaseDatabase();
51+
let database = openFirebaseDashboardDatabase();
5252

5353
// Write the results to the payloads object with the latest Git SHA as key.
5454
return database.ref('payloads').child(latestSha).set(results)

tools/gulp/tasks/screenshots.ts

+153
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import {task} from 'gulp';
2+
import {readdirSync, statSync, existsSync, mkdirp} from 'fs-extra';
3+
import * as path from 'path';
4+
import * as admin from 'firebase-admin';
5+
import {openScreenshotsBucket, openFirebaseScreenshotsDatabase} from '../task_helpers';
6+
import {updateGithubStatus} from '../util-functions';
7+
8+
const request = require('request');
9+
const imageDiff = require('image-diff');
10+
11+
const SCREENSHOT_DIR = './screenshots';
12+
const FIREBASE_REPORT = 'screenshot/reports';
13+
const FIREBASE_FILELIST = 'screenshot/filenames';
14+
15+
/** Task which upload screenshots generated from e2e test. */
16+
task('screenshots', () => {
17+
let prNumber = process.env['TRAVIS_PULL_REQUEST'];
18+
if (prNumber) {
19+
let database = openFirebaseScreenshotsDatabase();
20+
return getScreenshotFiles(database)
21+
.then((files: any[]) => downloadAllGoldsAndCompare(files, database, prNumber))
22+
.then((results: boolean) => updateResult(database, prNumber, results))
23+
.then((result: boolean) => updateGithubStatus(result, prNumber))
24+
.then(() => uploadScreenshots(prNumber, 'diff'))
25+
.then(() => uploadScreenshots(prNumber, 'test'))
26+
.then(() => updateTravis(database, prNumber))
27+
.then(() => setScreenFilenames(database, prNumber))
28+
.then(() => database.goOffline(), () => database.goOffline());
29+
}
30+
});
31+
32+
function updateFileResult(database: admin.database.Database, prNumber: string,
33+
filenameKey: string, result: boolean) {
34+
return database.ref(FIREBASE_REPORT).child(prNumber).child('results').child(filenameKey).set(result);
35+
}
36+
37+
function updateResult(database: admin.database.Database, prNumber: string,
38+
result: boolean) {
39+
return database.ref(FIREBASE_REPORT).child(prNumber).child('result').set(result).then(() => result);
40+
}
41+
42+
function updateTravis(database: admin.database.Database,
43+
prNumber: string) {
44+
return database.ref(FIREBASE_REPORT).child(prNumber).update({
45+
commit: process.env['TRAVIS_COMMIT'],
46+
sha: process.env['TRAVIS_PULL_REQUEST_SHA'],
47+
travis: process.env['TRAVIS_JOB_ID'],
48+
});
49+
}
50+
51+
/** Get a list of filenames from firebase database. */
52+
function getScreenshotFiles(database: admin.database.Database) {
53+
let bucket = openScreenshotsBucket();
54+
return bucket.getFiles({ prefix: 'golds/' }).then(function(data: any) {
55+
return data[0].filter((file:any) => file.name.endsWith('.screenshot.png'));
56+
});
57+
}
58+
59+
function extractScreenshotName(fileName: string) {
60+
return path.basename(fileName, '.screenshot.png');
61+
}
62+
63+
function getLocalScreenshotFiles(dir: string): string[] {
64+
return readdirSync(dir)
65+
.filter((fileName: string) => !statSync(path.join(SCREENSHOT_DIR, fileName)).isDirectory())
66+
.filter((fileName: string) => fileName.endsWith('.screenshot.png'));
67+
}
68+
69+
/**
70+
* Upload screenshots to google cloud storage.
71+
* @param prNumber - The key used in firebase. Here it is the PR number.
72+
* If there's no prNumber, we will upload images to 'golds/' folder
73+
* @param mode - Can be 'test' or 'diff' or null.
74+
* If the images are the test results, mode should be 'test'.
75+
* If the images are the diff images generated, mode should be 'diff'.
76+
* For golds mode should be null.
77+
*/
78+
function uploadScreenshots(prNumber?: string, mode?: 'test' | 'diff') {
79+
let bucket = openScreenshotsBucket();
80+
81+
let promises: any[] = [];
82+
let localDir = mode == 'diff' ? path.join(SCREENSHOT_DIR, 'diff') : SCREENSHOT_DIR;
83+
getLocalScreenshotFiles(localDir).forEach((file: string) => {
84+
let fileName = path.join(localDir, file);
85+
let destination = (mode == null || !prNumber) ?
86+
`golds/${file}` : `screenshots/${prNumber}/${mode}/${file}`;
87+
promises.push(bucket.upload(fileName, { destination: destination }));
88+
});
89+
return Promise.all(promises);
90+
}
91+
92+
/** Download golds screenshots. */
93+
function downloadAllGoldsAndCompare(
94+
files: any[], database: admin.database.Database,
95+
prNumber: string) {
96+
97+
mkdirp(path.join(SCREENSHOT_DIR, `golds`));
98+
mkdirp(path.join(SCREENSHOT_DIR, `diff`));
99+
100+
return Promise.all(files.map((file: any) => {
101+
return downloadGold(file).then(() => diffScreenshot(file.name, database, prNumber));
102+
})).then((results: boolean[]) => results.every((value: boolean) => value == true));
103+
}
104+
105+
/** Download one gold screenshot */
106+
function downloadGold(file: any) {
107+
return file.download({
108+
destination: path.join(SCREENSHOT_DIR, file.name)
109+
});
110+
}
111+
112+
function diffScreenshot(filename: string, database: admin.database.Database,
113+
prNumber: string) {
114+
// TODO(tinayuangao): Run the downloads and diffs in parallel.
115+
filename = path.basename(filename);
116+
let goldUrl = path.join(SCREENSHOT_DIR, `golds`, filename);
117+
let pullRequestUrl = path.join(SCREENSHOT_DIR, filename);
118+
let diffUrl = path.join(SCREENSHOT_DIR, `diff`, filename);
119+
let filenameKey = extractScreenshotName(filename);
120+
121+
if (existsSync(goldUrl) && existsSync(pullRequestUrl)) {
122+
return new Promise((resolve: any, reject: any) => {
123+
imageDiff({
124+
actualImage: pullRequestUrl,
125+
expectedImage: goldUrl,
126+
diffImage: diffUrl,
127+
}, (err: any, imagesAreSame: boolean) => {
128+
if (err) {
129+
console.log(err);
130+
imagesAreSame = false;
131+
reject(err);
132+
}
133+
resolve(imagesAreSame);
134+
return updateFileResult(database, prNumber, filenameKey, imagesAreSame);
135+
});
136+
});
137+
} else {
138+
return updateFileResult(database, prNumber, filenameKey, false).then(() => false);
139+
}
140+
}
141+
142+
/**
143+
* Upload a list of filenames to firebase database as gold.
144+
* This is necessary for control panel since google-cloud is not available to client side.
145+
*/
146+
function setScreenFilenames(database: admin.database.Database,
147+
prNumber?: string) {
148+
let filenames: string[] = getLocalScreenshotFiles(SCREENSHOT_DIR);
149+
let filelistDatabase = prNumber ?
150+
database.ref(FIREBASE_REPORT).child(prNumber).child('filenames') :
151+
database.ref(FIREBASE_FILELIST);
152+
return filelistDatabase.set(filenames);
153+
}

tools/gulp/util-functions.ts

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
const request = require('request');
2+
3+
/** Update github pr status to success/failure */
4+
export function updateGithubStatus(result: boolean, prNumber: string) {
5+
let state = result ? 'success' : 'failure';
6+
let sha = process.env['TRAVIS_PULL_REQUEST_SHA'];
7+
let token = decode(process.env['MATERIAL2_GITHUB_STATUS_TOKEN']);
8+
9+
let data = JSON.stringify({
10+
state: state,
11+
target_url: `http://material2-screenshots.firebaseapp.com/${prNumber}`,
12+
context: "screenshot-diff",
13+
description: `Screenshot test ${state}`
14+
});
15+
16+
let headers = {
17+
'Authorization': `token ${token}`,
18+
'User-Agent': 'ScreenshotDiff/1.0.0',
19+
'Content-Type': 'application/json',
20+
'Content-Length': Buffer.byteLength(data)
21+
};
22+
23+
return new Promise((resolve, reject) => {
24+
request({
25+
url: `https://api.github.com/repos/angular/material2/statuses/${sha}`,
26+
method: 'POST',
27+
form: data,
28+
headers: headers
29+
}, function (error: any, response: any, body: any){
30+
resolve(response.statusCode);
31+
});
32+
});
33+
}
34+
35+
function decode(value: string): string {
36+
return value.split('').reverse().join('');
37+
}

0 commit comments

Comments
 (0)