1
1
import { task } from 'gulp' ;
2
2
import { readdirSync , statSync , existsSync , mkdirp , readFileSync , writeFileSync } from 'fs-extra' ;
3
+ import { openScreenshotsBucket , connectFirebaseScreenshots } from '../util/firebase' ;
4
+ import { isTravisMasterBuild } from '../util/travis-ci' ;
5
+
3
6
import * as path from 'path' ;
4
7
import * 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' ;
10
8
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.
11
16
const imageDiff = require ( 'image-diff' ) ;
12
17
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
+
13
24
const SCREENSHOT_DIR = './screenshots' ;
14
25
const LOCAL_GOLDENS = path . join ( SCREENSHOT_DIR , `golds` ) ;
15
26
const LOCAL_DIFFS = path . join ( SCREENSHOT_DIR , `diff` ) ;
@@ -29,157 +40,169 @@ task('screenshots', () => {
29
40
30
41
if ( isTravisMasterBuild ( ) ) {
31
42
// Only update goldens for master build
32
- return uploadScreenshots ( ) ;
43
+ return uploadGoldenScreenshots ( ) ;
33
44
} else if ( prNumber ) {
34
45
const firebaseApp = connectFirebaseScreenshots ( ) ;
35
46
const database = firebaseApp . database ( ) ;
36
47
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 ) )
41
52
. then ( ( ) => uploadScreenshotsData ( database , 'diff' , prNumber ) )
42
53
. then ( ( ) => uploadScreenshotsData ( database , 'test' , prNumber ) )
43
54
. catch ( ( err : any ) => console . error ( err ) )
44
55
. then ( ( ) => firebaseApp . delete ( ) ) ;
45
56
}
46
57
} ) ;
47
58
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 ) ;
51
62
}
52
63
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 ) ;
56
67
}
57
68
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 ) ;
62
72
}
63
73
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 ) {
66
76
return getPullRequestRef ( database , prNumber ) . update ( {
67
77
sha : process . env [ 'TRAVIS_PULL_REQUEST_SHA' ] ,
68
78
travis : process . env [ 'TRAVIS_JOB_ID' ] ,
69
79
} ) ;
70
80
}
71
81
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.
74
85
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' ) ;
86
93
} ) ;
87
- } ) . catch ( ( error : any ) => console . log ( error ) ) ;
94
+ } ) ;
88
95
}
89
96
97
+ /** Extracts the name of a given screenshot file by removing the file extension. */
90
98
function extractScreenshotName ( fileName : string ) {
91
99
return path . basename ( fileName , '.screenshot.png' ) ;
92
100
}
93
101
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 ( ) )
97
106
. filter ( ( fileName : string ) => fileName . endsWith ( '.screenshot.png' ) ) ;
98
107
}
99
108
100
109
/**
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'.
109
117
*/
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 ( filePath ) ;
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
+ } ) ) ;
133
132
}
134
133
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 ) ) ;
135
138
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
+ }
138
143
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 ) ;
140
149
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 ) ;
145
151
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 ) ;
169
158
} ) ;
170
- } ) ;
171
159
} else {
172
- return updateFileResult ( database , prNumber , filenameKey , false ) . then ( ( ) => false ) ;
160
+ return setFileResult ( database , prNumber , screenshotName , false ) . then ( ( ) => false ) ;
173
161
}
174
162
}
175
163
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
+ } ) ;
183
193
} ) ;
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, '/' ) ;
185
208
}
0 commit comments