Skip to content

Commit 5f37f10

Browse files
committed
fix: batch requests to <=10, harden polling, cap scenarios
1 parent 31edfb5 commit 5f37f10

5 files changed

Lines changed: 352 additions & 66 deletions

File tree

src/tools/testmanagement-utils/TCG-utils/api.ts

Lines changed: 158 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,27 @@ import {
55
FETCH_DETAILS_URL,
66
FORM_FIELDS_URL,
77
BULK_CREATE_URL,
8+
TC_DETAILS_MAX_BATCH,
9+
BULK_CREATE_MAX_BATCH,
10+
MAX_SCENARIOS_PER_DOCUMENT,
811
} from "./config.js";
912
import {
1013
DefaultFieldMaps,
1114
Scenario,
1215
CreateTestCasesFromFileArgs,
1316
} from "./types.js";
14-
import { createTestCasePayload } from "./helpers.js";
17+
import {
18+
createTestCasePayload,
19+
chunkArray,
20+
canAcceptScenario,
21+
} from "./helpers.js";
1522
import { getBrowserStackAuth } from "../../../lib/get-auth.js";
1623
import { BrowserStackConfig } from "../../../lib/types.js";
1724
import { getTMBaseURL } from "../../../lib/tm-base-url.js";
25+
import logger from "../../../logger.js";
26+
27+
const POLL_INTERVAL_MS = 10000;
28+
const MAX_POLL_DURATION_MS = 8 * 60 * 1000;
1829

1930
/**
2031
* Fetch default and custom form fields for a project.
@@ -132,6 +143,7 @@ export async function fetchTestCaseDetails(
132143
export async function pollTestCaseDetails(
133144
traceRequestId: string,
134145
config: BrowserStackConfig,
146+
deadline: number = Date.now() + MAX_POLL_DURATION_MS,
135147
): Promise<Record<string, any>> {
136148
const detailMap: Record<string, any> = {};
137149
let done = false;
@@ -140,18 +152,27 @@ export async function pollTestCaseDetails(
140152

141153
while (!done) {
142154
// add a bit of jitter to avoid synchronized polling storms
143-
await new Promise((r) => setTimeout(r, 10000 + Math.random() * 5000));
155+
await new Promise((r) =>
156+
setTimeout(r, POLL_INTERVAL_MS + Math.random() * 5000),
157+
);
158+
159+
// Give up before the backend key TTL expires; return whatever we collected.
160+
if (Date.now() > deadline) break;
144161

145162
const poll = await apiClient.post({
146163
url: `${TCG_POLL_URL_VALUE}?x-bstack-traceRequestId=${encodeURIComponent(traceRequestId)}`,
147164
headers: {
148165
"API-TOKEN": getBrowserStackAuth(config),
149166
},
150167
body: {},
168+
// Don't throw on a non-2xx: an expired request key returns 400
169+
// ("Request ids does not exists") and simply means there is nothing more
170+
// to fetch — stop gracefully instead of failing the whole run.
171+
raise_error: false,
151172
});
152173

153-
if (!poll.data.data.success) {
154-
throw new Error(`Polling failed: ${poll.data.data.message}`);
174+
if (poll.status !== 200 || !poll.data?.data?.success) {
175+
break;
155176
}
156177

157178
for (const msg of poll.data.data.message) {
@@ -189,29 +210,55 @@ export async function pollScenariosTestDetails(
189210
let iteratorCount = 0;
190211
const tmBaseUrl = await getTMBaseURL(config);
191212
const TCG_POLL_URL_VALUE = TCG_POLL_URL(tmBaseUrl);
213+
const deadline = Date.now() + MAX_POLL_DURATION_MS;
192214

193215
// Promisify interval-style polling using a wrapper
194216
await new Promise<void>((resolve, reject) => {
195-
const intervalId = setInterval(async () => {
217+
let stopped = false;
218+
219+
const pollOnce = async () => {
220+
if (stopped) return;
196221
try {
197222
const poll = await apiClient.post({
198223
url: `${TCG_POLL_URL_VALUE}?x-bstack-traceRequestId=${encodeURIComponent(traceId)}`,
199224
headers: {
200225
"API-TOKEN": getBrowserStackAuth(config),
201226
},
202227
body: {},
228+
raise_error: false,
203229
});
204230

205231
if (poll.status !== 200) {
206-
clearInterval(intervalId);
207-
reject(new Error(`Polling error: ${poll.statusText || poll.status}`));
232+
stopped = true;
233+
if (Object.keys(scenariosMap).length > 0) {
234+
resolve();
235+
} else {
236+
reject(
237+
new Error(
238+
`Polling error: ${poll.status} ${typeof poll.data === "string" ? poll.data : JSON.stringify(poll.data)}`,
239+
),
240+
);
241+
}
208242
return;
209243
}
210244

245+
let terminated = false;
211246
for (const msg of poll.data.data.message) {
212247
if (msg.type === "scenario") {
213248
msg.data.scenarios.forEach((sc: any) => {
214-
scenariosMap[sc.id] = { id: sc.id, name: sc.name, testcases: [] };
249+
if (
250+
canAcceptScenario(
251+
scenariosMap,
252+
sc.id,
253+
MAX_SCENARIOS_PER_DOCUMENT,
254+
)
255+
) {
256+
scenariosMap[sc.id] ||= {
257+
id: sc.id,
258+
name: sc.name,
259+
testcases: [],
260+
};
261+
}
215262
});
216263
const count = Object.keys(scenariosMap).length;
217264
await context.sendNotification({
@@ -227,23 +274,32 @@ export async function pollScenariosTestDetails(
227274

228275
if (msg.type === "testcase") {
229276
const sc = msg.data.scenario;
230-
if (sc) {
277+
if (
278+
sc &&
279+
canAcceptScenario(scenariosMap, sc.id, MAX_SCENARIOS_PER_DOCUMENT)
280+
) {
231281
const array = Array.isArray(msg.data.testcases)
232282
? msg.data.testcases
233283
: msg.data.testcases
234284
? [msg.data.testcases]
235285
: [];
236-
const ids = array.map((tc: any) => tc.id || tc.test_case_id);
237-
238-
const reqId = await fetchTestCaseDetails(
239-
documentId,
240-
folderId,
241-
projectReferenceId,
242-
ids,
243-
source,
244-
config,
286+
const ids: string[] = array.map(
287+
(tc: any) => tc.id || tc.test_case_id,
245288
);
246-
detailPromises.push(pollTestCaseDetails(reqId, config));
289+
290+
for (const idChunk of chunkArray(ids, TC_DETAILS_MAX_BATCH)) {
291+
const reqId = await fetchTestCaseDetails(
292+
documentId,
293+
folderId,
294+
projectReferenceId,
295+
idChunk,
296+
source,
297+
config,
298+
);
299+
detailPromises.push(
300+
pollTestCaseDetails(reqId, config, deadline),
301+
);
302+
}
247303

248304
scenariosMap[sc.id] ||= {
249305
id: sc.id,
@@ -267,20 +323,41 @@ export async function pollScenariosTestDetails(
267323
}
268324

269325
if (msg.type === "termination") {
270-
clearInterval(intervalId);
271-
resolve();
326+
terminated = true;
272327
}
273328
}
329+
330+
if (terminated || Date.now() > deadline) {
331+
stopped = true;
332+
logger.info(
333+
`TCG scenario poll stopped (${terminated ? "termination received" : "max duration reached"}); ${Object.keys(scenariosMap).length} scenarios, ${detailPromises.length} detail fetches`,
334+
);
335+
resolve();
336+
return;
337+
}
338+
setTimeout(pollOnce, POLL_INTERVAL_MS);
274339
} catch (err) {
275-
clearInterval(intervalId);
340+
stopped = true;
276341
reject(err);
277342
}
278-
}, 10000); // 10 second interval
343+
};
344+
setTimeout(pollOnce, POLL_INTERVAL_MS);
279345
});
280346

281-
// once all detail fetches are triggered, wait for them to complete
282-
const detailsList = await Promise.all(detailPromises);
283-
const allDetails = detailsList.reduce((acc, cur) => ({ ...acc, ...cur }), {});
347+
const detailsList = await Promise.allSettled(detailPromises);
348+
const rejectedDetails = detailsList.filter(
349+
(r) => r.status === "rejected",
350+
).length;
351+
if (rejectedDetails > 0) {
352+
logger.info(
353+
`TCG detail fetches: ${detailsList.length - rejectedDetails}/${detailsList.length} succeeded, ${rejectedDetails} failed (degrading gracefully)`,
354+
);
355+
}
356+
const allDetails = detailsList.reduce<Record<string, any>>(
357+
(acc, result) =>
358+
result.status === "fulfilled" ? { ...acc, ...result.value } : acc,
359+
{},
360+
);
284361

285362
// attach the fetched detail objects back to each testcase
286363
for (const scenario of Object.values(scenariosMap)) {
@@ -311,37 +388,63 @@ export async function bulkCreateTestCases(
311388
const total = Object.keys(scenariosMap).length;
312389
let doneCount = 0;
313390
let testCaseCount = 0;
391+
const failedScenarios: string[] = [];
314392
const tmBaseUrl = await getTMBaseURL(config);
315393
const BULK_CREATE_URL_VALUE = BULK_CREATE_URL(tmBaseUrl, projectId, folderId);
316394

317395
for (const { id, testcases } of Object.values(scenariosMap)) {
318-
const testCaseLength = testcases.length;
319-
testCaseCount += testCaseLength;
320-
if (testCaseLength === 0) continue;
321-
const payload = {
322-
test_cases: testcases.map((tc) =>
323-
createTestCasePayload(
324-
tc,
325-
id,
326-
folderId,
327-
fieldMaps,
328-
documentId,
329-
booleanFieldId,
330-
traceId,
396+
if (testcases.length === 0) continue;
397+
398+
const batches = chunkArray(testcases, BULK_CREATE_MAX_BATCH);
399+
let createdInScenario = 0;
400+
let scenarioFailed = false;
401+
402+
for (const batch of batches) {
403+
const payload = {
404+
test_cases: batch.map((tc) =>
405+
createTestCasePayload(
406+
tc,
407+
id,
408+
folderId,
409+
fieldMaps,
410+
documentId,
411+
booleanFieldId,
412+
traceId,
413+
),
331414
),
332-
),
333-
};
415+
};
334416

335-
try {
336-
const resp = await apiClient.post({
337-
url: BULK_CREATE_URL_VALUE,
338-
headers: {
339-
"API-TOKEN": getBrowserStackAuth(config),
340-
"Content-Type": "application/json",
341-
},
342-
body: payload,
343-
});
344-
results[id] = resp.data;
417+
try {
418+
const resp = await apiClient.post({
419+
url: BULK_CREATE_URL_VALUE,
420+
headers: {
421+
"API-TOKEN": getBrowserStackAuth(config),
422+
"Content-Type": "application/json",
423+
},
424+
body: payload,
425+
});
426+
results[id] = resp.data;
427+
createdInScenario += batch.length;
428+
} catch (error) {
429+
scenarioFailed = true;
430+
await context.sendNotification({
431+
method: "notifications/progress",
432+
params: {
433+
progressToken: context._meta?.progressToken ?? traceId,
434+
message: `Creation failed for scenario ${id}: ${error instanceof Error ? error.message : "Unknown error"}`,
435+
total,
436+
progress: doneCount,
437+
},
438+
});
439+
}
440+
}
441+
442+
testCaseCount += createdInScenario;
443+
if (scenarioFailed) {
444+
failedScenarios.push(id);
445+
}
446+
if (createdInScenario > 0) {
447+
doneCount++;
345448
await context.sendNotification({
346449
method: "notifications/progress",
347450
params: {
@@ -351,23 +454,12 @@ export async function bulkCreateTestCases(
351454
progress: doneCount,
352455
},
353456
});
354-
} catch (error) {
355-
//send notification
356-
await context.sendNotification({
357-
method: "notifications/progress",
358-
params: {
359-
progressToken: context._meta?.progressToken ?? traceId,
360-
message: `Creation failed for scenario ${id}: ${error instanceof Error ? error.message : "Unknown error"}`,
361-
total,
362-
progress: doneCount,
363-
},
364-
});
365-
//continue to next scenario
366-
continue;
367457
}
368-
doneCount++;
369458
}
370-
const resultString = `Total of ${testCaseCount} test cases created in ${total} scenarios.`;
459+
let resultString = `Total of ${testCaseCount} test cases created in ${doneCount} of ${total} scenarios.`;
460+
if (failedScenarios.length > 0) {
461+
resultString += ` Failed to create test cases for ${failedScenarios.length} scenario(s): ${failedScenarios.join(", ")}.`;
462+
}
371463
return resultString;
372464
}
373465

src/tools/testmanagement-utils/TCG-utils/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
export const TC_DETAILS_MAX_BATCH = 10;
2+
3+
export const BULK_CREATE_MAX_BATCH = 10;
4+
5+
// Cap scenarios per document (mirrors TCG's former maxScenariosPerDocument=10).
6+
export const MAX_SCENARIOS_PER_DOCUMENT = 10;
7+
18
export const TCG_TRIGGER_URL = (baseUrl: string) =>
29
`${baseUrl}/api/v1/integration/tcg/test-generation/suggest-test-cases`;
310

src/tools/testmanagement-utils/TCG-utils/helpers.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,32 @@ export function findBooleanFieldId(customFields: any[]): number | undefined {
2828
return boolField?.id;
2929
}
3030

31+
// True if the scenario is already tracked or we're still under `max`.
32+
export function canAcceptScenario(
33+
scenariosMap: Record<string, unknown>,
34+
id: string,
35+
max: number,
36+
): boolean {
37+
return (
38+
scenariosMap[id] !== undefined || Object.keys(scenariosMap).length < max
39+
);
40+
}
41+
42+
/**
43+
* Split an array into consecutive chunks of at most `size` items.
44+
* Used to keep test-case-detail fetches within the backend's per-request limit.
45+
*/
46+
export function chunkArray<T>(items: T[], size: number): T[][] {
47+
if (size < 1) {
48+
throw new Error("chunk size must be at least 1");
49+
}
50+
const chunks: T[][] = [];
51+
for (let i = 0; i < items.length; i += size) {
52+
chunks.push(items.slice(i, i + size));
53+
}
54+
return chunks;
55+
}
56+
3157
/**
3258
* Construct payload for creating a single test case in bulk.
3359
*/

0 commit comments

Comments
 (0)