diff --git a/.gitignore b/.gitignore index 9fded7174921..24e821d2e946 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ /screenshots # dependencies -/node_modules +node_modules /bower_components # Dart diff --git a/functions/index.js b/functions/index.js deleted file mode 100644 index ec7a0f3ac675..000000000000 --- a/functions/index.js +++ /dev/null @@ -1,190 +0,0 @@ -'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); - } - }); - }); -} diff --git a/functions/package.json b/functions/package.json index 180b444d89e8..cd9f629113fe 100644 --- a/functions/package.json +++ b/functions/package.json @@ -5,6 +5,7 @@ "@google-cloud/storage": "^0.8.0", "firebase-admin": "^4.1.3", "firebase-functions": "^0.5.2", - "jsonwebtoken": "^7.3.0" + "jsonwebtoken": "^7.3.0", + "request": "^2.81.0" } } diff --git a/package.json b/package.json index ad4745123364..388f8c3440e0 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@angular/platform-browser-dynamic": "^4.0.0", "@angular/platform-server": "^4.0.0", "@angular/router": "^4.0.0", + "@google-cloud/storage": "^0.8.0", "@types/chalk": "^0.4.31", "@types/fs-extra": "0.0.37", "@types/glob": "^5.0.30", @@ -62,6 +63,7 @@ "dgeni-packages": "^0.16.5", "firebase": "^3.7.2", "firebase-admin": "^4.1.2", + "firebase-functions": "^0.5.2", "firebase-tools": "^2.2.1", "fs-extra": "^2.0.0", "glob": "^7.1.1", @@ -87,6 +89,7 @@ "http-rewrite-middleware": "^0.1.6", "image-diff": "^1.6.3", "jasmine-core": "^2.5.2", + "jsonwebtoken": "^7.3.0", "karma": "^1.5.0", "karma-browserstack-launcher": "^1.2.0", "karma-chrome-launcher": "^2.0.0", @@ -100,6 +103,7 @@ "minimist": "^1.2.0", "node-sass": "^4.5.0", "protractor": "^5.1.1", + "request": "^2.81.0", "resolve-bin": "^0.4.0", "rollup": "^0.41.6", "run-sequence": "^1.2.2", @@ -111,7 +115,7 @@ "ts-node": "^3.0.0", "tslint": "^5.0.0", "tslint-no-unused-var": "0.0.6", - "typescript": "~2.1.1", + "typescript": "~2.2.1", "uglify-js": "^2.8.14", "web-animations-js": "^2.2.2" } diff --git a/tools/gulp/tasks/coverage.ts b/tools/gulp/tasks/coverage.ts index 690ae631d506..a7832db58a62 100644 --- a/tools/gulp/tasks/coverage.ts +++ b/tools/gulp/tasks/coverage.ts @@ -2,7 +2,7 @@ import {task} from 'gulp'; import {existsSync} from 'fs-extra'; import {COVERAGE_RESULT_FILE} from '../constants'; import {spawnSync} from 'child_process'; -import {isTravisPushBuild} from '../util/travis-ci'; +import {isTravisMasterBuild} from '../util/travis-ci'; import {openFirebaseDashboardDatabase} from '../util/firebase'; task('coverage:upload', () => { @@ -10,7 +10,7 @@ task('coverage:upload', () => { throw new Error('No coverage file has been found!'); } - if (!isTravisPushBuild()) { + if (!isTravisMasterBuild()) { throw new Error('Coverage results will be only uploaded inside of Travis Push builds.'); } diff --git a/tools/gulp/tasks/payload.ts b/tools/gulp/tasks/payload.ts index a7657aaa9dd3..69c4496b2931 100644 --- a/tools/gulp/tasks/payload.ts +++ b/tools/gulp/tasks/payload.ts @@ -3,7 +3,7 @@ import {join} from 'path'; import {statSync} from 'fs'; import {DIST_ROOT} from '../constants'; import {spawnSync} from 'child_process'; -import {isTravisPushBuild} from '../util/travis-ci'; +import {isTravisMasterBuild} from '../util/travis-ci'; import {openFirebaseDashboardDatabase} from '../util/firebase'; const bundlesDir = join(DIST_ROOT, 'bundles'); @@ -23,7 +23,7 @@ task('payload', ['library:clean-build'], () => { console.log('Payload Results:', JSON.stringify(results, null, 2)); // Publish the results to firebase when it runs on Travis and not as a PR. - if (isTravisPushBuild()) { + if (isTravisMasterBuild()) { return publishResults(results); } diff --git a/tools/gulp/tasks/screenshots.ts b/tools/gulp/tasks/screenshots.ts index 4d9615231424..9b8152525aff 100644 --- a/tools/gulp/tasks/screenshots.ts +++ b/tools/gulp/tasks/screenshots.ts @@ -5,43 +5,41 @@ import * as admin from 'firebase-admin'; import * as firebase from 'firebase'; import { openScreenshotsBucket, - openFirebaseScreenshotsDatabase, connectFirebaseScreenshots} from '../util/firebase'; -import {setGithubStatus} from '../util/github'; -import {isTravisPushBuild} from '../util/travis-ci'; +import {isTravisMasterBuild} from '../util/travis-ci'; const imageDiff = require('image-diff'); +const SCREENSHOT_DIR = './screenshots'; +const LOCAL_GOLDENS = path.join(SCREENSHOT_DIR, `golds`); +const LOCAL_DIFFS = path.join(SCREENSHOT_DIR, `diff`); + // Directory to which untrusted screenshot results are temporarily written // (without authentication required) before they are verified and copied to // the final storage location. const TEMP_FOLDER = 'untrustedInbox'; -const SCREENSHOT_DIR = './screenshots'; const FIREBASE_REPORT = `${TEMP_FOLDER}/screenshot/reports`; const FIREBASE_IMAGE = `${TEMP_FOLDER}/screenshot/images`; -const FIREBASE_FILELIST = 'screenshot/filenames'; +const FIREBASE_DATA_GOLDENS = `screenshot/goldens`; +const FIREBASE_STORAGE_GOLDENS = 'goldens'; /** Task which upload screenshots generated from e2e test. */ task('screenshots', () => { let prNumber = process.env['TRAVIS_PULL_REQUEST']; - if (isTravisPushBuild()) { - // Only update golds and filenames for build - let database = openFirebaseScreenshotsDatabase(); - uploadScreenshots() - .then(() => setScreenFilenames(database)) - .then(() => database.goOffline(), () => database.goOffline()); + + if (isTravisMasterBuild()) { + // Only update goldens for master build + return uploadScreenshots(); } else if (prNumber) { let firebaseApp = connectFirebaseScreenshots(); let database = firebaseApp.database(); - return getScreenshotFiles(database) + return updateTravis(database, prNumber) + .then(() => getScreenshotFiles(database)) .then(() => downloadAllGoldsAndCompare(database, prNumber)) .then((results: boolean) => updateResult(database, prNumber, results)) - .then((result: boolean) => updateGithubStatus(prNumber, result)) .then(() => uploadScreenshotsData(database, 'diff', prNumber)) .then(() => uploadScreenshotsData(database, 'test', prNumber)) - .then(() => updateTravis(database, prNumber)) - .then(() => setScreenFilenames(database, prNumber)) .then(() => database.goOffline(), () => database.goOffline()); } }); @@ -65,7 +63,6 @@ function getPullRequestRef(database: firebase.database.Database | admin.database function updateTravis(database: firebase.database.Database, prNumber: string) { return getPullRequestRef(database, prNumber).update({ - commit: process.env['TRAVIS_COMMIT'], sha: process.env['TRAVIS_PULL_REQUEST_SHA'], travis: process.env['TRAVIS_JOB_ID'], }); @@ -73,16 +70,16 @@ function updateTravis(database: firebase.database.Database, /** Get a list of filenames from firebase database. */ function getScreenshotFiles(database: firebase.database.Database) { - mkdirp(path.join(SCREENSHOT_DIR, `golds`)); - mkdirp(path.join(SCREENSHOT_DIR, `diff`)); + mkdirp(LOCAL_GOLDENS); + mkdirp(LOCAL_DIFFS); - return database.ref('screenshot/goldens').once('value') + return database.ref(FIREBASE_DATA_GOLDENS).once('value') .then((snapshot: firebase.database.DataSnapshot) => { let counter = 0; snapshot.forEach((childSnapshot: firebase.database.DataSnapshot) => { let key = childSnapshot.key; let binaryData = new Buffer(childSnapshot.val(), 'base64').toString('binary'); - writeFileSync(`${SCREENSHOT_DIR}/golds/${key}.screenshot.png`, binaryData, 'binary'); + writeFileSync(`${LOCAL_GOLDENS}/${key}.screenshot.png`, binaryData, 'binary'); counter++; if (counter == snapshot.numChildren()) { return true; @@ -140,7 +137,7 @@ function uploadScreenshotsData(database: firebase.database.Database, /** Download golds screenshots. */ function downloadAllGoldsAndCompare(database: firebase.database.Database, prNumber: string) { - let filenames = getLocalScreenshotFiles(path.join(SCREENSHOT_DIR, `golds`)); + let filenames = getLocalScreenshotFiles(LOCAL_GOLDENS); return Promise.all(filenames.map((filename: string) => { return diffScreenshot(filename, database, prNumber); @@ -151,9 +148,9 @@ function diffScreenshot(filename: string, database: firebase.database.Database, prNumber: string) { // TODO(tinayuangao): Run the downloads and diffs in parallel. filename = path.basename(filename); - let goldUrl = path.join(SCREENSHOT_DIR, `golds`, filename); + let goldUrl = path.join(LOCAL_GOLDENS, filename); let pullRequestUrl = path.join(SCREENSHOT_DIR, filename); - let diffUrl = path.join(SCREENSHOT_DIR, `diff`, filename); + let diffUrl = path.join(LOCAL_DIFFS, filename); let filenameKey = extractScreenshotName(filename); if (existsSync(goldUrl) && existsSync(pullRequestUrl)) { @@ -177,35 +174,12 @@ function diffScreenshot(filename: string, database: firebase.database.Database, } } -/** - * Upload a list of filenames to firebase database as gold. - * This is necessary for control panel since google-cloud is not available to client side. - */ -function setScreenFilenames(database: admin.database.Database | firebase.database.Database, - prNumber?: string) { - let filenames: string[] = getLocalScreenshotFiles(SCREENSHOT_DIR); - let filelistDatabase = prNumber ? - getPullRequestRef(database, prNumber).child('filenames') : - database.ref(FIREBASE_FILELIST); - return filelistDatabase.set(filenames); -} - -/** Updates the Github Status of the given Pullrequest. */ -function updateGithubStatus(prNumber: number, result: boolean) { - setGithubStatus(process.env['TRAVIS_PULL_REQUEST_SHA'], { - result: result, - name: 'Screenshot Tests', - description: `Screenshot Tests ${result ? 'passed' : 'failed'})`, - url: `http://material2-screenshots.firebaseapp.com/${prNumber}` - }); -} - /** Upload screenshots to google cloud storage. */ function uploadScreenshots() { let bucket = openScreenshotsBucket(); let promises = getLocalScreenshotFiles(SCREENSHOT_DIR).map((file: string) => { let fileName = path.join(SCREENSHOT_DIR, file); - let destination = `golds/${file}`; + let destination = `${FIREBASE_STORAGE_GOLDENS}/${file}`; return bucket.upload(fileName, { destination: destination }); }); return Promise.all(promises); diff --git a/tools/gulp/util/firebase.ts b/tools/gulp/util/firebase.ts index 213f284a6e5e..554caeeba100 100644 --- a/tools/gulp/util/firebase.ts +++ b/tools/gulp/util/firebase.ts @@ -39,22 +39,6 @@ export function openScreenshotsBucket() { return gcs.bucket('material2-screenshots.appspot.com'); } -/** Opens a connection to the firebase database for screenshots. */ -export function openFirebaseScreenshotsDatabase() { - // Initialize the Firebase application with firebaseAdmin credentials. - // Credentials need to be for a Service Account, which can be created in the Firebase console. - let screenshotApp = firebaseAdmin.initializeApp({ - credential: firebaseAdmin.credential.cert({ - project_id: 'material2-screenshots', - client_email: 'firebase-adminsdk-t4209@material2-screenshots.iam.gserviceaccount.com', - private_key: decode(process.env['MATERIAL2_SCREENSHOT_FIREBASE_KEY']) - }), - databaseURL: 'https://material2-screenshots.firebaseio.com' - }, 'material2-screenshots'); - - return screenshotApp.database(); -} - /** Decodes a Travis CI variable that is public in favor for PRs. */ export function decode(str: string): string { // In Travis CI the private key will be incorrect because the line-breaks are escaped. diff --git a/tools/gulp/util/travis-ci.ts b/tools/gulp/util/travis-ci.ts index 3d6524ac05a7..bd8e328b29c8 100644 --- a/tools/gulp/util/travis-ci.ts +++ b/tools/gulp/util/travis-ci.ts @@ -1,4 +1,4 @@ /** Whether gulp currently runs inside of Travis as a push. */ -export function isTravisPushBuild() { +export function isTravisMasterBuild() { return process.env['TRAVIS_PULL_REQUEST'] === 'false'; } diff --git a/tools/screenshot-test/functions/data-image.ts b/tools/screenshot-test/functions/data-image.ts new file mode 100644 index 000000000000..4a120615d9f7 --- /dev/null +++ b/tools/screenshot-test/functions/data-image.ts @@ -0,0 +1,40 @@ +import * as firebaseFunctions from 'firebase-functions'; +import {writeFileSync} from 'fs'; +import {verifySecureToken} from './jwt-util'; +import {isCreateEvent} from './util/util'; + +const gcs = require('@google-cloud/storage')(); + +/** The storage bucket to store the images. The bucket is also used by Firebase Storage. */ +const bucket = gcs.bucket(firebaseFunctions.config().firebase.storageBucket); + +/** + * Writes base-64 encoded test images to png files on the filesystem. + * Image data posted to database will be saved as png files + * and uploaded to screenshot/$prNumber/dataType/$filename + * Convert BufferArray to .png image file + */ +export function writeTestImagesToFiles(event: any) { + // Only edit data when it is first created. Exit when the data is deleted. + if (!isCreateEvent(event)) { + return; + } + + let dataType = event.params.dataType; + let prNumber = event.params.prNumber; + let data = event.data.val(); + let saveFilename = `${event.params.filename}.screenshot.png`; + + // Check it's either diff images generated by screenshot comparison, or the test image results + if (dataType !== 'diff' && dataType !== 'test') { + return; + } + + return verifySecureToken(event).then(() => { + let tempPath = `/tmp/${dataType}-${saveFilename}`; + let filePath = `screenshots/${prNumber}/${dataType}/${saveFilename}`; + let binaryData = new Buffer(data, 'base64').toString('binary'); + writeFileSync(tempPath, binaryData, 'binary'); + return bucket.upload(tempPath, {destination: filePath}); + }); +} diff --git a/tools/screenshot-test/functions/github.ts b/tools/screenshot-test/functions/github.ts new file mode 100644 index 000000000000..ff2caf017666 --- /dev/null +++ b/tools/screenshot-test/functions/github.ts @@ -0,0 +1,31 @@ +import * as firebaseFunctions from 'firebase-functions'; +import {setGithubStatus} from './util/github'; + +/** Github status update token */ +const token = firebaseFunctions.config().secret.github; + +/** The repo slug. This is used to validate the JWT is sent from correct repo. */ +const repoSlug = firebaseFunctions.config().repo.slug; + +/** Domain to view the screenshots */ +const authDomain = firebaseFunctions.config().firebase.authDomain; + +/** The same of this screenshot testing tool */ +const toolName = firebaseFunctions.config().tool.name; + +export function updateGithubStatus(event: firebaseFunctions.Event) { + if (!event.data.exists() || typeof event.data.val() != 'boolean') { + return; + } + let result = event.data.val() == true; + let prNumber = event.params.prNumber; + return setGithubStatus(event.params.sha, + { + result: result, + name: toolName, + description: `${toolName} ${result ? 'passed' : 'failed'}`, + url: `http://${authDomain}/${prNumber}` + }, + repoSlug, + token); +} diff --git a/tools/screenshot-test/functions/image-data.ts b/tools/screenshot-test/functions/image-data.ts new file mode 100644 index 000000000000..b0adf05d6d25 --- /dev/null +++ b/tools/screenshot-test/functions/image-data.ts @@ -0,0 +1,41 @@ +import * as firebaseAdmin from 'firebase-admin'; +import * as path from 'path'; +import {readFileSync} from 'fs'; + +const gcs = require('@google-cloud/storage')(); + +/** Folder on Firebase database to store golden images data */ +const FIREBASE_DATA_GOLDENS = 'screenshot/goldens'; + +/** + * Read golden files under /goldens/ and store the image data to + * database /screenshot/goldens/$filename as base-64 encoded string + * Convert png image files to BufferArray data + */ +export function copyGoldImagesToDatabase(name: string, resourceState: string, fileBucket: any) { + // The name should always look like "goldens/xxx.png" + let parsedPath = path.parse(name); + // Get the file name. + if (parsedPath.root != '' || + parsedPath.dir != 'goldens' || + parsedPath.ext.toLowerCase() != '.png') { + return; + } + + let filenameKey = path.basename(parsedPath.name, '.screenshot'); + let databaseRef = firebaseAdmin.database().ref(FIREBASE_DATA_GOLDENS).child(filenameKey); + + // When a gold image is deleted, also delete the corresponding record in the firebase database. + if (resourceState === 'not_exists') { + return databaseRef.set(null); + } + + let tempFilePath = `/tmp/${parsedPath.base}`; + let bucket = gcs.bucket(fileBucket); + // Download file from bucket. + return bucket.file(name).download({destination: tempFilePath}) + .then(() => { + let data = readFileSync(tempFilePath); + return databaseRef.set(data); + }).catch((error: any) => console.error(`${filenameKey} ${error}`)); +} diff --git a/tools/screenshot-test/functions/index.ts b/tools/screenshot-test/functions/index.ts new file mode 100644 index 000000000000..977412e99af6 --- /dev/null +++ b/tools/screenshot-test/functions/index.ts @@ -0,0 +1,130 @@ +'use strict'; + +import * as firebaseFunctions from 'firebase-functions'; +import * as firebaseAdmin from 'firebase-admin'; + +import {verifyJwtAndTransferResultToTrustedLocation} from './verify-and-copy-report'; +import {copyGoldImagesToDatabase} from './image-data'; +import {writeTestImagesToFiles} from './data-image'; +import {copyTestImagesToGoldens} from './test-goldens'; +import {updateGithubStatus} from './github'; + +/** + * Usage: Firebase functions only accept javascript file index.js + * tsc -p tools/screenshot-test/functions/tsconfig.json + * cd functions + * npm install + * firebase deploy --only functions + * + * + * 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 = ['result', 'sha', 'travis']; + +/** 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'; + + +/** Untrusted report data for a PR */ +const reportPath = `${tempFolder}/screenshot/reports/{prNumber}/${jwtFormat}/`; +/** Untrusted image data for a PR */ +const imagePath = `${tempFolder}/screenshot/images/{prNumber}/${jwtFormat}/`; +/** Trusted report data for a PR */ +const trustedReportPath = `screenshot/reports/{prNumber}`; + +/** + * 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 testDataPath = `${reportPath}/{dataType}`; +export let testData = firebaseFunctions.database.ref(testDataPath) + .onWrite((event: any) => { + const dataType = event.params.dataType; + if (dataTypes.includes(dataType)) { + return verifyJwtAndTransferResultToTrustedLocation(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 testResultsPath = `${reportPath}/results/{filename}`; +export let testResults = firebaseFunctions.database.ref(testResultsPath) + .onWrite((event: any) => { + return verifyJwtAndTransferResultToTrustedLocation(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 imageDataToFilePath = `${imagePath}/{dataType}/{filename}`; +export let imageDataToFile = firebaseFunctions.database.ref(imageDataToFilePath) + .onWrite(writeTestImagesToFiles); + +/** + * Copy valid goldens from storage /goldens/ to database /screenshot/goldens/ + * so we can read the goldens without credentials. + */ +export let goldenImageToData = firebaseFunctions.storage.bucket( + firebaseFunctions.config().firebase.storageBucket).object().onChange((event: any) => { + return copyGoldImagesToDatabase(event.data.name, event.data.resourceState, event.data.bucket); +}); + +/** + * Copy test result images for PR to Goldens. + * Copy images from /screenshot/$prNumber/test/ to /goldens/ + */ +const approveImagesPath = `${trustedReportPath}/approved`; +export let approveImages = firebaseFunctions.database.ref(approveImagesPath) + .onWrite((event: any) => { + return copyTestImagesToGoldens(event.params.prNumber); +}); + +/** + * Update github status. When the result is true, update github commit status to `success`, + * otherwise update github status to `failure`. + * The Github Status Token is set in config.secret.github + */ +const githubStatusPath = `${trustedReportPath}/result/{sha}`; +export let githubStatus = firebaseFunctions.database.ref(githubStatusPath) + .onWrite(updateGithubStatus); diff --git a/tools/screenshot-test/functions/jwt-util.ts b/tools/screenshot-test/functions/jwt-util.ts new file mode 100644 index 000000000000..73571275089e --- /dev/null +++ b/tools/screenshot-test/functions/jwt-util.ts @@ -0,0 +1,37 @@ +import * as firebaseFunctions from 'firebase-functions'; +import {verifyJWT} from './util/jwt'; + +/** 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; + +/** + * 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: firebaseFunctions.Event) { + return `${event.params.jwtHeader}.${event.params.jwtPayload}.${event.params.jwtSignature}`; +} + +/** + * Verify that the event has a valid JsonWebToken. If the token is *not* valid, + * the data tied to the event will be deleted and the function will return a rejected promise. + */ +export function verifySecureToken(event: firebaseFunctions.Event) { + return new Promise((resolve, reject) => { + const prNumber = event.params.prNumber; + const secureToken = getSecureToken(event); + + return verifyJWT(secureToken, prNumber, secret, repoSlug).then(() => { + resolve(); + event.data.ref.parent.set(null); + }).catch((error: any) => { + console.error(`Invalid secure token ${secureToken} ${error}`); + event.data.ref.parent.set(null); + reject(); + }); + }); +} diff --git a/tools/screenshot-test/functions/test-goldens.ts b/tools/screenshot-test/functions/test-goldens.ts new file mode 100644 index 000000000000..fdb384d8654c --- /dev/null +++ b/tools/screenshot-test/functions/test-goldens.ts @@ -0,0 +1,38 @@ +import * as firebaseFunctions from 'firebase-functions'; +import * as firebaseAdmin from 'firebase-admin'; +import * as path from 'path'; + +const gcs = require('@google-cloud/storage')(); + +/** The storage bucket to store the images. The bucket is also used by Firebase Storage. */ +const bucket = gcs.bucket(firebaseFunctions.config().firebase.storageBucket); + +/** + * Copy files from /screenshot/$prNumber/test/ to goldens/ + * Only copy the files that test result is failure. Passed test images should be the same as + * goldens. + */ +export function copyTestImagesToGoldens(prNumber: string) { + return firebaseAdmin.database().ref(`screenshot/reports/${prNumber}/results`).once('value') + .then((snapshot: firebaseAdmin.database.DataSnapshot) => { + let failedFilenames: string[] = []; + let counter = 0; + snapshot.forEach((childSnapshot: firebaseAdmin.database.DataSnapshot) => { + if (childSnapshot.val() === false) { + failedFilenames.push(childSnapshot.key); + } + counter++; + if (counter == snapshot.numChildren()) { + return true; + } + }); + return failedFilenames; + }).then((failedFilenames: string[]) => { + return bucket.getFiles({prefix: `screenshots/${prNumber}/test`}).then((data: any) => { + return Promise.all(data[0] + .filter((file: any) => failedFilenames.includes( + path.basename(file.name, '.screenshot.png'))) + .map((file: any) => file.copy(`goldens/${path.basename(file.name)}`))); + }); + }); +} diff --git a/tools/screenshot-test/functions/tsconfig.json b/tools/screenshot-test/functions/tsconfig.json new file mode 100644 index 000000000000..cf893515ca49 --- /dev/null +++ b/tools/screenshot-test/functions/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "lib": ["es6", "es2016", "dom"], + "module": "commonjs", + "moduleResolution": "node", + "noEmitOnError": true, + "noImplicitAny": true, + "sourceMap": true, + "target": "es5", + "baseUrl": "", + "outDir": "../../../functions/", + "typeRoots": [ + "../../../functions/node_modules/@types/!(node)" + ] + }, + "files": [ + "./index.ts" + ] +} diff --git a/tools/screenshot-test/functions/util/github.ts b/tools/screenshot-test/functions/util/github.ts new file mode 100644 index 000000000000..405be48a45ab --- /dev/null +++ b/tools/screenshot-test/functions/util/github.ts @@ -0,0 +1,42 @@ +const request = require('request'); + +/** Data that must be specified to set a Github PR status. */ +export type GithubStatusData = { + result: boolean; + name: string; + description: string; + url: string; +}; + +/** Function that sets a Github commit status */ +export function setGithubStatus(commitSHA: string, + statusData: GithubStatusData, + repoSlug: string, + token: string) { + let state = statusData.result ? 'success' : 'failure'; + + let data = JSON.stringify({ + state: state, + target_url: statusData.url, + context: statusData.name, + description: statusData.description + }); + + let headers = { + 'Authorization': `token ${token}`, + 'User-Agent': `${statusData.name}/1.0`, + 'Content-Type': 'application/json' + }; + + return new Promise((resolve) => { + request({ + url: `https://api.github.com/repos/${repoSlug}/statuses/${commitSHA}`, + method: 'POST', + form: data, + headers: headers + }, function (error: any, response: any) { + console.log(response.statusCode); + resolve(response.statusCode); + }); + }); +} diff --git a/tools/screenshot-test/functions/util/jwt.ts b/tools/screenshot-test/functions/util/jwt.ts new file mode 100644 index 000000000000..04b43562532d --- /dev/null +++ b/tools/screenshot-test/functions/util/jwt.ts @@ -0,0 +1,20 @@ +import * as jwt from 'jsonwebtoken'; + +export function verifyJWT(token: string, + prNumber: string, + secret: string, + repoSlug: string) { + return new Promise((resolve, reject) => { + jwt.verify(token, secret, {issuer: 'Travis CI, GmbH'}, (err: any, payload: any) => { + 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}`); + } else { + resolve(payload); + } + }); + }); +} diff --git a/tools/screenshot-test/functions/util/util.ts b/tools/screenshot-test/functions/util/util.ts new file mode 100644 index 000000000000..4ca369275fdd --- /dev/null +++ b/tools/screenshot-test/functions/util/util.ts @@ -0,0 +1,11 @@ +export function isEditEvent(event: any) { + return event.data.previous.exists() && event.data.exists(); +} + +export function isDeleteEvent(event: any) { + return event.data.previous.exists() && !event.data.exists(); +} + +export function isCreateEvent(event: any) { + return !event.data.previous.exists() && event.data.exists(); +} diff --git a/tools/screenshot-test/functions/verify-and-copy-report.ts b/tools/screenshot-test/functions/verify-and-copy-report.ts new file mode 100644 index 000000000000..0b1c65aa3d51 --- /dev/null +++ b/tools/screenshot-test/functions/verify-and-copy-report.ts @@ -0,0 +1,22 @@ +import * as firebaseAdmin from 'firebase-admin'; +import {verifySecureToken} from './jwt-util'; +import {isCreateEvent} from './util/util'; + +/** + * Verifies that a screenshot report is valid (trusted via JWT) and, if so, copies it from the + * temporary, unauthenticated location to the more permanent, trusted location. + */ +export function verifyJwtAndTransferResultToTrustedLocation(event: any, path: string) { + // Only edit data when it is first created. Exit when the data is deleted. + if (!isCreateEvent(event)) { + return; + } + + let prNumber = event.params.prNumber; + let data = event.data.val(); + + return verifySecureToken(event).then(() => { + return firebaseAdmin.database().ref().child('screenshot/reports') + .child(prNumber).child(path).set(data); + }); +}