Skip to content

Commit 326b27c

Browse files
DavertMikDavertMikclaude
authored
feat(helpers): acting CDP helpers, HelperCapabilities type, ARIA snapshots for CDP and Puppeteer (#5749)
* feat(helpers): acting CDP helpers, HelperCapabilities, ARIA snapshots for CDP and Puppeteer - Add Obscura and Lightpanda to Container.STANDARD_ACTING_HELPERS so aiTrace, pageInfo, Decision, screenshot, pause and heal find them - Fix aiTrace crashing container creation via missing output.warn - Add CodeceptJS.HelperCapabilities type (screenshot, video, trace, aria + CDP-detected layout/xpath/innerText); Playwright, Puppeteer, WebDriver and CDPBrowser declare this.capabilities - Add grabAriaSnapshot to CDPBrowser (Accessibility.getFullAXTree) and Puppeteer (page.accessibility.snapshot), rendered in Playwright's ARIA snapshot format by lib/aria.js - Skip screenshots / ARIA in screenshot plugin, aiTrace and Decision when the helper declares the capability false - CDPBrowser screenshots resolve paths via screenshotOutputFolder so absolute paths from aiTrace work Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(webapi): cover grabAriaSnapshot across helpers; honor elementIndex in CDP snapshot Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(aria): render accessibility trees as-is instead of mimicking Playwright Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: DavertMik <davert@testomat.io> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 46d61bd commit 326b27c

17 files changed

Lines changed: 214 additions & 24 deletions

File tree

‎lib/aria.js‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,4 +257,39 @@ function diffAriaSnapshots(previous, current) {
257257
return formatDiff(added, removed)
258258
}
259259

260-
export { diffAriaSnapshots, compactAriaSnapshot }
260+
// ─────────────────────────────────────────────────────────────────
261+
// Build: accessibility tree → YAML snapshot
262+
// ─────────────────────────────────────────────────────────────────
263+
264+
const HIDDEN_ROLES = new Set(['RootWebArea', 'generic', 'none', 'LabelText', 'InlineTextBox', 'LineBreak', 'ListMarker'])
265+
266+
function renderAXTree(node, depth = 0, parentName = '') {
267+
if (!node) return []
268+
const indent = ' '.repeat(depth)
269+
const name = (node.name || '').trim()
270+
if (node.role === 'StaticText') return name && name !== parentName ? [`${indent}- text: ${JSON.stringify(name)}`] : []
271+
const hidden = HIDDEN_ROLES.has(node.role)
272+
const children = (node.children || []).flatMap(child => renderAXTree(child, hidden ? depth : depth + 1, hidden ? parentName : name))
273+
if (hidden) return children
274+
const line = `${indent}- ${node.role}${name ? ` ${JSON.stringify(name)}` : ''}`
275+
return [children.length ? `${line}:` : line, ...children]
276+
}
277+
278+
// Nested tree shaped like Puppeteer's SerializedAXNode: { role, name, children }
279+
function ariaSnapshotFromTree(root) {
280+
return renderAXTree(root).join('\n')
281+
}
282+
283+
// Flat `nodes` list from CDP `Accessibility.getFullAXTree`, rooted at a DOM node
284+
function ariaSnapshotFromAXNodes(axNodes, backendNodeId) {
285+
const byId = new Map(axNodes.map(node => [node.nodeId, node]))
286+
const toTree = node => ({
287+
role: node.ignored ? 'none' : node.role?.value,
288+
name: node.name?.value,
289+
children: (node.childIds || []).map(id => byId.get(id)).filter(Boolean).map(toTree),
290+
})
291+
const root = axNodes.find(node => node.backendDOMNodeId === backendNodeId)
292+
return root ? ariaSnapshotFromTree(toTree(root)) : ''
293+
}
294+
295+
export { diffAriaSnapshots, compactAriaSnapshot, ariaSnapshotFromTree, ariaSnapshotFromAXNodes }

‎lib/container.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ class Container {
6161
*
6262
*/
6363
static get STANDARD_ACTING_HELPERS() {
64-
return ['Playwright', 'WebDriver', 'Puppeteer', 'Appium']
64+
return ['Playwright', 'WebDriver', 'Puppeteer', 'Appium', 'Obscura', 'Lightpanda']
6565
}
6666
/**
6767
* Create container with all required helpers and support objects

‎lib/helper/CDPBrowser.js‎

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ import CDPConnection from './extras/CDPConnection.js'
88
import installCodeceptClient from './clientscripts/cdpBrowserClient.js'
99
import xpathPolyfillSource from './clientscripts/xpathPolyfill.js'
1010
import layoutProbe from './clientscripts/layoutProbe.js'
11+
import { ariaSnapshotFromAXNodes } from '../aria.js'
1112
import Locator from '../locator.js'
1213
import output from '../output.js'
1314
import store from '../store.js'
14-
import { xpathLocator, normalizePath, resolveUrl, toCamelCase, convertCssPropertiesToCamelCase, normalizeSpacesInString, fileExists, base64EncodeFile, getMimeType, isFile, isWindows } from '../utils.js'
15+
import { xpathLocator, normalizePath, resolveUrl, toCamelCase, convertCssPropertiesToCamelCase, normalizeSpacesInString, fileExists, base64EncodeFile, getMimeType, isFile, isWindows, screenshotOutputFolder } from '../utils.js'
1516
import ElementNotFound from './errors/ElementNotFound.js'
1617
import MultipleElementsFound from './errors/MultipleElementsFound.js'
1718
import { includes as stringIncludes } from '../assert/include.js'
@@ -38,7 +39,7 @@ import { assembleApng, isPng } from './extras/apngAssembler.js'
3839
* @prop {object} [headers={}] - headers sent when connecting to the endpoint, e.g. for authenticated remote browsers.
3940
* @prop {string} [input=auto] - how actions are dispatched: `cdp` (real mouse and keyboard events) or `synthetic` (DOM events). `auto` picks `cdp` when the browser renders layout.
4041
* @prop {string|boolean} [xpathPolyfill=auto] - inject an XPath polyfill for browsers with incomplete XPath support. `auto` detects when it is needed.
41-
* @prop {object} [capabilities={}] - browser capabilities (`layout`, `xpath`, `screenshot`, `innerText`) to use instead of detecting them at runtime.
42+
* @prop {object} [capabilities={}] - browser capabilities (`layout`, `xpath`, `screenshot`, `video`, `innerText`, `aria`) to use instead of detecting them at runtime.
4243
* @prop {number} [waitForTimeout=5] - default timeout for wait* actions, in seconds.
4344
* @prop {number} [waitForAction] - fixed delay after each action, in milliseconds. When unset, actions wait only for the navigation they trigger.
4445
* @prop {number} [pollInterval=25] - interval between checks while waiting, in milliseconds.
@@ -137,7 +138,8 @@ class CDPBrowser extends Helper {
137138
this.serverError = null
138139
this.binaryPath = null
139140
this._selfManagedResolved = false
140-
this.capabilities = { layout: null, xpath: null, screenshot: null, innerText: null, ...this.options.capabilities }
141+
/** @type {CodeceptJS.HelperCapabilities} */
142+
this.capabilities = { screenshot: null, video: null, trace: false, aria: true, layout: null, xpath: null, innerText: null, ...this.options.capabilities }
141143
this.withinCandidates = null
142144
this.requests = []
143145
this.recording = false
@@ -615,6 +617,9 @@ class CDPBrowser extends Helper {
615617
if (this.capabilities.screenshot === null) {
616618
this.capabilities.screenshot = this.capabilities.layout === 'real'
617619
}
620+
if (this.capabilities.video === null) {
621+
this.capabilities.video = this.capabilities.screenshot
622+
}
618623
if (this.options.input === 'auto') {
619624
this.options.input = this.capabilities.layout === 'real' ? 'cdp' : 'synthetic'
620625
}
@@ -754,6 +759,32 @@ class CDPBrowser extends Helper {
754759
return this._evaluate('document.documentElement.outerHTML')
755760
}
756761

762+
/**
763+
* Retrieves the ARIA snapshot of the page, or of an element, built from the browser accessibility tree.
764+
* The format matches Playwright's [ARIA snapshots](https://playwright.dev/docs/aria-snapshots).
765+
*
766+
* ```js
767+
* const snapshot = await I.grabAriaSnapshot();
768+
* const formSnapshot = await I.grabAriaSnapshot('#login-form');
769+
* ```
770+
*
771+
* @param {string|object} [locator='//body'] element located by CSS|XPath|strict locator. Defaults to body element.
772+
* @return {Promise<string>} YAML representation of the accessibility tree
773+
*/
774+
async grabAriaSnapshot(locator = '//body') {
775+
const candidates = this._candidates(locator)
776+
const { found } = await this._run(candidates, 'count')
777+
if (!found) throw new ElementNotFound(locator)
778+
const scope = this.withinCandidates ? [this.withinCandidates] : null
779+
const expression = `window.__codecept.run(${JSON.stringify(candidates)}, 'element', null, ${JSON.stringify(scope)}, ${JSON.stringify(this._selectionDescriptor())}).result`
780+
const { result } = await this.cdp.send('Runtime.evaluate', { expression }, this.sessionId)
781+
const { node } = await this.cdp.send('DOM.describeNode', { objectId: result.objectId }, this.sessionId)
782+
const { nodes } = await this.cdp.send('Accessibility.getFullAXTree', {}, this.sessionId)
783+
const snapshot = ariaSnapshotFromAXNodes(nodes, node.backendNodeId)
784+
this.debugSection('Aria Snapshot', `${snapshot.split('\n').length} lines`)
785+
return snapshot
786+
}
787+
757788
_candidates(locator, kind = 'element') {
758789
locator = new Locator(locator)
759790
if (locator.isShadow()) {
@@ -1368,9 +1399,8 @@ class CDPBrowser extends Helper {
13681399
this.debug('saveScreenshot skipped: this browser has no rendering engine')
13691400
return
13701401
}
1371-
const outputDir = global.output_dir || '.'
13721402
const res = await this.cdp.send('Page.captureScreenshot', { format: 'png' }, this.sessionId)
1373-
fs.writeFileSync(path.join(outputDir, fileName), Buffer.from(res.data, 'base64'))
1403+
fs.writeFileSync(screenshotOutputFolder(fileName), Buffer.from(res.data, 'base64'))
13741404
}
13751405

13761406
/**
@@ -1383,10 +1413,9 @@ class CDPBrowser extends Helper {
13831413
}
13841414
const res = await this._run(this._candidates(locator), 'rect')
13851415
if (!res.found) throw new ElementNotFound(locator)
1386-
const outputDir = global.output_dir || '.'
13871416
const { x, y, width, height } = res.result
13881417
const shot = await this.cdp.send('Page.captureScreenshot', { format: 'png', clip: { x, y, width, height, scale: 1 } }, this.sessionId)
1389-
fs.writeFileSync(path.join(outputDir, fileName), Buffer.from(shot.data, 'base64'))
1418+
fs.writeFileSync(screenshotOutputFolder(fileName), Buffer.from(shot.data, 'base64'))
13901419
}
13911420

13921421
/**

‎lib/helper/Decision.js‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ class Decision extends Helper {
221221
title: await helper.grabTitle(),
222222
}
223223

224-
if (helper.grabAriaSnapshot) {
224+
if (helper.grabAriaSnapshot && !(helper.capabilities && helper.capabilities.aria === false)) {
225225
state.aria = compactAriaSnapshot(await helper.grabAriaSnapshot()).slice(0, this.options.maxLength)
226226
return state
227227
}
@@ -244,7 +244,7 @@ class Decision extends Helper {
244244

245245
_actingHelper() {
246246
const helper = pickActingHelper(Container.helpers())
247-
if (!helper) throw new Error('Decision helper requires a web helper: Playwright, Puppeteer or WebDriver')
247+
if (!helper) throw new Error('Decision helper requires a web helper: Playwright, Puppeteer, WebDriver, Obscura or Lightpanda')
248248
return helper
249249
}
250250
}

‎lib/helper/Playwright.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,8 @@ class Playwright extends Helper {
340340
super(config)
341341

342342
// playwright will be loaded dynamically in _init method
343+
/** @type {CodeceptJS.HelperCapabilities} */
344+
this.capabilities = { screenshot: true, video: true, trace: true, aria: true }
343345

344346
// set defaults
345347
this.isRemoteBrowser = false

‎lib/helper/Puppeteer.js‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import Helper from '@codeceptjs/helper'
66
import { v4 as uuidv4 } from 'uuid'
77
import promiseRetry from 'promise-retry'
88
import Locator from '../locator.js'
9+
import { ariaSnapshotFromTree } from '../aria.js'
910
import recorder from '../recorder.js'
1011
import store from '../store.js'
1112
import { checkFocusBeforeType, checkFocusBeforePressKey } from './extras/focusCheck.js'
@@ -237,6 +238,8 @@ class Puppeteer extends Helper {
237238
super(config)
238239

239240
// puppeteer will be loaded dynamically in _init method
241+
/** @type {CodeceptJS.HelperCapabilities} */
242+
this.capabilities = { screenshot: true, video: false, trace: true, aria: true }
240243
// set defaults
241244
this.isRemoteBrowser = false
242245
this.isRunning = false
@@ -289,6 +292,7 @@ class Puppeteer extends Helper {
289292

290293
_setConfig(config) {
291294
this.options = this._validateConfig(config)
295+
this.capabilities.aria = this.options.browser !== 'firefox'
292296
this.puppeteerOptions = {
293297
headless: !this.options.show,
294298
...this._getOptions(config),
@@ -1827,6 +1831,27 @@ class Puppeteer extends Helper {
18271831
return this.page.content()
18281832
}
18291833

1834+
/**
1835+
* Retrieves the ARIA snapshot of the page, or of an element, built from the browser accessibility tree.
1836+
* The format matches Playwright's [ARIA snapshots](https://playwright.dev/docs/aria-snapshots).
1837+
*
1838+
* ```js
1839+
* const snapshot = await I.grabAriaSnapshot();
1840+
* const formSnapshot = await I.grabAriaSnapshot('#login-form');
1841+
* ```
1842+
*
1843+
* @param {string|object} [locator='//body'] element located by CSS|XPath|strict locator. Defaults to body element.
1844+
* @return {Promise<string>} YAML representation of the accessibility tree
1845+
*/
1846+
async grabAriaSnapshot(locator = '//body') {
1847+
const els = await this._locate(locator)
1848+
assertElementExists(els, locator)
1849+
const tree = await this.page.accessibility.snapshot({ root: els[0], interestingOnly: false })
1850+
const snapshot = ariaSnapshotFromTree(tree)
1851+
this.debugSection('Aria Snapshot', `${snapshot.split('\n').length} lines`)
1852+
return snapshot
1853+
}
1854+
18301855
/**
18311856
* Get JS log from browser.
18321857
*

‎lib/helper/WebDriver.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,8 @@ class WebDriver extends Helper {
461461
constructor(config) {
462462
super(config)
463463
// webdriverio will be loaded dynamically in _init method
464+
/** @type {CodeceptJS.HelperCapabilities} */
465+
this.capabilities = { screenshot: true, video: false, trace: false, aria: false }
464466

465467
// set defaults
466468
this.root = webRoot

‎lib/helper/clientscripts/cdpBrowserClient.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ export default function installCodeceptClient(xpathNeedsPolyfill) {
319319
els[0].blur()
320320
return true
321321
},
322+
element: els => els[0],
322323
rect: els => {
323324
if (els[0].scrollIntoView) els[0].scrollIntoView({ block: 'center', inline: 'center' })
324325
const r = els[0].getBoundingClientRect()

‎lib/plugin/aiTrace.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export default function (config = {}) {
7878
const helper = pickActingHelper(Container.helpers())
7979

8080
if (!helper) {
81-
output.warn('aiTrace plugin: No supported helper found (Playwright, Puppeteer, WebDriver). Plugin disabled.')
81+
output.print('aiTrace plugin: No supported helper found. Plugin disabled.')
8282
return
8383
}
8484

‎lib/plugin/pause.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ function initUrlMode(pattern) {
109109
const helper = getBrowserHelper()
110110

111111
if (!helper) {
112-
output.error('pause:on=url requires a browser helper (Playwright, WebDriver, Puppeteer, Appium)')
112+
output.error('pause:on=url requires a browser helper (Playwright, WebDriver, Puppeteer, Appium, Obscura, Lightpanda)')
113113
return
114114
}
115115

0 commit comments

Comments
 (0)