Skip to content

Commit 0d6d0f2

Browse files
DavertMikclaude
andcommitted
refactor(codeceptq): build per-strategy XPath fragments
Replaces the post-hoc regex pre-resolver with strategy-level construction. Each semantic locator (--click/--field/--checkable) is built as a list of XPath branches; doc-wide subqueries (label[@for] resolution, ids by visible text) are evaluated once and inlined as literal predicates instead of sitting nested inside outer per-element predicates that the engine re-executes on every match. Eval loop runs each branch separately and sorts results by source offset to preserve the document-order contract of XPath unions. Github 'Sign up' --click: 9000ms → 264ms (independent of XPath engine — fontoxpath benched the same as xpath@0.0.34 on the original union). All 45 runner tests pass with identical line/snippet output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c5825fc commit 0d6d0f2

1 file changed

Lines changed: 104 additions & 44 deletions

File tree

lib/command/query.js

Lines changed: 104 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/\.\/(option|optgroup)/g, './/$1')
116+
return [Locator.select.byVisibleText(lit).replace(/\.\/(option|optgroup)/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-
/\/\/\*\[@id\]\[normalize-space\(string\(\.\)\)\s*=\s*('[^']*'|"[^"]*")\]\/@id/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, '&apos;')}'`
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

140200
function htmlToDoc(html) {

0 commit comments

Comments
 (0)