Skip to content

Commit 9c4f680

Browse files
michaelzoechthechenky
authored andcommitted
Test Lab Functions (#229)
* Firebase Test Lab Provider * Minor code cleanups * Update event type and provider namespace to new format * Rename testlab to testLab * Add testMatrix handler function to HandlerBuilder * Resolve PR comments * Resolve PR comments * Add changelog message for new Test Lab trigger * Resolve minor pr comments * Promisify makeRequest and reuse in existing http request helpers * Fix testLab import in handler-builder.ts * Necessary fixes after merging master
1 parent e3cff3a commit 9c4f680

File tree

12 files changed

+775
-24
lines changed

12 files changed

+775
-24
lines changed

changelog.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
feature - Adds region support for us-east4.
1+
feature - Adds support for Test Lab triggered functions with `functions.testLab`.
2+
feature - Adds region support for us-east4.

integration_test/functions/src/index.ts

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,36 +14,29 @@ export * from './firestore-tests';
1414
export * from './https-tests';
1515
export * from './remoteConfig-tests';
1616
export * from './storage-tests';
17+
export * from './testLab-tests';
1718
const numTests = Object.keys(exports).length; // Assumption: every exported function is its own test.
1819

20+
import * as utils from './test-utils';
21+
import * as testLab from './testLab-utils';
22+
1923
import 'firebase-functions'; // temporary shim until process.env.FIREBASE_CONFIG available natively in GCF(BUG 63586213)
2024
const firebaseConfig = JSON.parse(process.env.FIREBASE_CONFIG);
2125
admin.initializeApp();
2226

2327
// TODO(klimt): Get rid of this once the JS client SDK supports callable triggers.
2428
function callHttpsTrigger(name: string, data: any, baseUrl) {
25-
return new Promise((resolve, reject) => {
26-
const request = https.request(
27-
{
28-
method: 'POST',
29-
host: 'us-central1-' + firebaseConfig.projectId + '.' + baseUrl,
30-
path: '/' + name,
31-
headers: {
32-
'Content-Type': 'application/json',
33-
},
29+
return utils.makeRequest(
30+
{
31+
method: 'POST',
32+
host: 'us-central1-' + firebaseConfig.projectId + '.' + baseUrl,
33+
path: '/' + name,
34+
headers: {
35+
'Content-Type': 'application/json',
3436
},
35-
(response) => {
36-
let body = '';
37-
response.on('data', (chunk) => {
38-
body += chunk;
39-
});
40-
response.on('end', () => resolve(body));
41-
}
42-
);
43-
request.on('error', reject);
44-
request.write(JSON.stringify({ data }));
45-
request.end();
46-
});
37+
},
38+
JSON.stringify({ data })
39+
);
4740
}
4841

4942
function callScheduleTrigger(functionName: string, region: string) {
@@ -149,6 +142,7 @@ export const integrationTests: any = functions
149142
.storage()
150143
.bucket()
151144
.upload('/tmp/' + testId + '.txt'),
145+
testLab.startTestRun(firebaseConfig.projectId, testId),
152146
// Invoke the schedule for our scheduled function to fire
153147
callScheduleTrigger('schedule', 'us-central1'),
154148
])
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import * as https from 'https';
2+
3+
/**
4+
* Makes an http request asynchronously and returns the response data.
5+
*
6+
* This function wraps the callback-based `http.request()` function with a
7+
* Promise. The returned Promise will be rejected in case the request fails with an
8+
* error, or the response code is not in the 200-299 range.
9+
*
10+
* @param options Request options for the request.
11+
* @param body Optional body to send as part of the request.
12+
* @returns Promise returning the response data as string.
13+
*/
14+
export function makeRequest(
15+
options: https.RequestOptions,
16+
body?: string
17+
): Promise<string> {
18+
return new Promise((resolve, reject) => {
19+
const request = https.request(options, (response) => {
20+
let body = '';
21+
response.on('data', (chunk) => {
22+
body += chunk;
23+
});
24+
response.on('end', () => {
25+
if (response.statusCode < 200 || response.statusCode > 299) {
26+
reject(body);
27+
return;
28+
}
29+
resolve(body);
30+
});
31+
});
32+
if (body) {
33+
request.write(body);
34+
}
35+
request.on('error', reject);
36+
request.end();
37+
});
38+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import * as functions from 'firebase-functions';
2+
import * as _ from 'lodash';
3+
import { TestSuite, expectEq } from './testing';
4+
import TestMatrix = functions.testLab.TestMatrix;
5+
6+
export const testLabTests: any = functions
7+
.runWith({
8+
timeoutSeconds: 540,
9+
})
10+
.testLab.testMatrix()
11+
.onComplete((matrix, context) => {
12+
return new TestSuite<TestMatrix>('test matrix complete')
13+
.it('should have eventId', (snap, context) => context.eventId)
14+
15+
.it('should have timestamp', (snap, context) => context.timestamp)
16+
17+
.it('should have right eventType', (_, context) =>
18+
expectEq(context.eventType, 'google.testing.testMatrix.complete')
19+
)
20+
21+
.it("should be in state 'INVALID'", (matrix, _) =>
22+
expectEq(matrix.state, 'INVALID')
23+
)
24+
25+
.run(_.get(matrix, 'clientInfo.details.testId'), matrix, context);
26+
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import * as http from 'http';
2+
import * as https from 'https';
3+
import * as admin from 'firebase-admin';
4+
import * as _ from 'lodash';
5+
import * as utils from './test-utils';
6+
7+
interface AndroidDevice {
8+
androidModelId: string;
9+
androidVersionId: string;
10+
locale: string;
11+
orientation: string;
12+
}
13+
14+
const TESTING_API_SERVICE_NAME = 'testing.googleapis.com';
15+
16+
/**
17+
* Creates a new TestMatrix in Test Lab which is expected to be rejected as
18+
* invalid.
19+
*
20+
* @param projectId Project for which the test run will be created
21+
* @param testId Test id which will be encoded in client info details
22+
*/
23+
export async function startTestRun(projectId: string, testId: string) {
24+
const accessToken = await admin.credential
25+
.applicationDefault()
26+
.getAccessToken();
27+
const device = await fetchDefaultDevice(accessToken);
28+
return await createTestMatrix(accessToken, projectId, testId, device);
29+
}
30+
31+
async function fetchDefaultDevice(
32+
accessToken: admin.GoogleOAuthAccessToken
33+
): Promise<AndroidDevice> {
34+
const response = await utils.makeRequest(
35+
requestOptions(accessToken, 'GET', '/v1/testEnvironmentCatalog/ANDROID')
36+
);
37+
const data = JSON.parse(response);
38+
const models = _.get(data, 'androidDeviceCatalog.models', []);
39+
const defaultModels = models.filter(
40+
(m) =>
41+
m.tags !== undefined &&
42+
m.tags.indexOf('default') > -1 &&
43+
m.supportedVersionIds !== undefined &&
44+
m.supportedVersionIds.length > 0
45+
);
46+
47+
if (defaultModels.length === 0) {
48+
throw new Error('No default device found');
49+
}
50+
51+
const model = defaultModels[0];
52+
const versions = model.supportedVersionIds;
53+
54+
return <AndroidDevice>{
55+
androidModelId: model.id,
56+
androidVersionId: versions[versions.length - 1],
57+
locale: 'en',
58+
orientation: 'portrait',
59+
};
60+
}
61+
62+
function createTestMatrix(
63+
accessToken: admin.GoogleOAuthAccessToken,
64+
projectId: string,
65+
testId: string,
66+
device: AndroidDevice
67+
): Promise<string> {
68+
const options = requestOptions(
69+
accessToken,
70+
'POST',
71+
'/v1/projects/' + projectId + '/testMatrices'
72+
);
73+
const body = {
74+
projectId: projectId,
75+
testSpecification: {
76+
androidRoboTest: {
77+
appApk: {
78+
gcsPath: 'gs://path/to/non-existing-app.apk',
79+
},
80+
},
81+
},
82+
environmentMatrix: {
83+
androidDeviceList: {
84+
androidDevices: [device],
85+
},
86+
},
87+
resultStorage: {
88+
googleCloudStorage: {
89+
gcsPath: 'gs://' + admin.storage().bucket().name,
90+
},
91+
},
92+
clientInfo: {
93+
name: 'CloudFunctionsSDKIntegrationTest',
94+
clientInfoDetails: {
95+
key: 'testId',
96+
value: testId,
97+
},
98+
},
99+
};
100+
return utils.makeRequest(options, JSON.stringify(body));
101+
}
102+
103+
function requestOptions(
104+
accessToken: admin.GoogleOAuthAccessToken,
105+
method: string,
106+
path: string
107+
): https.RequestOptions {
108+
return {
109+
method: method,
110+
hostname: TESTING_API_SERVICE_NAME,
111+
path: path,
112+
headers: {
113+
Authorization: 'Bearer ' + accessToken.access_token,
114+
'Content-Type': 'application/json',
115+
},
116+
};
117+
}

integration_test/run_tests.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,9 @@ function delete_all_functions {
6060
# Try to delete, if there are errors it is because the project is already empty,
6161
# in that case do nothing.
6262
if [[ "${TOKEN}" == "" ]]; then
63-
firebase functions:delete callableTests createUserTests databaseTests deleteUserTests firestoreTests integrationTests pubsubTests remoteConfigTests --force --project=$PROJECT_ID || : &
63+
firebase functions:delete callableTests createUserTests databaseTests deleteUserTests firestoreTests integrationTests pubsubTests remoteConfigTests testLabTests --force --project=$PROJECT_ID || : &
6464
else
65-
firebase functions:delete callableTests createUserTests databaseTests deleteUserTests firestoreTests integrationTests pubsubTests remoteConfigTests --force --project=$PROJECT_ID --token=$TOKEN || : &
65+
firebase functions:delete callableTests createUserTests databaseTests deleteUserTests firestoreTests integrationTests pubsubTests remoteConfigTests testLabTests --force --project=$PROJECT_ID --token=$TOKEN || : &
6666
fi
6767
wait
6868
announce "Project emptied."

spec/index.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import './providers/https.spec';
4242
import './providers/pubsub.spec';
4343
import './providers/remoteConfig.spec';
4444
import './providers/storage.spec';
45+
import './providers/testLab.spec';
4546
import './setup.spec';
4647
import './testing.spec';
4748
import './utils.spec';

0 commit comments

Comments
 (0)