Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions bin/codeceptq.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env node
import { Command } from 'commander'
import query from '../lib/command/query.js'

const program = new Command()

program
.name('codeceptq')
.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.')
.argument('<locator>', 'locator string (CSS, XPath, or text for semantic match)')
.argument('[context]', 'scope locator — restrict matches to descendants of context')
.option('--field', 'treat locator as form field (input/textarea/select)')
.option('--click', 'treat locator as clickable element (link, button, role=button, ...)')
.option('--clickable', 'alias for --click')
.option('--checkable', 'treat locator as checkbox/radio')
.option('--select', 'treat locator as <option> visible text')
.option('--xpath', 'force XPath interpretation')
.option('--css', 'force CSS interpretation')
.option('--file <path>', 'read HTML from file instead of stdin')
.option('--limit <n>', 'cap matches printed', '20')
.option('--snippet <chars>', 'truncate outerHTML per match to N characters', '500')
.option('--full', 'print full outerHTML (no truncation)')
.option('--json', 'output JSON')
.addHelpText(
'after',
`
Examples:
cat trace/0001_page.html | codeceptq './/input'
cat trace/0001_page.html | codeceptq 'Username' --field
cat trace/0001_page.html | codeceptq 'Username' '.form' --field
codeceptq './/button' --file trace/0001_page.html
codeceptq 'Login' --click --file page.html

Exit codes:
0 matches found
1 no matches
2 invalid input or XPath
`,
)
.action(async (locator, context, options) => {
try {
await query(locator, context, options)
} catch (err) {
console.error(`codeceptq: ${err.message}`)
process.exitCode = 2
}
})

program.parseAsync(process.argv)
3 changes: 3 additions & 0 deletions bin/mcp-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ function collectRunCompletion(errorMessage) {
}
return {
status: error ? 'failed' : 'completed',
aiTraceDir: currentAiTraceDir,
reporterJson: { stats, tests: results },
error,
aiTraceHint: aiTraceHint(),
Expand All @@ -475,11 +476,13 @@ function pausedPayload() {
return {
status: 'paused',
file: pendingTestFile,
aiTraceDir: currentAiTraceDir,
pausedAfter: pendingStepInfo,
suggestions: [
'Call snapshot to capture URL/HTML/ARIA/screenshot/console/storage at this point',
'Call run_code to inspect or manipulate state (e.g. return await I.grabText("h1"))',
'Call continue to release the pause and let the test run the next step (or finish)',
'Query a saved step snapshot offline: codeceptq <locator> --file <aiTraceDir>/<NNNN>_<step>_page.html',
],
}
}
Expand Down
220 changes: 220 additions & 0 deletions lib/command/query.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import fs from 'fs'
import * as parse5 from 'parse5'
import { DOMImplementation, XMLSerializer } from '@xmldom/xmldom'
import xpath from 'xpath'
import Locator from '../locator.js'
import { xpathLocator } from '../utils.js'

export default async function query(locator, context, options = {}) {
const html = options.file ? fs.readFileSync(options.file, 'utf8') : await readStdin()

if (!html || !html.trim()) {
console.error('codeceptq: no HTML input. Pipe HTML via stdin or use --file <path>.')
process.exitCode = 2
return
}

let xpathExpr
let contextExpr = null
try {
xpathExpr = buildXPath(locator, options)
if (context) contextExpr = buildXPath(context, {})
} catch (err) {
console.error(`codeceptq: cannot build XPath: ${err.message}`)
process.exitCode = 2
return
}

const { doc, source } = htmlToDoc(html)

let nodes
try {
if (contextExpr) {
const ctxNodes = toArray(xpath.select(contextExpr, doc))
const seen = new Set()
nodes = []
for (const ctx of ctxNodes) {
for (const m of toArray(xpath.select(xpathExpr, ctx))) {
if (!seen.has(m)) {
seen.add(m)
nodes.push(m)
}
}
}
} else {
nodes = toArray(xpath.select(xpathExpr, doc))
}
} catch (err) {
console.error(`codeceptq: XPath evaluation failed for "${xpathExpr}": ${err.message}`)
process.exitCode = 2
return
}

const limit = parseInt(options.limit, 10) || 20
const snippetLen = parseInt(options.snippet, 10) || 500
const truncated = nodes.slice(0, limit)
const where = options.file || 'stdin'

if (options.json) {
process.stdout.write(
JSON.stringify(
{
locator,
context: context || null,
xpath: xpathExpr,
contextXPath: contextExpr,
source: where,
total: nodes.length,
shown: truncated.length,
matches: truncated.map(n => ({
line: n.__line ?? null,
snippet: renderSnippet(n, source, snippetLen, options.full),
})),
},
null,
2,
) + '\n',
)
} else {
if (nodes.length === 0) {
console.log(`No matches for ${quote(locator)}${context ? ` within ${quote(context)}` : ''} in ${where}`)
console.log(`(xpath: ${xpathExpr})`)
} else {
const noun = nodes.length === 1 ? 'match' : 'matches'
const more = nodes.length > truncated.length ? ` (showing first ${truncated.length})` : ''
console.log(`${nodes.length} ${noun} for ${quote(locator)}${context ? ` within ${quote(context)}` : ''} in ${where}${more}`)
console.log()
truncated.forEach((node, i) => {
const line = node.__line ?? '?'
console.log(`${i + 1}. Line ${line}`)
const snippet = renderSnippet(node, source, snippetLen, options.full)
snippet.split('\n').forEach(l => console.log(' ' + l))
console.log()
})
}
}

if (nodes.length === 0) process.exitCode = 1
}

function buildXPath(input, options) {
const literal = xpathLocator.literal(input)
if (options.field) return Locator.field.byText(literal)
if (options.click || options.clickable) return Locator.clickable.wide(literal)
if (options.checkable) return Locator.checkable.byText(literal)
if (options.select) {
// Locator.select.byVisibleText is meant to be evaluated within a <select>
// context (`./option`). Rewrite to descendant-of-document for standalone use.
return Locator.select.byVisibleText(literal).replace(/\.\/(option|optgroup)/g, './/$1')
}

if (options.xpath) return new Locator({ xpath: input }).toXPath()
if (options.css) return new Locator({ css: input }).toXPath()

const loc = new Locator(input)
if (loc.type === 'fuzzy') {
return xpathLocator.combine([Locator.clickable.wide(literal), Locator.field.byText(literal)])
}
return loc.toXPath()
}

function htmlToDoc(html) {
const p5doc = parse5.parse(html, { sourceCodeLocationInfo: true })
const impl = new DOMImplementation()
const doc = impl.createDocument(null, null, null)
walkParse5(p5doc, doc, doc)
return { doc, source: html }
}

function walkParse5(p5node, xmlParent, xmlDoc) {
for (const child of p5node.childNodes || []) {
const name = child.nodeName
if (name === '#text') {
if (child.value != null) {
const t = xmlDoc.createTextNode(child.value)
if (child.sourceCodeLocation) t.__line = child.sourceCodeLocation.startLine
xmlParent.appendChild(t)
}
} else if (name === '#comment') {
try {
xmlParent.appendChild(xmlDoc.createComment(child.data || ''))
} catch {
// ignore comments xmldom rejects
}
} else if (name === '#documentType') {
// skip doctype
} else {
const tagName = child.tagName || name
let el
try {
el = xmlDoc.createElement(tagName)
} catch {
continue
}
for (const attr of child.attrs || []) {
try {
el.setAttribute(attr.name, attr.value)
} catch {
// ignore attrs xmldom rejects (namespaces, invalid names)
}
}
const loc = child.sourceCodeLocation
if (loc) {
el.__line = loc.startLine
el.__startOffset = loc.startOffset
el.__endOffset = loc.endOffset
el.__startTagEndOffset = loc.startTag ? loc.startTag.endOffset : loc.endOffset
}
xmlParent.appendChild(el)
walkParse5(child, el, xmlDoc)
}
}
}

function renderSnippet(node, source, snippetLen, full) {
if (typeof node.__startOffset !== 'number') {
try {
return new XMLSerializer().serializeToString(node)
} catch {
return `<${node.nodeName || '?'}>`
}
}
const start = node.__startOffset
const end = node.__endOffset ?? start
if (full) return source.slice(start, end)

const tagEnd = node.__startTagEndOffset ?? end
const openingTag = source.slice(start, tagEnd)
if (end <= tagEnd) return openingTag

const totalLen = end - start
if (totalLen <= snippetLen) return source.slice(start, end)

const remaining = Math.max(0, snippetLen - openingTag.length)
if (remaining < 20) return openingTag + ' …'
return openingTag + source.slice(tagEnd, tagEnd + remaining) + ' …'
}

function readStdin() {
return new Promise((resolve, reject) => {
if (process.stdin.isTTY) {
resolve('')
return
}
let data = ''
process.stdin.setEncoding('utf8')
process.stdin.on('data', chunk => (data += chunk))
process.stdin.on('end', () => resolve(data))
process.stdin.on('error', reject)
})
}

function toArray(v) {
if (Array.isArray(v)) return v
if (v == null || v === '' || typeof v === 'boolean' || typeof v === 'number') return []
return [v]
}

function quote(s) {
return `'${String(s).replace(/'/g, "\\'")}'`
}
3 changes: 3 additions & 0 deletions lib/html.js
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,9 @@ async function formatHtml(html) {
wrap_line_length: 0,
preserve_newlines: false,
end_with_newline: false,
// Force every element onto its own line so line numbers in trace HTML
// map 1:1 to elements (consumed by codeceptq for AI/agent debugging).
inline: [],
})
} catch (e) {
return processed
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
},
"bin": {
"codeceptjs": "./bin/codecept.js",
"codeceptjs-mcp": "./bin/mcp-server.js"
"codeceptjs-mcp": "./bin/mcp-server.js",
"codeceptq": "./bin/codeceptq.js"
},
"repository": "codeceptjs/CodeceptJS",
"scripts": {
Expand Down Expand Up @@ -132,6 +133,7 @@
"resq": "1.11.0",
"sprintf-js": "1.1.3",
"uuid": "11.1.0",
"xpath": "0.0.34",
"zod": "^4.1.11"
},
"optionalDependencies": {
Expand Down Expand Up @@ -193,8 +195,7 @@
"typescript": "5.9.3",
"wdio-docker-service": "3.2.1",
"webdriverio": "9.23.0",
"xml2js": "0.6.2",
"xpath": "0.0.34"
"xml2js": "0.6.2"
},
"peerDependencies": {
"tsx": "^4.0.0"
Expand Down
Loading
Loading