Skip to content

Commit

Permalink
test(screenshot): enable screenshot tests w/ Firebase Functions and J…
Browse files Browse the repository at this point in the history
…WT (#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
  • Loading branch information
tinayuangao authored and jelbourn committed Mar 25, 2017
1 parent 5f360f9 commit 23d18a8
Show file tree
Hide file tree
Showing 9 changed files with 335 additions and 52 deletions.
11 changes: 10 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ sudo: false
node_js:
- '6.9.4'

addons:
jwt:
# SAUCE_ACCESS_KEY<=secret for FIREBASE_ACCESS_TOKEN to work around travis-ci/travis-ci#7223, unencrypted value in valentine as FIREBASE_ACCESS_TOKEN>
# we alias FIREBASE_ACCESS_TOKEN to $SAUCE_ACCESS_KEY in env.sh and set the SAUCE_ACCESS_KEY there
- 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="

branches:
only:
- master
Expand All @@ -12,11 +18,11 @@ env:
global:
- LOGS_DIR=/tmp/angular-material2-build/logs
- SAUCE_USERNAME=angular-ci
- SAUCE_ACCESS_KEY=9b988f434ff8-fbca-8aa4-4ae3-35442987
- BROWSER_STACK_USERNAME=angularteam1
- BROWSER_STACK_ACCESS_KEY=BWCd4SynLzdDcv8xtzsB
- BROWSER_PROVIDER_READY_FILE=/tmp/angular-material2-build/readyfile
- BROWSER_PROVIDER_ERROR_FILE=/tmp/angular-material2-build/errorfile

matrix:
# Order: a slower build first, so that we don't occupy an idle travis worker waiting for others to complete.
- MODE=lint
Expand All @@ -32,6 +38,9 @@ matrix:
- env: "MODE=saucelabs_optional"
- env: "MODE=browserstack_optional"

before_install:
- source ./scripts/ci/env.sh

install:
- npm install

Expand Down
9 changes: 9 additions & 0 deletions functions/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"firebase": {
"apiKey": "AIzaSyBekh5ZSi1vEhaE2qetH4RU91gHmUmpqgg",
"authDomain": "material2-screenshots.firebaseapp.com",
"databaseURL": "https://material2-screenshots.firebaseio.com",
"storageBucket": "material2-screenshots.appspot.com",
"messagingSenderId": "975527407245"
}
}
190 changes: 190 additions & 0 deletions functions/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
'use strict';

const firebaseFunctions = require('firebase-functions');
const firebaseAdmin = require('firebase-admin');
const gcs = require('@google-cloud/storage')();
const jwt = require('jsonwebtoken');
const fs = require('fs');

/**
* Data and images handling for Screenshot test.
*
* All users can post data to temporary folder. These Functions will check the data with JsonWebToken and
* move the valid data out of temporary folder.
*
* For valid data posted to database /$temp/screenshot/reports/$prNumber/$secureToken, move it to
* /screenshot/reports/$prNumber.
* These are data for screenshot results (success or failure), GitHub PR/commit and TravisCI job information
*
* For valid image results written to database /$temp/screenshot/images/$prNumber/$secureToken/, save the image
* data to image files and upload to google cloud storage under location /screenshots/$prNumber
* These are screenshot test result images, and difference images generated from screenshot comparison.
*
* For golden images uploaded to /goldens, read the data from images files and write the data to Firebase database
* under location /screenshot/goldens
* Screenshot tests can only read restricted database data with no credentials, and they cannot access
* Google Cloud Storage. Therefore we copy the image data to database to make it available to screenshot tests.
*
* The JWT is stored in the data path, so every write to database needs a valid JWT to be copied to database/storage.
* All invalid data will be removed.
* The JWT has 3 parts: header, payload and signature. These three parts are joint by '/' in path.
*/

// Initailize the admin app
firebaseAdmin.initializeApp(firebaseFunctions.config().firebase);

/** The valid data types database accepts */
const dataTypes = ['filenames', 'commit', 'result', 'sha', 'travis'];

/** The repo slug. This is used to validate the JWT is sent from correct repo. */
const repoSlug = firebaseFunctions.config().repo.slug;

/** The JWT secret. This is used to validate JWT. */
const secret = firebaseFunctions.config().secret.key;

/** The storage bucket to store the images. The bucket is also used by Firebase Storage. */
const bucket = gcs.bucket(firebaseFunctions.config().firebase.storageBucket);

/** The Json Web Token format. The token is stored in data path. */
const jwtFormat = '{jwtHeader}/{jwtPayload}/{jwtSignature}';

/** The temporary folder name for screenshot data that needs to be validated via JWT. */
const tempFolder = '/untrustedInbox';

/**
* Copy valid data from /$temp/screenshot/reports/$prNumber/$secureToken/ to /screenshot/reports/$prNumber
* Data copied: filenames(image results names), commit(github PR info),
* sha (github PR info), result (true or false for all the tests), travis job number
*/
const copyDataPath = `${tempFolder}/screenshot/reports/{prNumber}/${jwtFormat}/{dataType}`;
exports.copyData = firebaseFunctions.database.ref(copyDataPath).onWrite(event => {
const dataType = event.params.dataType;
if (dataTypes.includes(dataType)) {
return verifyAndCopyScreenshotResult(event, dataType);
}
});

/**
* Copy valid data from /$temp/screenshot/reports/$prNumber/$secureToken/ to /screenshot/reports/$prNumber
* Data copied: test result for each file/test with ${filename}. The value should be true or false.
*/
const copyDataResultPath = `${tempFolder}/screenshot/reports/{prNumber}/${jwtFormat}/results/{filename}`;
exports.copyDataResult = firebaseFunctions.database.ref(copyDataResultPath).onWrite(event => {
return verifyAndCopyScreenshotResult(event, `results/${event.params.filename}`);
});

/**
* Copy valid data from database /$temp/screenshot/images/$prNumber/$secureToken/ to storage /screenshots/$prNumber
* Data copied: test result images. Convert from data to image files in storage.
*/
const copyImagePath = `${tempFolder}/screenshot/images/{prNumber}/${jwtFormat}/{dataType}/{filename}`;
exports.copyImage = firebaseFunctions.database.ref(copyImagePath).onWrite(event => {
// Only edit data when it is first created. Exit when the data is deleted.
if (event.data.previous.exists() || !event.data.exists()) {
return;
}

const dataType = event.params.dataType;
const prNumber = event.params.prNumber;
const secureToken = getSecureToken(event);
const saveFilename = `${event.params.filename}.screenshot.png`;

if (dataType != 'diff' && dataType != 'test') {
return;
}

return verifySecureToken(secureToken, prNumber).then((payload) => {
const tempPath = `/tmp/${dataType}-${saveFilename}`
const filePath = `screenshots/${prNumber}/${dataType}/${saveFilename}`;
const binaryData = new Buffer(event.data.val(), 'base64').toString('binary');
fs.writeFile(tempPath, binaryData, 'binary');
return bucket.upload(tempPath, {destination: filePath}).then(() => {
// Clear the data in temporary folder after processed.
return event.data.ref.parent.set(null);
});
}).catch((error) => {
console.error(`Invalid secure token ${secureToken} ${error}`);
return event.data.ref.parent.set(null);
});
});

/**
* Copy valid goldens from storage /goldens/ to database /screenshot/goldens/
* so we can read the goldens without credentials.
*/
exports.copyGoldens = firebaseFunctions.storage.bucket(firebaseFunctions.config().firebase.storageBucket)
.object().onChange(event => {
// The filePath should always l ook like "goldens/xxx.png"
const filePath = event.data.name;

// Get the file name.
const fileNames = filePath.split('/');
if (fileNames.length != 2 && fileNames[0] != 'goldens') {
return;
}
const filenameKey = fileNames[1].replace('.screenshot.png', '');

// When a gold image is deleted, also delete the corresponding record in the firebase database.
if (event.data.resourceState === 'not_exists') {
return firebaseAdmin.database().ref(`screenshot/goldens/${filenameKey}`).set(null);
}

// Download file from bucket.
const bucket = gcs.bucket(event.data.bucket);
const tempFilePath = `/tmp/${fileNames[1]}`;
return bucket.file(filePath).download({destination: tempFilePath}).then(() => {
const data = fs.readFileSync(tempFilePath);
return firebaseAdmin.database().ref(`screenshot/goldens/${filenameKey}`).set(data);
});
});

/**
* Handle data written to temporary folder. Validate the JWT and move the data out of
* temporary folder if the token is valid.
*/
function verifyAndCopyScreenshotResult(event, path) {
// Only edit data when it is first created. Exit when the data is deleted.
if (event.data.previous.exists() || !event.data.exists()) {
return;
}

const prNumber = event.params.prNumber;
const secureToken = getSecureToken(event);
const original = event.data.val();

return verifySecureToken(secureToken, prNumber).then((payload) => {
return firebaseAdmin.database().ref().child('screenshot/reports')
.child(prNumber).child(path).set(original).then(() => {
// Clear the data in temporary folder after processed.
return event.data.ref.parent.set(null);
});
}).catch((error) => {
console.error(`Invalid secure token ${secureToken} ${error}`);
return event.data.ref.parent.set(null);
});
}

/**
* Extract the Json Web Token from event params.
* In screenshot gulp task the path we use is {jwtHeader}/{jwtPayload}/{jwtSignature}.
* Replace '/' with '.' to get the token.
*/
function getSecureToken(event) {
return `${event.params.jwtHeader}.${event.params.jwtPayload}.${event.params.jwtSignature}`;
}

function verifySecureToken(token, prNumber) {
return new Promise((resolve, reject) => {
jwt.verify(token, secret, {issuer: 'Travis CI, GmbH'}, (err, payload) => {
if (err) {
reject(err.message || err);
} else if (payload.slug !== repoSlug) {
reject(`jwt slug invalid. expected: ${repoSlug}`);
} else if (payload['pull-request'].toString() !== prNumber) {
reject(`jwt pull-request invalid. expected: ${prNumber} actual: ${payload['pull-request']}`);
} else {
resolve(payload);
}
});
});
}
10 changes: 10 additions & 0 deletions functions/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "angular-material2-functions",
"description": "Angular Material2 screenshot test functions",
"dependencies": {
"@google-cloud/storage": "^0.8.0",
"firebase-admin": "^4.1.3",
"firebase-functions": "^0.5.2",
"jsonwebtoken": "^7.3.0"
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"dgeni-packages": "^0.16.5",
"firebase-admin": "^4.1.2",
"firebase-tools": "^2.2.1",
"firebase": "^3.7.2",
"fs-extra": "^2.0.0",
"glob": "^7.1.1",
"google-cloud": "^0.48.0",
Expand Down
12 changes: 12 additions & 0 deletions scripts/ci/env.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/usr/bin/env bash


if [[ ${TRAVIS:-} ]]; then
# 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.
# This is a workaround for travis-ci/travis-ci#7223
# WARNING: FIREBASE_ACCESS_TOKEN should NOT be printed
export FIREBASE_ACCESS_TOKEN=${FIREBASE_ACCESS_TOKEN:-$SAUCE_ACCESS_KEY}

# - we overwrite the value set by Travis JWT addon here to work around travis-ci/travis-ci#7223 for FIREBASE_ACCESS_TOKEN
export SAUCE_ACCESS_KEY=9b988f434ff8-fbca-8aa4-4ae3-35442987
fi
2 changes: 2 additions & 0 deletions tools/gulp/tasks/e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,6 @@ task('e2e', sequenceTask(
[':test:protractor:setup', 'serve:e2eapp'],
':test:protractor',
':serve:e2eapp:stop',
'screenshots',
));

Loading

0 comments on commit 23d18a8

Please sign in to comment.