@@ -198,8 +198,9 @@ export async function runBrowserActionsCommand({
198198 throw error
199199 }
200200 }
201- const descriptors = await discoverBrowserActionCorpusDescriptors ( page )
202- actionCorpusArtifact = browserActionCorpusArtifact ( runPlan . actionCorpus , descriptors , now ( ) )
201+ const discovery = await discoverBrowserActionCorpusDescriptors ( page )
202+ actionCorpusArtifact = browserActionCorpusArtifact ( runPlan . actionCorpus , discovery . descriptors , now ( ) )
203+ actionCorpusArtifact . plan . diagnostics . push ( ...discovery . diagnostics )
203204 await artifactSession . writeJson ( "actionCorpus" , "action-corpus.json" , actionCorpusArtifact )
204205 const corpusSteps = actionCorpusArtifact . plan . steps
205206 steps . unshift ( ...corpusSteps )
@@ -605,23 +606,87 @@ function browserActionCorpusFromArgs(args: string[]): BrowserActionCorpusContrac
605606 return browserActionCorpusContract ( parsed )
606607}
607608
608- async function discoverBrowserActionCorpusDescriptors ( page : Page ) : Promise < BrowserActionCorpusDescriptor [ ] > {
609+ export async function discoverBrowserActionCorpusDescriptors ( page : Page ) : Promise < {
610+ descriptors : BrowserActionCorpusDescriptor [ ]
611+ diagnostics : Array < { code : string ; message : string ; metadata ?: Record < string , unknown > } >
612+ } > {
609613 return await page . evaluate ( ( ) => {
614+ const MAX_REJECTION_DIAGNOSTICS = 20
610615 const descriptors : BrowserActionCorpusDescriptor [ ] = [ ]
616+ const rejected : Array < { kind : string ; tag : string ; label : string } > = [ ]
611617 const cssEscape = ( value : string ) => {
612618 const escapeFn = ( globalThis as typeof globalThis & { CSS ?: { escape ?: ( raw : string ) => string } } ) . CSS ?. escape
613619 return escapeFn ? escapeFn ( value ) : value . replace ( / [ ^ a - z A - Z 0 - 9 _ - ] / g, "\\$&" )
614620 }
615621 const text = ( value : string | null | undefined ) => ( value || "" ) . replace ( / \s + / g, " " ) . trim ( ) . slice ( 0 , 120 )
616- const selectorFor = ( element : Element ) => {
622+ const attributeSelector = ( name : string , value : string ) => `[${ name } =${ cssEscape ( value ) } ]`
623+ const uniquelySelects = ( selector : string , element : Element ) => {
624+ try {
625+ const matches = document . querySelectorAll ( selector )
626+ return matches . length === 1 && matches [ 0 ] === element
627+ } catch {
628+ return false
629+ }
630+ }
631+ const selectorFor = ( element : Element ) : string | undefined => {
632+ const tag = element . tagName . toLowerCase ( )
633+ const candidates : string [ ] = [ ]
634+ const seen = new Set < string > ( )
635+ const addCandidate = ( selector : string ) => {
636+ if ( ! seen . has ( selector ) ) {
637+ seen . add ( selector )
638+ candidates . push ( selector )
639+ }
640+ }
617641 const id = element . getAttribute ( "id" )
618- if ( id ) return `#${ cssEscape ( id ) } `
619- const name = element . getAttribute ( "name" )
620- if ( name ) return `${ element . tagName . toLowerCase ( ) } [name="${ cssEscape ( name ) } "]`
621- const aria = element . getAttribute ( "aria-label" )
622- if ( aria ) return `${ element . tagName . toLowerCase ( ) } [aria-label="${ cssEscape ( aria ) } "]`
623- const type = element . getAttribute ( "type" )
624- return `${ element . tagName . toLowerCase ( ) } ${ type ? `[type="${ cssEscape ( type ) } "]` : "" } :nth-of-type(${ Array . from ( element . parentElement ?. children || [ ] ) . filter ( ( child ) => child . tagName === element . tagName ) . indexOf ( element ) + 1 } )`
642+ if ( id ) addCandidate ( `#${ cssEscape ( id ) } ` )
643+
644+ const attributes = new Map ( [ "aria-label" , "aria-labelledby" , "name" , "type" , "value" , "title" , "href" , "role" ] . flatMap ( ( name ) => {
645+ const value = element . getAttribute ( name )
646+ return value ? [ [ name , value ] as const ] : [ ]
647+ } ) )
648+ const addAttributeCombination = ( ...names : string [ ] ) => {
649+ if ( names . every ( ( name ) => attributes . has ( name ) ) ) {
650+ addCandidate ( `${ tag } ${ names . map ( ( name ) => attributeSelector ( name , attributes . get ( name ) ! ) ) . join ( "" ) } ` )
651+ }
652+ }
653+ for ( const names of [
654+ [ "aria-label" ] , [ "aria-labelledby" ] ,
655+ [ "name" , "type" , "value" ] , [ "name" , "type" ] , [ "name" , "value" ] , [ "name" ] ,
656+ [ "href" ] , [ "title" ] ,
657+ [ "role" , "type" , "value" ] , [ "role" , "type" ] , [ "role" ] ,
658+ [ "type" , "value" ] , [ "value" ] , [ "type" ] ,
659+ ] ) {
660+ addAttributeCombination ( ...names )
661+ }
662+
663+ const form = ( element as HTMLInputElement ) . form
664+ const formId = form ?. getAttribute ( "id" )
665+ if ( form && formId ) {
666+ const formSelector = `#${ cssEscape ( formId ) } `
667+ if ( uniquelySelects ( formSelector , form ) ) {
668+ for ( const candidate of [ ...candidates ] ) addCandidate ( `${ formSelector } ${ candidate } ` )
669+ }
670+ }
671+ for ( const candidate of candidates ) {
672+ if ( uniquelySelects ( candidate , element ) ) return candidate
673+ }
674+
675+ const parts : string [ ] = [ ]
676+ let current : Element | null = element
677+ while ( current ) {
678+ let part = current . tagName . toLowerCase ( )
679+ const parent : Element | null = current . parentElement
680+ if ( parent ) {
681+ const sameTagSiblings = Array . from ( parent . children ) . filter ( ( child ) => child . tagName === current ?. tagName )
682+ if ( sameTagSiblings . length > 1 ) part += `:nth-of-type(${ sameTagSiblings . indexOf ( current ) + 1 } )`
683+ }
684+ parts . unshift ( part )
685+ const candidate = parts . join ( " > " )
686+ if ( uniquelySelects ( candidate , element ) ) return candidate
687+ current = parent
688+ }
689+ return undefined
625690 }
626691 const labelFor = ( element : Element ) => {
627692 const labelledBy = element . getAttribute ( "aria-labelledby" )
@@ -636,7 +701,7 @@ async function discoverBrowserActionCorpusDescriptors(page: Page): Promise<Brows
636701 if ( label && text ( label . textContent ) ) return text ( label . textContent )
637702 return text ( element . textContent )
638703 }
639- const descriptorId = ( kind : string , element : Element ) => `${ kind } :${ selectorFor ( element ) } :${ element . getAttribute ( "name" ) || "" } :${ labelFor ( element ) } `
704+ const descriptorId = ( kind : string , selector : string , element : Element ) => `${ kind } :${ selector } :${ element . getAttribute ( "name" ) || "" } :${ labelFor ( element ) } `
640705 const visible = ( element : Element ) => {
641706 const htmlElement = element as HTMLElement
642707 const style = window . getComputedStyle ( htmlElement )
@@ -649,8 +714,12 @@ async function discoverBrowserActionCorpusDescriptors(page: Page): Promise<Brows
649714 const input = element as HTMLInputElement
650715 const kind = tag === "a" ? "link" : tag === "button" ? "button" : tag === "textarea" ? "textarea" : tag === "select" ? "select" : "input"
651716 const selector = selectorFor ( element )
717+ if ( ! selector ) {
718+ rejected . push ( { kind, tag, label : labelFor ( element ) } )
719+ return
720+ }
652721 const descriptor : BrowserActionCorpusDescriptor = {
653- id : descriptorId ( kind , element ) ,
722+ id : descriptorId ( kind , selector , element ) ,
654723 kind,
655724 selector,
656725 label : labelFor ( element ) ,
@@ -665,7 +734,19 @@ async function discoverBrowserActionCorpusDescriptors(page: Page): Promise<Brows
665734 }
666735 descriptors . push ( descriptor )
667736 } )
668- return descriptors
737+ const diagnostics : Array < { code : string ; message : string ; metadata ?: Record < string , unknown > } > = rejected . slice ( 0 , MAX_REJECTION_DIAGNOSTICS ) . map ( ( item ) => ( {
738+ code : "browser_action_corpus_selector_not_unique" ,
739+ message : "An actionable control was rejected because discovery could not produce a selector resolving uniquely to that element." ,
740+ metadata : item ,
741+ } ) )
742+ if ( rejected . length > MAX_REJECTION_DIAGNOSTICS ) {
743+ diagnostics [ MAX_REJECTION_DIAGNOSTICS - 1 ] = {
744+ code : "browser_action_corpus_selector_rejections_truncated" ,
745+ message : "Additional actionable controls without unique selectors were omitted from diagnostics." ,
746+ metadata : { rejected : rejected . length , retainedDiagnostics : MAX_REJECTION_DIAGNOSTICS - 1 } ,
747+ }
748+ }
749+ return { descriptors, diagnostics }
669750 } )
670751}
671752
0 commit comments