Skip to content

Commit dd86b5d

Browse files
committed
Add approve functions and use typescript.
1 parent 23d18a8 commit dd86b5d

File tree

13 files changed

+434
-65
lines changed

13 files changed

+434
-65
lines changed

functions/data.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import * as firebaseAdmin from 'firebase-admin';
2+
import {verifySecureTokenAndExecute} from './jwt_util';
3+
4+
/**
5+
* Handle data written to temporary folder. Validate the JWT and move the data out of
6+
* temporary folder if the token is valid.
7+
* Move the data to 'screenshot/reports/$prNumber/$path
8+
*/
9+
export function verifyJWTAndUpdateData(event: any, path: string) {
10+
// Only edit data when it is first created. Exit when the data is deleted.
11+
if (event.data.previous.exists() || !event.data.exists()) {
12+
return;
13+
}
14+
15+
let prNumber = event.params.prNumber;
16+
let data = event.data.val();
17+
18+
return verifySecureTokenAndExecute(event).then(() => {
19+
return firebaseAdmin.database().ref().child('screenshot/reports')
20+
.child(prNumber).child(path).set(data);
21+
});
22+
};

functions/data_image.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import * as firebaseFunctions from 'firebase-functions';
2+
import {writeFileSync} from 'fs';
3+
import {verifySecureTokenAndExecute} from './jwt_util';
4+
5+
const gcs = require('@google-cloud/storage')();
6+
7+
/** The storage bucket to store the images. The bucket is also used by Firebase Storage. */
8+
const bucket = gcs.bucket(firebaseFunctions.config().firebase.storageBucket);
9+
10+
/**
11+
* Convert data to images. Image data posted to database will be saved as png files
12+
* and upload to screenshot/$prNumber/dataType/$filename
13+
*/
14+
export function convertTestImageDataToFiles(event: any) {
15+
// Only edit data when it is first created. Exit when the data is deleted.
16+
if (event.data.previous.exists() || !event.data.exists()) {
17+
return;
18+
}
19+
20+
let dataType = event.params.dataType;
21+
let prNumber = event.params.prNumber;
22+
let data = event.data.val();
23+
let saveFilename = `${event.params.filename}.screenshot.png`;
24+
25+
if (dataType != 'diff' && dataType != 'test') {
26+
return;
27+
}
28+
29+
return verifySecureTokenAndExecute(event).then(() => {
30+
let tempPath = `/tmp/${dataType}-${saveFilename}`;
31+
let filePath = `screenshots/${prNumber}/${dataType}/${saveFilename}`;
32+
let binaryData = new Buffer(data, 'base64').toString('binary');
33+
writeFileSync(tempPath, binaryData, 'binary');
34+
return bucket.upload(tempPath, {destination: filePath});
35+
});
36+
};

functions/github.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import * as firebaseAdmin from 'firebase-admin';
2+
import * as firebaseFunctions from 'firebase-functions';
3+
import {setGithubStatus} from './util/github';
4+
5+
/** Github status update token */
6+
const token = firebaseFunctions.config().secret.github;
7+
8+
/** The repo slug. This is used to validate the JWT is sent from correct repo. */
9+
const repoSlug = firebaseFunctions.config().repo.slug;
10+
11+
/** Domain to view the screenshots */
12+
const authDomain = firebaseFunctions.config().firebase.authDomain;
13+
14+
/** The same of this screenshot testing tool */
15+
const toolName = firebaseFunctions.config().tool.name;
16+
17+
export function updateGithubStatus(event: firebaseFunctions.Event<any>) {
18+
if (!event.data.exists() || typeof event.data.val() != 'boolean') {
19+
return;
20+
}
21+
let result = event.data.val() == true;
22+
let prNumber = event.params.prNumber;
23+
return event.data.ref.parent.child('sha').once('value').then((sha: firebaseAdmin.database.DataSnapshot) => {
24+
return setGithubStatus(sha.val(),
25+
result,
26+
toolName,
27+
`${toolName} ${result ? 'passed' : 'failed'}`,
28+
`http://${authDomain}/${prNumber}`,
29+
repoSlug,
30+
token);
31+
});
32+
}

functions/image_data.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import * as firebaseAdmin from 'firebase-admin';
2+
import * as path from 'path';
3+
import {readFileSync} from 'fs';
4+
5+
const gcs = require('@google-cloud/storage')();
6+
7+
const FIREBASE_DATA_GOLDENS = 'screenshot/goldens';
8+
9+
/**
10+
* Read golden files under /goldens/ and store the image data to
11+
* database /screenshot/goldens/$filename
12+
*/
13+
export function convertGoldenImagesToData(name: string, resourceState: string, fileBucket: any) {
14+
// The name should always look like "goldens/xxx.png"
15+
let parsedPath = path.parse(name);
16+
// Get the file name.
17+
if (parsedPath.root != '' || parsedPath.dir != 'goldens' || parsedPath.ext != '.png') {
18+
return;
19+
}
20+
21+
let filenameKey = path.basename(parsedPath.name, '.screenshot');
22+
let databaseRef = firebaseAdmin.database().ref(FIREBASE_DATA_GOLDENS).child(filenameKey);
23+
24+
// When a gold image is deleted, also delete the corresponding record in the firebase database.
25+
if (resourceState === 'not_exists') {
26+
return databaseRef.set(null);
27+
}
28+
29+
let tempFilePath = `/tmp/${parsedPath.base}`;
30+
let bucket = gcs.bucket(fileBucket);
31+
// Download file from bucket.
32+
return bucket.file(name).download({destination: tempFilePath})
33+
.then(() => {
34+
let data = readFileSync(tempFilePath);
35+
return databaseRef.set(data);
36+
}).catch((error: any) => console.error(`${filenameKey} ${error}`));
37+
};

functions/index.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
'use strict';
2+
3+
import * as firebaseFunctions from 'firebase-functions';
4+
import * as firebaseAdmin from 'firebase-admin';
5+
6+
import {verifyJWTAndUpdateData} from './data';
7+
import {convertGoldenImagesToData} from './image_data';
8+
import {convertTestImageDataToFiles} from './data_image';
9+
import {copyTestImagesToGoldens} from './test_goldens';
10+
import {updateGithubStatus} from './github';
11+
12+
/**
13+
* Usage: Firebase functions only accept javascript file index.js
14+
* tsc
15+
* firebase deploy --only functions
16+
*
17+
*
18+
* Data and images handling for Screenshot test.
19+
*
20+
* All users can post data to temporary folder. These Functions will check the data with JsonWebToken and
21+
* move the valid data out of temporary folder.
22+
*
23+
* For valid data posted to database /$temp/screenshot/reports/$prNumber/$secureToken, move it to
24+
* /screenshot/reports/$prNumber.
25+
* These are data for screenshot results (success or failure), GitHub PR/commit and TravisCI job information
26+
*
27+
* For valid image results written to database /$temp/screenshot/images/$prNumber/$secureToken/, save the image
28+
* data to image files and upload to google cloud storage under location /screenshots/$prNumber
29+
* These are screenshot test result images, and difference images generated from screenshot comparison.
30+
*
31+
* For golden images uploaded to /goldens, read the data from images files and write the data to Firebase database
32+
* under location /screenshot/goldens
33+
* Screenshot tests can only read restricted database data with no credentials, and they cannot access
34+
* Google Cloud Storage. Therefore we copy the image data to database to make it available to screenshot tests.
35+
*
36+
* The JWT is stored in the data path, so every write to database needs a valid JWT to be copied to database/storage.
37+
* All invalid data will be removed.
38+
* The JWT has 3 parts: header, payload and signature. These three parts are joint by '/' in path.
39+
*/
40+
41+
// Initailize the admin app
42+
firebaseAdmin.initializeApp(firebaseFunctions.config().firebase);
43+
44+
/** The valid data types database accepts */
45+
const dataTypes = ['result', 'sha', 'travis'];
46+
47+
/** The Json Web Token format. The token is stored in data path. */
48+
const jwtFormat = '{jwtHeader}/{jwtPayload}/{jwtSignature}';
49+
50+
/** The temporary folder name for screenshot data that needs to be validated via JWT. */
51+
const tempFolder = '/untrustedInbox';
52+
53+
54+
/** Untrusted report data for a PR */
55+
const reportPath = `${tempFolder}/screenshot/reports/{prNumber}/${jwtFormat}/`;
56+
/** Untrusted image data for a PR */
57+
const imagePath = `${tempFolder}/screenshot/images/{prNumber}/${jwtFormat}/`;
58+
/** Trusted report data for a PR */
59+
const trustedReportPath = `screenshot/reports/{prNumber}`;
60+
61+
/**
62+
* Copy valid data from /$temp/screenshot/reports/$prNumber/$secureToken/
63+
* to /screenshot/reports/$prNumber
64+
* Data copied: filenames(image results names), commit(github PR info),
65+
* sha (github PR info), result (true or false for all the tests), travis job number
66+
*/
67+
const testDataPath = `${reportPath}/{dataType}`;
68+
exports.testData = firebaseFunctions.database.ref(testDataPath)
69+
.onWrite((event: any) => {
70+
const dataType = event.params.dataType;
71+
if (dataTypes.includes(dataType)) {
72+
return verifyJWTAndUpdateData(event, dataType);
73+
}
74+
});
75+
76+
/**
77+
* Copy valid data from /$temp/screenshot/reports/$prNumber/$secureToken/
78+
* to /screenshot/reports/$prNumber
79+
* Data copied: test result for each file/test with ${filename}. The value should be true or false.
80+
*/
81+
const testResultsPath = `${reportPath}/results/{filename}`;
82+
exports.testResults = firebaseFunctions.database.ref(testResultsPath)
83+
.onWrite((event: any) => {
84+
return verifyJWTAndUpdateData(event, `results/${event.params.filename}`);
85+
});
86+
87+
/**
88+
* Copy valid data from database /$temp/screenshot/images/$prNumber/$secureToken/
89+
* to storage /screenshots/$prNumber
90+
* Data copied: test result images. Convert from data to image files in storage.
91+
*/
92+
const imageDataToFilePath = `${imagePath}/{dataType}/{filename}`;
93+
exports.imageDataToFile = firebaseFunctions.database.ref(imageDataToFilePath)
94+
.onWrite(convertTestImageDataToFiles);
95+
96+
/**
97+
* Copy valid goldens from storage /goldens/ to database /screenshot/goldens/
98+
* so we can read the goldens without credentials.
99+
*/
100+
exports.goldenImageToData = firebaseFunctions.storage.bucket(
101+
firebaseFunctions.config().firebase.storageBucket).object().onChange((event: any) => {
102+
return convertGoldenImagesToData(event.data.name, event.data.resourceState, event.data.bucket);
103+
});
104+
105+
/**
106+
* Copy test result images for PR to Goldens.
107+
* Copy images from /screenshot/$prNumber/test/ to /goldens/
108+
*/
109+
const approveImagesPath = `${trustedReportPath}/approved`;
110+
exports.approveImages = firebaseFunctions.database.ref(approveImagesPath).onWrite((event: any) => {
111+
return copyTestImagesToGoldens(event.params.prNumber);
112+
});
113+
114+
/**
115+
* Update github status. When the result is true, update github commit status to `success`,
116+
* otherwise update github status to `failure`.
117+
* The Github Status Token is set in config.secret.github
118+
*/
119+
const githubStatusPath = `${trustedReportPath}/result`;
120+
exports.githubStatus = firebaseFunctions.database.ref(githubStatusPath).onWrite(updateGithubStatus);

functions/jwt_util.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import * as firebaseFunctions from 'firebase-functions';
2+
import {verifySecureToken} from './util/jwt';
3+
4+
/** The repo slug. This is used to validate the JWT is sent from correct repo. */
5+
const repoSlug = firebaseFunctions.config().repo.slug;
6+
7+
/** The JWT secret. This is used to validate JWT. */
8+
const secret = firebaseFunctions.config().secret.key;
9+
10+
/**
11+
* Extract the Json Web Token from event params.
12+
* In screenshot gulp task the path we use is {jwtHeader}/{jwtPayload}/{jwtSignature}.
13+
* Replace '/' with '.' to get the token.
14+
*/
15+
function getSecureToken(event: firebaseFunctions.Event<any>) {
16+
return `${event.params.jwtHeader}.${event.params.jwtPayload}.${event.params.jwtSignature}`;
17+
};
18+
19+
/**
20+
* Verify event params have correct JsonWebToken, and execute callback when the JWT is verified.
21+
* Delete the data if there's an error or the callback is done
22+
*/
23+
export function verifySecureTokenAndExecute(event: firebaseFunctions.Event<any>) {
24+
return new Promise((resolve, reject) => {
25+
const prNumber = event.params.prNumber;
26+
const secureToken = getSecureToken(event);
27+
28+
return verifySecureToken(secureToken, prNumber, secret, repoSlug).then(() => {
29+
resolve();
30+
event.data.ref.parent.set(null);
31+
}).catch((error: any) => {
32+
console.error(`Invalid secure token ${secureToken} ${error}`);
33+
event.data.ref.parent.set(null);
34+
reject();
35+
});
36+
});
37+
};

functions/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
"@google-cloud/storage": "^0.8.0",
66
"firebase-admin": "^4.1.3",
77
"firebase-functions": "^0.5.2",
8-
"jsonwebtoken": "^7.3.0"
8+
"jsonwebtoken": "^7.3.0",
9+
"request": "^2.81.0",
10+
"typescript": "^2.2.1"
911
}
1012
}

functions/test_goldens.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import * as firebaseFunctions from 'firebase-functions';
2+
import * as firebaseAdmin from 'firebase-admin';
3+
import * as path from 'path';
4+
5+
const gcs = require('@google-cloud/storage')();
6+
7+
/** The storage bucket to store the images. The bucket is also used by Firebase Storage. */
8+
const bucket = gcs.bucket(firebaseFunctions.config().firebase.storageBucket);
9+
10+
/**
11+
* Copy files from /screenshot/$prNumber/test/ to goldens/
12+
* Only copy the files that test result is failure. Passed test images should be the same as
13+
* goldens.
14+
*/
15+
export function copyTestImagesToGoldens(prNumber: string) {
16+
return firebaseAdmin.database().ref(`screenshot/reports/${prNumber}/results`).once('value')
17+
.then((snapshot: firebaseAdmin.database.DataSnapshot) => {
18+
let keys: string[] = [];
19+
let counter = 0;
20+
snapshot.forEach((childSnapshot: firebaseAdmin.database.DataSnapshot) => {
21+
if (childSnapshot.val() == false) {
22+
keys.push(childSnapshot.key);
23+
}
24+
counter ++;
25+
if (counter == snapshot.numChildren()) return true;
26+
});
27+
return keys;
28+
}).then((keys: string[]) => {
29+
return bucket.getFiles({prefix: `screenshots/${prNumber}/test`}).then(function (data: any) {
30+
return Promise.all(data[0]
31+
.filter((file: any) => keys.includes(path.basename(file.name, '.screenshot.png')))
32+
.map((file: any) => file.copy(`goldens/${path.basename(file.name)}`)));
33+
});
34+
})
35+
36+
};

functions/tsconfig.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"compilerOptions": {
3+
"declaration": false,
4+
"emitDecoratorMetadata": true,
5+
"experimentalDecorators": true,
6+
"lib": ["es6", "es2016", "dom"],
7+
"module": "commonjs",
8+
"moduleResolution": "node",
9+
"noEmitOnError": true,
10+
"noImplicitAny": true,
11+
"outDir": "./",
12+
"sourceMap": true,
13+
"target": "es5",
14+
"stripInternal": false,
15+
"baseUrl": "",
16+
"typeRoots": [
17+
"../node_modules/@types/!(node)"
18+
]
19+
},
20+
"files": [
21+
"data.ts",
22+
"data_image.ts",
23+
"github.ts",
24+
"image_data.ts",
25+
"index.ts",
26+
"jwt_util.ts",
27+
"test_goldens.ts",
28+
"util/github.ts",
29+
"util/jwt.ts"
30+
]
31+
}

0 commit comments

Comments
 (0)