Skip to content

Commit 248f293

Browse files
committed
Add support for --test-case-ids and --test-case-ids-file to
`appdistribution:distribute` to launch AI Testing Agent.
1 parent 6366115 commit 248f293

File tree

5 files changed

+92
-50
lines changed

5 files changed

+92
-50
lines changed

src/appdistribution/client.ts

+2
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ export class AppDistributionClient {
281281
releaseName: string,
282282
devices: TestDevice[],
283283
loginCredential?: LoginCredential,
284+
testCaseName?: string,
284285
): Promise<ReleaseTest> {
285286
try {
286287
const response = await this.appDistroV1AlphaClient.request<ReleaseTest, ReleaseTest>({
@@ -289,6 +290,7 @@ export class AppDistributionClient {
289290
body: {
290291
deviceExecutions: devices.map(mapDeviceToExecution),
291292
loginCredential,
293+
testCase: testCaseName,
292294
},
293295
});
294296
return response.body;

src/appdistribution/options-parser-util.spec.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect } from "chai";
2-
import { getLoginCredential, getTestDevices } from "./options-parser-util";
2+
import { getLoginCredential, parseTestDevicesFromStringOrFile } from "./options-parser-util";
33
import { FirebaseError } from "../error";
44
import * as fs from "fs-extra";
55
import { rmSync } from "node:fs";
@@ -21,7 +21,7 @@ describe("options-parser-util", () => {
2121
it("parses a test device", () => {
2222
const optionValue = "model=modelname,version=123,orientation=landscape,locale=en_US";
2323

24-
const result = getTestDevices(optionValue, "");
24+
const result = parseTestDevicesFromStringOrFile(optionValue, "");
2525

2626
expect(result).to.deep.equal([
2727
{
@@ -37,7 +37,7 @@ describe("options-parser-util", () => {
3737
const optionValue =
3838
"model=modelname,version=123,orientation=landscape,locale=en_US;model=modelname2,version=456,orientation=portrait,locale=es";
3939

40-
const result = getTestDevices(optionValue, "");
40+
const result = parseTestDevicesFromStringOrFile(optionValue, "");
4141

4242
expect(result).to.deep.equal([
4343
{
@@ -59,7 +59,7 @@ describe("options-parser-util", () => {
5959
const optionValue =
6060
"model=modelname,version=123,orientation=landscape,locale=en_US\nmodel=modelname2,version=456,orientation=portrait,locale=es";
6161

62-
const result = getTestDevices(optionValue, "");
62+
const result = parseTestDevicesFromStringOrFile(optionValue, "");
6363

6464
expect(result).to.deep.equal([
6565
{
@@ -80,7 +80,7 @@ describe("options-parser-util", () => {
8080
it("throws an error with correct format when missing a field", () => {
8181
const optionValue = "model=modelname,version=123,locale=en_US";
8282

83-
expect(() => getTestDevices(optionValue, "")).to.throw(
83+
expect(() => parseTestDevicesFromStringOrFile(optionValue, "")).to.throw(
8484
FirebaseError,
8585
"model=<model-id>,version=<os-version-id>,locale=<locale>,orientation=<orientation>",
8686
);
@@ -90,7 +90,7 @@ describe("options-parser-util", () => {
9090
const optionValue =
9191
"model=modelname,version=123,orientation=landscape,locale=en_US,notafield=blah";
9292

93-
expect(() => getTestDevices(optionValue, "")).to.throw(
93+
expect(() => parseTestDevicesFromStringOrFile(optionValue, "")).to.throw(
9494
FirebaseError,
9595
"model, version, orientation, locale",
9696
);

src/appdistribution/options-parser-util.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { FieldHints, LoginCredential, TestDevice } from "./types";
88
* and converts the input into an string[] of testers or groups. Value takes precedent
99
* over file.
1010
*/
11-
export function getTestersOrGroups(value: string, file: string): string[] {
11+
export function parseCommaSeparatedStringOrFile(value: string, file: string): string[] {
1212
// If there is no value then the file gets parsed into a string to be split
1313
if (!value && file) {
1414
ensureFileExists(file);
@@ -70,7 +70,7 @@ export function getAppName(options: any): string {
7070
* and converts the input into a string[] of test device strings. Value takes precedent
7171
* over file.
7272
*/
73-
export function getTestDevices(value: string, file: string): TestDevice[] {
73+
export function parseTestDevicesFromStringOrFile(value: string, file: string): TestDevice[] {
7474
// If there is no value then the file gets parsed into a string to be split
7575
if (!value && file) {
7676
ensureFileExists(file);

src/appdistribution/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,5 @@ export interface ReleaseTest {
126126
name?: string;
127127
deviceExecutions: DeviceExecution[];
128128
loginCredential?: LoginCredential;
129+
testCase?: string;
129130
}

src/commands/appdistribution-distribute.ts

+81-42
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,16 @@ import {
99
IntegrationState,
1010
UploadReleaseResult,
1111
TestDevice,
12+
ReleaseTest,
1213
} from "../appdistribution/types";
1314
import { FirebaseError, getErrMsg, getErrStatus } from "../error";
1415
import { Distribution, DistributionFileType } from "../appdistribution/distribution";
1516
import {
1617
ensureFileExists,
1718
getAppName,
1819
getLoginCredential,
19-
getTestDevices,
20-
getTestersOrGroups,
20+
parseTestDevicesFromStringOrFile,
21+
parseCommaSeparatedStringOrFile,
2122
} from "../appdistribution/options-parser-util";
2223

2324
const TEST_MAX_POLLING_RETRIES = 40;
@@ -35,7 +36,9 @@ function getReleaseNotes(releaseNotes: string, releaseNotesFile: string): string
3536
}
3637

3738
export const command = new Command("appdistribution:distribute <release-binary-file>")
38-
.description("upload a release binary")
39+
.description(
40+
"upload a release binary, optionally distribute it to testers and/or run automated tests",
41+
)
3942
.option("--app <app_id>", "the app id of your Firebase app")
4043
.option("--release-notes <string>", "release notes to include")
4144
.option("--release-notes-file <file>", "path to file with release notes")
@@ -75,14 +78,28 @@ export const command = new Command("appdistribution:distribute <release-binary-f
7578
"--test-non-blocking",
7679
"run automated tests without waiting for them to complete. Visit the Firebase console for the test results.",
7780
)
81+
.option("--test-case-ids <string>", "a comma separated list of test case IDs.")
82+
.option(
83+
"--test-case-ids-file <file>",
84+
"path to file with a comma separated list of test case IDs.",
85+
)
7886
.before(requireAuth)
7987
.action(async (file: string, options: any) => {
8088
const appName = getAppName(options);
8189
const distribution = new Distribution(file);
8290
const releaseNotes = getReleaseNotes(options.releaseNotes, options.releaseNotesFile);
83-
const testers = getTestersOrGroups(options.testers, options.testersFile);
84-
const groups = getTestersOrGroups(options.groups, options.groupsFile);
85-
const testDevices = getTestDevices(options.testDevices, options.testDevicesFile);
91+
const testers = parseCommaSeparatedStringOrFile(options.testers, options.testersFile);
92+
const groups = parseCommaSeparatedStringOrFile(options.groups, options.groupsFile);
93+
const testCases = parseCommaSeparatedStringOrFile(options.testCaseIds, options.testCaseIdsFile);
94+
const testDevices = parseTestDevicesFromStringOrFile(
95+
options.testDevices,
96+
options.testDevicesFile,
97+
);
98+
if (testCases.length && (options.testUsernameResource || options.testPasswordResource)) {
99+
throw new FirebaseError(
100+
"Username and password resource names are not supported for the AI testing agent.",
101+
);
102+
}
86103
const loginCredential = getLoginCredential({
87104
username: options.testUsername,
88105
password: options.testPassword,
@@ -210,56 +227,78 @@ export const command = new Command("appdistribution:distribute <release-binary-f
210227
await requests.distribute(releaseName, testers, groups);
211228

212229
// Run automated tests
213-
if (testDevices?.length) {
214-
utils.logBullet("starting automated tests (note: this feature is in beta)");
215-
const releaseTest = await requests.createReleaseTest(
216-
releaseName,
217-
testDevices,
218-
loginCredential,
219-
);
220-
utils.logSuccess(`Release test created successfully`);
230+
if (testDevices.length) {
231+
utils.logBullet("starting automated test (note: this feature is in beta)");
232+
const releaseTestPromises: Promise<ReleaseTest>[] = [];
233+
if (!testCases.length) {
234+
// fallback to basic automated test
235+
releaseTestPromises.push(
236+
requests.createReleaseTest(releaseName, testDevices, loginCredential),
237+
);
238+
} else {
239+
for (const testCaseId of testCases) {
240+
releaseTestPromises.push(
241+
requests.createReleaseTest(
242+
releaseName,
243+
testDevices,
244+
loginCredential,
245+
`${appName}/testCases/${testCaseId}`,
246+
),
247+
);
248+
}
249+
}
250+
const releaseTests = await Promise.all(releaseTestPromises);
251+
utils.logSuccess(`${releaseTests.length} Release test(s) started successfully`);
221252
if (!options.testNonBlocking) {
222-
await awaitTestResults(releaseTest.name!, requests);
253+
const releaseTestNames = new Set(releaseTests.map((rt) => rt.name!!));
254+
await awaitTestResults(releaseTestNames, requests);
223255
}
224256
}
225257
});
226258

227259
async function awaitTestResults(
228-
releaseTestName: string,
260+
releaseTestNames: Set<string>,
229261
requests: AppDistributionClient,
230262
): Promise<void> {
231263
for (let i = 0; i < TEST_MAX_POLLING_RETRIES; i++) {
232-
utils.logBullet("the automated tests results are pending");
264+
utils.logBullet("the automated test results are pending");
233265
await delay(TEST_POLLING_INTERVAL_MILLIS);
234-
const releaseTest = await requests.getReleaseTest(releaseTestName);
235-
if (releaseTest.deviceExecutions.every((e) => e.state === "PASSED")) {
236-
utils.logSuccess("automated test(s) passed!");
237-
return;
238-
}
239-
for (const execution of releaseTest.deviceExecutions) {
240-
switch (execution.state) {
241-
case "PASSED":
242-
case "IN_PROGRESS":
266+
for (const releaseTestName of releaseTestNames) {
267+
const releaseTest = await requests.getReleaseTest(releaseTestName);
268+
if (releaseTest.deviceExecutions.every((e) => e.state === "PASSED")) {
269+
releaseTestNames.delete(releaseTestName);
270+
if (releaseTestNames.size === 0) {
271+
utils.logSuccess("automated test(s) passed!");
272+
return;
273+
} else {
243274
continue;
244-
case "FAILED":
245-
throw new FirebaseError(
246-
`Automated test failed for ${deviceToString(execution.device)}: ${execution.failedReason}`,
247-
{ exit: 1 },
248-
);
249-
case "INCONCLUSIVE":
250-
throw new FirebaseError(
251-
`Automated test inconclusive for ${deviceToString(execution.device)}: ${execution.inconclusiveReason}`,
252-
{ exit: 1 },
253-
);
254-
default:
255-
throw new FirebaseError(
256-
`Unsupported automated test state for ${deviceToString(execution.device)}: ${execution.state}`,
257-
{ exit: 1 },
258-
);
275+
}
276+
}
277+
for (const execution of releaseTest.deviceExecutions) {
278+
switch (execution.state) {
279+
case "PASSED":
280+
case "IN_PROGRESS":
281+
continue;
282+
case "FAILED":
283+
throw new FirebaseError(
284+
`Automated test failed for ${deviceToString(execution.device)}: ${execution.failedReason}`,
285+
{ exit: 1 },
286+
);
287+
case "INCONCLUSIVE":
288+
throw new FirebaseError(
289+
`Automated test inconclusive for ${deviceToString(execution.device)}: ${execution.inconclusiveReason}`,
290+
{ exit: 1 },
291+
);
292+
default:
293+
throw new FirebaseError(
294+
`Unsupported automated test state for ${deviceToString(execution.device)}: ${execution.state}`,
295+
{ exit: 1 },
296+
);
297+
}
259298
}
260299
}
261300
}
262-
throw new FirebaseError("It took longer than expected to process your test, please try again.", {
301+
throw new FirebaseError("It took longer than expected to run your test(s), please try again.", {
263302
exit: 1,
264303
});
265304
}

0 commit comments

Comments
 (0)