Skip to content

Commit 6e4cb38

Browse files
authored
Add export/import of app distribution test cases as YAML. (#9397)
* Add export/import of app distribution test cases as YAML. * Add missing App Distribution commands to README
1 parent 8cb0de1 commit 6e4cb38

File tree

11 files changed

+540
-22
lines changed

11 files changed

+540
-22
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
- Added `functions.list_functions` as a MCP tool (#9369)
33
- Added AI Logic to `firebase init` CLI command and `firebase_init` MCP tool. (#9185)
44
- Improved error messages for Firebase AI Logic provisioning during 'firebase init' (#9377)
5+
- Added `appdistribution:testcases:export` and `appdistribution:testcases:import` (#9397)
56
- Updated to v2.16.0 of the Data Connect emulator, which includes internal improvements.
67
- Data Connect now allows executing a valid query / operation even if the other operations are invalid. (This toleration provides convenience on a best-effort basis. Some errors like invalid syntax can still cause the whole request to be rejected.)
78
- Fixed enum list deserialization in Data Connect generated Dart SDKs.

README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,17 @@ These commands let you deploy and interact with your Firebase services.
8787

8888
### App Distribution Commands
8989

90-
| Command | Description |
91-
| ------------------------------ | ---------------------- |
92-
| **appdistribution:distribute** | Upload a distribution. |
90+
| Command | Description |
91+
| ------------------------------------ | ---------------------------------------------------------------------------------------- |
92+
| **appdistribution:distribute** | Upload a release binary and optionally distribute it to testers and run automated tests. |
93+
| **appdistribution:testers:list** | List testers in project. |
94+
| **appdistribution:testers:add** | Add testers to project (and group, if specified via flag). |
95+
| **appdistribution:testers:remove** | Remove testers from a project (or group, if specified via flag). |
96+
| **appdistribution:groups:list** | List groups (of testers). |
97+
| **appdistribution:groups:create** | Create a group (of testers). |
98+
| **appdistribution:groups:delete** | Delete a group (of testers). |
99+
| **appdistribution:testcases:export** | Export test cases as a YAML file. |
100+
| **appdistribution:testcases:import** | Import test cases from YAML file. |
93101

94102
### Auth Commands
95103

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"mocha": "nyc --reporter=html mocha 'src/**/*.spec.{ts,js}'",
2727
"prepare": "npm run clean && npm run build:publish",
2828
"test": "npm run lint:quiet && npm run test:compile && npm run mocha",
29+
"test:appdistribution": "npm run lint:quiet && npm run test:compile && nyc mocha 'src/appdistribution/*.spec.{ts,js}'",
2930
"test:apptesting": "npm run lint:quiet && npm run test:compile && nyc mocha 'src/apptesting/*.spec.{ts,js}'",
3031
"test:client-integration": "bash ./scripts/client-integration-tests/run.sh",
3132
"test:compile": "tsc --project tsconfig.compile.json",

src/appdistribution/client.spec.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import * as sinon from "sinon";
77
import * as tmp from "tmp";
88

99
import { AppDistributionClient } from "./client";
10-
import { BatchRemoveTestersResponse, Group, TestDevice } from "./types";
10+
import { BatchRemoveTestersResponse, Group, TestCase, TestDevice } from "./types";
1111
import { appDistributionOrigin } from "../api";
1212
import { Distribution } from "./distribution";
1313
import { FirebaseError } from "../error";
@@ -501,4 +501,91 @@ describe("distribution", () => {
501501
expect(nock.isDone()).to.be.true;
502502
});
503503
});
504+
505+
describe("listTestCases", () => {
506+
it("should throw error if request fails", async () => {
507+
nock(appDistributionOrigin())
508+
.get(`/v1alpha/${appName}/testCases`)
509+
.reply(400, { error: { status: "FAILED_PRECONDITION" } });
510+
await expect(appDistributionClient.listTestCases(appName)).to.be.rejectedWith(
511+
FirebaseError,
512+
"Client failed to list test cases",
513+
);
514+
expect(nock.isDone()).to.be.true;
515+
});
516+
517+
it("should resolve with array of test cases when request succeeds", async () => {
518+
const testCases: TestCase[] = [
519+
{
520+
name: `${appName}/testCases/tc_1`,
521+
displayName: "Test Case 1",
522+
aiInstructions: {
523+
steps: [
524+
{
525+
goal: "Win at all costs",
526+
},
527+
],
528+
},
529+
},
530+
{
531+
name: `${appName}/testCases/tc_2`,
532+
displayName: "Test Case 2",
533+
aiInstructions: { steps: [] },
534+
},
535+
];
536+
537+
nock(appDistributionOrigin()).get(`/v1alpha/${appName}/testCases`).reply(200, {
538+
testCases: testCases,
539+
});
540+
await expect(appDistributionClient.listTestCases(appName)).to.eventually.deep.eq(testCases);
541+
expect(nock.isDone()).to.be.true;
542+
});
543+
});
544+
545+
describe("createTestCase", () => {
546+
const mockTestCase = { displayName: "Case", aiInstructions: { steps: [] } };
547+
548+
it("should throw error if request fails", async () => {
549+
nock(appDistributionOrigin())
550+
.post(`/v1alpha/${appName}/testCases`)
551+
.reply(400, { error: { status: "FAILED_PRECONDITION" } });
552+
await expect(appDistributionClient.createTestCase(appName, mockTestCase)).to.be.rejectedWith(
553+
FirebaseError,
554+
"Failed to create test case",
555+
);
556+
expect(nock.isDone()).to.be.true;
557+
});
558+
559+
it("should resolve with TestCase when request succeeds", async () => {
560+
nock(appDistributionOrigin()).post(`/v1alpha/${appName}/testCases`).reply(200, mockTestCase);
561+
await expect(
562+
appDistributionClient.createTestCase(appName, mockTestCase),
563+
).to.be.eventually.deep.eq(mockTestCase);
564+
expect(nock.isDone()).to.be.true;
565+
});
566+
});
567+
568+
describe("batchUpsertTestCases", () => {
569+
const mockTestCase = { displayName: "Case", aiInstructions: { steps: [] } };
570+
571+
it("should throw error if request fails", async () => {
572+
nock(appDistributionOrigin())
573+
.post(`/v1alpha/${appName}/testCases:batchUpdate`)
574+
.reply(400, { error: { status: "FAILED_PRECONDITION" } });
575+
await expect(
576+
appDistributionClient.batchUpsertTestCases(appName, [mockTestCase]),
577+
).to.be.rejectedWith(FirebaseError, "Failed to upsert test cases");
578+
expect(nock.isDone()).to.be.true;
579+
});
580+
581+
it("should resolve with TestCase when request succeeds", async () => {
582+
nock(appDistributionOrigin())
583+
.post(`/v1alpha/${appName}/testCases:batchUpdate`)
584+
.reply(200, { testCases: [mockTestCase] });
585+
await expect(
586+
appDistributionClient.batchUpsertTestCases(appName, [mockTestCase]),
587+
).to.be.eventually.deep.eq([mockTestCase]);
588+
expect(nock.isDone()).to.be.true;
589+
});
590+
});
504591
});

src/appdistribution/client.ts

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@ import { appDistributionOrigin } from "../api";
1010
import {
1111
AabInfo,
1212
BatchRemoveTestersResponse,
13+
BatchUpdateTestCasesRequest,
14+
BatchUpdateTestCasesResponse,
1315
Group,
1416
ListGroupsResponse,
17+
ListTestCasesResponse,
1518
ListTestersResponse,
1619
LoginCredential,
17-
mapDeviceToExecution,
1820
ReleaseTest,
21+
TestCase,
1922
TestDevice,
2023
Tester,
2124
UploadReleaseResponse,
@@ -112,7 +115,7 @@ export class AppDistributionClient {
112115
try {
113116
await this.appDistroV1Client.post(`/${releaseName}:distribute`, data);
114117
} catch (err: any) {
115-
let errorMessage = err.message;
118+
let errorMessage = getErrMsg(err);
116119
const errorStatus = err?.context?.body?.error?.status;
117120
if (errorStatus === "FAILED_PRECONDITION") {
118121
errorMessage = "invalid testers";
@@ -144,8 +147,8 @@ export class AppDistributionClient {
144147
apiResponse = await client.get<ListTestersResponse>(`${projectName}/testers`, {
145148
queryParams,
146149
});
147-
} catch (err) {
148-
throw new FirebaseError(`Client request failed to list testers ${err}`);
150+
} catch (err: unknown) {
151+
throw new FirebaseError(`Client request failed to list testers ${getErrMsg(err)}`);
149152
}
150153

151154
for (const t of apiResponse.body.testers ?? []) {
@@ -206,8 +209,8 @@ export class AppDistributionClient {
206209
});
207210
groups.push(...(apiResponse.body.groups ?? []));
208211
pageToken = apiResponse.body.nextPageToken;
209-
} catch (err) {
210-
throw new FirebaseError(`Client failed to list groups ${err}`);
212+
} catch (err: unknown) {
213+
throw new FirebaseError(`Client failed to list groups ${getErrMsg(err)}`);
211214
}
212215
} while (pageToken);
213216
return groups;
@@ -280,7 +283,7 @@ export class AppDistributionClient {
280283
method: "POST",
281284
path: `${releaseName}/tests`,
282285
body: {
283-
deviceExecutions: devices.map(mapDeviceToExecution),
286+
deviceExecutions: devices.map((device) => ({ device })),
284287
loginCredential,
285288
testCase: testCaseName,
286289
},
@@ -295,4 +298,58 @@ export class AppDistributionClient {
295298
const response = await this.appDistroV1AlphaClient.get<ReleaseTest>(releaseTestName);
296299
return response.body;
297300
}
301+
302+
async listTestCases(appName: string): Promise<TestCase[]> {
303+
const testCases: TestCase[] = [];
304+
const client = this.appDistroV1AlphaClient;
305+
306+
let pageToken: string | undefined;
307+
do {
308+
const queryParams: Record<string, string> = pageToken ? { pageToken } : {};
309+
try {
310+
const apiResponse = await client.get<ListTestCasesResponse>(`${appName}/testCases`, {
311+
queryParams,
312+
});
313+
testCases.push(...(apiResponse.body.testCases ?? []));
314+
pageToken = apiResponse.body.nextPageToken;
315+
} catch (err: unknown) {
316+
throw new FirebaseError(`Client failed to list test cases ${getErrMsg(err)}`);
317+
}
318+
} while (pageToken);
319+
return testCases;
320+
}
321+
322+
async createTestCase(appName: string, testCase: TestCase): Promise<TestCase> {
323+
try {
324+
const response = await this.appDistroV1AlphaClient.request<TestCase, TestCase>({
325+
method: "POST",
326+
path: `${appName}/testCases`,
327+
body: testCase,
328+
});
329+
return response.body;
330+
} catch (err: unknown) {
331+
throw new FirebaseError(`Failed to create test case ${getErrMsg(err)}`);
332+
}
333+
}
334+
335+
async batchUpsertTestCases(appName: string, testCases: TestCase[]): Promise<TestCase[]> {
336+
try {
337+
const response = await this.appDistroV1AlphaClient.request<
338+
BatchUpdateTestCasesRequest,
339+
BatchUpdateTestCasesResponse
340+
>({
341+
method: "POST",
342+
path: `${appName}/testCases:batchUpdate`,
343+
body: {
344+
requests: testCases.map((tc) => ({
345+
testCase: tc,
346+
allowMissing: true,
347+
})),
348+
},
349+
});
350+
return response.body.testCases;
351+
} catch (err: unknown) {
352+
throw new FirebaseError(`Failed to upsert test cases ${getErrMsg(err)}`);
353+
}
354+
}
298355
}

src/appdistribution/types.ts

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -100,17 +100,6 @@ export interface DeviceExecution {
100100
inconclusiveReason?: string;
101101
}
102102

103-
export function mapDeviceToExecution(device: TestDevice): DeviceExecution {
104-
return {
105-
device: {
106-
model: device.model,
107-
version: device.version,
108-
orientation: device.orientation,
109-
locale: device.locale,
110-
},
111-
};
112-
}
113-
114103
export interface FieldHints {
115104
usernameResourceName?: string;
116105
passwordResourceName?: string;
@@ -128,3 +117,38 @@ export interface ReleaseTest {
128117
loginCredential?: LoginCredential;
129118
testCase?: string;
130119
}
120+
121+
export interface AiStep {
122+
goal: string;
123+
hint?: string;
124+
successCriteria?: string;
125+
}
126+
127+
export interface AiInstructions {
128+
steps: AiStep[];
129+
}
130+
131+
export interface TestCase {
132+
name?: string;
133+
displayName: string;
134+
prerequisiteTestCase?: string;
135+
aiInstructions: AiInstructions;
136+
}
137+
138+
export interface ListTestCasesResponse {
139+
testCases: TestCase[];
140+
nextPageToken?: string;
141+
}
142+
143+
export interface UpdateTestCaseRequest {
144+
testCase: TestCase;
145+
allowMissing?: boolean;
146+
}
147+
148+
export interface BatchUpdateTestCasesRequest {
149+
requests: UpdateTestCaseRequest[];
150+
}
151+
152+
export interface BatchUpdateTestCasesResponse {
153+
testCases: TestCase[];
154+
}

0 commit comments

Comments
 (0)