diff --git a/scripts/integration-helpers/framework.spec.ts b/scripts/integration-helpers/framework.spec.ts new file mode 100644 index 00000000000..3572850a7ac --- /dev/null +++ b/scripts/integration-helpers/framework.spec.ts @@ -0,0 +1,151 @@ +import { expect } from "chai"; +import { waitForCondition, TriggerEndToEndTest } from "./framework"; + +describe("waitForCondition", () => { + it("should resolve immediately if condition is already true", async () => { + let callCount = 0; + const start = Date.now(); + await waitForCondition( + () => { + callCount++; + return true; + }, + 1000, + 50, + ); + const duration = Date.now() - start; + + expect(callCount).to.equal(1); + expect(duration).to.be.lessThan(100); + }); + + it("should poll and resolve when condition becomes true", async () => { + let count = 0; + setTimeout(() => { + count = 5; + }, 60); + + const start = Date.now(); + await waitForCondition(() => count >= 5, 2000, 20); + const duration = Date.now() - start; + + expect(count).to.be.at.least(5); + expect(duration).to.be.at.least(40); + expect(duration).to.be.lessThan(1500); + }); + + it("should support async predicates", async () => { + let asyncFlag = false; + setTimeout(() => { + asyncFlag = true; + }, 50); + + await waitForCondition( + async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + return asyncFlag; + }, + 2000, + 20, + ); + + expect(asyncFlag).to.be.true; + }); + + it("should reject with timeout error if condition never becomes true", async () => { + let error: Error | undefined; + try { + await waitForCondition(() => false, 100, 20); + } catch (err) { + error = err as Error; + } + + expect(error).to.exist; + expect(error?.message).to.include("Timed out waiting for condition after 100ms"); + }); + + it("should reject if predicate throws an error", async () => { + let error: Error | undefined; + try { + await waitForCondition( + () => { + throw new Error("Predicate failure"); + }, + 500, + 20, + ); + } catch (err) { + error = err as Error; + } + + expect(error).to.exist; + expect(error?.message).to.equal("Predicate failure"); + }); + + describe("TriggerEndToEndTest instance method", () => { + let test: TriggerEndToEndTest; + + beforeEach(() => { + test = new TriggerEndToEndTest("test-project", "/tmp", {}); + }); + + it("should return a Promise that resolves when condition is true", async () => { + let count = 0; + setTimeout(() => { + count = 3; + }, 50); + + await test.waitForCondition(() => count >= 3, 1000, 20); + expect(count).to.equal(3); + }); + + it("should reject the Promise on timeout", async () => { + let error: Error | undefined; + try { + await test.waitForCondition(() => false, 100, 20); + } catch (err) { + error = err as Error; + } + + expect(error).to.exist; + expect(error?.message).to.include("Timed out waiting for condition after 100ms"); + }); + + it("should support legacy callback on success", (done) => { + let count = 0; + setTimeout(() => { + count = 1; + }, 30); + + test.waitForCondition( + () => count === 1, + 1000, + (err) => { + try { + expect(err).to.be.undefined; + expect(count).to.equal(1); + done(); + } catch (assertErr) { + done(assertErr); + } + }, + ); + }); + + it("should support legacy callback on timeout", (done) => { + test.waitForCondition( + () => false, + 100, + (err) => { + try { + expect(err).to.exist; + expect(err?.message).to.include("Timed out waiting for condition"); + done(); + } catch (assertErr) { + done(assertErr); + } + }, + ); + }); + }); +}); diff --git a/scripts/integration-helpers/framework.ts b/scripts/integration-helpers/framework.ts index 8542ebd43fc..39235676b35 100644 --- a/scripts/integration-helpers/framework.ts +++ b/scripts/integration-helpers/framework.ts @@ -395,26 +395,40 @@ export class TriggerEndToEndTest extends EmulatorEndToEndTest { return this.invokeHttpFunction("updateDeleteFromSpecificStorageBucket"); } + waitForCondition( + conditionFn: () => boolean | Promise, + timeoutMs?: number, + intervalMs?: number, + ): Promise; waitForCondition( conditionFn: () => boolean, - timeout: number, + timeoutMs: number, callback: (err?: Error) => void, - ): void { - let elapsed = 0; - const interval = 10; - const id = setInterval(() => { - elapsed += interval; - if (elapsed > timeout) { - clearInterval(id); - callback(new Error(`Timed out waiting for condition: ${conditionFn.toString()}}`)); - return; - } + ): void; + waitForCondition( + conditionFn: () => boolean | Promise, + timeoutMs = 10000, + intervalMsOrCallback: number | ((err?: Error) => void) = 50, + callback?: (err?: Error) => void, + ): Promise | void { + let intervalMs = 50; + let cb: ((err?: Error) => void) | undefined; + if (typeof intervalMsOrCallback === "function") { + cb = intervalMsOrCallback; + } else if (typeof intervalMsOrCallback === "number") { + intervalMs = intervalMsOrCallback; + cb = callback; + } - if (conditionFn()) { - clearInterval(id); - callback(); - } - }, interval); + const promise = waitForCondition(conditionFn, timeoutMs, intervalMs); + if (cb) { + promise.then( + () => cb?.(), + (err: unknown) => cb?.(err instanceof Error ? err : new Error(String(err))), + ); + return; + } + return promise; } disableBackgroundTriggers(): Promise { @@ -427,3 +441,29 @@ export class TriggerEndToEndTest extends EmulatorEndToEndTest { return fetch(url, { method: "PUT" }); } } + +/** + * Polls for a condition to be met within a specified timeout. + * Resolves immediately once predicate returns true. + * @param predicate A synchronous or asynchronous function returning a boolean. + * @param timeoutMs Maximum time to wait in milliseconds (default: 10000ms). + * @param intervalMs Time between predicate evaluations in milliseconds (default: 50ms). + */ +export async function waitForCondition( + predicate: () => boolean | Promise, + timeoutMs = 10000, + intervalMs = 50, +): Promise { + const startTime = Date.now(); + for (;;) { + if (await predicate()) { + return; + } + if (Date.now() - startTime >= timeoutMs) { + throw new Error( + `Timed out waiting for condition after ${timeoutMs}ms: ${predicate.toString()}`, + ); + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } +} diff --git a/scripts/triggers-end-to-end-tests/tests.inspect.ts b/scripts/triggers-end-to-end-tests/tests.inspect.ts index 36162ad32eb..e8bf54a30a1 100755 --- a/scripts/triggers-end-to-end-tests/tests.inspect.ts +++ b/scripts/triggers-end-to-end-tests/tests.inspect.ts @@ -2,7 +2,11 @@ import { expect } from "chai"; import * as fs from "fs"; import * as path from "path"; -import { FrameworkOptions, TriggerEndToEndTest } from "../integration-helpers/framework"; +import { + FrameworkOptions, + TriggerEndToEndTest, + waitForCondition, +} from "../integration-helpers/framework"; const FIREBASE_PROJECT = process.env.FBTOOLS_TARGET_PROJECT || ""; /* @@ -10,7 +14,6 @@ const FIREBASE_PROJECT = process.env.FBTOOLS_TARGET_PROJECT || ""; * parallel emulator subprocesses. */ const TEST_SETUP_TIMEOUT = process.platform === "win32" ? 180000 : 80000; -const EMULATORS_WRITE_DELAY_MS = process.platform === "win32" ? 10000 : 5000; const EMULATORS_SHUTDOWN_DELAY_MS = process.platform === "win32" ? 30000 : 5000; function readConfig(): FrameworkOptions { @@ -78,7 +81,7 @@ describe("function triggers with inspect flag", () => { this.timeout(TEST_SETUP_TIMEOUT); const response = await test.writeToAuth(); expect(response.status).to.equal(200); - await new Promise((resolve) => setTimeout(resolve, EMULATORS_WRITE_DELAY_MS)); + await waitForCondition(() => test.authTriggerCount >= 1); expect(test.authTriggerCount).to.equal(1); }); @@ -87,7 +90,9 @@ describe("function triggers with inspect flag", () => { const response = await test.writeToDefaultStorage(); expect(response.status).to.equal(200); - await new Promise((resolve) => setTimeout(resolve, EMULATORS_WRITE_DELAY_MS)); + await waitForCondition( + () => test.storageFinalizedTriggerCount >= 1 && test.storageV2FinalizedTriggerCount >= 1, + ); expect(test.storageFinalizedTriggerCount).to.equal(1); expect(test.storageV2FinalizedTriggerCount).to.equal(1); diff --git a/scripts/triggers-end-to-end-tests/tests.ts b/scripts/triggers-end-to-end-tests/tests.ts index fe00c9483d2..3fcb85f7758 100644 --- a/scripts/triggers-end-to-end-tests/tests.ts +++ b/scripts/triggers-end-to-end-tests/tests.ts @@ -4,7 +4,11 @@ import { Firestore } from "@google-cloud/firestore"; import * as fs from "fs"; import * as path from "path"; -import { FrameworkOptions, TriggerEndToEndTest } from "../integration-helpers/framework"; +import { + FrameworkOptions, + TriggerEndToEndTest, + waitForCondition, +} from "../integration-helpers/framework"; const FIREBASE_PROJECT = process.env.FBTOOLS_TARGET_PROJECT || ""; const ADMIN_CREDENTIAL = { @@ -151,13 +155,18 @@ describe("function triggers", () => { expect(response.status).to.equal(200); /* - * We delay again here because the functions triggered - * by the previous two writes run parallel to this and - * we need to give them and previous installed test - * fixture state handlers to complete before we check - * that state in the next test. + * We wait for the functions triggered by the previous two writes + * and previously installed test fixture state handlers to complete + * before we check that state in the next test. */ - await new Promise((resolve) => setTimeout(resolve, EMULATORS_WRITE_DELAY_MS * 2)); + await waitForCondition( + () => + test.rtdbTriggerCount >= 1 && + test.rtdbV2TriggerCount >= 1 && + test.firestoreTriggerCount >= 1 && + test.firestoreV2TriggerCount >= 1 && + test.success(), + ); }); it("should have have triggered cloud functions", () => { @@ -179,7 +188,7 @@ describe("function triggers", () => { const response = await test.writeToPubsub(); expect(response.status).to.equal(200); - await new Promise((resolve) => setTimeout(resolve, EMULATORS_WRITE_DELAY_MS)); + await waitForCondition(() => test.pubsubTriggerCount >= 1 && test.pubsubV2TriggerCount >= 1); }); it("should have have triggered cloud functions", () => { @@ -192,7 +201,7 @@ describe("function triggers", () => { const response = await test.writeToScheduledPubsub(); expect(response.status).to.equal(200); - await new Promise((resolve) => setTimeout(resolve, EMULATORS_WRITE_DELAY_MS)); + await waitForCondition(() => test.pubsubTriggerCount >= 2); }); it("should have have triggered cloud functions", () => { @@ -205,7 +214,7 @@ describe("function triggers", () => { this.timeout(EMULATOR_TEST_TIMEOUT); const response = await test.writeToAuth(); expect(response.status).to.equal(200); - await new Promise((resolve) => setTimeout(resolve, EMULATORS_WRITE_DELAY_MS)); + await waitForCondition(() => test.authTriggerCount >= 1); }); it("should have have triggered cloud functions", () => { @@ -216,7 +225,10 @@ describe("function triggers", () => { this.timeout(EMULATOR_TEST_TIMEOUT * 2); const response = await test.createUserFromAuth(); expect(response.status).to.equal(200); - await new Promise((resolve) => setTimeout(resolve, EMULATORS_WRITE_DELAY_MS)); + await waitForCondition( + () => + test.authBlockingCreateV2TriggerCount >= 1 && test.authBlockingSignInV2TriggerCount >= 1, + ); }); it("should have triggered cloud functions", () => { @@ -229,7 +241,7 @@ describe("function triggers", () => { this.timeout(EMULATOR_TEST_TIMEOUT * 2); const response = await test.signInUserFromAuth(); expect(response.status).to.equal(200); - await new Promise((resolve) => setTimeout(resolve, EMULATORS_WRITE_DELAY_MS)); + await waitForCondition(() => test.authBlockingSignInV2TriggerCount >= 2); }); it("should have triggered cloud functions", () => { @@ -243,7 +255,9 @@ describe("function triggers", () => { const response = await test.writeToDefaultStorage(); expect(response.status).to.equal(200); - await new Promise((resolve) => setTimeout(resolve, EMULATORS_WRITE_DELAY_MS)); + await waitForCondition( + () => test.storageFinalizedTriggerCount >= 1 && test.storageV2FinalizedTriggerCount >= 1, + ); }); it("should have triggered cloud functions", () => { @@ -270,7 +284,11 @@ describe("function triggers", () => { const response = await test.writeToSpecificStorageBucket(); expect(response.status).to.equal(200); - await new Promise((resolve) => setTimeout(resolve, EMULATORS_WRITE_DELAY_MS)); + await waitForCondition( + () => + test.storageBucketFinalizedTriggerCount >= 1 && + test.storageBucketV2FinalizedTriggerCount >= 1, + ); }); it("should have triggered cloud functions", () => { @@ -297,7 +315,13 @@ describe("function triggers", () => { const response = await test.updateMetadataDefaultStorage(); expect(response.status).to.equal(200); - await new Promise((resolve) => setTimeout(resolve, EMULATORS_WRITE_DELAY_MS)); + await waitForCondition( + () => + test.storageFinalizedTriggerCount >= 1 && + test.storageV2FinalizedTriggerCount >= 1 && + test.storageMetadataTriggerCount >= 1 && + test.storageV2MetadataTriggerCount >= 1, + ); }); it("should have triggered cloud functions", () => { @@ -325,7 +349,13 @@ describe("function triggers", () => { const response = await test.updateMetadataSpecificStorageBucket(); expect(response.status).to.equal(200); - await new Promise((resolve) => setTimeout(resolve, EMULATORS_WRITE_DELAY_MS)); + await waitForCondition( + () => + test.storageBucketFinalizedTriggerCount >= 1 && + test.storageBucketV2FinalizedTriggerCount >= 1 && + test.storageBucketMetadataTriggerCount >= 1 && + test.storageBucketV2MetadataTriggerCount >= 1, + ); }); it("should have triggered cloud functions", () => { @@ -353,7 +383,13 @@ describe("function triggers", () => { const response = await test.updateDeleteFromDefaultStorage(); expect(response.status).to.equal(200); - await new Promise((resolve) => setTimeout(resolve, EMULATORS_WRITE_DELAY_MS)); + await waitForCondition( + () => + test.storageFinalizedTriggerCount >= 1 && + test.storageV2FinalizedTriggerCount >= 1 && + test.storageDeletedTriggerCount >= 1 && + test.storageV2DeletedTriggerCount >= 1, + ); }); it("should have triggered cloud functions", () => { @@ -381,7 +417,13 @@ describe("function triggers", () => { const response = await test.updateDeleteFromSpecificStorageBucket(); expect(response.status).to.equal(200); - await new Promise((resolve) => setTimeout(resolve, EMULATORS_WRITE_DELAY_MS)); + await waitForCondition( + () => + test.storageBucketFinalizedTriggerCount >= 1 && + test.storageBucketV2FinalizedTriggerCount >= 1 && + test.storageBucketDeletedTriggerCount >= 1 && + test.storageBucketV2DeletedTriggerCount >= 1, + ); }); it("should have triggered cloud functions", () => { @@ -484,7 +526,7 @@ describe("function triggers", () => { test.writeToAuth(), ]); - await new Promise((resolve) => setTimeout(resolve, EMULATORS_WRITE_DELAY_MS * 3)); + await waitForCondition(() => test.authTriggerCount >= 1); // TODO(danielylee): Trying to respond to all triggers at once often results in Functions // Emulator hanging indefinitely. Only triggering 1 trigger for now. Re-enable other triggers // once the root cause is identified.