From 5c31eac31db4df938e218b774eb41f29341741c3 Mon Sep 17 00:00:00 2001 From: Tunde Agboola Date: Thu, 18 Dec 2025 11:03:25 -0500 Subject: [PATCH 1/2] Add support for prerequisite tests --- src/apptesting/parseTestFiles.spec.ts | 145 ++++++++++++++++++++++++++ src/apptesting/parseTestFiles.ts | 136 +++++++++++++++++------- src/apptesting/types.ts | 2 + 3 files changed, 248 insertions(+), 35 deletions(-) diff --git a/src/apptesting/parseTestFiles.spec.ts b/src/apptesting/parseTestFiles.spec.ts index 3a1aa13f9ed..cd0b6629c1e 100644 --- a/src/apptesting/parseTestFiles.spec.ts +++ b/src/apptesting/parseTestFiles.spec.ts @@ -53,6 +53,8 @@ describe("parseTestFiles", () => { expect(tests).to.eql([ { testCase: { + id: undefined, + prerequisiteTestCaseId: undefined, displayName: "my test", startUri: "http://www.foo.com/mypage", instructions: { @@ -74,6 +76,8 @@ describe("parseTestFiles", () => { expect(tests).to.eql([ { testCase: { + id: undefined, + prerequisiteTestCaseId: undefined, displayName: "Smoke test", startUri: "http://www.foo.com", instructions: { @@ -115,6 +119,8 @@ describe("parseTestFiles", () => { expect(tests).to.eql([ { testCase: { + id: undefined, + prerequisiteTestCaseId: undefined, displayName: "my test", startUri: "https://www.foo.com", instructions: { @@ -129,6 +135,8 @@ describe("parseTestFiles", () => { }, { testCase: { + id: undefined, + prerequisiteTestCaseId: undefined, displayName: "my second test", startUri: "https://www.foo.com", instructions: { @@ -144,6 +152,8 @@ describe("parseTestFiles", () => { { testCase: { + id: undefined, + prerequisiteTestCaseId: undefined, displayName: "my third test", startUri: "https://www.foo.com/mypage", instructions: { @@ -204,4 +214,139 @@ describe("parseTestFiles", () => { expect(await getTestCaseNames("a$", "xx")).to.eql(["axx"]); }); }); + describe("prerequisite test cases", () => { + it("merges the steps from the prerequisite test case", async () => { + writeFile( + "my_test.yaml", + stringify({ + tests: [ + { + id: "my-first-test", + testName: "my first test", + steps: [{ goal: "do something first" }], + }, + { + testName: "my second test", + prerequisiteTestCaseId: "my-first-test", + steps: [{ goal: "do something second" }], + }, + ], + }), + ); + + const tests = await parseTestFiles(tempdir.name, "https://www.foo.com"); + expect(tests.length).to.equal(2); + const secondTest = tests[1]; + expect(secondTest.testCase.instructions.steps).to.eql([ + { goal: "do something first" }, + { goal: "do something second" }, + ]); + }); + + it("throws an error for a non-existent prerequisite test case", async () => { + writeFile( + "my_test.yaml", + stringify({ + tests: [ + { + testName: "my second test", + prerequisiteTestCaseId: "my-first-test", + steps: [{ goal: "do something second" }], + }, + ], + }), + ); + + await expect(parseTestFiles(tempdir.name, "https://www.foo.com")).to.be.rejectedWith( + FirebaseError, + "Invalid prerequisiteTestCaseId. There is no test case with id my-first-test", + ); + }); + + it("handles an undefined prerequisite test case id", async () => { + writeFile( + "my_test.yaml", + stringify({ + tests: [ + { + testName: "my test", + steps: [{ goal: "do something" }], + }, + ], + }), + ); + + const tests = await parseTestFiles(tempdir.name, "https://www.foo.com"); + expect(tests.length).to.equal(1); + expect(tests[0].testCase.instructions.steps).to.eql([{ goal: "do something" }]); + }); + + it("works correctly with filtering", async () => { + writeFile( + "my_test.yaml", + stringify({ + tests: [ + { + id: "my-first-test", + testName: "my first test", + steps: [{ goal: "do something first" }], + }, + { + testName: "my second test", + prerequisiteTestCaseId: "my-first-test", + steps: [{ goal: "do something second" }], + }, + ], + }), + ); + + const tests = await parseTestFiles( + tempdir.name, + "https://www.foo.com", + /* filePattern= */ "", + /* namePattern= */ "my second test", + ); + expect(tests.length).to.equal(1); + const secondTest = tests[0]; + expect(secondTest.testCase.instructions.steps).to.eql([ + { goal: "do something first" }, + { goal: "do something second" }, + ]); + }); + + it("works correctly with multiple levels of prerequisites", async () => { + writeFile( + "my_test.yaml", + stringify({ + tests: [ + { + id: "my-first-test", + testName: "my first test", + steps: [{ goal: "do something first" }], + }, + { + id: "my-second-test", + testName: "my second test", + prerequisiteTestCaseId: "my-first-test", + steps: [{ goal: "do something second" }], + }, + { + testName: "my third test", + prerequisiteTestCaseId: "my-second-test", + steps: [{ goal: "do something third" }], + }, + ], + }), + ); + + const tests = await parseTestFiles(tempdir.name, "https://www.foo.com"); + expect(tests.length).to.equal(3); + const thirdTest = tests[2]; + expect(thirdTest.testCase.instructions.steps).to.eql([ + { goal: "do something first" }, + { goal: "do something second" }, + { goal: "do something third" }, + ]); + }); + }); }); diff --git a/src/apptesting/parseTestFiles.ts b/src/apptesting/parseTestFiles.ts index 1e12a14fa39..e8dddb53b1d 100644 --- a/src/apptesting/parseTestFiles.ts +++ b/src/apptesting/parseTestFiles.ts @@ -1,15 +1,10 @@ import { dirExistsSync, fileExistsSync, listFiles } from "../fsutils"; import { join } from "path"; import { logger } from "../logger"; -import { Browser, TestCaseInvocation } from "./types"; +import { Browser, TestCaseInvocation, TestStep } from "./types"; import { readFileFromDirectory, wrappedSafeLoad } from "../utils"; import { FirebaseError, getErrMsg, getError } from "../error"; -function createFilter(pattern?: string) { - const regex = pattern ? new RegExp(pattern) : undefined; - return (s: string) => !regex || regex.test(s); -} - export async function parseTestFiles( dir: string, targetUri?: string, @@ -26,52 +21,123 @@ export async function parseTestFiles( } } + const files = await parseTestFilesRecursive({ testDir: dir, targetUri }); + const idToInvocation = files + .flatMap((file) => file.invocations) + .reduce( + (accumulator, invocation) => { + if (invocation.testCase.id) { + accumulator[invocation.testCase.id] = invocation; + } + return accumulator; + }, + {} as Record, + ); + const fileFilterFn = createFilter(filePattern); const nameFilterFn = createFilter(namePattern); + const filteredInvocations = files + .filter((file) => fileFilterFn(file.path)) + .flatMap((file) => file.invocations) + .filter((invocation) => nameFilterFn(invocation.testCase.displayName)); + + return filteredInvocations.map((invocation) => { + let prerequisiteTestCaseId = invocation.testCase.prerequisiteTestCaseId; + if (prerequisiteTestCaseId === undefined) { + return invocation; + } + + const prerequisiteSteps: TestStep[] = []; + while (prerequisiteTestCaseId) { + const prerequisiteTestCaseInvocation: TestCaseInvocation | undefined = + idToInvocation[prerequisiteTestCaseId]; + if (prerequisiteTestCaseInvocation === undefined) { + const errMsg = `Invalid prerequisiteTestCaseId. There is no test case with id ${prerequisiteTestCaseId}`; + throw new FirebaseError(errMsg); + } + prerequisiteSteps.unshift(...prerequisiteTestCaseInvocation.testCase.instructions.steps); + prerequisiteTestCaseId = prerequisiteTestCaseInvocation.testCase.prerequisiteTestCaseId; + } + + return { + ...invocation, + testCase: { + ...invocation.testCase, + instructions: { + ...invocation.testCase.instructions, + steps: prerequisiteSteps.concat(invocation.testCase.instructions.steps), + }, + }, + }; + }); +} - async function parseTestFilesRecursive(testDir: string): Promise { - const items = listFiles(testDir); - const results = []; - for (const item of items) { - const path = join(testDir, item); - if (dirExistsSync(path)) { - results.push(...(await parseTestFilesRecursive(path))); - } else if (fileFilterFn(path) && fileExistsSync(path)) { - try { - const file = await readFileFromDirectory(testDir, item); - const parsedFile = wrappedSafeLoad(file.source); - const tests = parsedFile.tests; - const defaultConfig = parsedFile.defaultConfig; - if (!tests || !tests.length) { - logger.info(`No tests found in ${path}. Ignoring.`); - continue; - } - for (const rawTestDef of parsedFile.tests) { - if (!nameFilterFn(rawTestDef.testName)) continue; - const testDef = toTestDef(rawTestDef, defaultConfig, targetUri); - results.push(testDef); - } - } catch (ex) { - const errMsg = getErrMsg(ex); - const errDetails = errMsg ? `Error details: \n${errMsg}` : ""; - logger.info(`Unable to parse test file ${path}. Ignoring.${errDetails}`); +function createFilter(pattern?: string) { + const regex = pattern ? new RegExp(pattern) : undefined; + return (s: string) => !regex || regex.test(s); +} + +interface TestCaseFile { + path: string; + invocations: TestCaseInvocation[]; +} + +async function parseTestFilesRecursive(params: { + testDir: string; + targetUri?: string; +}): Promise { + const testDir = params.testDir; + const targetUri = params.targetUri; + const items = listFiles(testDir); + const results = []; + for (const item of items) { + const path = join(testDir, item); + if (dirExistsSync(path)) { + results.push(...(await parseTestFilesRecursive({ testDir: path, targetUri }))); + } else if (fileExistsSync(path)) { + try { + const file = await readFileFromDirectory(testDir, item); + logger.info(`Read the file ${file.source}.`); + const parsedFile = wrappedSafeLoad(file.source); + logger.info(`Parsed the file.`); + const tests = parsedFile.tests; + logger.info(`There are ${tests.length} tests.`); + const defaultConfig = parsedFile.defaultConfig; + if (!tests || !tests.length) { + logger.info(`No tests found in ${path}. Ignoring.`); continue; } + const invocations = []; + for (const rawTestDef of tests) { + const invocation = toTestCaseInvocation(rawTestDef, targetUri, defaultConfig); + invocations.push(invocation); + } + results.push({ path, invocations: invocations }); + } catch (ex) { + const errMsg = getErrMsg(ex); + const errDetails = errMsg ? `Error details: \n${errMsg}` : ""; + logger.info(`Unable to parse test file ${path}. Ignoring.${errDetails}`); + continue; } } - return results; } - return parseTestFilesRecursive(dir); + return results; } -function toTestDef(testDef: any, defaultConfig: any, targetUri?: string): TestCaseInvocation { +function toTestCaseInvocation( + testDef: any, + targetUri: any, + defaultConfig: any, +): TestCaseInvocation { const steps = testDef.steps ?? []; const route = testDef.testConfig?.route ?? defaultConfig?.route ?? ""; const browsers: Browser[] = testDef.testConfig?.browsers ?? defaultConfig?.browsers ?? [Browser.CHROME]; return { testCase: { + id: testDef.id, + prerequisiteTestCaseId: testDef.prerequisiteTestCaseId, startUri: targetUri + route, displayName: testDef.testName, instructions: { steps }, diff --git a/src/apptesting/types.ts b/src/apptesting/types.ts index 615c1d5b84a..75ad93754f0 100644 --- a/src/apptesting/types.ts +++ b/src/apptesting/types.ts @@ -57,9 +57,11 @@ export interface TestExecutionResult { } export interface TestCase { + id?: string; startUri?: string; displayName: string; instructions: Instructions; + prerequisiteTestCaseId?: string; } export interface Instructions { From 530d6bf040937e542efbda9a0757c083fa836762 Mon Sep 17 00:00:00 2001 From: Tunde Agboola Date: Thu, 18 Dec 2025 11:37:11 -0500 Subject: [PATCH 2/2] Address GCA comments --- src/apptesting/parseTestFiles.spec.ts | 27 +++++++++++++++++++++++++++ src/apptesting/parseTestFiles.ts | 20 +++++++++++++------- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/apptesting/parseTestFiles.spec.ts b/src/apptesting/parseTestFiles.spec.ts index cd0b6629c1e..74f6f2be6af 100644 --- a/src/apptesting/parseTestFiles.spec.ts +++ b/src/apptesting/parseTestFiles.spec.ts @@ -348,5 +348,32 @@ describe("parseTestFiles", () => { { goal: "do something third" }, ]); }); + + it("throws error if there is a circular depedency", async () => { + writeFile( + "my_test.yaml", + stringify({ + tests: [ + { + id: "my-first-test", + testName: "my first test", + prerequisiteTestCaseId: "my-second-test", + steps: [{ goal: "do something first" }], + }, + { + id: "my-second-test", + testName: "my second test", + prerequisiteTestCaseId: "my-first-test", + steps: [{ goal: "do something second" }], + }, + ], + }), + ); + + await expect(parseTestFiles(tempdir.name, "https://www.foo.com")).to.be.rejectedWith( + FirebaseError, + "Detected a cycle in prerequisite test cases.", + ); + }); }); }); diff --git a/src/apptesting/parseTestFiles.ts b/src/apptesting/parseTestFiles.ts index e8dddb53b1d..6935f8912fa 100644 --- a/src/apptesting/parseTestFiles.ts +++ b/src/apptesting/parseTestFiles.ts @@ -48,12 +48,18 @@ export async function parseTestFiles( } const prerequisiteSteps: TestStep[] = []; + const previousTestCaseIds = new Set(); while (prerequisiteTestCaseId) { + if (previousTestCaseIds.has(prerequisiteTestCaseId)) { + throw new FirebaseError(`Detected a cycle in prerequisite test cases.`); + } + previousTestCaseIds.add(prerequisiteTestCaseId); const prerequisiteTestCaseInvocation: TestCaseInvocation | undefined = idToInvocation[prerequisiteTestCaseId]; if (prerequisiteTestCaseInvocation === undefined) { - const errMsg = `Invalid prerequisiteTestCaseId. There is no test case with id ${prerequisiteTestCaseId}`; - throw new FirebaseError(errMsg); + throw new FirebaseError( + `Invalid prerequisiteTestCaseId. There is no test case with id ${prerequisiteTestCaseId}`, + ); } prerequisiteSteps.unshift(...prerequisiteTestCaseInvocation.testCase.instructions.steps); prerequisiteTestCaseId = prerequisiteTestCaseInvocation.testCase.prerequisiteTestCaseId; @@ -97,14 +103,14 @@ async function parseTestFilesRecursive(params: { } else if (fileExistsSync(path)) { try { const file = await readFileFromDirectory(testDir, item); - logger.info(`Read the file ${file.source}.`); + logger.debug(`Read the file ${file.source}.`); const parsedFile = wrappedSafeLoad(file.source); - logger.info(`Parsed the file.`); + logger.debug(`Parsed the file.`); const tests = parsedFile.tests; - logger.info(`There are ${tests.length} tests.`); + logger.debug(`There are ${tests.length} tests.`); const defaultConfig = parsedFile.defaultConfig; if (!tests || !tests.length) { - logger.info(`No tests found in ${path}. Ignoring.`); + logger.debug(`No tests found in ${path}. Ignoring.`); continue; } const invocations = []; @@ -116,7 +122,7 @@ async function parseTestFilesRecursive(params: { } catch (ex) { const errMsg = getErrMsg(ex); const errDetails = errMsg ? `Error details: \n${errMsg}` : ""; - logger.info(`Unable to parse test file ${path}. Ignoring.${errDetails}`); + logger.debug(`Unable to parse test file ${path}. Ignoring.${errDetails}`); continue; } }