11import { task } from 'gulp' ;
22import { readdirSync , statSync , existsSync , mkdirp , readFileSync , writeFileSync } from 'fs-extra' ;
3+ import { openScreenshotsBucket , connectFirebaseScreenshots } from '../util/firebase' ;
4+ import { isTravisMasterBuild } from '../util/travis-ci' ;
5+
36import * as path from 'path' ;
47import * as firebaseAdmin from 'firebase-admin' ;
5- import * as firebase from 'firebase' ;
6- import {
7- openScreenshotsBucket ,
8- connectFirebaseScreenshots } from '../util/firebase' ;
9- import { isTravisMasterBuild } from '../util/travis-ci' ;
108
9+ // Firebase provides TypeScript definitions that are only accessible from specific namespaces.
10+ // This means that those types are really long and it's nearly impossible to write a function that
11+ // doesn't exceed the maximum columns. Import the types from the namespace so they are shorter.
12+ import Database = firebaseAdmin . database . Database ;
13+ import DataSnapshot = firebaseAdmin . database . DataSnapshot ;
14+
15+ // This import lacks of type definitions.
1116const imageDiff = require ( 'image-diff' ) ;
1217
18+ /** Travis secure token that will be used by the Screenshot functions to verify the identity. */
19+ const travisSecureToken = getSecureToken ( ) ;
20+
21+ /** Git SHA of the current Pull Request being checked by Travis. */
22+ const pullRequestSha = process . env [ 'TRAVIS_PULL_REQUEST_SHA' ] ;
23+
1324const SCREENSHOT_DIR = './screenshots' ;
1425const LOCAL_GOLDENS = path . join ( SCREENSHOT_DIR , `golds` ) ;
1526const LOCAL_DIFFS = path . join ( SCREENSHOT_DIR , `diff` ) ;
@@ -29,157 +40,169 @@ task('screenshots', () => {
2940
3041 if ( isTravisMasterBuild ( ) ) {
3142 // Only update goldens for master build
32- return uploadScreenshots ( ) ;
43+ return uploadGoldenScreenshots ( ) ;
3344 } else if ( prNumber ) {
3445 const firebaseApp = connectFirebaseScreenshots ( ) ;
3546 const database = firebaseApp . database ( ) ;
3647
37- return updateTravis ( database , prNumber )
38- . then ( ( ) => getScreenshotFiles ( database ) )
39- . then ( ( ) => downloadAllGoldsAndCompare ( database , prNumber ) )
40- . then ( ( results : boolean ) => updateResult ( database , prNumber , results ) )
48+ return uploadTravisJobInfo ( database , prNumber )
49+ . then ( ( ) => downloadGoldScreenshotFiles ( database ) )
50+ . then ( ( ) => compareScreenshotFiles ( database , prNumber ) )
51+ . then ( passedAll => setPullRequestResult ( database , prNumber , passedAll ) )
4152 . then ( ( ) => uploadScreenshotsData ( database , 'diff' , prNumber ) )
4253 . then ( ( ) => uploadScreenshotsData ( database , 'test' , prNumber ) )
4354 . catch ( ( err : any ) => console . error ( err ) )
4455 . then ( ( ) => firebaseApp . delete ( ) ) ;
4556 }
4657} ) ;
4758
48- function updateFileResult ( database : firebase . database . Database , prNumber : string ,
49- filenameKey : string , result : boolean ) {
50- return getPullRequestRef ( database , prNumber ) . child ( 'results' ) . child ( filenameKey ) . set ( result ) ;
59+ /** Sets the screenshot diff result for a given file of a Pull Request. */
60+ function setFileResult ( database : Database , prNumber : string , fileName : string , result : boolean ) {
61+ return getPullRequestRef ( database , prNumber ) . child ( 'results' ) . child ( fileName ) . set ( result ) ;
5162}
5263
53- function updateResult ( database : firebase . database . Database , prNumber : string , result : boolean ) {
54- return getPullRequestRef ( database , prNumber ) . child ( ' result' )
55- . child ( process . env [ 'TRAVIS_PULL_REQUEST_SHA' ] ) . set ( result ) . then ( ( ) => result ) ;
64+ /** Sets the full diff result for the current Pull Request that runs inside of Travis. */
65+ function setPullRequestResult ( database : Database , prNumber : string , result : boolean ) {
66+ return getPullRequestRef ( database , prNumber ) . child ( 'result' ) . child ( pullRequestSha ) . set ( result ) ;
5667}
5768
58- function getPullRequestRef ( database : firebase . database . Database | firebaseAdmin . database . Database ,
59- prNumber : string ) {
60- let secureToken = getSecureToken ( ) ;
61- return database . ref ( FIREBASE_REPORT ) . child ( prNumber ) . child ( secureToken ) ;
69+ /** Returns the Firebase Reference that contains all data related to the specified PR. */
70+ function getPullRequestRef ( database : Database , prNumber : string ) {
71+ return database . ref ( FIREBASE_REPORT ) . child ( prNumber ) . child ( travisSecureToken ) ;
6272}
6373
64- function updateTravis ( database : firebase . database . Database ,
65- prNumber : string ) {
74+ /** Uploads necessary Travis CI job variables that will be used in the Screenshot Panel. */
75+ function uploadTravisJobInfo ( database : Database , prNumber : string ) {
6676 return getPullRequestRef ( database , prNumber ) . update ( {
6777 sha : process . env [ 'TRAVIS_PULL_REQUEST_SHA' ] ,
6878 travis : process . env [ 'TRAVIS_JOB_ID' ] ,
6979 } ) ;
7080}
7181
72- /** Get a list of filenames from firebase database. */
73- function getScreenshotFiles ( database : firebase . database . Database ) {
82+ /** Downloads all golden screenshot files and stores them in the local file system. */
83+ function downloadGoldScreenshotFiles ( database : Database ) {
84+ // Create the directory that will contain all goldens if it's not present yet.
7485 mkdirp ( LOCAL_GOLDENS ) ;
75- mkdirp ( LOCAL_DIFFS ) ;
76-
77- return database . ref ( FIREBASE_DATA_GOLDENS ) . once ( 'value' )
78- . then ( ( snapshot : firebase . database . DataSnapshot ) => {
79- let counter = 0 ;
80- snapshot . forEach ( ( childSnapshot : firebase . database . DataSnapshot ) => {
81- let key = childSnapshot . key ;
82- let binaryData = new Buffer ( childSnapshot . val ( ) , 'base64' ) . toString ( 'binary' ) ;
83- writeFileSync ( `${ LOCAL_GOLDENS } /${ key } .screenshot.png` , binaryData , 'binary' ) ;
84- counter ++ ;
85- return counter == snapshot . numChildren ( ) ;
86+
87+ return database . ref ( FIREBASE_DATA_GOLDENS ) . once ( 'value' ) . then ( snapshot => {
88+ snapshot . forEach ( ( childSnapshot : DataSnapshot ) => {
89+ const screenshotName = childSnapshot . key ;
90+ const binaryData = new Buffer ( childSnapshot . val ( ) , 'base64' ) . toString ( 'binary' ) ;
91+
92+ writeFileSync ( `${ LOCAL_GOLDENS } /${ screenshotName } .screenshot.png` , binaryData , 'binary' ) ;
8693 } ) ;
87- } ) . catch ( ( error : any ) => console . log ( error ) ) ;
94+ } ) ;
8895}
8996
97+ /** Extracts the name of a given screenshot file by removing the file extension. */
9098function extractScreenshotName ( fileName : string ) {
9199 return path . basename ( fileName , '.screenshot.png' ) ;
92100}
93101
94- function getLocalScreenshotFiles ( dir : string ) : string [ ] {
95- return readdirSync ( dir )
96- . filter ( ( fileName : string ) => ! statSync ( path . join ( dir , fileName ) ) . isDirectory ( ) )
102+ /** Gets a list of files inside of a directory that end with `.screenshot.png`. */
103+ function getLocalScreenshotFiles ( directory : string ) : string [ ] {
104+ return readdirSync ( directory )
105+ . filter ( ( fileName : string ) => ! statSync ( path . join ( directory , fileName ) ) . isDirectory ( ) )
97106 . filter ( ( fileName : string ) => fileName . endsWith ( '.screenshot.png' ) ) ;
98107}
99108
100109/**
101- * Get processed secure token. The jwt token has 3 parts: header, payload, signature and has format
102- * {jwtHeader}.{jwtPayload}.{jwtSignature}
103- * The three parts is connected by '.', while '.' is not a valid path in firebase database.
104- * Replace all '.' to '/' to make the path valid
105- * Output is {jwtHeader}/{jwtPayload}/{jwtSignature}.
106- * This secure token is used to validate the write access is from our TravisCI under our repo.
107- * All data is written to /$path/$secureToken/$data and after validated the
108- * secure token, the data is moved to /$path/$data in database.
110+ * Upload screenshots to a Firebase Database path that will then upload the file to a Google
111+ * Cloud Storage bucket if the Auth token is valid.
112+ * @param database Firebase database instance.
113+ * @param prNumber The key used in firebase. Here it is the PR number.
114+ * @param mode Upload mode. This can be either 'test' or 'diff'.
115+ * - If the images are the test results, mode should be 'test'.
116+ * - If the images are the diff images generated, mode should be 'diff'.
109117 */
110- function getSecureToken ( ) {
111- return process . env [ 'FIREBASE_ACCESS_TOKEN' ] . replace ( / [ . ] / g, '/' ) ;
112- }
113-
114- /**
115- * Upload screenshots to google cloud storage.
116- * @param prNumber - The key used in firebase. Here it is the PR number.
117- * @param mode - Can be 'test' or 'diff' .
118- * If the images are the test results, mode should be 'test'.
119- * If the images are the diff images generated, mode should be 'diff'.
120- */
121- function uploadScreenshotsData ( database : firebase . database . Database ,
122- mode : 'test' | 'diff' , prNumber : string ) {
123- let localDir = mode == 'diff' ? path . join ( SCREENSHOT_DIR , 'diff' ) : SCREENSHOT_DIR ;
124- let promises = getLocalScreenshotFiles ( localDir ) . map ( ( file : string ) => {
125- let fileName = path . join ( localDir , file ) ;
126- let filenameKey = extractScreenshotName ( fileName ) ;
127- let secureToken = getSecureToken ( ) ;
128- let data = readFileSync ( fileName ) ;
129- return database . ref ( FIREBASE_IMAGE ) . child ( prNumber )
130- . child ( secureToken ) . child ( mode ) . child ( filenameKey ) . set ( data ) ;
131- } ) ;
132- return Promise . all ( promises ) ;
118+ function uploadScreenshotsData ( database : Database , mode : 'test' | 'diff' , prNumber : string ) {
119+ const localDir = mode == 'diff' ? path . join ( SCREENSHOT_DIR , 'diff' ) : SCREENSHOT_DIR ;
120+
121+ return Promise . all ( getLocalScreenshotFiles ( localDir ) . map ( file => {
122+ const filePath = path . join ( localDir , file ) ;
123+ const fileName = extractScreenshotName ( filePath ) ;
124+ const binaryContent = readFileSync ( fileName ) ;
125+
126+ // Upload the Buffer of the screenshot image to a Firebase Database reference that will
127+ // then upload the screenshot file to a Google Cloud Storage bucket if the JWT token is valid.
128+ return database . ref ( FIREBASE_IMAGE )
129+ . child ( prNumber ) . child ( travisSecureToken ) . child ( mode ) . child ( fileName )
130+ . set ( binaryContent ) ;
131+ } ) ) ;
133132}
134133
134+ /** Concurrently compares every golden screenshot with the newly taken screenshots. */
135+ function compareScreenshotFiles ( database : Database , prNumber : string ) {
136+ const fileNames = getLocalScreenshotFiles ( LOCAL_GOLDENS ) ;
137+ const compares = fileNames . map ( fileName => compareScreenshotFile ( fileName , database , prNumber ) ) ;
135138
136- /** Download golds screenshots. */
137- function downloadAllGoldsAndCompare ( database : firebase . database . Database , prNumber : string ) {
139+ // Wait for all compares to finish and then return a Promise that resolves with a boolean that
140+ // shows whether the tests passed or not.
141+ return Promise . all ( compares ) . then ( ( results : boolean [ ] ) => results . every ( Boolean ) ) ;
142+ }
138143
139- let filenames = getLocalScreenshotFiles ( LOCAL_GOLDENS ) ;
144+ /** Compare the specified screenshot file with the golden file from Firebase. */
145+ function compareScreenshotFile ( fileName : string , database : Database , prNumber : string ) {
146+ const goldScreenshotPath = path . join ( LOCAL_GOLDENS , fileName ) ;
147+ const localScreenshotPath = path . join ( SCREENSHOT_DIR , fileName ) ;
148+ const diffScreenshotPath = path . join ( LOCAL_DIFFS , fileName ) ;
140149
141- return Promise . all ( filenames . map ( ( filename : string ) => {
142- return diffScreenshot ( filename , database , prNumber ) ;
143- } ) ) . then ( ( results : boolean [ ] ) => results . every ( ( value : boolean ) => value == true ) ) ;
144- }
150+ const screenshotName = extractScreenshotName ( fileName ) ;
145151
146- function diffScreenshot ( filename : string , database : firebase . database . Database ,
147- prNumber : string ) {
148- // TODO(tinayuangao): Run the downloads and diffs in parallel.
149- filename = path . basename ( filename ) ;
150- let goldUrl = path . join ( LOCAL_GOLDENS , filename ) ;
151- let pullRequestUrl = path . join ( SCREENSHOT_DIR , filename ) ;
152- let diffUrl = path . join ( LOCAL_DIFFS , filename ) ;
153- let filenameKey = extractScreenshotName ( filename ) ;
154-
155- if ( existsSync ( goldUrl ) && existsSync ( pullRequestUrl ) ) {
156- return new Promise ( ( resolve : any , reject : any ) => {
157- imageDiff ( {
158- actualImage : pullRequestUrl ,
159- expectedImage : goldUrl ,
160- diffImage : diffUrl ,
161- } , ( err : any , imagesAreSame : boolean ) => {
162- if ( err ) {
163- console . log ( err ) ;
164- imagesAreSame = false ;
165- reject ( err ) ;
166- }
167- resolve ( imagesAreSame ) ;
168- return updateFileResult ( database , prNumber , filenameKey , imagesAreSame ) ;
152+ if ( existsSync ( goldScreenshotPath ) && existsSync ( localScreenshotPath ) ) {
153+ return compareImage ( localScreenshotPath , goldScreenshotPath , diffScreenshotPath )
154+ . then ( result => {
155+ // Set the screenshot diff result in Firebase and afterwards pass the result boolean
156+ // to the Promise chain again.
157+ return setFileResult ( database , prNumber , screenshotName , result ) . then ( ( ) => result ) ;
169158 } ) ;
170- } ) ;
171159 } else {
172- return updateFileResult ( database , prNumber , filenameKey , false ) . then ( ( ) => false ) ;
160+ return setFileResult ( database , prNumber , screenshotName , false ) . then ( ( ) => false ) ;
173161 }
174162}
175163
176- /** Upload screenshots to google cloud storage. */
177- function uploadScreenshots ( ) {
178- let bucket = openScreenshotsBucket ( ) ;
179- let promises = getLocalScreenshotFiles ( SCREENSHOT_DIR ) . map ( ( file : string ) => {
180- let fileName = path . join ( SCREENSHOT_DIR , file ) ;
181- let destination = `${ FIREBASE_STORAGE_GOLDENS } /${ file } ` ;
182- return bucket . upload ( fileName , { destination : destination } ) ;
164+ /** Uploads golden screenshots to the Google Cloud Storage bucket for the screenshots. */
165+ function uploadGoldenScreenshots ( ) {
166+ const bucket = openScreenshotsBucket ( ) ;
167+
168+ return Promise . all ( getLocalScreenshotFiles ( SCREENSHOT_DIR ) . map ( fileName => {
169+ const filePath = path . join ( SCREENSHOT_DIR , fileName ) ;
170+ const storageDestination = `${ FIREBASE_STORAGE_GOLDENS } /${ filePath } ` ;
171+
172+ return bucket . upload ( fileName , { destination : storageDestination } ) ;
173+ } ) ) ;
174+ }
175+
176+ /**
177+ * Compares two images using the Node package image-diff. A difference screenshot will be created.
178+ * The returned promise will resolve with a boolean that will be true if the images are equal.
179+ */
180+ function compareImage ( actualPath : string , goldenPath : string , diffPath : string ) : Promise < boolean > {
181+ return new Promise ( resolve => {
182+ imageDiff ( {
183+ actualImage : actualPath ,
184+ expectedImage : goldenPath ,
185+ diffImage : diffPath ,
186+ } , ( err : any , imagesAreEqual : boolean ) => {
187+ if ( err ) {
188+ throw err ;
189+ }
190+
191+ resolve ( imagesAreEqual ) ;
192+ } ) ;
183193 } ) ;
184- return Promise . all ( promises ) ;
194+ }
195+
196+ /**
197+ * Get processed secure token. The jwt token has 3 parts: header, payload, signature and has format
198+ * {jwtHeader}.{jwtPayload}.{jwtSignature}
199+ * The three parts is connected by '.', while '.' is not a valid path in firebase database.
200+ * Replace all '.' to '/' to make the path valid
201+ * Output is {jwtHeader}/{jwtPayload}/{jwtSignature}.
202+ * This secure token is used to validate the write access is from our TravisCI under our repo.
203+ * All data is written to /$path/$secureToken/$data and after validated the
204+ * secure token, the data is moved to /$path/$data in database.
205+ */
206+ function getSecureToken ( ) {
207+ return ( process . env [ 'FIREBASE_ACCESS_TOKEN' ] || '' ) . replace ( / [ . ] / g, '/' ) ;
185208}
0 commit comments