diff --git a/src/tools/testmanagement-utils/TCG-utils/api.ts b/src/tools/testmanagement-utils/TCG-utils/api.ts index b2977ad6..f06c098a 100644 --- a/src/tools/testmanagement-utils/TCG-utils/api.ts +++ b/src/tools/testmanagement-utils/TCG-utils/api.ts @@ -6,6 +6,8 @@ import { FORM_FIELDS_URL, BULK_CREATE_URL, TC_DETAILS_MAX_BATCH, + TCG_POLL_INTERVAL_MS, + TCG_POLL_MAX_WAIT_MS, } from "./config.js"; import { DefaultFieldMaps, @@ -192,10 +194,21 @@ export async function pollTestCaseDetails( let done = false; const tmBaseUrl = await getTMBaseURL(config); const TCG_POLL_URL_VALUE = TCG_POLL_URL(tmBaseUrl); + const deadline = Date.now() + TCG_POLL_MAX_WAIT_MS; while (!done) { + // Bail out if the backend never sends a "termination" message, so a stuck + // job cannot keep this loop (and its callers) alive forever. + if (Date.now() > deadline) { + throw new Error( + `TCG test-case detail polling timed out after ${TCG_POLL_MAX_WAIT_MS}ms (trace ${traceRequestId})`, + ); + } + // add a bit of jitter to avoid synchronized polling storms - await new Promise((r) => setTimeout(r, 10000 + Math.random() * 5000)); + await new Promise((r) => + setTimeout(r, TCG_POLL_INTERVAL_MS + Math.random() * 5000), + ); const poll = await apiClient.post({ url: `${TCG_POLL_URL_VALUE}?x-bstack-traceRequestId=${encodeURIComponent(traceRequestId)}`, @@ -247,7 +260,27 @@ export async function pollScenariosTestDetails( // Promisify interval-style polling using a wrapper await new Promise((resolve, reject) => { - const intervalId = setInterval(async () => { + const timers: { + interval?: ReturnType; + timeout?: ReturnType; + } = {}; + const stop = () => { + if (timers.interval) clearInterval(timers.interval); + if (timers.timeout) clearTimeout(timers.timeout); + }; + + // Hard wall-clock deadline: if the backend never sends a "termination" + // message, reject instead of letting the interval fire forever. + timers.timeout = setTimeout(() => { + stop(); + reject( + new Error( + `TCG scenario polling timed out after ${TCG_POLL_MAX_WAIT_MS}ms (trace ${traceId})`, + ), + ); + }, TCG_POLL_MAX_WAIT_MS); + + timers.interval = setInterval(async () => { try { const poll = await apiClient.post({ url: `${TCG_POLL_URL_VALUE}?x-bstack-traceRequestId=${encodeURIComponent(traceId)}`, @@ -258,7 +291,7 @@ export async function pollScenariosTestDetails( }); if (poll.status !== 200) { - clearInterval(intervalId); + stop(); reject(new Error(`Polling error: ${poll.statusText || poll.status}`)); return; } @@ -324,15 +357,15 @@ export async function pollScenariosTestDetails( } if (msg.type === "termination") { - clearInterval(intervalId); + stop(); resolve(); } } } catch (err) { - clearInterval(intervalId); + stop(); reject(err); } - }, 10000); // 10 second interval + }, TCG_POLL_INTERVAL_MS); }); // once all detail fetches are triggered, wait for them to complete diff --git a/src/tools/testmanagement-utils/TCG-utils/config.ts b/src/tools/testmanagement-utils/TCG-utils/config.ts index 828d7fce..21dd5ada 100644 --- a/src/tools/testmanagement-utils/TCG-utils/config.ts +++ b/src/tools/testmanagement-utils/TCG-utils/config.ts @@ -1,5 +1,11 @@ export const TC_DETAILS_MAX_BATCH = 10; +// Wall-clock bounds for the TCG generation polling loops. Without a hard +// deadline, a job that never emits a "termination" message keeps the loop +// (and every caller awaiting it) alive forever. +export const TCG_POLL_INTERVAL_MS = 10_000; +export const TCG_POLL_MAX_WAIT_MS = 10 * 60 * 1000; // 10 minutes + export const TCG_TRIGGER_URL = (baseUrl: string) => `${baseUrl}/api/v1/integration/tcg/test-generation/suggest-test-cases`; diff --git a/tests/tools/tcg-poll-timeout.test.ts b/tests/tools/tcg-poll-timeout.test.ts new file mode 100644 index 00000000..3f5b1f4d --- /dev/null +++ b/tests/tools/tcg-poll-timeout.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, afterEach, describe, it, expect, vi, Mock } from "vitest"; +import { apiClient } from "../../src/lib/apiClient"; +import { getTMBaseURL } from "../../src/lib/tm-base-url"; +import { pollTestCaseDetails } from "../../src/tools/testmanagement-utils/TCG-utils/api"; +import { TCG_POLL_MAX_WAIT_MS } from "../../src/tools/testmanagement-utils/TCG-utils/config"; + +// The TCG polling loops must give up after a hard wall-clock deadline instead +// of polling forever when the backend never sends a "termination" message. +vi.mock("../../src/lib/apiClient", () => ({ + apiClient: { get: vi.fn(), post: vi.fn() }, +})); +vi.mock("../../src/lib/tm-base-url", () => ({ + getTMBaseURL: vi.fn(async () => "https://test-management.browserstack.com"), +})); +vi.mock("../../src/lib/get-auth", () => ({ + getBrowserStackAuth: vi.fn(() => "fake-user:fake-key"), +})); + +const mockConfig = { + "browserstack-username": "fake-user", + "browserstack-access-key": "fake-key", +} as any; + +describe("TCG polling hard timeout", () => { + let setTimeoutSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + (getTMBaseURL as Mock).mockResolvedValue( + "https://test-management.browserstack.com", + ); + // Make the inter-poll sleep resolve immediately so the loop advances + // without waiting real seconds. + setTimeoutSpy = vi + .spyOn(global, "setTimeout") + .mockImplementation(((fn: () => void) => { + fn(); + return 0 as unknown as NodeJS.Timeout; + }) as unknown as typeof setTimeout); + }); + + afterEach(() => { + setTimeoutSpy.mockRestore(); + vi.restoreAllMocks(); + }); + + it("pollTestCaseDetails rejects once the deadline passes with no termination", async () => { + // Backend keeps replying successfully but never emits a terminal message. + (apiClient.post as Mock).mockResolvedValue({ + data: { data: { success: true, message: [] } }, + }); + + // First Date.now() sets the deadline; the next read is past it. + const base = 1_000_000; + let call = 0; + vi.spyOn(Date, "now").mockImplementation(() => + ++call === 1 ? base : base + TCG_POLL_MAX_WAIT_MS + 1, + ); + + await expect(pollTestCaseDetails("trace-abc", mockConfig)).rejects.toThrow( + /timed out/i, + ); + }); + + it("pollTestCaseDetails resolves normally when termination arrives", async () => { + (apiClient.post as Mock).mockResolvedValue({ + data: { + data: { + success: true, + message: [ + { + type: "testcase_details", + data: { + testcase_details: [ + { id: "tc-1", steps: ["s1"], preconditions: "p1" }, + ], + }, + }, + { type: "termination" }, + ], + }, + }, + }); + + await expect( + pollTestCaseDetails("trace-xyz", mockConfig), + ).resolves.toEqual({ "tc-1": { steps: ["s1"], preconditions: "p1" } }); + }); +});