@@ -14,40 +14,42 @@ export default async function query(locator, context, options = {}) {
1414 return
1515 }
1616
17- let xpathExpr
18- let contextExpr = null
17+ const { doc, source } = htmlToDoc ( html )
18+
19+ let xpathBranches
20+ let contextBranches = null
1921 try {
20- xpathExpr = buildXPath ( locator , options )
21- if ( context ) contextExpr = buildXPath ( context , { } )
22+ xpathBranches = buildXPaths ( locator , options , doc )
23+ if ( context ) contextBranches = buildXPaths ( context , { } , doc )
2224 } catch ( err ) {
2325 console . error ( `codeceptq: cannot build XPath: ${ err . message } ` )
2426 process . exitCode = 2
2527 return
2628 }
2729
28- const { doc, source } = htmlToDoc ( html )
29- xpathExpr = preresolveIdLookups ( xpathExpr , doc )
30- if ( contextExpr ) contextExpr = preresolveIdLookups ( contextExpr , doc )
30+ const xpathExpr = xpathBranches . join ( ' | ' )
31+ const contextExpr = contextBranches ? contextBranches . join ( ' | ' ) : null
3132
3233 let nodes
3334 try {
34- if ( contextExpr ) {
35- const ctxNodes = toArray ( xpath . select ( contextExpr , doc ) )
36- const seen = new Set ( )
37- nodes = [ ]
38- for ( const ctx of ctxNodes ) {
39- for ( const m of toArray ( xpath . select ( xpathExpr , ctx ) ) ) {
35+ const contexts = contextBranches ? collectNodes ( contextBranches , doc ) : [ doc ]
36+ const seen = new Set ( )
37+ nodes = [ ]
38+ for ( const ctx of contexts ) {
39+ for ( const branch of xpathBranches ) {
40+ for ( const m of toArray ( xpath . select ( branch , ctx ) ) ) {
4041 if ( ! seen . has ( m ) ) {
4142 seen . add ( m )
4243 nodes . push ( m )
4344 }
4445 }
4546 }
46- } else {
47- nodes = toArray ( xpath . select ( xpathExpr , doc ) )
4847 }
48+ // XPath unions return nodes in document order. We evaluate branches
49+ // separately so re-sort by source position to match that contract.
50+ nodes . sort ( ( a , b ) => ( a . __startOffset ?? 0 ) - ( b . __startOffset ?? 0 ) )
4951 } catch ( err ) {
50- console . error ( `codeceptq: XPath evaluation failed for " ${ xpathExpr } " : ${ err . message } ` )
52+ console . error ( `codeceptq: XPath evaluation failed: ${ err . message } ` )
5153 process . exitCode = 2
5254 return
5355 }
@@ -99,42 +101,100 @@ export default async function query(locator, context, options = {}) {
99101 if ( nodes . length === 0 ) process . exitCode = 1
100102}
101103
102- function buildXPath ( input , options ) {
103- const literal = xpathLocator . literal ( input )
104- if ( options . field ) return Locator . field . byText ( literal )
105- if ( options . click || options . clickable ) return Locator . clickable . wide ( literal )
106- if ( options . checkable ) return Locator . checkable . byText ( literal )
104+ // Returns the array of XPath branches for a given input + options.
105+ // The semantic locators (--click/--field/--checkable) bake doc-wide subqueries
106+ // (label[@for] resolution, id-by-visible-text) into literal values so the
107+ // evaluator sees flat predicates. Without this, xpath npm re-evaluates each
108+ // inner //path per outer node — O(N²) on any non-trivial document.
109+ function buildXPaths ( input , options , doc ) {
110+ const lit = xpathLocator . literal ( input )
111+
112+ if ( options . field ) return fieldByText ( input , doc )
113+ if ( options . click || options . clickable ) return clickableWide ( input , doc )
114+ if ( options . checkable ) return checkableByText ( input , doc )
107115 if ( options . select ) {
108- // Locator.select.byVisibleText is meant to be evaluated within a <select>
109- // context (`./option`). Rewrite to descendant-of-document for standalone use.
110- return Locator . select . byVisibleText ( literal ) . replace ( / \. \/ ( o p t i o n | o p t g r o u p ) / g, './/$1' )
116+ return [ Locator . select . byVisibleText ( lit ) . replace ( / \. \/ ( o p t i o n | o p t g r o u p ) / g, './/$1' ) ]
111117 }
112118
113- if ( options . xpath ) return new Locator ( { xpath : input } ) . toXPath ( )
114- if ( options . css ) return new Locator ( { css : input } ) . toXPath ( )
119+ if ( options . xpath ) return [ new Locator ( { xpath : input } ) . toXPath ( ) ]
120+ if ( options . css ) return [ new Locator ( { css : input } ) . toXPath ( ) ]
115121
116122 const loc = new Locator ( input )
117- if ( loc . type === 'fuzzy' ) {
118- return xpathLocator . combine ( [ Locator . clickable . wide ( literal ) , Locator . field . byText ( literal ) ] )
119- }
120- return loc . toXPath ( )
123+ if ( loc . type === 'fuzzy' ) return [ ...clickableWide ( input , doc ) , ...fieldByText ( input , doc ) ]
124+ return [ loc . toXPath ( ) ]
125+ }
126+
127+ function clickableWide ( text , doc ) {
128+ const lit = xpathLocator . literal ( text )
129+ const labelledIds = idsByVisibleText ( doc , text )
130+ const ariaLabelledBy = labelledIds . length ? `.//*[${ anyAttrEquals ( '@aria-labelledby' , labelledIds ) } ]` : null
131+
132+ return [
133+ `.//a[./@href][((contains(normalize-space(string(.)), ${ lit } )) or .//img[contains(./@alt, ${ lit } )])]` ,
134+ `.//input[./@type = 'submit' or ./@type = 'image' or ./@type = 'button'][contains(./@value, ${ lit } )]` ,
135+ `.//input[./@type = 'image'][contains(./@alt, ${ lit } )]` ,
136+ `.//button[contains(normalize-space(string(.)), ${ lit } )]` ,
137+ `.//label[contains(normalize-space(string(.)), ${ lit } )]` ,
138+ `.//input[./@type = 'submit' or ./@type = 'image' or ./@type = 'button'][./@name = ${ lit } ]` ,
139+ `.//button[./@name = ${ lit } ]` ,
140+ `.//*[@aria-label = ${ lit } ]` ,
141+ `.//*[@title = ${ lit } ]` ,
142+ ariaLabelledBy ,
143+ `.//*[@role='button'][normalize-space(.)=${ lit } ]` ,
144+ `.//*[@role='tab' or @role='link' or @role='menuitem' or @role='menuitemcheckbox' or @role='menuitemradio' or @role='option' or @role='treeitem'][contains(normalize-space(string(.)), ${ lit } )]` ,
145+ ] . filter ( Boolean )
146+ }
147+
148+ function fieldByText ( text , doc ) {
149+ const lit = xpathLocator . literal ( text )
150+ const fieldGuard = `[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]`
151+
152+ const labelFors = labelForsByContainsText ( doc , text )
153+ const idMatch = labelFors . length ? ` or ${ anyAttrEquals ( './@id' , labelFors ) } ` : ''
154+
155+ return [
156+ `.//*${ fieldGuard } [((./@name = ${ lit } ) or ./@placeholder = ${ lit } ${ idMatch } )]` ,
157+ `.//label[contains(normalize-space(string(.)), ${ lit } )]//.//*${ fieldGuard } ` ,
158+ ]
159+ }
160+
161+ function checkableByText ( text , doc ) {
162+ const lit = xpathLocator . literal ( text )
163+ const labelFors = labelForsByContainsText ( doc , text )
164+ const idMatch = labelFors . length ? `${ anyAttrEquals ( '@id' , labelFors ) } or ` : ''
165+
166+ return [
167+ `.//input[@type = 'checkbox' or @type = 'radio'][${ idMatch } @placeholder = ${ lit } ]` ,
168+ `.//label[contains(normalize-space(string(.)), ${ lit } )]//input[@type = 'radio' or @type = 'checkbox']` ,
169+ ]
170+ }
171+
172+ function idsByVisibleText ( doc , text ) {
173+ const lit = xpathLocator . literal ( text )
174+ return toArray ( xpath . select ( `//*[@id][normalize-space(string(.)) = ${ lit } ]/@id` , doc ) ) . map ( a => a . value || '' )
121175}
122176
123- // `Locator.clickable.wide` and `Locator.field.labelContains` emit
124- // `[@aria-labelledby = //*[@id][normalize-space(string(.)) = '...']/@id]`.
125- // xpath@0.0.34 re-runs the inner //* scan once per outer match — O(N²) on
126- // any non-trivial document. Pre-resolve the inner subquery to a literal so
127- // the engine sees a flat attribute compare. Dropped a 2k-line github fixture
128- // from 8500ms to ~50ms.
129- function preresolveIdLookups ( expr , doc ) {
130- return expr . replace (
131- / \/ \/ \* \[ @ i d \] \[ n o r m a l i z e - s p a c e \( s t r i n g \( \. \) \) \s * = \s * ( ' [ ^ ' ] * ' | " [ ^ " ] * " ) \] \/ @ i d / g,
132- sub => {
133- const ids = toArray ( xpath . select ( sub , doc ) ) . map ( a => a . value || '' )
134- if ( ids . length === 0 ) return "'__codeceptq_no_match__'"
135- return `'${ ids [ 0 ] . replace ( / ' / g, ''' ) } '`
177+ function labelForsByContainsText ( doc , text ) {
178+ const lit = xpathLocator . literal ( text )
179+ return toArray ( xpath . select ( `//label[@for][contains(normalize-space(string(.)), ${ lit } )]/@for` , doc ) ) . map ( a => a . value || '' )
180+ }
181+
182+ function anyAttrEquals ( lhs , values ) {
183+ return values . map ( v => `${ lhs } = ${ xpathLocator . literal ( v ) } ` ) . join ( ' or ' )
184+ }
185+
186+ function collectNodes ( branches , ctx ) {
187+ const seen = new Set ( )
188+ const out = [ ]
189+ for ( const expr of branches ) {
190+ for ( const n of toArray ( xpath . select ( expr , ctx ) ) ) {
191+ if ( ! seen . has ( n ) ) {
192+ seen . add ( n )
193+ out . push ( n )
194+ }
136195 }
137- )
196+ }
197+ return out
138198}
139199
140200function htmlToDoc ( html ) {
0 commit comments