Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 39 additions & 6 deletions src/tools/testmanagement-utils/TCG-utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)}`,
Expand Down Expand Up @@ -247,7 +260,27 @@ export async function pollScenariosTestDetails(

// Promisify interval-style polling using a wrapper
await new Promise<void>((resolve, reject) => {
const intervalId = setInterval(async () => {
const timers: {
interval?: ReturnType<typeof setInterval>;
timeout?: ReturnType<typeof setTimeout>;
} = {};
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)}`,
Expand All @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/tools/testmanagement-utils/TCG-utils/config.ts
Original file line number Diff line number Diff line change
@@ -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`;

Expand Down
89 changes: 89 additions & 0 deletions tests/tools/tcg-poll-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.spyOn>;

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" } });
});
});