@@ -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" ;
912import {
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" ;
1522import { getBrowserStackAuth } from "../../../lib/get-auth.js" ;
1623import { BrowserStackConfig } from "../../../lib/types.js" ;
1724import { 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(
132143export 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
0 commit comments