Skip to content

Commit 23d18a8

Browse files
tinayuangaojelbourn
authored andcommitted
test(screenshot): enable screenshot tests w/ Firebase Functions and JWT (#3628)
Set up firebase functions to copy valid data to firebase storage/database Use JasonWebToken to validate data written to firebase Github Status change will be done in another PR Result at: https://material2-screenshots.firebaseapp.com/3628
1 parent 5f360f9 commit 23d18a8

File tree

9 files changed

+335
-52
lines changed

9 files changed

+335
-52
lines changed

.travis.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ sudo: false
44
node_js:
55
- '6.9.4'
66

7+
addons:
8+
jwt:
9+
# SAUCE_ACCESS_KEY<=secret for FIREBASE_ACCESS_TOKEN to work around travis-ci/travis-ci#7223, unencrypted value in valentine as FIREBASE_ACCESS_TOKEN>
10+
# we alias FIREBASE_ACCESS_TOKEN to $SAUCE_ACCESS_KEY in env.sh and set the SAUCE_ACCESS_KEY there
11+
- secure: "PKts/IbxuJRWWOEeiGbl8Z9zds0M+hIdCH/g/E4WbQ9yzSvSbdwzfmRfFccQFjxjsrY7+SJMVjsURZy+xUyBpzqgWYHUItnSVqjZb8DlyAU2IXyg8TM9BVLkGGe6k5k4PIFVmfMMMzQwWMM0X0W9w3oYmfHL5egxwSHvf9HIqLolLNXg8sqamIdS5d5KoCXf1c+oRjN/IMBktzNBR6N4OFOZQXVoepXNiIvTWAcTtOPBvFWdKP2n7RVioHKdm4a85aCUpDJp+LYGaLqiQZoRzmzfVTnAhTAPdd4ao5w/+jojrfZIHV55bqYF9rLnQMTneKsiyVNVYJzOLuxmARa/EEKfZld+J3rX4/o4cogrU38YSZF+T7J9g/7CTsnIZ3F6W6m+8iJbIBh55nGOQi5PVe458Q/nGb3fgQd2Z4+6lK9k479H4Ssh/Y7hbVQbepqEVIXzZKqWX6/ZE4iWoR/Q2dm0hySFmmB/R2etixX5JxhnHvgobTYIQ+1liJVp/3YFW1ru64Yg6yz/V291Bhh9g31znmTROCJ/usAmZZaLUqW1TDKnLIMP+M74MF9XERqcWKywXRFwxP4E5uDnx/vAyN49gL+SDfrBUxUtXrTkKZAlglwo9SgA7cOYEPWrionvKcGm87gCBYHFUmXZNQVzh212fpuJYXb/vy0sPDj8La4="
12+
713
branches:
814
only:
915
- master
@@ -12,11 +18,11 @@ env:
1218
global:
1319
- LOGS_DIR=/tmp/angular-material2-build/logs
1420
- SAUCE_USERNAME=angular-ci
15-
- SAUCE_ACCESS_KEY=9b988f434ff8-fbca-8aa4-4ae3-35442987
1621
- BROWSER_STACK_USERNAME=angularteam1
1722
- BROWSER_STACK_ACCESS_KEY=BWCd4SynLzdDcv8xtzsB
1823
- BROWSER_PROVIDER_READY_FILE=/tmp/angular-material2-build/readyfile
1924
- BROWSER_PROVIDER_ERROR_FILE=/tmp/angular-material2-build/errorfile
25+
2026
matrix:
2127
# Order: a slower build first, so that we don't occupy an idle travis worker waiting for others to complete.
2228
- MODE=lint
@@ -32,6 +38,9 @@ matrix:
3238
- env: "MODE=saucelabs_optional"
3339
- env: "MODE=browserstack_optional"
3440

41+
before_install:
42+
- source ./scripts/ci/env.sh
43+
3544
install:
3645
- npm install
3746

functions/config.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"firebase": {
3+
"apiKey": "AIzaSyBekh5ZSi1vEhaE2qetH4RU91gHmUmpqgg",
4+
"authDomain": "material2-screenshots.firebaseapp.com",
5+
"databaseURL": "https://material2-screenshots.firebaseio.com",
6+
"storageBucket": "material2-screenshots.appspot.com",
7+
"messagingSenderId": "975527407245"
8+
}
9+
}

functions/index.js

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
'use strict';
2+
3+
const firebaseFunctions = require('firebase-functions');
4+
const firebaseAdmin = require('firebase-admin');
5+
const gcs = require('@google-cloud/storage')();
6+
const jwt = require('jsonwebtoken');
7+
const fs = require('fs');
8+
9+
/**
10+
* Data and images handling for Screenshot test.
11+
*
12+
* All users can post data to temporary folder. These Functions will check the data with JsonWebToken and
13+
* move the valid data out of temporary folder.
14+
*
15+
* For valid data posted to database /$temp/screenshot/reports/$prNumber/$secureToken, move it to
16+
* /screenshot/reports/$prNumber.
17+
* These are data for screenshot results (success or failure), GitHub PR/commit and TravisCI job information
18+
*
19+
* For valid image results written to database /$temp/screenshot/images/$prNumber/$secureToken/, save the image
20+
* data to image files and upload to google cloud storage under location /screenshots/$prNumber
21+
* These are screenshot test result images, and difference images generated from screenshot comparison.
22+
*
23+
* For golden images uploaded to /goldens, read the data from images files and write the data to Firebase database
24+
* under location /screenshot/goldens
25+
* Screenshot tests can only read restricted database data with no credentials, and they cannot access
26+
* Google Cloud Storage. Therefore we copy the image data to database to make it available to screenshot tests.
27+
*
28+
* The JWT is stored in the data path, so every write to database needs a valid JWT to be copied to database/storage.
29+
* All invalid data will be removed.
30+
* The JWT has 3 parts: header, payload and signature. These three parts are joint by '/' in path.
31+
*/
32+
33+
// Initailize the admin app
34+
firebaseAdmin.initializeApp(firebaseFunctions.config().firebase);
35+
36+
/** The valid data types database accepts */
37+
const dataTypes = ['filenames', 'commit', 'result', 'sha', 'travis'];
38+
39+
/** The repo slug. This is used to validate the JWT is sent from correct repo. */
40+
const repoSlug = firebaseFunctions.config().repo.slug;
41+
42+
/** The JWT secret. This is used to validate JWT. */
43+
const secret = firebaseFunctions.config().secret.key;
44+
45+
/** The storage bucket to store the images. The bucket is also used by Firebase Storage. */
46+
const bucket = gcs.bucket(firebaseFunctions.config().firebase.storageBucket);
47+
48+
/** The Json Web Token format. The token is stored in data path. */
49+
const jwtFormat = '{jwtHeader}/{jwtPayload}/{jwtSignature}';
50+
51+
/** The temporary folder name for screenshot data that needs to be validated via JWT. */
52+
const tempFolder = '/untrustedInbox';
53+
54+
/**
55+
* Copy valid data from /$temp/screenshot/reports/$prNumber/$secureToken/ to /screenshot/reports/$prNumber
56+
* Data copied: filenames(image results names), commit(github PR info),
57+
* sha (github PR info), result (true or false for all the tests), travis job number
58+
*/
59+
const copyDataPath = `${tempFolder}/screenshot/reports/{prNumber}/${jwtFormat}/{dataType}`;
60+
exports.copyData = firebaseFunctions.database.ref(copyDataPath).onWrite(event => {
61+
const dataType = event.params.dataType;
62+
if (dataTypes.includes(dataType)) {
63+
return verifyAndCopyScreenshotResult(event, dataType);
64+
}
65+
});
66+
67+
/**
68+
* Copy valid data from /$temp/screenshot/reports/$prNumber/$secureToken/ to /screenshot/reports/$prNumber
69+
* Data copied: test result for each file/test with ${filename}. The value should be true or false.
70+
*/
71+
const copyDataResultPath = `${tempFolder}/screenshot/reports/{prNumber}/${jwtFormat}/results/{filename}`;
72+
exports.copyDataResult = firebaseFunctions.database.ref(copyDataResultPath).onWrite(event => {
73+
return verifyAndCopyScreenshotResult(event, `results/${event.params.filename}`);
74+
});
75+
76+
/**
77+
* Copy valid data from database /$temp/screenshot/images/$prNumber/$secureToken/ to storage /screenshots/$prNumber
78+
* Data copied: test result images. Convert from data to image files in storage.
79+
*/
80+
const copyImagePath = `${tempFolder}/screenshot/images/{prNumber}/${jwtFormat}/{dataType}/{filename}`;
81+
exports.copyImage = firebaseFunctions.database.ref(copyImagePath).onWrite(event => {
82+
// Only edit data when it is first created. Exit when the data is deleted.
83+
if (event.data.previous.exists() || !event.data.exists()) {
84+
return;
85+
}
86+
87+
const dataType = event.params.dataType;
88+
const prNumber = event.params.prNumber;
89+
const secureToken = getSecureToken(event);
90+
const saveFilename = `${event.params.filename}.screenshot.png`;
91+
92+
if (dataType != 'diff' && dataType != 'test') {
93+
return;
94+
}
95+
96+
return verifySecureToken(secureToken, prNumber).then((payload) => {
97+
const tempPath = `/tmp/${dataType}-${saveFilename}`
98+
const filePath = `screenshots/${prNumber}/${dataType}/${saveFilename}`;
99+
const binaryData = new Buffer(event.data.val(), 'base64').toString('binary');
100+
fs.writeFile(tempPath, binaryData, 'binary');
101+
return bucket.upload(tempPath, {destination: filePath}).then(() => {
102+
// Clear the data in temporary folder after processed.
103+
return event.data.ref.parent.set(null);
104+
});
105+
}).catch((error) => {
106+
console.error(`Invalid secure token ${secureToken} ${error}`);
107+
return event.data.ref.parent.set(null);
108+
});
109+
});
110+
111+
/**
112+
* Copy valid goldens from storage /goldens/ to database /screenshot/goldens/
113+
* so we can read the goldens without credentials.
114+
*/
115+
exports.copyGoldens = firebaseFunctions.storage.bucket(firebaseFunctions.config().firebase.storageBucket)
116+
.object().onChange(event => {
117+
// The filePath should always l ook like "goldens/xxx.png"
118+
const filePath = event.data.name;
119+
120+
// Get the file name.
121+
const fileNames = filePath.split('/');
122+
if (fileNames.length != 2 && fileNames[0] != 'goldens') {
123+
return;
124+
}
125+
const filenameKey = fileNames[1].replace('.screenshot.png', '');
126+
127+
// When a gold image is deleted, also delete the corresponding record in the firebase database.
128+
if (event.data.resourceState === 'not_exists') {
129+
return firebaseAdmin.database().ref(`screenshot/goldens/${filenameKey}`).set(null);
130+
}
131+
132+
// Download file from bucket.
133+
const bucket = gcs.bucket(event.data.bucket);
134+
const tempFilePath = `/tmp/${fileNames[1]}`;
135+
return bucket.file(filePath).download({destination: tempFilePath}).then(() => {
136+
const data = fs.readFileSync(tempFilePath);
137+
return firebaseAdmin.database().ref(`screenshot/goldens/${filenameKey}`).set(data);
138+
});
139+
});
140+
141+
/**
142+
* Handle data written to temporary folder. Validate the JWT and move the data out of
143+
* temporary folder if the token is valid.
144+
*/
145+
function verifyAndCopyScreenshotResult(event, path) {
146+
// Only edit data when it is first created. Exit when the data is deleted.
147+
if (event.data.previous.exists() || !event.data.exists()) {
148+
return;
149+
}
150+
151+
const prNumber = event.params.prNumber;
152+
const secureToken = getSecureToken(event);
153+
const original = event.data.val();
154+
155+
return verifySecureToken(secureToken, prNumber).then((payload) => {
156+
return firebaseAdmin.database().ref().child('screenshot/reports')
157+
.child(prNumber).child(path).set(original).then(() => {
158+
// Clear the data in temporary folder after processed.
159+
return event.data.ref.parent.set(null);
160+
});
161+
}).catch((error) => {
162+
console.error(`Invalid secure token ${secureToken} ${error}`);
163+
return event.data.ref.parent.set(null);
164+
});
165+
}
166+
167+
/**
168+
* Extract the Json Web Token from event params.
169+
* In screenshot gulp task the path we use is {jwtHeader}/{jwtPayload}/{jwtSignature}.
170+
* Replace '/' with '.' to get the token.
171+
*/
172+
function getSecureToken(event) {
173+
return `${event.params.jwtHeader}.${event.params.jwtPayload}.${event.params.jwtSignature}`;
174+
}
175+
176+
function verifySecureToken(token, prNumber) {
177+
return new Promise((resolve, reject) => {
178+
jwt.verify(token, secret, {issuer: 'Travis CI, GmbH'}, (err, payload) => {
179+
if (err) {
180+
reject(err.message || err);
181+
} else if (payload.slug !== repoSlug) {
182+
reject(`jwt slug invalid. expected: ${repoSlug}`);
183+
} else if (payload['pull-request'].toString() !== prNumber) {
184+
reject(`jwt pull-request invalid. expected: ${prNumber} actual: ${payload['pull-request']}`);
185+
} else {
186+
resolve(payload);
187+
}
188+
});
189+
});
190+
}

functions/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"name": "angular-material2-functions",
3+
"description": "Angular Material2 screenshot test functions",
4+
"dependencies": {
5+
"@google-cloud/storage": "^0.8.0",
6+
"firebase-admin": "^4.1.3",
7+
"firebase-functions": "^0.5.2",
8+
"jsonwebtoken": "^7.3.0"
9+
}
10+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"dgeni-packages": "^0.16.5",
6363
"firebase-admin": "^4.1.2",
6464
"firebase-tools": "^2.2.1",
65+
"firebase": "^3.7.2",
6566
"fs-extra": "^2.0.0",
6667
"glob": "^7.1.1",
6768
"google-cloud": "^0.48.0",

scripts/ci/env.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/usr/bin/env bash
2+
3+
4+
if [[ ${TRAVIS:-} ]]; then
5+
# If FIREBASE_ACCESS_TOKEN not set yet, export the FIREBASE_ACCESS_TOKEN using the JWT token that Travis generated and exported for SAUCE_ACCESS_KEY.
6+
# This is a workaround for travis-ci/travis-ci#7223
7+
# WARNING: FIREBASE_ACCESS_TOKEN should NOT be printed
8+
export FIREBASE_ACCESS_TOKEN=${FIREBASE_ACCESS_TOKEN:-$SAUCE_ACCESS_KEY}
9+
10+
# - we overwrite the value set by Travis JWT addon here to work around travis-ci/travis-ci#7223 for FIREBASE_ACCESS_TOKEN
11+
export SAUCE_ACCESS_KEY=9b988f434ff8-fbca-8aa4-4ae3-35442987
12+
fi

tools/gulp/tasks/e2e.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,6 @@ task('e2e', sequenceTask(
5757
[':test:protractor:setup', 'serve:e2eapp'],
5858
':test:protractor',
5959
':serve:e2eapp:stop',
60+
'screenshots',
6061
));
62+

0 commit comments

Comments
 (0)