Skip to content

Commit 557941d

Browse files
DavertMikclaude
andcommitted
feat(codeceptq): CLI to query HTML with CodeceptJS locators
Adds `codeceptq` — a standalone CLI that takes an HTML stream (stdin or --file) plus a CodeceptJS locator (CSS / XPath / fuzzy / semantic) and prints matched elements with line numbers and outerHTML snippets. Designed to give AI agents a fast feedback loop against `aiTrace`'s per-step HTML snapshots: "would this locator match at step N?" without re-running the test or spawning a browser. - Reuses Locator class for CSS→XPath conversion + semantic builders (--field, --click, --checkable, --select). - Optional context arg scopes matches: `codeceptq 'Save' '.modal' --click`. - Stable output flags: --limit, --snippet (default 500), --full, --json. - Exit codes: 0 match, 1 no match, 2 invalid input/XPath. - formatHtml now uses `inline: []` so every element gets its own line in trace HTML — line numbers map 1:1 to elements for codeceptq output. - 45 runner tests against test/data/checkout.html, github.html, gitlab.html, drag_drop.html assert exact line + snippet for every locator strategy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent af8de03 commit 557941d

5 files changed

Lines changed: 728 additions & 3 deletions

File tree

bin/codeceptq.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/env node
2+
import { Command } from 'commander'
3+
import query from '../lib/command/query.js'
4+
5+
const program = new Command()
6+
7+
program
8+
.name('codeceptq')
9+
.description('Query HTML with CodeceptJS locators (CSS, XPath, fuzzy text, semantic).\n\nReads HTML from stdin or --file and prints matching elements with line numbers.')
10+
.argument('<locator>', 'locator string (CSS, XPath, or text for semantic match)')
11+
.argument('[context]', 'scope locator — restrict matches to descendants of context')
12+
.option('--field', 'treat locator as form field (input/textarea/select)')
13+
.option('--click', 'treat locator as clickable element (link, button, role=button, ...)')
14+
.option('--clickable', 'alias for --click')
15+
.option('--checkable', 'treat locator as checkbox/radio')
16+
.option('--select', 'treat locator as <option> visible text')
17+
.option('--xpath', 'force XPath interpretation')
18+
.option('--css', 'force CSS interpretation')
19+
.option('--file <path>', 'read HTML from file instead of stdin')
20+
.option('--limit <n>', 'cap matches printed', '20')
21+
.option('--snippet <chars>', 'truncate outerHTML per match to N characters', '500')
22+
.option('--full', 'print full outerHTML (no truncation)')
23+
.option('--json', 'output JSON')
24+
.addHelpText(
25+
'after',
26+
`
27+
Examples:
28+
cat trace/0001_page.html | codeceptq './/input'
29+
cat trace/0001_page.html | codeceptq 'Username' --field
30+
cat trace/0001_page.html | codeceptq 'Username' '.form' --field
31+
codeceptq './/button' --file trace/0001_page.html
32+
codeceptq 'Login' --click --file page.html
33+
34+
Exit codes:
35+
0 matches found
36+
1 no matches
37+
2 invalid input or XPath
38+
`,
39+
)
40+
.action(async (locator, context, options) => {
41+
try {
42+
await query(locator, context, options)
43+
} catch (err) {
44+
console.error(`codeceptq: ${err.message}`)
45+
process.exitCode = 2
46+
}
47+
})
48+
49+
program.parseAsync(process.argv)

lib/command/query.js

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import fs from 'fs'
2+
import * as parse5 from 'parse5'
3+
import { DOMImplementation, XMLSerializer } from '@xmldom/xmldom'
4+
import xpath from 'xpath'
5+
import Locator from '../locator.js'
6+
import { xpathLocator } from '../utils.js'
7+
8+
export default async function query(locator, context, options = {}) {
9+
const html = options.file ? fs.readFileSync(options.file, 'utf8') : await readStdin()
10+
11+
if (!html || !html.trim()) {
12+
console.error('codeceptq: no HTML input. Pipe HTML via stdin or use --file <path>.')
13+
process.exitCode = 2
14+
return
15+
}
16+
17+
let xpathExpr
18+
let contextExpr = null
19+
try {
20+
xpathExpr = buildXPath(locator, options)
21+
if (context) contextExpr = buildXPath(context, {})
22+
} catch (err) {
23+
console.error(`codeceptq: cannot build XPath: ${err.message}`)
24+
process.exitCode = 2
25+
return
26+
}
27+
28+
const { doc, source } = htmlToDoc(html)
29+
30+
let nodes
31+
try {
32+
if (contextExpr) {
33+
const ctxNodes = toArray(xpath.select(contextExpr, doc))
34+
const seen = new Set()
35+
nodes = []
36+
for (const ctx of ctxNodes) {
37+
for (const m of toArray(xpath.select(xpathExpr, ctx))) {
38+
if (!seen.has(m)) {
39+
seen.add(m)
40+
nodes.push(m)
41+
}
42+
}
43+
}
44+
} else {
45+
nodes = toArray(xpath.select(xpathExpr, doc))
46+
}
47+
} catch (err) {
48+
console.error(`codeceptq: XPath evaluation failed for "${xpathExpr}": ${err.message}`)
49+
process.exitCode = 2
50+
return
51+
}
52+
53+
const limit = parseInt(options.limit, 10) || 20
54+
const snippetLen = parseInt(options.snippet, 10) || 500
55+
const truncated = nodes.slice(0, limit)
56+
const where = options.file || 'stdin'
57+
58+
if (options.json) {
59+
process.stdout.write(
60+
JSON.stringify(
61+
{
62+
locator,
63+
context: context || null,
64+
xpath: xpathExpr,
65+
contextXPath: contextExpr,
66+
source: where,
67+
total: nodes.length,
68+
shown: truncated.length,
69+
matches: truncated.map(n => ({
70+
line: n.__line ?? null,
71+
snippet: renderSnippet(n, source, snippetLen, options.full),
72+
})),
73+
},
74+
null,
75+
2,
76+
) + '\n',
77+
)
78+
} else {
79+
if (nodes.length === 0) {
80+
console.log(`No matches for ${quote(locator)}${context ? ` within ${quote(context)}` : ''} in ${where}`)
81+
console.log(`(xpath: ${xpathExpr})`)
82+
} else {
83+
const noun = nodes.length === 1 ? 'match' : 'matches'
84+
const more = nodes.length > truncated.length ? ` (showing first ${truncated.length})` : ''
85+
console.log(`${nodes.length} ${noun} for ${quote(locator)}${context ? ` within ${quote(context)}` : ''} in ${where}${more}`)
86+
console.log()
87+
truncated.forEach((node, i) => {
88+
const line = node.__line ?? '?'
89+
console.log(`${i + 1}. Line ${line}`)
90+
const snippet = renderSnippet(node, source, snippetLen, options.full)
91+
snippet.split('\n').forEach(l => console.log(' ' + l))
92+
console.log()
93+
})
94+
}
95+
}
96+
97+
if (nodes.length === 0) process.exitCode = 1
98+
}
99+
100+
function buildXPath(input, options) {
101+
const literal = xpathLocator.literal(input)
102+
if (options.field) return Locator.field.byText(literal)
103+
if (options.click || options.clickable) return Locator.clickable.wide(literal)
104+
if (options.checkable) return Locator.checkable.byText(literal)
105+
if (options.select) {
106+
// Locator.select.byVisibleText is meant to be evaluated within a <select>
107+
// context (`./option`). Rewrite to descendant-of-document for standalone use.
108+
return Locator.select.byVisibleText(literal).replace(/\.\/(option|optgroup)/g, './/$1')
109+
}
110+
111+
if (options.xpath) return new Locator({ xpath: input }).toXPath()
112+
if (options.css) return new Locator({ css: input }).toXPath()
113+
114+
const loc = new Locator(input)
115+
if (loc.type === 'fuzzy') {
116+
return xpathLocator.combine([Locator.clickable.wide(literal), Locator.field.byText(literal)])
117+
}
118+
return loc.toXPath()
119+
}
120+
121+
function htmlToDoc(html) {
122+
const p5doc = parse5.parse(html, { sourceCodeLocationInfo: true })
123+
const impl = new DOMImplementation()
124+
const doc = impl.createDocument(null, null, null)
125+
walkParse5(p5doc, doc, doc)
126+
return { doc, source: html }
127+
}
128+
129+
function walkParse5(p5node, xmlParent, xmlDoc) {
130+
for (const child of p5node.childNodes || []) {
131+
const name = child.nodeName
132+
if (name === '#text') {
133+
if (child.value != null) {
134+
const t = xmlDoc.createTextNode(child.value)
135+
if (child.sourceCodeLocation) t.__line = child.sourceCodeLocation.startLine
136+
xmlParent.appendChild(t)
137+
}
138+
} else if (name === '#comment') {
139+
try {
140+
xmlParent.appendChild(xmlDoc.createComment(child.data || ''))
141+
} catch {
142+
// ignore comments xmldom rejects
143+
}
144+
} else if (name === '#documentType') {
145+
// skip doctype
146+
} else {
147+
const tagName = child.tagName || name
148+
let el
149+
try {
150+
el = xmlDoc.createElement(tagName)
151+
} catch {
152+
continue
153+
}
154+
for (const attr of child.attrs || []) {
155+
try {
156+
el.setAttribute(attr.name, attr.value)
157+
} catch {
158+
// ignore attrs xmldom rejects (namespaces, invalid names)
159+
}
160+
}
161+
const loc = child.sourceCodeLocation
162+
if (loc) {
163+
el.__line = loc.startLine
164+
el.__startOffset = loc.startOffset
165+
el.__endOffset = loc.endOffset
166+
el.__startTagEndOffset = loc.startTag ? loc.startTag.endOffset : loc.endOffset
167+
}
168+
xmlParent.appendChild(el)
169+
walkParse5(child, el, xmlDoc)
170+
}
171+
}
172+
}
173+
174+
function renderSnippet(node, source, snippetLen, full) {
175+
if (typeof node.__startOffset !== 'number') {
176+
try {
177+
return new XMLSerializer().serializeToString(node)
178+
} catch {
179+
return `<${node.nodeName || '?'}>`
180+
}
181+
}
182+
const start = node.__startOffset
183+
const end = node.__endOffset ?? start
184+
if (full) return source.slice(start, end)
185+
186+
const tagEnd = node.__startTagEndOffset ?? end
187+
const openingTag = source.slice(start, tagEnd)
188+
if (end <= tagEnd) return openingTag
189+
190+
const totalLen = end - start
191+
if (totalLen <= snippetLen) return source.slice(start, end)
192+
193+
const remaining = Math.max(0, snippetLen - openingTag.length)
194+
if (remaining < 20) return openingTag + ' …'
195+
return openingTag + source.slice(tagEnd, tagEnd + remaining) + ' …'
196+
}
197+
198+
function readStdin() {
199+
return new Promise((resolve, reject) => {
200+
if (process.stdin.isTTY) {
201+
resolve('')
202+
return
203+
}
204+
let data = ''
205+
process.stdin.setEncoding('utf8')
206+
process.stdin.on('data', chunk => (data += chunk))
207+
process.stdin.on('end', () => resolve(data))
208+
process.stdin.on('error', reject)
209+
})
210+
}
211+
212+
function toArray(v) {
213+
if (Array.isArray(v)) return v
214+
if (v == null || v === '' || typeof v === 'boolean' || typeof v === 'number') return []
215+
return [v]
216+
}
217+
218+
function quote(s) {
219+
return `'${String(s).replace(/'/g, "\\'")}'`
220+
}

lib/html.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,9 @@ async function formatHtml(html) {
323323
wrap_line_length: 0,
324324
preserve_newlines: false,
325325
end_with_newline: false,
326+
// Force every element onto its own line so line numbers in trace HTML
327+
// map 1:1 to elements (consumed by codeceptq for AI/agent debugging).
328+
inline: [],
326329
})
327330
} catch (e) {
328331
return processed

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
4646
},
4747
"bin": {
4848
"codeceptjs": "./bin/codecept.js",
49-
"codeceptjs-mcp": "./bin/mcp-server.js"
49+
"codeceptjs-mcp": "./bin/mcp-server.js",
50+
"codeceptq": "./bin/codeceptq.js"
5051
},
5152
"repository": "codeceptjs/CodeceptJS",
5253
"scripts": {
@@ -131,6 +132,7 @@
131132
"resq": "1.11.0",
132133
"sprintf-js": "1.1.3",
133134
"uuid": "11.1.0",
135+
"xpath": "0.0.34",
134136
"zod": "^4.1.11"
135137
},
136138
"optionalDependencies": {
@@ -192,8 +194,7 @@
192194
"typescript": "5.9.3",
193195
"wdio-docker-service": "3.2.1",
194196
"webdriverio": "9.23.0",
195-
"xml2js": "0.6.2",
196-
"xpath": "0.0.34"
197+
"xml2js": "0.6.2"
197198
},
198199
"peerDependencies": {
199200
"tsx": "^4.0.0"

0 commit comments

Comments
 (0)