test(screenshot): enable screenshot tests w/ Firebase Functions and JWT (#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
This commit is contained in:
tinayuangao
2017-03-25 08:40:25 +08:00
committed by Jeremy Elbourn
parent 5f360f9a08
commit 23d18a80b3
9 changed files with 335 additions and 52 deletions
+10 -1
View File
@@ -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
@@ -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
@@ -32,6 +38,9 @@ matrix:
- env: "MODE=saucelabs_optional"
- env: "MODE=browserstack_optional"
before_install:
- source ./scripts/ci/env.sh
install:
- npm install
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+12
View File
@@ -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
View File
@@ -57,4 +57,6 @@ task('e2e', sequenceTask(
[':test:protractor:setup', 'serve:e2eapp'],
':test:protractor',
':serve:e2eapp:stop',
'screenshots',
));
+86 -47
View File
@@ -1,55 +1,68 @@
import {task} from 'gulp';
import {readdirSync, statSync, existsSync, mkdirp} from 'fs-extra';
import {readdirSync, statSync, existsSync, mkdirp, readFileSync, writeFileSync} from 'fs-extra';
import * as path from 'path';
import * as admin from 'firebase-admin';
import {openScreenshotsBucket, openFirebaseScreenshotsDatabase} from '../util/firebase';
import * as firebase from 'firebase';
import {
openScreenshotsBucket,
openFirebaseScreenshotsDatabase,
connectFirebaseScreenshots} from '../util/firebase';
import {setGithubStatus} from '../util/github';
const imageDiff = require('image-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 = 'screenshot/reports';
const FIREBASE_REPORT = `${TEMP_FOLDER}/screenshot/reports`;
const FIREBASE_IMAGE = `${TEMP_FOLDER}/screenshot/images`;
const FIREBASE_FILELIST = 'screenshot/filenames';
/** Task which upload screenshots generated from e2e test. */
task('screenshots', () => {
let prNumber = process.env['TRAVIS_PULL_REQUEST'];
if (prNumber) {
let database = openFirebaseScreenshotsDatabase();
let firebaseApp = connectFirebaseScreenshots();
let database = firebaseApp.database();
return getScreenshotFiles(database)
.then((files: any[]) => downloadAllGoldsAndCompare(files, database, prNumber))
.then(() => downloadAllGoldsAndCompare(database, prNumber))
.then((results: boolean) => updateResult(database, prNumber, results))
.then((result: boolean) => updateGithubStatus(prNumber, result))
.then(() => uploadScreenshots('diff', prNumber))
.then(() => uploadScreenshots('test', prNumber))
.then(() => uploadScreenshotsData(database, 'diff', prNumber))
.then(() => uploadScreenshotsData(database, 'test', prNumber))
.then(() => updateTravis(database, prNumber))
.then(() => setScreenFilenames(database, prNumber))
.then(() => database.goOffline(), () => database.goOffline());
} else if (process.env['TRAVIS']) {
// Only update golds and filenames for build
let database = openFirebaseScreenshotsDatabase();
uploadScreenshots('gold')
uploadScreenshots()
.then(() => setScreenFilenames(database))
.then(() => database.goOffline(), () => database.goOffline());
}
});
function updateFileResult(database: admin.database.Database, prNumber: string,
function updateFileResult(database: firebase.database.Database, prNumber: string,
filenameKey: string, result: boolean) {
return getPullRequestRef(database, prNumber).child('results').child(filenameKey).set(result);
}
function updateResult(database: admin.database.Database, prNumber: string, result: boolean) {
function updateResult(database: firebase.database.Database, prNumber: string, result: boolean) {
return getPullRequestRef(database, prNumber).child('result').set(result).then(() => result);
}
function getPullRequestRef(database: admin.database.Database, prNumber: string) {
return database.ref(FIREBASE_REPORT).child(prNumber);
function getPullRequestRef(database: firebase.database.Database | admin.database.Database,
prNumber: string) {
let secureToken = getSecureToken();
return database.ref(FIREBASE_REPORT).child(prNumber).child(secureToken);
}
function updateTravis(database: admin.database.Database,
function updateTravis(database: firebase.database.Database,
prNumber: string) {
return database.ref(FIREBASE_REPORT).child(prNumber).update({
return getPullRequestRef(database, prNumber).update({
commit: process.env['TRAVIS_COMMIT'],
sha: process.env['TRAVIS_PULL_REQUEST_SHA'],
travis: process.env['TRAVIS_JOB_ID'],
@@ -57,11 +70,23 @@ function updateTravis(database: admin.database.Database,
}
/** Get a list of filenames from firebase database. */
function getScreenshotFiles(database: admin.database.Database) {
let bucket = openScreenshotsBucket();
return bucket.getFiles({ prefix: 'golds/' }).then(function(data: any) {
return data[0].filter((file: any) => file.name.endsWith('.screenshot.png'));
});
function getScreenshotFiles(database: firebase.database.Database) {
mkdirp(path.join(SCREENSHOT_DIR, `golds`));
mkdirp(path.join(SCREENSHOT_DIR, `diff`));
return database.ref('screenshot/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');
counter++;
if (counter == snapshot.numChildren()) {
return true;
}
});
}).catch((error: any) => console.log(error));
}
function extractScreenshotName(fileName: string) {
@@ -74,50 +99,53 @@ function getLocalScreenshotFiles(dir: string): string[] {
.filter((fileName: string) => fileName.endsWith('.screenshot.png'));
}
/**
* Get processed secure token. The jwt token has 3 parts: header, payload, signature and has format
* {jwtHeader}.{jwtPayload}.{jwtSignature}
* The three parts is connected by '.', while '.' is not a valid path in firebase database.
* Replace all '.' to '/' to make the path valid
* Output is {jwtHeader}/{jwtPayload}/{jwtSignature}.
* This secure token is used to validate the write access is from our TravisCI under our repo.
* All data is written to /$path/$secureToken/$data and after validated the
* secure token, the data is moved to /$path/$data in database.
*/
function getSecureToken() {
return process.env['FIREBASE_ACCESS_TOKEN'].replace(/[.]/g, '/');
}
/**
* Upload screenshots to google cloud storage.
* @param prNumber - The key used in firebase. Here it is the PR number.
* If there's no prNumber, we will upload images to 'golds/' folder
* @param mode - Can be 'test' or 'diff' or 'gold'.
* @param mode - Can be 'test' or 'diff' .
* If the images are the test results, mode should be 'test'.
* If the images are the diff images generated, mode should be 'diff'.
* For golds mode should be 'gold'.
*/
function uploadScreenshots(mode?: 'test' | 'diff' | 'gold', prNumber?: string) {
let bucket = openScreenshotsBucket();
let promises: any[] = [];
function uploadScreenshotsData(database: firebase.database.Database,
mode: 'test' | 'diff', prNumber: string) {
let localDir = mode == 'diff' ? path.join(SCREENSHOT_DIR, 'diff') : SCREENSHOT_DIR;
getLocalScreenshotFiles(localDir).forEach((file: string) => {
let promises = getLocalScreenshotFiles(localDir).map((file: string) => {
let fileName = path.join(localDir, file);
let destination = (mode == 'gold' || !prNumber) ?
`golds/${file}` : `screenshots/${prNumber}/${mode}/${file}`;
promises.push(bucket.upload(fileName, { destination: destination }));
let filenameKey = extractScreenshotName(fileName);
let secureToken = getSecureToken();
let data = readFileSync(fileName);
return database.ref(FIREBASE_IMAGE).child(prNumber)
.child(secureToken).child(mode).child(filenameKey).set(data);
});
return Promise.all(promises);
}
/** Download golds screenshots. */
function downloadAllGoldsAndCompare(
files: any[], database: admin.database.Database,
prNumber: string) {
function downloadAllGoldsAndCompare(database: firebase.database.Database, prNumber: string) {
mkdirp(path.join(SCREENSHOT_DIR, `golds`));
mkdirp(path.join(SCREENSHOT_DIR, `diff`));
let filenames = getLocalScreenshotFiles(path.join(SCREENSHOT_DIR, `golds`));
return Promise.all(files.map((file: any) => {
return downloadGold(file).then(() => diffScreenshot(file.name, database, prNumber));
return Promise.all(filenames.map((filename: string) => {
return diffScreenshot(filename, database, prNumber);
})).then((results: boolean[]) => results.every((value: boolean) => value == true));
}
/** Download one gold screenshot */
function downloadGold(file: any) {
return file.download({
destination: path.join(SCREENSHOT_DIR, file.name)
});
}
function diffScreenshot(filename: string, database: admin.database.Database,
function diffScreenshot(filename: string, database: firebase.database.Database,
prNumber: string) {
// TODO(tinayuangao): Run the downloads and diffs in parallel.
filename = path.basename(filename);
@@ -151,11 +179,11 @@ function diffScreenshot(filename: string, database: admin.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,
function setScreenFilenames(database: admin.database.Database | firebase.database.Database,
prNumber?: string) {
let filenames: string[] = getLocalScreenshotFiles(SCREENSHOT_DIR);
let filelistDatabase = prNumber ?
database.ref(FIREBASE_REPORT).child(prNumber).child('filenames') :
getPullRequestRef(database, prNumber).child('filenames') :
database.ref(FIREBASE_FILELIST);
return filelistDatabase.set(filenames);
}
@@ -169,3 +197,14 @@ function updateGithubStatus(prNumber: number, result: boolean) {
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}`;
return bucket.upload(fileName, { destination: destination });
});
return Promise.all(promises);
}
+15 -4
View File
@@ -1,9 +1,12 @@
const firebaseAdmin = require('firebase-admin');
const firebase = require('firebase');
const gcloud = require('google-cloud');
const config = require('../../../functions/config.json');
/** Opens a connection to the firebase realtime database. */
export function openFirebaseDashboardDatabase() {
// Initialize the Firebase application with admin credentials.
// Initialize the Firebase application with firebaseAdmin credentials.
// Credentials need to be for a Service Account, which can be created in the Firebase console.
firebaseAdmin.initializeApp({
credential: firebaseAdmin.credential.cert({
@@ -11,7 +14,7 @@ export function openFirebaseDashboardDatabase() {
client_email: 'firebase-adminsdk-ch1ob@material2-dashboard.iam.gserviceaccount.com',
// In Travis CI the private key will be incorrect because the line-breaks are escaped.
// The line-breaks need to persist in the service account private key.
private_key: (process.env['MATERIAL2_FIREBASE_PRIVATE_KEY'] || '').replace(/\\n/g, '\n')
private_key: decode(process.env['MATERIAL2_FIREBASE_PRIVATE_KEY'])
}),
databaseURL: 'https://material2-dashboard.firebaseio.com'
});
@@ -38,7 +41,7 @@ export function openScreenshotsBucket() {
/** Opens a connection to the firebase database for screenshots. */
export function openFirebaseScreenshotsDatabase() {
// Initialize the Firebase application with admin credentials.
// 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({
@@ -56,6 +59,14 @@ export function openFirebaseScreenshotsDatabase() {
export function decode(str: string): string {
// In Travis CI the private key will be incorrect because the line-breaks are escaped.
// The line-breaks need to persist in the service account private key.
return (str || '').split('\\n').reverse().join('\\n').replace(/\\n/g, '\n');
return (str || '').replace(/\\n/g, '\n');
}
/**
* Open firebase connection for screenshot
* This connection is client side connection with no credentials
*/
export function connectFirebaseScreenshots() {
return firebase.initializeApp(config.firebase);
}