@@ -259,6 +259,7 @@ export class PageStateCollector {
259259 }
260260 }
261261 } catch ( error_ ) {
262+ // NOSONAR
262263 // intentionally ignored: Skip selectors that may not be valid in this context
263264 }
264265 }
@@ -279,6 +280,7 @@ export class PageStateCollector {
279280 return results ;
280281 } ) ;
281282 } catch ( error_ ) {
283+ // NOSONAR
282284 // intentionally ignored: DOM query failure returns empty result
283285 return [ ] ;
284286 }
@@ -314,55 +316,57 @@ export class PageStateCollector {
314316 return this . page . $$eval (
315317 'a, button, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="switch"]' ,
316318 ( elements ) => {
319+ function deriveRole ( el : Element ) : string | undefined {
320+ // NOSONAR — browser-context helper, cannot be in outer scope
321+ const explicitRole = el . getAttribute ( "role" ) ;
322+ if ( explicitRole ) return explicitRole ;
323+ const tag = el . tagName . toLowerCase ( ) ;
324+ if ( tag === "a" ) return "link" ;
325+ if ( tag === "button" ) return "button" ;
326+ if ( tag === "input" ) return "textbox" ;
327+ if ( tag === "select" ) return "combobox" ;
328+ if ( tag === "textarea" ) return "textbox" ;
329+ return undefined ;
330+ }
331+
332+ function buildSelector ( el : Element ) : string {
333+ // NOSONAR — browser-context helper, cannot be in outer scope
334+ if ( el . id ) return `#${ CSS . escape ( el . id ) } ` ;
335+ const tag = el . tagName . toLowerCase ( ) ;
336+ const name = el . getAttribute ( "name" ) ;
337+ if ( name ) return `${ tag } [name="${ name } "]` ;
338+ const ariaLabel = el . getAttribute ( "aria-label" ) ;
339+ if ( ariaLabel ) return `${ tag } [aria-label="${ CSS . escape ( ariaLabel ) } "]` ;
340+ const placeholder = el . getAttribute ( "placeholder" ) ;
341+ if ( placeholder ) return `${ tag } [placeholder="${ CSS . escape ( placeholder ) } "]` ;
342+ const type = el . getAttribute ( "type" ) ;
343+ if ( type ) return `${ tag } [type="${ type } "]` ;
344+ const parent = el . parentElement ;
345+ if ( parent ) {
346+ const siblings = Array . from ( parent . children ) . filter ( ( c ) => c . tagName === el . tagName ) ;
347+ if ( siblings . length === 1 ) return tag ;
348+ return `${ tag } :nth-of-type(${ siblings . indexOf ( el ) + 1 } )` ;
349+ }
350+ return tag ;
351+ }
352+
317353 return elements
318354 . filter ( ( el ) => {
319- // Skip Talox-injected overlay elements
320355 if ( el . id ?. startsWith ( "__talox" ) ) return false ;
321- // Skip aria-hidden / presentation elements
322356 if ( el . getAttribute ( "aria-hidden" ) === "true" ) return false ;
323357 if ( el . getAttribute ( "role" ) === "presentation" ) return false ;
324358 return true ;
325359 } )
326360 . map ( ( el , i ) => {
327361 const rect = el . getBoundingClientRect ( ) ;
328- // Derive semantic role from explicit attribute or tagName
329- const explicitRole = el . getAttribute ( "role" ) ;
330- let role : string | undefined = explicitRole || undefined ;
331- if ( ! role ) {
332- const tag = el . tagName . toLowerCase ( ) ;
333- if ( tag === "a" ) role = "link" ;
334- else if ( tag === "button" ) role = "button" ;
335- else if ( tag === "input" ) role = "textbox" ;
336- else if ( tag === "select" ) role = "combobox" ;
337- else if ( tag === "textarea" ) role = "textbox" ;
338- }
339- // Get visible text: prefer label association, fallback to textContent
362+ const role = deriveRole ( el ) ;
340363 const label =
341364 ( el as HTMLInputElement ) . labels ?. [ 0 ] ?. textContent ?. trim ( ) ||
342365 el . getAttribute ( "aria-label" ) ?. trim ( ) ||
343366 el . getAttribute ( "placeholder" ) ?. trim ( ) ||
344367 el . textContent ?. trim ( ) . slice ( 0 , 120 ) ||
345368 "" ;
346- // Build a usable CSS selector for agent interaction
347- let selector = "" ;
348- if ( el . id ) selector = `#${ CSS . escape ( el . id ) } ` ;
349- else if ( el . getAttribute ( "name" ) )
350- selector = `${ el . tagName . toLowerCase ( ) } [name="${ el . getAttribute ( "name" ) } "]` ;
351- else if ( el . getAttribute ( "aria-label" ) )
352- selector = `${ el . tagName . toLowerCase ( ) } [aria-label="${ CSS . escape ( el . getAttribute ( "aria-label" ) ) } "]` ;
353- else if ( el . getAttribute ( "placeholder" ) )
354- selector = `${ el . tagName . toLowerCase ( ) } [placeholder="${ CSS . escape ( el . getAttribute ( "placeholder" ) ) } "]` ;
355- else if ( el . getAttribute ( "type" ) )
356- selector = `${ el . tagName . toLowerCase ( ) } [type="${ el . getAttribute ( "type" ) } "]` ;
357- else {
358- const parent = el . parentElement ;
359- if ( parent ) {
360- const siblings = Array . from ( parent . children ) . filter ( ( c ) => c . tagName === el . tagName ) ;
361- if ( siblings . length === 1 ) selector = el . tagName . toLowerCase ( ) ;
362- else selector = `${ el . tagName . toLowerCase ( ) } :nth-of-type(${ siblings . indexOf ( el ) + 1 } )` ;
363- }
364- if ( ! selector ) selector = el . tagName . toLowerCase ( ) ;
365- }
369+ const selector = buildSelector ( el ) ;
366370 return {
367371 id : selector || `dom-${ i } ` ,
368372 tagName : el . tagName . toLowerCase ( ) ,
@@ -413,6 +417,49 @@ export class PageStateCollector {
413417 return result ;
414418 }
415419
420+ private async collectWithRetry ( nodeThreshold : number ) : Promise < { nodes : TaloxNode [ ] ; shouldUseFallback : boolean } > {
421+ const { maxRetries = DEFAULT_RETRY_OPTIONS . maxRetries } = this . options . retry ;
422+ let nodes : TaloxNode [ ] = [ ] ;
423+ let axSnapshot : any = null ;
424+ let axTreeError : Error | null = null ;
425+
426+ for ( let attempt = 0 ; attempt <= maxRetries ; attempt ++ ) {
427+ this . retryStats . axTreeAttempts ++ ;
428+
429+ try {
430+ if ( attempt > 0 ) {
431+ const delay = this . calculateBackoff ( attempt - 1 ) ;
432+ this . retryStats . totalDelayMs += delay ;
433+ await this . sleep ( delay ) ;
434+ }
435+
436+ try {
437+ // @ts -expect-error - accessibility might not be in types
438+ axSnapshot = await this . page . accessibility ?. snapshot ( ) ;
439+ } catch ( error_ ) {
440+ axTreeError = error_ as Error ;
441+ axSnapshot = null ;
442+ }
443+
444+ if ( axSnapshot ) {
445+ nodes = this . flattenAXTree ( axSnapshot ) ;
446+ this . retryStats . axTreeSuccesses ++ ;
447+ break ;
448+ }
449+
450+ axTreeError = new Error ( "AX-Tree snapshot returned null" ) ;
451+ } catch ( err ) {
452+ axTreeError = err as Error ;
453+ this . retryStats . lastError = axTreeError . message ;
454+ }
455+ }
456+
457+ const shouldUseFallback =
458+ this . options . useDomFallback && ( nodes . length < nodeThreshold || axTreeError !== null || axSnapshot === null ) ;
459+
460+ return { nodes, shouldUseFallback } ;
461+ }
462+
416463 async collect ( ) : Promise < TaloxPageState > {
417464 // Guard against calling collect() on a page that has already been closed
418465 // (e.g. during browser teardown or headed/headless restart races).
@@ -438,54 +485,16 @@ export class PageStateCollector {
438485 this . retryStats . attempts ++ ;
439486
440487 let nodes : TaloxNode [ ] = [ ] ;
441- let axSnapshot : any = null ;
442- let axTreeError : Error | null = null ;
443488 let shouldUseFallback = false ;
444- const { maxRetries = DEFAULT_RETRY_OPTIONS . maxRetries } = this . options . retry ;
445-
446- // Progressive State Collection: Retry if node count is below threshold
447489 const nodeThreshold = this . options . domFallbackThreshold ;
490+
448491 let collectionAttempts = 0 ;
449492 const maxCollectionAttempts = 3 ;
450493
451494 while ( collectionAttempts < maxCollectionAttempts ) {
452- nodes = [ ] ;
453- axSnapshot = null ;
454- axTreeError = null ;
455-
456- for ( let attempt = 0 ; attempt <= maxRetries ; attempt ++ ) {
457- this . retryStats . axTreeAttempts ++ ;
458-
459- try {
460- if ( attempt > 0 ) {
461- const delay = this . calculateBackoff ( attempt - 1 ) ;
462- this . retryStats . totalDelayMs += delay ;
463- await this . sleep ( delay ) ;
464- }
465-
466- try {
467- // @ts -expect-error - accessibility might not be in types
468- axSnapshot = await this . page . accessibility ?. snapshot ( ) ;
469- } catch ( error_ ) {
470- axTreeError = error_ as Error ;
471- axSnapshot = null ;
472- }
473-
474- if ( axSnapshot ) {
475- nodes = this . flattenAXTree ( axSnapshot ) ;
476- this . retryStats . axTreeSuccesses ++ ;
477- break ;
478- }
479-
480- axTreeError = new Error ( "AX-Tree snapshot returned null" ) ;
481- } catch ( err ) {
482- axTreeError = err as Error ;
483- this . retryStats . lastError = axTreeError . message ;
484- }
485- }
486-
487- shouldUseFallback =
488- this . options . useDomFallback && ( nodes . length < nodeThreshold || axTreeError !== null || axSnapshot === null ) ;
495+ const result = await this . collectWithRetry ( nodeThreshold ) ;
496+ nodes = result . nodes ;
497+ shouldUseFallback = result . shouldUseFallback ;
489498
490499 if ( shouldUseFallback ) {
491500 nodes = await this . collectDomFallback ( ) ;
0 commit comments