Skip to content

Commit 9eaae7a

Browse files
committed
Add export/import of app distribution test cases as YAML.
1 parent be433bc commit 9eaae7a

File tree

9 files changed

+458
-1
lines changed

9 files changed

+458
-1
lines changed

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: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,14 @@ import {
1212
BatchRemoveTestersResponse,
1313
Group,
1414
ListGroupsResponse,
15+
ListTestCasesResponse,
1516
ListTestersResponse,
1617
LoginCredential,
1718
mapDeviceToExecution,
1819
ReleaseTest,
20+
TestCase,
21+
BatchUpdateTestCasesRequest,
22+
BatchUpdateTestCasesResponse,
1923
TestDevice,
2024
Tester,
2125
UploadReleaseResponse,
@@ -295,4 +299,58 @@ export class AppDistributionClient {
295299
const response = await this.appDistroV1AlphaClient.get<ReleaseTest>(releaseTestName);
296300
return response.body;
297301
}
302+
303+
async listTestCases(appName: string): Promise<TestCase[]> {
304+
const testCases: TestCase[] = [];
305+
const client = this.appDistroV1AlphaClient;
306+
307+
let pageToken: string | undefined;
308+
do {
309+
const queryParams: Record<string, string> = pageToken ? { pageToken } : {};
310+
try {
311+
const apiResponse = await client.get<ListTestCasesResponse>(`${appName}/testCases`, {
312+
queryParams,
313+
});
314+
testCases.push(...(apiResponse.body.testCases ?? []));
315+
pageToken = apiResponse.body.nextPageToken;
316+
} catch (err) {
317+
throw new FirebaseError(`Client failed to list test cases ${err}`);
318+
}
319+
} while (pageToken);
320+
return testCases;
321+
}
322+
323+
async createTestCase(appName: string, testCase: TestCase): Promise<TestCase> {
324+
try {
325+
const response = await this.appDistroV1AlphaClient.request<TestCase, TestCase>({
326+
method: "POST",
327+
path: `${appName}/testCases`,
328+
body: testCase,
329+
});
330+
return response.body;
331+
} catch (err: unknown) {
332+
throw new FirebaseError(`Failed to create test case ${getErrMsg(err)}`);
333+
}
334+
}
335+
336+
async batchUpsertTestCases(appName: string, testCases: TestCase[]): Promise<TestCase[]> {
337+
try {
338+
const response = await this.appDistroV1AlphaClient.request<
339+
BatchUpdateTestCasesRequest,
340+
BatchUpdateTestCasesResponse
341+
>({
342+
method: "POST",
343+
path: `${appName}/testCases:batchUpdate`,
344+
body: {
345+
requests: testCases.map((tc) => ({
346+
testCase: tc,
347+
allowMissing: true,
348+
})),
349+
},
350+
});
351+
return response.body.testCases;
352+
} catch (err: unknown) {
353+
throw new FirebaseError(`Failed to upsert test cases ${getErrMsg(err)}`);
354+
}
355+
}
298356
}

src/appdistribution/types.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,38 @@ export interface ReleaseTest {
128128
loginCredential?: LoginCredential;
129129
testCase?: string;
130130
}
131+
132+
export interface AiStep {
133+
goal: string;
134+
hint?: string;
135+
successCriteria?: string;
136+
}
137+
138+
export interface AiInstructions {
139+
steps: AiStep[];
140+
}
141+
142+
export interface TestCase {
143+
name?: string;
144+
displayName: string;
145+
prerequisiteTestCase?: string;
146+
aiInstructions: AiInstructions;
147+
}
148+
149+
export interface ListTestCasesResponse {
150+
testCases: TestCase[];
151+
nextPageToken?: string;
152+
}
153+
154+
export interface UpdateTestCaseRequest {
155+
testCase: TestCase;
156+
allowMissing?: boolean;
157+
}
158+
159+
export interface BatchUpdateTestCasesRequest {
160+
requests: UpdateTestCaseRequest[];
161+
}
162+
163+
export interface BatchUpdateTestCasesResponse {
164+
testCases: TestCase[];
165+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { TestCase } from "./types";
2+
import * as jsYaml from "js-yaml";
3+
import { fromYaml, toYaml } from "./yaml_helper";
4+
import { expect } from "chai";
5+
6+
const APP_NAME = "projects/12345/apps/1:12345:android:beef";
7+
8+
const TEST_CASE: TestCase = {
9+
displayName: "test-display-name",
10+
name: "projects/12345/apps/1:12345:android:beef/testCases/test-case-id",
11+
prerequisiteTestCase:
12+
"projects/12345/apps/1:12345:android:beef/testCases/prerequisite-test-case-id",
13+
aiInstructions: {
14+
steps: [
15+
{
16+
goal: "test-goal",
17+
hint: "test-hint",
18+
successCriteria: "test-success-criteria",
19+
},
20+
],
21+
},
22+
};
23+
24+
const YAML_STRING = `- displayName: test-display-name
25+
id: test-case-id
26+
prerequisiteTestCaseId: prerequisite-test-case-id
27+
steps:
28+
- goal: test-goal
29+
hint: test-hint
30+
successCriteria: test-success-criteria
31+
`;
32+
33+
const YAML_DATA = {
34+
displayName: "test-display-name",
35+
id: "test-case-id",
36+
prerequisiteTestCaseId: "prerequisite-test-case-id",
37+
steps: [
38+
{
39+
goal: "test-goal",
40+
hint: "test-hint",
41+
successCriteria: "test-success-criteria",
42+
},
43+
],
44+
};
45+
46+
describe("YamlHelper", () => {
47+
it("converts TestCase[] to YAML string", () => {
48+
const yamlString = toYaml([TEST_CASE]);
49+
expect(yamlString).to.eq(YAML_STRING); // brittle ¯\_(ツ)_/¯
50+
expect(jsYaml.safeLoad(yamlString)).to.eql([YAML_DATA]);
51+
});
52+
53+
it("converts YAML string to TestCase[]", () => {
54+
const testCases = fromYaml(APP_NAME, YAML_STRING);
55+
expect(testCases).to.eql([TEST_CASE]);
56+
});
57+
58+
it("throws error if displayName is missing", () => {
59+
expect(() =>
60+
fromYaml(
61+
APP_NAME,
62+
`- steps:
63+
- goal: test-goal
64+
hint: test-hint
65+
successCriteria: test-success-criteria
66+
`,
67+
),
68+
).to.throw(/"displayName" is required/);
69+
});
70+
71+
it("throws error if steps is missing", () => {
72+
expect(() => fromYaml(APP_NAME, `- displayName: test-display-name`)).to.throw(
73+
/"steps" is required/,
74+
);
75+
});
76+
77+
it("throws error if goal is missing", () => {
78+
expect(() =>
79+
fromYaml(
80+
APP_NAME,
81+
`- displayName: test-display-name
82+
steps:
83+
- hint: test-hint
84+
successCriteria: test-success-criteria
85+
`,
86+
),
87+
).to.throw(/"goal" is required/);
88+
});
89+
90+
it("throws error if additional property is present in test case", () => {
91+
expect(() =>
92+
fromYaml(
93+
APP_NAME,
94+
`- displayName: test-display-name
95+
extraTestCaseProperty: property
96+
steps:
97+
- goal: test-goal
98+
`,
99+
),
100+
).to.throw(/unexpected property "extraTestCaseProperty"/);
101+
});
102+
103+
it("throws error if additional property is present in step", () => {
104+
expect(() =>
105+
fromYaml(
106+
APP_NAME,
107+
`- displayName: test-display-name
108+
steps:
109+
- goal: test-goal
110+
extraStepProperty: property
111+
`,
112+
),
113+
).to.throw(/unexpected property "extraStepProperty"/);
114+
});
115+
116+
it("throws error if YAML is invalid", () => {
117+
expect(() => fromYaml(APP_NAME, "this is not YAML")).to.throw();
118+
});
119+
});

0 commit comments

Comments
 (0)