Skip to content

Commit f2138c3

Browse files
committed
fix: cap test cases per scenario to backend per-request limit
1 parent 039dbd0 commit f2138c3

7 files changed

Lines changed: 79 additions & 448 deletions

File tree

src/lib/apiClient.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -150,18 +150,8 @@ class ApiClient {
150150
const res = await fn(this.axiosAgent);
151151
return new ApiResponse<T>(res);
152152
} catch (error: any) {
153-
if (error.response) {
154-
if (!raise_error) {
155-
return new ApiResponse<T>(error.response);
156-
}
157-
const body = error.response.data;
158-
const serverMessage =
159-
typeof body === "string"
160-
? body
161-
: (body?.message ?? body?.error ?? JSON.stringify(body));
162-
if (serverMessage) {
163-
error.message = `Request failed with status code ${error.response.status}: ${serverMessage}`;
164-
}
153+
if (error.response && !raise_error) {
154+
return new ApiResponse<T>(error.response);
165155
}
166156
throw error;
167157
}

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

Lines changed: 77 additions & 161 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,16 @@ import {
66
FORM_FIELDS_URL,
77
BULK_CREATE_URL,
88
TC_DETAILS_MAX_BATCH,
9-
BULK_CREATE_MAX_BATCH,
10-
MAX_SCENARIOS_PER_DOCUMENT,
119
} from "./config.js";
1210
import {
1311
DefaultFieldMaps,
1412
Scenario,
1513
CreateTestCasesFromFileArgs,
1614
} from "./types.js";
17-
import {
18-
createTestCasePayload,
19-
chunkArray,
20-
canAcceptScenario,
21-
} from "./helpers.js";
15+
import { createTestCasePayload } from "./helpers.js";
2216
import { getBrowserStackAuth } from "../../../lib/get-auth.js";
2317
import { BrowserStackConfig } from "../../../lib/types.js";
2418
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;
2919

3020
/**
3121
* Fetch default and custom form fields for a project.
@@ -143,7 +133,6 @@ export async function fetchTestCaseDetails(
143133
export async function pollTestCaseDetails(
144134
traceRequestId: string,
145135
config: BrowserStackConfig,
146-
deadline: number = Date.now() + MAX_POLL_DURATION_MS,
147136
): Promise<Record<string, any>> {
148137
const detailMap: Record<string, any> = {};
149138
let done = false;
@@ -152,27 +141,18 @@ export async function pollTestCaseDetails(
152141

153142
while (!done) {
154143
// add a bit of jitter to avoid synchronized polling storms
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;
144+
await new Promise((r) => setTimeout(r, 10000 + Math.random() * 5000));
161145

162146
const poll = await apiClient.post({
163147
url: `${TCG_POLL_URL_VALUE}?x-bstack-traceRequestId=${encodeURIComponent(traceRequestId)}`,
164148
headers: {
165149
"API-TOKEN": getBrowserStackAuth(config),
166150
},
167151
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,
172152
});
173153

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

178158
for (const msg of poll.data.data.message) {
@@ -210,55 +190,29 @@ export async function pollScenariosTestDetails(
210190
let iteratorCount = 0;
211191
const tmBaseUrl = await getTMBaseURL(config);
212192
const TCG_POLL_URL_VALUE = TCG_POLL_URL(tmBaseUrl);
213-
const deadline = Date.now() + MAX_POLL_DURATION_MS;
214193

215194
// Promisify interval-style polling using a wrapper
216195
await new Promise<void>((resolve, reject) => {
217-
let stopped = false;
218-
219-
const pollOnce = async () => {
220-
if (stopped) return;
196+
const intervalId = setInterval(async () => {
221197
try {
222198
const poll = await apiClient.post({
223199
url: `${TCG_POLL_URL_VALUE}?x-bstack-traceRequestId=${encodeURIComponent(traceId)}`,
224200
headers: {
225201
"API-TOKEN": getBrowserStackAuth(config),
226202
},
227203
body: {},
228-
raise_error: false,
229204
});
230205

231206
if (poll.status !== 200) {
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-
}
207+
clearInterval(intervalId);
208+
reject(new Error(`Polling error: ${poll.statusText || poll.status}`));
242209
return;
243210
}
244211

245-
let terminated = false;
246212
for (const msg of poll.data.data.message) {
247213
if (msg.type === "scenario") {
248214
msg.data.scenarios.forEach((sc: any) => {
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-
}
215+
scenariosMap[sc.id] = { id: sc.id, name: sc.name, testcases: [] };
262216
});
263217
const count = Object.keys(scenariosMap).length;
264218
await context.sendNotification({
@@ -274,32 +228,25 @@ export async function pollScenariosTestDetails(
274228

275229
if (msg.type === "testcase") {
276230
const sc = msg.data.scenario;
277-
if (
278-
sc &&
279-
canAcceptScenario(scenariosMap, sc.id, MAX_SCENARIOS_PER_DOCUMENT)
280-
) {
281-
const array = Array.isArray(msg.data.testcases)
282-
? msg.data.testcases
283-
: msg.data.testcases
284-
? [msg.data.testcases]
285-
: [];
286-
const ids: string[] = array.map(
287-
(tc: any) => tc.id || tc.test_case_id,
231+
if (sc) {
232+
const array = (
233+
Array.isArray(msg.data.testcases)
234+
? msg.data.testcases
235+
: msg.data.testcases
236+
? [msg.data.testcases]
237+
: []
238+
).slice(0, TC_DETAILS_MAX_BATCH);
239+
const ids = array.map((tc: any) => tc.id || tc.test_case_id);
240+
241+
const reqId = await fetchTestCaseDetails(
242+
documentId,
243+
folderId,
244+
projectReferenceId,
245+
ids,
246+
source,
247+
config,
288248
);
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-
}
249+
detailPromises.push(pollTestCaseDetails(reqId, config));
303250

304251
scenariosMap[sc.id] ||= {
305252
id: sc.id,
@@ -323,41 +270,20 @@ export async function pollScenariosTestDetails(
323270
}
324271

325272
if (msg.type === "termination") {
326-
terminated = true;
273+
clearInterval(intervalId);
274+
resolve();
327275
}
328276
}
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);
339277
} catch (err) {
340-
stopped = true;
278+
clearInterval(intervalId);
341279
reject(err);
342280
}
343-
};
344-
setTimeout(pollOnce, POLL_INTERVAL_MS);
281+
}, 10000); // 10 second interval
345282
});
346283

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-
);
284+
// once all detail fetches are triggered, wait for them to complete
285+
const detailsList = await Promise.all(detailPromises);
286+
const allDetails = detailsList.reduce((acc, cur) => ({ ...acc, ...cur }), {});
361287

362288
// attach the fetched detail objects back to each testcase
363289
for (const scenario of Object.values(scenariosMap)) {
@@ -384,65 +310,44 @@ export async function bulkCreateTestCases(
384310
documentId: number,
385311
config: BrowserStackConfig,
386312
): Promise<string> {
313+
const results: Record<string, any> = {};
387314
const total = Object.keys(scenariosMap).length;
388315
let doneCount = 0;
389316
let testCaseCount = 0;
390-
const failedScenarios: string[] = [];
391317
const tmBaseUrl = await getTMBaseURL(config);
392318
const BULK_CREATE_URL_VALUE = BULK_CREATE_URL(tmBaseUrl, projectId, folderId);
393319

394320
for (const { id, testcases } of Object.values(scenariosMap)) {
395-
if (testcases.length === 0) continue;
396-
397-
const batches = chunkArray(testcases, BULK_CREATE_MAX_BATCH);
398-
let createdInScenario = 0;
399-
let scenarioFailed = false;
400-
401-
for (const batch of batches) {
402-
const payload = {
403-
test_cases: batch.map((tc) =>
404-
createTestCasePayload(
405-
tc,
406-
id,
407-
folderId,
408-
fieldMaps,
409-
documentId,
410-
booleanFieldId,
411-
traceId,
412-
),
321+
// Cap per-scenario test cases to the backend's per-request limit so the
322+
// bulk-create call never exceeds it ("More than permitted test cases sent").
323+
const cappedTestcases = testcases.slice(0, TC_DETAILS_MAX_BATCH);
324+
const testCaseLength = cappedTestcases.length;
325+
testCaseCount += testCaseLength;
326+
if (testCaseLength === 0) continue;
327+
const payload = {
328+
test_cases: cappedTestcases.map((tc) =>
329+
createTestCasePayload(
330+
tc,
331+
id,
332+
folderId,
333+
fieldMaps,
334+
documentId,
335+
booleanFieldId,
336+
traceId,
413337
),
414-
};
415-
416-
try {
417-
await apiClient.post({
418-
url: BULK_CREATE_URL_VALUE,
419-
headers: {
420-
"API-TOKEN": getBrowserStackAuth(config),
421-
"Content-Type": "application/json",
422-
},
423-
body: payload,
424-
});
425-
createdInScenario += batch.length;
426-
} catch (error) {
427-
scenarioFailed = true;
428-
await context.sendNotification({
429-
method: "notifications/progress",
430-
params: {
431-
progressToken: context._meta?.progressToken ?? traceId,
432-
message: `Creation failed for scenario ${id}: ${error instanceof Error ? error.message : "Unknown error"}`,
433-
total,
434-
progress: doneCount,
435-
},
436-
});
437-
}
438-
}
338+
),
339+
};
439340

440-
testCaseCount += createdInScenario;
441-
if (scenarioFailed) {
442-
failedScenarios.push(id);
443-
}
444-
if (createdInScenario > 0) {
445-
doneCount++;
341+
try {
342+
const resp = await apiClient.post({
343+
url: BULK_CREATE_URL_VALUE,
344+
headers: {
345+
"API-TOKEN": getBrowserStackAuth(config),
346+
"Content-Type": "application/json",
347+
},
348+
body: payload,
349+
});
350+
results[id] = resp.data;
446351
await context.sendNotification({
447352
method: "notifications/progress",
448353
params: {
@@ -452,12 +357,23 @@ export async function bulkCreateTestCases(
452357
progress: doneCount,
453358
},
454359
});
360+
} catch (error) {
361+
//send notification
362+
await context.sendNotification({
363+
method: "notifications/progress",
364+
params: {
365+
progressToken: context._meta?.progressToken ?? traceId,
366+
message: `Creation failed for scenario ${id}: ${error instanceof Error ? error.message : "Unknown error"}`,
367+
total,
368+
progress: doneCount,
369+
},
370+
});
371+
//continue to next scenario
372+
continue;
455373
}
374+
doneCount++;
456375
}
457-
let resultString = `Total of ${testCaseCount} test cases created in ${doneCount} of ${total} scenarios.`;
458-
if (failedScenarios.length > 0) {
459-
resultString += ` Failed to create test cases for ${failedScenarios.length} scenario(s): ${failedScenarios.join(", ")}.`;
460-
}
376+
const resultString = `Total of ${testCaseCount} test cases created in ${total} scenarios.`;
461377
return resultString;
462378
}
463379

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

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
export const TC_DETAILS_MAX_BATCH = 10;
22

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-
83
export const TCG_TRIGGER_URL = (baseUrl: string) =>
94
`${baseUrl}/api/v1/integration/tcg/test-generation/suggest-test-cases`;
105

0 commit comments

Comments
 (0)