Skip to content

Commit 4b7de39

Browse files
authored
Add support for prerequisite tests (#9651)
* Add support for prerequisite tests * Address GCA comments
1 parent df382b6 commit 4b7de39

File tree

3 files changed

+281
-35
lines changed

3 files changed

+281
-35
lines changed

src/apptesting/parseTestFiles.spec.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ describe("parseTestFiles", () => {
5353
expect(tests).to.eql([
5454
{
5555
testCase: {
56+
id: undefined,
57+
prerequisiteTestCaseId: undefined,
5658
displayName: "my test",
5759
startUri: "http://www.foo.com/mypage",
5860
instructions: {
@@ -74,6 +76,8 @@ describe("parseTestFiles", () => {
7476
expect(tests).to.eql([
7577
{
7678
testCase: {
79+
id: undefined,
80+
prerequisiteTestCaseId: undefined,
7781
displayName: "Smoke test",
7882
startUri: "http://www.foo.com",
7983
instructions: {
@@ -115,6 +119,8 @@ describe("parseTestFiles", () => {
115119
expect(tests).to.eql([
116120
{
117121
testCase: {
122+
id: undefined,
123+
prerequisiteTestCaseId: undefined,
118124
displayName: "my test",
119125
startUri: "https://www.foo.com",
120126
instructions: {
@@ -129,6 +135,8 @@ describe("parseTestFiles", () => {
129135
},
130136
{
131137
testCase: {
138+
id: undefined,
139+
prerequisiteTestCaseId: undefined,
132140
displayName: "my second test",
133141
startUri: "https://www.foo.com",
134142
instructions: {
@@ -144,6 +152,8 @@ describe("parseTestFiles", () => {
144152

145153
{
146154
testCase: {
155+
id: undefined,
156+
prerequisiteTestCaseId: undefined,
147157
displayName: "my third test",
148158
startUri: "https://www.foo.com/mypage",
149159
instructions: {
@@ -204,4 +214,166 @@ describe("parseTestFiles", () => {
204214
expect(await getTestCaseNames("a$", "xx")).to.eql(["axx"]);
205215
});
206216
});
217+
describe("prerequisite test cases", () => {
218+
it("merges the steps from the prerequisite test case", async () => {
219+
writeFile(
220+
"my_test.yaml",
221+
stringify({
222+
tests: [
223+
{
224+
id: "my-first-test",
225+
testName: "my first test",
226+
steps: [{ goal: "do something first" }],
227+
},
228+
{
229+
testName: "my second test",
230+
prerequisiteTestCaseId: "my-first-test",
231+
steps: [{ goal: "do something second" }],
232+
},
233+
],
234+
}),
235+
);
236+
237+
const tests = await parseTestFiles(tempdir.name, "https://www.foo.com");
238+
expect(tests.length).to.equal(2);
239+
const secondTest = tests[1];
240+
expect(secondTest.testCase.instructions.steps).to.eql([
241+
{ goal: "do something first" },
242+
{ goal: "do something second" },
243+
]);
244+
});
245+
246+
it("throws an error for a non-existent prerequisite test case", async () => {
247+
writeFile(
248+
"my_test.yaml",
249+
stringify({
250+
tests: [
251+
{
252+
testName: "my second test",
253+
prerequisiteTestCaseId: "my-first-test",
254+
steps: [{ goal: "do something second" }],
255+
},
256+
],
257+
}),
258+
);
259+
260+
await expect(parseTestFiles(tempdir.name, "https://www.foo.com")).to.be.rejectedWith(
261+
FirebaseError,
262+
"Invalid prerequisiteTestCaseId. There is no test case with id my-first-test",
263+
);
264+
});
265+
266+
it("handles an undefined prerequisite test case id", async () => {
267+
writeFile(
268+
"my_test.yaml",
269+
stringify({
270+
tests: [
271+
{
272+
testName: "my test",
273+
steps: [{ goal: "do something" }],
274+
},
275+
],
276+
}),
277+
);
278+
279+
const tests = await parseTestFiles(tempdir.name, "https://www.foo.com");
280+
expect(tests.length).to.equal(1);
281+
expect(tests[0].testCase.instructions.steps).to.eql([{ goal: "do something" }]);
282+
});
283+
284+
it("works correctly with filtering", async () => {
285+
writeFile(
286+
"my_test.yaml",
287+
stringify({
288+
tests: [
289+
{
290+
id: "my-first-test",
291+
testName: "my first test",
292+
steps: [{ goal: "do something first" }],
293+
},
294+
{
295+
testName: "my second test",
296+
prerequisiteTestCaseId: "my-first-test",
297+
steps: [{ goal: "do something second" }],
298+
},
299+
],
300+
}),
301+
);
302+
303+
const tests = await parseTestFiles(
304+
tempdir.name,
305+
"https://www.foo.com",
306+
/* filePattern= */ "",
307+
/* namePattern= */ "my second test",
308+
);
309+
expect(tests.length).to.equal(1);
310+
const secondTest = tests[0];
311+
expect(secondTest.testCase.instructions.steps).to.eql([
312+
{ goal: "do something first" },
313+
{ goal: "do something second" },
314+
]);
315+
});
316+
317+
it("works correctly with multiple levels of prerequisites", async () => {
318+
writeFile(
319+
"my_test.yaml",
320+
stringify({
321+
tests: [
322+
{
323+
id: "my-first-test",
324+
testName: "my first test",
325+
steps: [{ goal: "do something first" }],
326+
},
327+
{
328+
id: "my-second-test",
329+
testName: "my second test",
330+
prerequisiteTestCaseId: "my-first-test",
331+
steps: [{ goal: "do something second" }],
332+
},
333+
{
334+
testName: "my third test",
335+
prerequisiteTestCaseId: "my-second-test",
336+
steps: [{ goal: "do something third" }],
337+
},
338+
],
339+
}),
340+
);
341+
342+
const tests = await parseTestFiles(tempdir.name, "https://www.foo.com");
343+
expect(tests.length).to.equal(3);
344+
const thirdTest = tests[2];
345+
expect(thirdTest.testCase.instructions.steps).to.eql([
346+
{ goal: "do something first" },
347+
{ goal: "do something second" },
348+
{ goal: "do something third" },
349+
]);
350+
});
351+
352+
it("throws error if there is a circular depedency", async () => {
353+
writeFile(
354+
"my_test.yaml",
355+
stringify({
356+
tests: [
357+
{
358+
id: "my-first-test",
359+
testName: "my first test",
360+
prerequisiteTestCaseId: "my-second-test",
361+
steps: [{ goal: "do something first" }],
362+
},
363+
{
364+
id: "my-second-test",
365+
testName: "my second test",
366+
prerequisiteTestCaseId: "my-first-test",
367+
steps: [{ goal: "do something second" }],
368+
},
369+
],
370+
}),
371+
);
372+
373+
await expect(parseTestFiles(tempdir.name, "https://www.foo.com")).to.be.rejectedWith(
374+
FirebaseError,
375+
"Detected a cycle in prerequisite test cases.",
376+
);
377+
});
378+
});
207379
});

src/apptesting/parseTestFiles.ts

Lines changed: 107 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
import { dirExistsSync, fileExistsSync, listFiles } from "../fsutils";
22
import { join } from "path";
33
import { logger } from "../logger";
4-
import { Browser, TestCaseInvocation } from "./types";
4+
import { Browser, TestCaseInvocation, TestStep } from "./types";
55
import { readFileFromDirectory, wrappedSafeLoad } from "../utils";
66
import { FirebaseError, getErrMsg, getError } from "../error";
77

8-
function createFilter(pattern?: string) {
9-
const regex = pattern ? new RegExp(pattern) : undefined;
10-
return (s: string) => !regex || regex.test(s);
11-
}
12-
138
export async function parseTestFiles(
149
dir: string,
1510
targetUri?: string,
@@ -26,52 +21,129 @@ export async function parseTestFiles(
2621
}
2722
}
2823

24+
const files = await parseTestFilesRecursive({ testDir: dir, targetUri });
25+
const idToInvocation = files
26+
.flatMap((file) => file.invocations)
27+
.reduce(
28+
(accumulator, invocation) => {
29+
if (invocation.testCase.id) {
30+
accumulator[invocation.testCase.id] = invocation;
31+
}
32+
return accumulator;
33+
},
34+
{} as Record<string, TestCaseInvocation>,
35+
);
36+
2937
const fileFilterFn = createFilter(filePattern);
3038
const nameFilterFn = createFilter(namePattern);
39+
const filteredInvocations = files
40+
.filter((file) => fileFilterFn(file.path))
41+
.flatMap((file) => file.invocations)
42+
.filter((invocation) => nameFilterFn(invocation.testCase.displayName));
43+
44+
return filteredInvocations.map((invocation) => {
45+
let prerequisiteTestCaseId = invocation.testCase.prerequisiteTestCaseId;
46+
if (prerequisiteTestCaseId === undefined) {
47+
return invocation;
48+
}
3149

32-
async function parseTestFilesRecursive(testDir: string): Promise<TestCaseInvocation[]> {
33-
const items = listFiles(testDir);
34-
const results = [];
35-
for (const item of items) {
36-
const path = join(testDir, item);
37-
if (dirExistsSync(path)) {
38-
results.push(...(await parseTestFilesRecursive(path)));
39-
} else if (fileFilterFn(path) && fileExistsSync(path)) {
40-
try {
41-
const file = await readFileFromDirectory(testDir, item);
42-
const parsedFile = wrappedSafeLoad(file.source);
43-
const tests = parsedFile.tests;
44-
const defaultConfig = parsedFile.defaultConfig;
45-
if (!tests || !tests.length) {
46-
logger.info(`No tests found in ${path}. Ignoring.`);
47-
continue;
48-
}
49-
for (const rawTestDef of parsedFile.tests) {
50-
if (!nameFilterFn(rawTestDef.testName)) continue;
51-
const testDef = toTestDef(rawTestDef, defaultConfig, targetUri);
52-
results.push(testDef);
53-
}
54-
} catch (ex) {
55-
const errMsg = getErrMsg(ex);
56-
const errDetails = errMsg ? `Error details: \n${errMsg}` : "";
57-
logger.info(`Unable to parse test file ${path}. Ignoring.${errDetails}`);
50+
const prerequisiteSteps: TestStep[] = [];
51+
const previousTestCaseIds = new Set<string>();
52+
while (prerequisiteTestCaseId) {
53+
if (previousTestCaseIds.has(prerequisiteTestCaseId)) {
54+
throw new FirebaseError(`Detected a cycle in prerequisite test cases.`);
55+
}
56+
previousTestCaseIds.add(prerequisiteTestCaseId);
57+
const prerequisiteTestCaseInvocation: TestCaseInvocation | undefined =
58+
idToInvocation[prerequisiteTestCaseId];
59+
if (prerequisiteTestCaseInvocation === undefined) {
60+
throw new FirebaseError(
61+
`Invalid prerequisiteTestCaseId. There is no test case with id ${prerequisiteTestCaseId}`,
62+
);
63+
}
64+
prerequisiteSteps.unshift(...prerequisiteTestCaseInvocation.testCase.instructions.steps);
65+
prerequisiteTestCaseId = prerequisiteTestCaseInvocation.testCase.prerequisiteTestCaseId;
66+
}
67+
68+
return {
69+
...invocation,
70+
testCase: {
71+
...invocation.testCase,
72+
instructions: {
73+
...invocation.testCase.instructions,
74+
steps: prerequisiteSteps.concat(invocation.testCase.instructions.steps),
75+
},
76+
},
77+
};
78+
});
79+
}
80+
81+
function createFilter(pattern?: string) {
82+
const regex = pattern ? new RegExp(pattern) : undefined;
83+
return (s: string) => !regex || regex.test(s);
84+
}
85+
86+
interface TestCaseFile {
87+
path: string;
88+
invocations: TestCaseInvocation[];
89+
}
90+
91+
async function parseTestFilesRecursive(params: {
92+
testDir: string;
93+
targetUri?: string;
94+
}): Promise<TestCaseFile[]> {
95+
const testDir = params.testDir;
96+
const targetUri = params.targetUri;
97+
const items = listFiles(testDir);
98+
const results = [];
99+
for (const item of items) {
100+
const path = join(testDir, item);
101+
if (dirExistsSync(path)) {
102+
results.push(...(await parseTestFilesRecursive({ testDir: path, targetUri })));
103+
} else if (fileExistsSync(path)) {
104+
try {
105+
const file = await readFileFromDirectory(testDir, item);
106+
logger.debug(`Read the file ${file.source}.`);
107+
const parsedFile = wrappedSafeLoad(file.source);
108+
logger.debug(`Parsed the file.`);
109+
const tests = parsedFile.tests;
110+
logger.debug(`There are ${tests.length} tests.`);
111+
const defaultConfig = parsedFile.defaultConfig;
112+
if (!tests || !tests.length) {
113+
logger.debug(`No tests found in ${path}. Ignoring.`);
58114
continue;
59115
}
116+
const invocations = [];
117+
for (const rawTestDef of tests) {
118+
const invocation = toTestCaseInvocation(rawTestDef, targetUri, defaultConfig);
119+
invocations.push(invocation);
120+
}
121+
results.push({ path, invocations: invocations });
122+
} catch (ex) {
123+
const errMsg = getErrMsg(ex);
124+
const errDetails = errMsg ? `Error details: \n${errMsg}` : "";
125+
logger.debug(`Unable to parse test file ${path}. Ignoring.${errDetails}`);
126+
continue;
60127
}
61128
}
62-
return results;
63129
}
64130

65-
return parseTestFilesRecursive(dir);
131+
return results;
66132
}
67133

68-
function toTestDef(testDef: any, defaultConfig: any, targetUri?: string): TestCaseInvocation {
134+
function toTestCaseInvocation(
135+
testDef: any,
136+
targetUri: any,
137+
defaultConfig: any,
138+
): TestCaseInvocation {
69139
const steps = testDef.steps ?? [];
70140
const route = testDef.testConfig?.route ?? defaultConfig?.route ?? "";
71141
const browsers: Browser[] = testDef.testConfig?.browsers ??
72142
defaultConfig?.browsers ?? [Browser.CHROME];
73143
return {
74144
testCase: {
145+
id: testDef.id,
146+
prerequisiteTestCaseId: testDef.prerequisiteTestCaseId,
75147
startUri: targetUri + route,
76148
displayName: testDef.testName,
77149
instructions: { steps },

src/apptesting/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,11 @@ export interface TestExecutionResult {
5757
}
5858

5959
export interface TestCase {
60+
id?: string;
6061
startUri?: string;
6162
displayName: string;
6263
instructions: Instructions;
64+
prerequisiteTestCaseId?: string;
6365
}
6466

6567
export interface Instructions {

0 commit comments

Comments
 (0)