From bc472aec98f9932c4eab5f06abfc4df6b1d645a7 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Mon, 12 Oct 2026 00:04:38 +0300 Subject: [PATCH 1/3] 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 --- lib/aria.js | 85 +++++++++++++++++- lib/container.js | 2 +- lib/helper/CDPBrowser.js | 43 ++++++++-- lib/helper/Decision.js | 4 +- lib/helper/Playwright.js | 2 + lib/helper/Puppeteer.js | 25 ++++++ lib/helper/WebDriver.js | 2 + lib/helper/clientscripts/cdpBrowserClient.js | 1 + lib/plugin/aiTrace.js | 2 +- lib/plugin/pause.js | 2 +- lib/plugin/screencast.js | 9 +- lib/plugin/screenshot.js | 8 +- lib/utils/trace.js | 6 +- test/unit/aria_test.js | 90 ++++++++++++++++++++ typings/index.d.ts | 14 +++ typings/tests/helper.types.ts | 22 +++++ 16 files changed, 293 insertions(+), 24 deletions(-) create mode 100644 test/unit/aria_test.js diff --git a/lib/aria.js b/lib/aria.js index f4a05fe31..3784d3d1c 100644 --- a/lib/aria.js +++ b/lib/aria.js @@ -257,4 +257,87 @@ function diffAriaSnapshots(previous, current) { return formatDiff(added, removed) } -export { diffAriaSnapshots, compactAriaSnapshot } +// ───────────────────────────────────────────────────────────────── +// Build: accessibility tree → YAML snapshot +// ───────────────────────────────────────────────────────────────── + +const TRANSPARENT_ROLES = new Set(['RootWebArea', 'WebArea', 'generic', 'none', 'presentation', 'Ignored', 'LabelText', 'Section', 'strong', 'emphasis', 'superscript', 'subscript', 'mark', 'time', 'abbr']) +const DROPPED_ROLES = new Set(['InlineTextBox', 'LineBreak', 'ListMarker']) +const NAME_FROM_CONTENT_ROLES = new Set(['button', 'link', 'heading', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'treeitem', 'cell', 'gridcell', 'columnheader', 'rowheader', 'switch', 'checkbox', 'radio']) +const VALUE_ROLES = new Set(['textbox', 'searchbox', 'combobox', 'spinbutton', 'slider']) +const ROLE_ALIASES = { image: 'img', StaticText: 'text' } + +function textOf(node) { + if (node.role === 'StaticText') return node.name || '' + return (node.children || []).map(textOf).join(' ') +} + +function quoteValue(value) { + if (/^[\s\-?:,[\]{}#&*!|>'"%@`]|: | #|\s$/.test(value)) return JSON.stringify(value) + return value +} + +function toAriaNodes(node) { + if (!node || DROPPED_ROLES.has(node.role)) return [] + if (node.role === 'StaticText') { + const text = (node.name || '').trim() + if (!text) return [] + return [{ role: 'text', attributes: {}, text, value: quoteValue(text), children: [] }] + } + + let children = (node.children || []).flatMap(toAriaNodes) + if (TRANSPARENT_ROLES.has(node.role)) return children + + let name = (node.name || '').trim() + if (!name && NAME_FROM_CONTENT_ROLES.has(node.role)) name = textOf(node).replace(/\s+/g, ' ').trim() + if (name) children = children.filter(child => child.role !== 'text' || !name.includes(child.text)) + + const attributes = {} + if (node.level && node.role === 'heading') attributes.level = node.level + for (const key of ['checked', 'pressed']) { + if (node[key] === true || node[key] === 'true') attributes[key] = true + if (node[key] === 'mixed') attributes[key] = 'mixed' + } + for (const key of ['disabled', 'expanded', 'selected']) { + if (node[key] === true || node[key] === 'true') attributes[key] = true + } + + const ariaNode = { role: ROLE_ALIASES[node.role] || node.role, name: name.replace(/"/g, '\\"'), attributes, children } + if (VALUE_ROLES.has(node.role) && node.value !== undefined && node.value !== null && String(node.value).trim()) { + ariaNode.value = quoteValue(String(node.value).trim()) + } + return [ariaNode] +} + +function renderSnapshot(nodes, depth = 0) { + return nodes + .map(node => { + const head = `${' '.repeat(depth)}- ${formatNode(node)}` + if (node.children.length === 0) return head + return `${head}:\n${renderSnapshot(node.children, depth + 1)}` + }) + .join('\n') +} + +// Accepts a nested tree shaped like Puppeteer's SerializedAXNode: { role, name, value, level, checked, ..., children } +function ariaSnapshotFromTree(root) { + return renderSnapshot(toAriaNodes(root)) +} + +// Accepts the flat `nodes` list returned by CDP `Accessibility.getFullAXTree` +function ariaSnapshotFromAXNodes(axNodes, rootBackendNodeId) { + const byId = new Map(axNodes.map(node => [node.nodeId, node])) + const build = axNode => { + const node = { role: axNode.role?.value, name: axNode.name?.value, value: axNode.value?.value } + if (axNode.ignored) node.role = 'Ignored' + for (const prop of axNode.properties || []) node[prop.name] = prop.value?.value + node.children = (axNode.childIds || []).map(id => byId.get(id)).filter(Boolean).map(build) + return node + } + let root = axNodes.find(node => node.role?.value === 'RootWebArea') || axNodes[0] + if (rootBackendNodeId !== undefined) root = axNodes.find(node => node.backendDOMNodeId === rootBackendNodeId) + if (!root) return '' + return ariaSnapshotFromTree(build(root)) +} + +export { diffAriaSnapshots, compactAriaSnapshot, ariaSnapshotFromTree, ariaSnapshotFromAXNodes } diff --git a/lib/container.js b/lib/container.js index ea10bd570..f9084a786 100644 --- a/lib/container.js +++ b/lib/container.js @@ -61,7 +61,7 @@ class Container { * */ static get STANDARD_ACTING_HELPERS() { - return ['Playwright', 'WebDriver', 'Puppeteer', 'Appium'] + return ['Playwright', 'WebDriver', 'Puppeteer', 'Appium', 'Obscura', 'Lightpanda'] } /** * Create container with all required helpers and support objects diff --git a/lib/helper/CDPBrowser.js b/lib/helper/CDPBrowser.js index 2373531ea..77e19061c 100644 --- a/lib/helper/CDPBrowser.js +++ b/lib/helper/CDPBrowser.js @@ -8,10 +8,11 @@ import CDPConnection from './extras/CDPConnection.js' import installCodeceptClient from './clientscripts/cdpBrowserClient.js' import xpathPolyfillSource from './clientscripts/xpathPolyfill.js' import layoutProbe from './clientscripts/layoutProbe.js' +import { ariaSnapshotFromAXNodes } from '../aria.js' import Locator from '../locator.js' import output from '../output.js' import store from '../store.js' -import { xpathLocator, normalizePath, resolveUrl, toCamelCase, convertCssPropertiesToCamelCase, normalizeSpacesInString, fileExists, base64EncodeFile, getMimeType, isFile, isWindows } from '../utils.js' +import { xpathLocator, normalizePath, resolveUrl, toCamelCase, convertCssPropertiesToCamelCase, normalizeSpacesInString, fileExists, base64EncodeFile, getMimeType, isFile, isWindows, screenshotOutputFolder } from '../utils.js' import ElementNotFound from './errors/ElementNotFound.js' import MultipleElementsFound from './errors/MultipleElementsFound.js' import { includes as stringIncludes } from '../assert/include.js' @@ -38,7 +39,7 @@ import { assembleApng, isPng } from './extras/apngAssembler.js' * @prop {object} [headers={}] - headers sent when connecting to the endpoint, e.g. for authenticated remote browsers. * @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. * @prop {string|boolean} [xpathPolyfill=auto] - inject an XPath polyfill for browsers with incomplete XPath support. `auto` detects when it is needed. - * @prop {object} [capabilities={}] - browser capabilities (`layout`, `xpath`, `screenshot`, `innerText`) to use instead of detecting them at runtime. + * @prop {object} [capabilities={}] - browser capabilities (`layout`, `xpath`, `screenshot`, `video`, `innerText`) to use instead of detecting them at runtime. * @prop {number} [waitForTimeout=5] - default timeout for wait* actions, in seconds. * @prop {number} [waitForAction] - fixed delay after each action, in milliseconds. When unset, actions wait only for the navigation they trigger. * @prop {number} [pollInterval=25] - interval between checks while waiting, in milliseconds. @@ -137,7 +138,8 @@ class CDPBrowser extends Helper { this.serverError = null this.binaryPath = null this._selfManagedResolved = false - this.capabilities = { layout: null, xpath: null, screenshot: null, innerText: null, ...this.options.capabilities } + /** @type {CodeceptJS.HelperCapabilities} */ + this.capabilities = { screenshot: null, video: null, trace: false, aria: true, layout: null, xpath: null, innerText: null, ...this.options.capabilities } this.withinCandidates = null this.requests = [] this.recording = false @@ -615,6 +617,9 @@ class CDPBrowser extends Helper { if (this.capabilities.screenshot === null) { this.capabilities.screenshot = this.capabilities.layout === 'real' } + if (this.capabilities.video === null) { + this.capabilities.video = this.capabilities.screenshot + } if (this.options.input === 'auto') { this.options.input = this.capabilities.layout === 'real' ? 'cdp' : 'synthetic' } @@ -754,6 +759,32 @@ class CDPBrowser extends Helper { return this._evaluate('document.documentElement.outerHTML') } + /** + * Retrieves the ARIA snapshot of the page, or of an element, built from the browser accessibility tree. + * The format matches Playwright's [ARIA snapshots](https://playwright.dev/docs/aria-snapshots). + * + * ```js + * const snapshot = await I.grabAriaSnapshot(); + * const formSnapshot = await I.grabAriaSnapshot('#login-form'); + * ``` + * + * @param {string|object} [locator='//body'] element located by CSS|XPath|strict locator. Defaults to body element. + * @return {Promise} YAML representation of the accessibility tree + */ + async grabAriaSnapshot(locator = '//body') { + const candidates = this._candidates(locator) + const { found } = await this._run(candidates, 'count') + if (!found) throw new ElementNotFound(locator) + const scope = this.withinCandidates ? [this.withinCandidates] : null + const expression = `window.__codecept.run(${JSON.stringify(candidates)}, 'element', null, ${JSON.stringify(scope)}, null).result` + const { result } = await this.cdp.send('Runtime.evaluate', { expression }, this.sessionId) + const { node } = await this.cdp.send('DOM.describeNode', { objectId: result.objectId }, this.sessionId) + const { nodes } = await this.cdp.send('Accessibility.getFullAXTree', {}, this.sessionId) + const snapshot = ariaSnapshotFromAXNodes(nodes, node.backendNodeId) + this.debugSection('Aria Snapshot', `${snapshot.split('\n').length} lines`) + return snapshot + } + _candidates(locator, kind = 'element') { locator = new Locator(locator) if (locator.isShadow()) { @@ -1368,9 +1399,8 @@ class CDPBrowser extends Helper { this.debug('saveScreenshot skipped: this browser has no rendering engine') return } - const outputDir = global.output_dir || '.' const res = await this.cdp.send('Page.captureScreenshot', { format: 'png' }, this.sessionId) - fs.writeFileSync(path.join(outputDir, fileName), Buffer.from(res.data, 'base64')) + fs.writeFileSync(screenshotOutputFolder(fileName), Buffer.from(res.data, 'base64')) } /** @@ -1383,10 +1413,9 @@ class CDPBrowser extends Helper { } const res = await this._run(this._candidates(locator), 'rect') if (!res.found) throw new ElementNotFound(locator) - const outputDir = global.output_dir || '.' const { x, y, width, height } = res.result const shot = await this.cdp.send('Page.captureScreenshot', { format: 'png', clip: { x, y, width, height, scale: 1 } }, this.sessionId) - fs.writeFileSync(path.join(outputDir, fileName), Buffer.from(shot.data, 'base64')) + fs.writeFileSync(screenshotOutputFolder(fileName), Buffer.from(shot.data, 'base64')) } /** diff --git a/lib/helper/Decision.js b/lib/helper/Decision.js index 4bc2bc505..83f596a8a 100644 --- a/lib/helper/Decision.js +++ b/lib/helper/Decision.js @@ -221,7 +221,7 @@ class Decision extends Helper { title: await helper.grabTitle(), } - if (helper.grabAriaSnapshot) { + if (helper.grabAriaSnapshot && !(helper.capabilities && helper.capabilities.aria === false)) { state.aria = compactAriaSnapshot(await helper.grabAriaSnapshot()).slice(0, this.options.maxLength) return state } @@ -244,7 +244,7 @@ class Decision extends Helper { _actingHelper() { const helper = pickActingHelper(Container.helpers()) - if (!helper) throw new Error('Decision helper requires a web helper: Playwright, Puppeteer or WebDriver') + if (!helper) throw new Error('Decision helper requires a web helper: Playwright, Puppeteer, WebDriver, Obscura or Lightpanda') return helper } } diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index fb2037272..617be92af 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -340,6 +340,8 @@ class Playwright extends Helper { super(config) // playwright will be loaded dynamically in _init method + /** @type {CodeceptJS.HelperCapabilities} */ + this.capabilities = { screenshot: true, video: true, trace: true, aria: true } // set defaults this.isRemoteBrowser = false diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js index a679477a1..402d32a5c 100644 --- a/lib/helper/Puppeteer.js +++ b/lib/helper/Puppeteer.js @@ -6,6 +6,7 @@ import Helper from '@codeceptjs/helper' import { v4 as uuidv4 } from 'uuid' import promiseRetry from 'promise-retry' import Locator from '../locator.js' +import { ariaSnapshotFromTree } from '../aria.js' import recorder from '../recorder.js' import store from '../store.js' import { checkFocusBeforeType, checkFocusBeforePressKey } from './extras/focusCheck.js' @@ -237,6 +238,8 @@ class Puppeteer extends Helper { super(config) // puppeteer will be loaded dynamically in _init method + /** @type {CodeceptJS.HelperCapabilities} */ + this.capabilities = { screenshot: true, video: false, trace: true, aria: true } // set defaults this.isRemoteBrowser = false this.isRunning = false @@ -289,6 +292,7 @@ class Puppeteer extends Helper { _setConfig(config) { this.options = this._validateConfig(config) + this.capabilities.aria = this.options.browser !== 'firefox' this.puppeteerOptions = { headless: !this.options.show, ...this._getOptions(config), @@ -1827,6 +1831,27 @@ class Puppeteer extends Helper { return this.page.content() } + /** + * Retrieves the ARIA snapshot of the page, or of an element, built from the browser accessibility tree. + * The format matches Playwright's [ARIA snapshots](https://playwright.dev/docs/aria-snapshots). + * + * ```js + * const snapshot = await I.grabAriaSnapshot(); + * const formSnapshot = await I.grabAriaSnapshot('#login-form'); + * ``` + * + * @param {string|object} [locator='//body'] element located by CSS|XPath|strict locator. Defaults to body element. + * @return {Promise} YAML representation of the accessibility tree + */ + async grabAriaSnapshot(locator = '//body') { + const els = await this._locate(locator) + assertElementExists(els, locator) + const tree = await this.page.accessibility.snapshot({ root: els[0], interestingOnly: false }) + const snapshot = ariaSnapshotFromTree(tree) + this.debugSection('Aria Snapshot', `${snapshot.split('\n').length} lines`) + return snapshot + } + /** * Get JS log from browser. * diff --git a/lib/helper/WebDriver.js b/lib/helper/WebDriver.js index 2a8890697..b2395255c 100644 --- a/lib/helper/WebDriver.js +++ b/lib/helper/WebDriver.js @@ -461,6 +461,8 @@ class WebDriver extends Helper { constructor(config) { super(config) // webdriverio will be loaded dynamically in _init method + /** @type {CodeceptJS.HelperCapabilities} */ + this.capabilities = { screenshot: true, video: false, trace: false, aria: false } // set defaults this.root = webRoot diff --git a/lib/helper/clientscripts/cdpBrowserClient.js b/lib/helper/clientscripts/cdpBrowserClient.js index 63e086c0d..d72fcff68 100644 --- a/lib/helper/clientscripts/cdpBrowserClient.js +++ b/lib/helper/clientscripts/cdpBrowserClient.js @@ -319,6 +319,7 @@ export default function installCodeceptClient(xpathNeedsPolyfill) { els[0].blur() return true }, + element: els => els[0], rect: els => { if (els[0].scrollIntoView) els[0].scrollIntoView({ block: 'center', inline: 'center' }) const r = els[0].getBoundingClientRect() diff --git a/lib/plugin/aiTrace.js b/lib/plugin/aiTrace.js index 9843e421d..21c2891ad 100644 --- a/lib/plugin/aiTrace.js +++ b/lib/plugin/aiTrace.js @@ -78,7 +78,7 @@ export default function (config = {}) { const helper = pickActingHelper(Container.helpers()) if (!helper) { - output.warn('aiTrace plugin: No supported helper found (Playwright, Puppeteer, WebDriver). Plugin disabled.') + output.print('aiTrace plugin: No supported helper found. Plugin disabled.') return } diff --git a/lib/plugin/pause.js b/lib/plugin/pause.js index 114a09bb8..cf78ff6c8 100644 --- a/lib/plugin/pause.js +++ b/lib/plugin/pause.js @@ -109,7 +109,7 @@ function initUrlMode(pattern) { const helper = getBrowserHelper() if (!helper) { - output.error('pause:on=url requires a browser helper (Playwright, WebDriver, Puppeteer, Appium)') + output.error('pause:on=url requires a browser helper (Playwright, WebDriver, Puppeteer, Appium, Obscura, Lightpanda)') return } diff --git a/lib/plugin/screencast.js b/lib/plugin/screencast.js index 8ffe10fe8..525a13e85 100644 --- a/lib/plugin/screencast.js +++ b/lib/plugin/screencast.js @@ -350,12 +350,9 @@ function buildSrt(steps) { return out } -// `getBrowserHelper` (from `pluginParser.js`) only recognizes `Container.STANDARD_ACTING_HELPERS` -// (`Playwright`/`WebDriver`/`Puppeteer`/`Appium`), so it never finds `CDPBrowser`/`Obscura`/ -// `Kitesurf`. Rather than widen that shared list — used by several other plugins with their own, -// Playwright/WebDriver-specific assumptions — this plugin does its own duck-typed fallback lookup, -// scoped to itself: any active helper exposing a `startScreencast` function qualifies, with no -// hardcoded class names, so any future CDP-family helper picks this up automatically too. +// `getBrowserHelper` (from `pluginParser.js`) only recognizes `Container.STANDARD_ACTING_HELPERS`, +// so helpers outside that list (`CDPBrowser`, `Kitesurf`) are found by a duck-typed fallback: +// any active helper exposing a `startScreencast` function qualifies. function getScreencastHelper() { const standard = getBrowserHelper() if (standard) return standard diff --git a/lib/plugin/screenshot.js b/lib/plugin/screenshot.js index 08e493d70..52154a172 100644 --- a/lib/plugin/screenshot.js +++ b/lib/plugin/screenshot.js @@ -189,14 +189,15 @@ function scheduleScreenshot(test, fileName, options) { } async function takeScreenshot(test, fileName, options) { + const helper = getBrowserHelper() + if (!helper || typeof helper.saveScreenshot !== 'function') return + if (helper.capabilities && helper.capabilities.screenshot === false) return + const quietMode = !store.outputDir if (!quietMode) { output.plugin('screenshot', `Saving screenshot ${fileName}`) } - const helper = getBrowserHelper() - if (!helper || typeof helper.saveScreenshot !== 'function') return - try { if (options.reportDir) { fileName = path.join(options.reportDir, fileName) @@ -332,6 +333,7 @@ function wireSlides(options, trigger) { const helper = getBrowserHelper() if (!helper || typeof helper.saveScreenshot !== 'function') return + if (helper.capabilities && helper.capabilities.screenshot === false) return try { const screenshotPath = path.join(dir, fileName) diff --git a/lib/utils/trace.js b/lib/utils/trace.js index a62ded4b5..cb92da983 100644 --- a/lib/utils/trace.js +++ b/lib/utils/trace.js @@ -176,7 +176,8 @@ export async function captureSnapshot(helper, { } catch {} } - if (captureScreenshot && helper.saveScreenshot) { + const canScreenshot = !(helper.capabilities && helper.capabilities.screenshot === false) + if (captureScreenshot && canScreenshot && helper.saveScreenshot) { try { const file = `${prefix}_screenshot.png` await helper.saveScreenshot(path.join(dir, file), fullPage) @@ -201,7 +202,8 @@ export async function captureSnapshot(helper, { } catch {} } - if (captureARIA && helper.grabAriaSnapshot) { + const canAria = !(helper.capabilities && helper.capabilities.aria === false) + if (captureARIA && canAria && helper.grabAriaSnapshot) { try { const aria = await helper.grabAriaSnapshot() const file = `${prefix}_aria.txt` diff --git a/test/unit/aria_test.js b/test/unit/aria_test.js new file mode 100644 index 000000000..4415d652f --- /dev/null +++ b/test/unit/aria_test.js @@ -0,0 +1,90 @@ +import { expect } from 'chai' +import { ariaSnapshotFromAXNodes, ariaSnapshotFromTree, compactAriaSnapshot, diffAriaSnapshots } from '../../lib/aria.js' + +const role = value => ({ type: 'role', value }) +const name = value => ({ type: 'computedString', value }) +const prop = (n, value) => ({ name: n, value: { type: 'token', value } }) + +const lightpandaNodes = [ + { nodeId: '1', backendDOMNodeId: 1, role: role('RootWebArea'), name: name('Checkout'), childIds: ['2'] }, + { nodeId: '2', backendDOMNodeId: 2, role: role('none'), childIds: ['3', '5', '7', '8'] }, + { nodeId: '3', backendDOMNodeId: 3, role: role('heading'), name: name('Checkout form'), properties: [{ name: 'level', value: { type: 'integer', value: 4 } }], childIds: ['4'] }, + { nodeId: '4', backendDOMNodeId: 4, role: role('StaticText'), name: name('Checkout form'), childIds: [] }, + { nodeId: '5', backendDOMNodeId: 5, role: role('form'), name: name(''), childIds: ['6', '9'] }, + { nodeId: '6', backendDOMNodeId: 6, role: role('textbox'), name: name('Promo code'), value: { type: 'string', value: 'SALE' }, properties: [prop('invalid', 'false')], childIds: [] }, + { nodeId: '7', backendDOMNodeId: 7, role: role('checkbox'), name: name('Same address'), value: { type: 'string', value: 'on' }, properties: [prop('checked', 'true')], childIds: [] }, + { nodeId: '8', backendDOMNodeId: 8, role: role('paragraph'), childIds: ['10'] }, + { nodeId: '9', backendDOMNodeId: 9, role: role('button'), name: name('Redeem'), childIds: ['11'] }, + { nodeId: '10', backendDOMNodeId: 10, role: role('StaticText'), name: name('Total: $20'), childIds: [] }, + { nodeId: '11', backendDOMNodeId: 11, role: role('StaticText'), name: name('Redeem'), childIds: [] }, +] + +const obscuraNodes = [ + { nodeId: '1', ignored: false, role: role('RootWebArea'), childIds: ['2'], backendDOMNodeId: 1 }, + { nodeId: '2', ignored: false, role: role('generic'), childIds: ['3', '5'], backendDOMNodeId: 2 }, + { nodeId: '3', ignored: false, role: role('button'), properties: [{ name: 'focusable', value: { type: 'boolean', value: true } }], childIds: ['4'], backendDOMNodeId: 3 }, + { nodeId: '4', ignored: false, role: role('StaticText'), name: { type: 'string', value: 'Redeem' }, backendDOMNodeId: 4 }, + { nodeId: '5', ignored: false, role: role('heading'), properties: [{ name: 'level', value: { type: 'integer', value: 2 } }], childIds: ['6'], backendDOMNodeId: 5 }, + { nodeId: '6', ignored: false, role: role('LabelText'), childIds: ['7'], backendDOMNodeId: 6 }, + { nodeId: '7', ignored: false, role: role('StaticText'), name: { type: 'string', value: 'Billing address' }, backendDOMNodeId: 7 }, +] + +describe('aria', () => { + describe('#ariaSnapshotFromAXNodes', () => { + it('renders a Lightpanda accessibility tree as an ARIA snapshot', () => { + expect(ariaSnapshotFromAXNodes(lightpandaNodes)).to.equal( + [ + '- heading "Checkout form" [level=4]', + '- form:', + ' - textbox "Promo code": SALE', + ' - button "Redeem"', + '- checkbox "Same address" [checked]', + '- paragraph:', + ' - text: "Total: $20"', + ].join('\n'), + ) + }) + + it('names buttons and headings from their text when the engine leaves names empty', () => { + expect(ariaSnapshotFromAXNodes(obscuraNodes)).to.equal(['- button "Redeem"', '- heading "Billing address" [level=2]'].join('\n')) + }) + + it('scopes the snapshot to an element by its backend node id', () => { + expect(ariaSnapshotFromAXNodes(lightpandaNodes, 5)).to.equal(['- form:', ' - textbox "Promo code": SALE', ' - button "Redeem"'].join('\n')) + }) + + it('returns an empty snapshot when the element is not in the tree', () => { + expect(ariaSnapshotFromAXNodes(lightpandaNodes, 999)).to.equal('') + }) + + it('produces snapshots that compact and diff like Playwright ones', () => { + const before = ariaSnapshotFromAXNodes(lightpandaNodes) + const after = ariaSnapshotFromAXNodes(lightpandaNodes.filter(node => node.nodeId !== '9')) + expect(compactAriaSnapshot(before)).to.include('button "Redeem"') + expect(diffAriaSnapshots(before, after)).to.include('removed: 1 interactive elements') + }) + }) + + describe('#ariaSnapshotFromTree', () => { + it('renders a Puppeteer accessibility snapshot', () => { + const tree = { + role: 'RootWebArea', + name: 'Login', + children: [ + { role: 'heading', name: 'Sign "in"', level: 1, children: [{ role: 'StaticText', name: 'Sign "in"', children: [{ role: 'InlineTextBox', name: 'Sign "in"' }] }] }, + { role: 'textbox', name: 'Email', value: '' }, + { role: 'checkbox', name: 'Remember me', checked: 'mixed' }, + { role: 'button', name: 'Submit', disabled: true }, + { role: 'image', name: 'Logo' }, + ], + } + expect(ariaSnapshotFromTree(tree)).to.equal( + ['- heading "Sign \\"in\\"" [level=1]', '- textbox "Email"', '- checkbox "Remember me" [checked=mixed]', '- button "Submit" [disabled]', '- img "Logo"'].join('\n'), + ) + }) + + it('returns an empty snapshot for an empty tree', () => { + expect(ariaSnapshotFromTree(null)).to.equal('') + }) + }) +}) diff --git a/typings/index.d.ts b/typings/index.d.ts index 50804f3a9..d4cb014d1 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -465,6 +465,20 @@ declare namespace CodeceptJS { y: number } + /** + * Features an acting helper supports. Plugins read them to decide what to capture. + * `null` means the value is detected at runtime. + */ + interface HelperCapabilities { + screenshot: boolean | null + video: boolean | null + trace: boolean + aria: boolean + layout?: 'real' | 'none' | null + xpath?: 'native' | 'polyfill' | null + innerText?: 'native' | 'computed' | null + } + // Could get extended by user generated typings interface Methods extends ActorStatic {} interface I {} diff --git a/typings/tests/helper.types.ts b/typings/tests/helper.types.ts index 432aaf0e3..67eb432e7 100644 --- a/typings/tests/helper.types.ts +++ b/typings/tests/helper.types.ts @@ -50,3 +50,25 @@ expectType(customClass._afterStep()) expectType(customClass._afterSuite()) expectType(customClass._useTo()) + +class CapableHelper extends Helper { + capabilities: CodeceptJS.HelperCapabilities = { screenshot: true, video: false, trace: false, aria: true } +} + +expectType(new CapableHelper({}).capabilities) + +class DetectingHelper extends Helper { + capabilities: CodeceptJS.HelperCapabilities = { screenshot: null, video: null, trace: false, aria: false, layout: null } +} + +expectType(new DetectingHelper({}).capabilities) + +class IncompleteHelper extends Helper { + // @ts-expect-error + capabilities: CodeceptJS.HelperCapabilities = { screenshot: true } +} + +class WrongValueHelper extends Helper { + // @ts-expect-error + capabilities: CodeceptJS.HelperCapabilities = { screenshot: true, video: false, trace: false, aria: false, layout: 'partial' } +} From f1c5875339598e534ded9d352f98ee8fe330dfea Mon Sep 17 00:00:00 2001 From: DavertMik Date: Mon, 12 Oct 2026 00:07:14 +0300 Subject: [PATCH 2/3] test(webapi): cover grabAriaSnapshot across helpers; honor elementIndex in CDP snapshot Co-Authored-By: Claude Opus 5.5 --- lib/helper/CDPBrowser.js | 4 ++-- test/helper/webapi.js | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/lib/helper/CDPBrowser.js b/lib/helper/CDPBrowser.js index 77e19061c..105c4b420 100644 --- a/lib/helper/CDPBrowser.js +++ b/lib/helper/CDPBrowser.js @@ -39,7 +39,7 @@ import { assembleApng, isPng } from './extras/apngAssembler.js' * @prop {object} [headers={}] - headers sent when connecting to the endpoint, e.g. for authenticated remote browsers. * @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. * @prop {string|boolean} [xpathPolyfill=auto] - inject an XPath polyfill for browsers with incomplete XPath support. `auto` detects when it is needed. - * @prop {object} [capabilities={}] - browser capabilities (`layout`, `xpath`, `screenshot`, `video`, `innerText`) to use instead of detecting them at runtime. + * @prop {object} [capabilities={}] - browser capabilities (`layout`, `xpath`, `screenshot`, `video`, `innerText`, `aria`) to use instead of detecting them at runtime. * @prop {number} [waitForTimeout=5] - default timeout for wait* actions, in seconds. * @prop {number} [waitForAction] - fixed delay after each action, in milliseconds. When unset, actions wait only for the navigation they trigger. * @prop {number} [pollInterval=25] - interval between checks while waiting, in milliseconds. @@ -776,7 +776,7 @@ class CDPBrowser extends Helper { const { found } = await this._run(candidates, 'count') if (!found) throw new ElementNotFound(locator) const scope = this.withinCandidates ? [this.withinCandidates] : null - const expression = `window.__codecept.run(${JSON.stringify(candidates)}, 'element', null, ${JSON.stringify(scope)}, null).result` + const expression = `window.__codecept.run(${JSON.stringify(candidates)}, 'element', null, ${JSON.stringify(scope)}, ${JSON.stringify(this._selectionDescriptor())}).result` const { result } = await this.cdp.send('Runtime.evaluate', { expression }, this.sessionId) const { node } = await this.cdp.send('DOM.describeNode', { objectId: result.objectId }, this.sessionId) const { nodes } = await this.cdp.send('Accessibility.getFullAXTree', {}, this.sessionId) diff --git a/test/helper/webapi.js b/test/helper/webapi.js index 226701ae7..642ab3fa8 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -1550,6 +1550,27 @@ export function tests() { }) }) + describe('#grabAriaSnapshot', () => { + beforeEach(function () { + if (!I.grabAriaSnapshot || I.capabilities?.aria === false) this.skip() + }) + + it('should grab the ARIA snapshot of the page', async () => { + await I.amOnPage('/form/field') + const snapshot = await I.grabAriaSnapshot() + assert.match(snapshot, /^- /m) + assert.ok(snapshot.includes('textbox'), snapshot) + assert.ok(snapshot.includes('button'), snapshot) + }) + + it('should grab the ARIA snapshot of an element', async () => { + await I.amOnPage('/info') + const snapshot = await I.grabAriaSnapshot('form') + assert.ok(snapshot.includes('textbox'), snapshot) + assert.ok(!snapshot.includes('Information'), snapshot) + }) + }) + describe('#saveScreenshot', () => { beforeEach(function () { if (I.capabilities?.screenshot === false) this.skip() // no rendering engine, screenshots are unsupported From 3e5a058d76d1ea45c261985d73fae5c91bfdee6e Mon Sep 17 00:00:00 2001 From: DavertMik Date: Mon, 12 Oct 2026 00:12:21 +0300 Subject: [PATCH 3/3] refactor(aria): render accessibility trees as-is instead of mimicking Playwright Co-Authored-By: Claude Opus 5.5 --- lib/aria.js | 94 +++++++++--------------------------- test/unit/aria_test.js | 106 +++++++++++------------------------------ 2 files changed, 50 insertions(+), 150 deletions(-) diff --git a/lib/aria.js b/lib/aria.js index 3784d3d1c..4535ddf7d 100644 --- a/lib/aria.js +++ b/lib/aria.js @@ -261,83 +261,35 @@ function diffAriaSnapshots(previous, current) { // Build: accessibility tree → YAML snapshot // ───────────────────────────────────────────────────────────────── -const TRANSPARENT_ROLES = new Set(['RootWebArea', 'WebArea', 'generic', 'none', 'presentation', 'Ignored', 'LabelText', 'Section', 'strong', 'emphasis', 'superscript', 'subscript', 'mark', 'time', 'abbr']) -const DROPPED_ROLES = new Set(['InlineTextBox', 'LineBreak', 'ListMarker']) -const NAME_FROM_CONTENT_ROLES = new Set(['button', 'link', 'heading', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'treeitem', 'cell', 'gridcell', 'columnheader', 'rowheader', 'switch', 'checkbox', 'radio']) -const VALUE_ROLES = new Set(['textbox', 'searchbox', 'combobox', 'spinbutton', 'slider']) -const ROLE_ALIASES = { image: 'img', StaticText: 'text' } - -function textOf(node) { - if (node.role === 'StaticText') return node.name || '' - return (node.children || []).map(textOf).join(' ') +const HIDDEN_ROLES = new Set(['RootWebArea', 'generic', 'none', 'LabelText', 'InlineTextBox', 'LineBreak', 'ListMarker']) + +function renderAXTree(node, depth = 0, parentName = '') { + if (!node) return [] + const indent = ' '.repeat(depth) + const name = (node.name || '').trim() + if (node.role === 'StaticText') return name && name !== parentName ? [`${indent}- text: ${JSON.stringify(name)}`] : [] + const hidden = HIDDEN_ROLES.has(node.role) + const children = (node.children || []).flatMap(child => renderAXTree(child, hidden ? depth : depth + 1, hidden ? parentName : name)) + if (hidden) return children + const line = `${indent}- ${node.role}${name ? ` ${JSON.stringify(name)}` : ''}` + return [children.length ? `${line}:` : line, ...children] } -function quoteValue(value) { - if (/^[\s\-?:,[\]{}#&*!|>'"%@`]|: | #|\s$/.test(value)) return JSON.stringify(value) - return value -} - -function toAriaNodes(node) { - if (!node || DROPPED_ROLES.has(node.role)) return [] - if (node.role === 'StaticText') { - const text = (node.name || '').trim() - if (!text) return [] - return [{ role: 'text', attributes: {}, text, value: quoteValue(text), children: [] }] - } - - let children = (node.children || []).flatMap(toAriaNodes) - if (TRANSPARENT_ROLES.has(node.role)) return children - - let name = (node.name || '').trim() - if (!name && NAME_FROM_CONTENT_ROLES.has(node.role)) name = textOf(node).replace(/\s+/g, ' ').trim() - if (name) children = children.filter(child => child.role !== 'text' || !name.includes(child.text)) - - const attributes = {} - if (node.level && node.role === 'heading') attributes.level = node.level - for (const key of ['checked', 'pressed']) { - if (node[key] === true || node[key] === 'true') attributes[key] = true - if (node[key] === 'mixed') attributes[key] = 'mixed' - } - for (const key of ['disabled', 'expanded', 'selected']) { - if (node[key] === true || node[key] === 'true') attributes[key] = true - } - - const ariaNode = { role: ROLE_ALIASES[node.role] || node.role, name: name.replace(/"/g, '\\"'), attributes, children } - if (VALUE_ROLES.has(node.role) && node.value !== undefined && node.value !== null && String(node.value).trim()) { - ariaNode.value = quoteValue(String(node.value).trim()) - } - return [ariaNode] -} - -function renderSnapshot(nodes, depth = 0) { - return nodes - .map(node => { - const head = `${' '.repeat(depth)}- ${formatNode(node)}` - if (node.children.length === 0) return head - return `${head}:\n${renderSnapshot(node.children, depth + 1)}` - }) - .join('\n') -} - -// Accepts a nested tree shaped like Puppeteer's SerializedAXNode: { role, name, value, level, checked, ..., children } +// Nested tree shaped like Puppeteer's SerializedAXNode: { role, name, children } function ariaSnapshotFromTree(root) { - return renderSnapshot(toAriaNodes(root)) + return renderAXTree(root).join('\n') } -// Accepts the flat `nodes` list returned by CDP `Accessibility.getFullAXTree` -function ariaSnapshotFromAXNodes(axNodes, rootBackendNodeId) { +// Flat `nodes` list from CDP `Accessibility.getFullAXTree`, rooted at a DOM node +function ariaSnapshotFromAXNodes(axNodes, backendNodeId) { const byId = new Map(axNodes.map(node => [node.nodeId, node])) - const build = axNode => { - const node = { role: axNode.role?.value, name: axNode.name?.value, value: axNode.value?.value } - if (axNode.ignored) node.role = 'Ignored' - for (const prop of axNode.properties || []) node[prop.name] = prop.value?.value - node.children = (axNode.childIds || []).map(id => byId.get(id)).filter(Boolean).map(build) - return node - } - let root = axNodes.find(node => node.role?.value === 'RootWebArea') || axNodes[0] - if (rootBackendNodeId !== undefined) root = axNodes.find(node => node.backendDOMNodeId === rootBackendNodeId) - if (!root) return '' - return ariaSnapshotFromTree(build(root)) + const toTree = node => ({ + role: node.ignored ? 'none' : node.role?.value, + name: node.name?.value, + children: (node.childIds || []).map(id => byId.get(id)).filter(Boolean).map(toTree), + }) + const root = axNodes.find(node => node.backendDOMNodeId === backendNodeId) + return root ? ariaSnapshotFromTree(toTree(root)) : '' } export { diffAriaSnapshots, compactAriaSnapshot, ariaSnapshotFromTree, ariaSnapshotFromAXNodes } diff --git a/test/unit/aria_test.js b/test/unit/aria_test.js index 4415d652f..fb3661465 100644 --- a/test/unit/aria_test.js +++ b/test/unit/aria_test.js @@ -1,90 +1,38 @@ import { expect } from 'chai' import { ariaSnapshotFromAXNodes, ariaSnapshotFromTree, compactAriaSnapshot, diffAriaSnapshots } from '../../lib/aria.js' -const role = value => ({ type: 'role', value }) -const name = value => ({ type: 'computedString', value }) -const prop = (n, value) => ({ name: n, value: { type: 'token', value } }) - -const lightpandaNodes = [ - { nodeId: '1', backendDOMNodeId: 1, role: role('RootWebArea'), name: name('Checkout'), childIds: ['2'] }, - { nodeId: '2', backendDOMNodeId: 2, role: role('none'), childIds: ['3', '5', '7', '8'] }, - { nodeId: '3', backendDOMNodeId: 3, role: role('heading'), name: name('Checkout form'), properties: [{ name: 'level', value: { type: 'integer', value: 4 } }], childIds: ['4'] }, - { nodeId: '4', backendDOMNodeId: 4, role: role('StaticText'), name: name('Checkout form'), childIds: [] }, - { nodeId: '5', backendDOMNodeId: 5, role: role('form'), name: name(''), childIds: ['6', '9'] }, - { nodeId: '6', backendDOMNodeId: 6, role: role('textbox'), name: name('Promo code'), value: { type: 'string', value: 'SALE' }, properties: [prop('invalid', 'false')], childIds: [] }, - { nodeId: '7', backendDOMNodeId: 7, role: role('checkbox'), name: name('Same address'), value: { type: 'string', value: 'on' }, properties: [prop('checked', 'true')], childIds: [] }, - { nodeId: '8', backendDOMNodeId: 8, role: role('paragraph'), childIds: ['10'] }, - { nodeId: '9', backendDOMNodeId: 9, role: role('button'), name: name('Redeem'), childIds: ['11'] }, - { nodeId: '10', backendDOMNodeId: 10, role: role('StaticText'), name: name('Total: $20'), childIds: [] }, - { nodeId: '11', backendDOMNodeId: 11, role: role('StaticText'), name: name('Redeem'), childIds: [] }, -] - -const obscuraNodes = [ - { nodeId: '1', ignored: false, role: role('RootWebArea'), childIds: ['2'], backendDOMNodeId: 1 }, - { nodeId: '2', ignored: false, role: role('generic'), childIds: ['3', '5'], backendDOMNodeId: 2 }, - { nodeId: '3', ignored: false, role: role('button'), properties: [{ name: 'focusable', value: { type: 'boolean', value: true } }], childIds: ['4'], backendDOMNodeId: 3 }, - { nodeId: '4', ignored: false, role: role('StaticText'), name: { type: 'string', value: 'Redeem' }, backendDOMNodeId: 4 }, - { nodeId: '5', ignored: false, role: role('heading'), properties: [{ name: 'level', value: { type: 'integer', value: 2 } }], childIds: ['6'], backendDOMNodeId: 5 }, - { nodeId: '6', ignored: false, role: role('LabelText'), childIds: ['7'], backendDOMNodeId: 6 }, - { nodeId: '7', ignored: false, role: role('StaticText'), name: { type: 'string', value: 'Billing address' }, backendDOMNodeId: 7 }, +const axNode = (nodeId, role, name, childIds = []) => ({ nodeId, backendDOMNodeId: Number(nodeId), role: { value: role }, name: name === undefined ? undefined : { value: name }, childIds }) + +const axNodes = [ + axNode('1', 'generic', undefined, ['2', '4', '6']), + axNode('2', 'heading', 'Checkout', ['3']), + axNode('3', 'StaticText', 'Checkout'), + axNode('4', 'form', '', ['5', '7']), + axNode('5', 'textbox', 'Promo code'), + axNode('6', 'StaticText', 'Total: $20'), + axNode('7', 'button', undefined, ['8']), + axNode('8', 'StaticText', 'Redeem'), ] describe('aria', () => { - describe('#ariaSnapshotFromAXNodes', () => { - it('renders a Lightpanda accessibility tree as an ARIA snapshot', () => { - expect(ariaSnapshotFromAXNodes(lightpandaNodes)).to.equal( - [ - '- heading "Checkout form" [level=4]', - '- form:', - ' - textbox "Promo code": SALE', - ' - button "Redeem"', - '- checkbox "Same address" [checked]', - '- paragraph:', - ' - text: "Total: $20"', - ].join('\n'), - ) - }) - - it('names buttons and headings from their text when the engine leaves names empty', () => { - expect(ariaSnapshotFromAXNodes(obscuraNodes)).to.equal(['- button "Redeem"', '- heading "Billing address" [level=2]'].join('\n')) - }) - - it('scopes the snapshot to an element by its backend node id', () => { - expect(ariaSnapshotFromAXNodes(lightpandaNodes, 5)).to.equal(['- form:', ' - textbox "Promo code": SALE', ' - button "Redeem"'].join('\n')) - }) - - it('returns an empty snapshot when the element is not in the tree', () => { - expect(ariaSnapshotFromAXNodes(lightpandaNodes, 999)).to.equal('') - }) - - it('produces snapshots that compact and diff like Playwright ones', () => { - const before = ariaSnapshotFromAXNodes(lightpandaNodes) - const after = ariaSnapshotFromAXNodes(lightpandaNodes.filter(node => node.nodeId !== '9')) - expect(compactAriaSnapshot(before)).to.include('button "Redeem"') - expect(diffAriaSnapshots(before, after)).to.include('removed: 1 interactive elements') - }) + it('renders a CDP accessibility tree from a DOM node', () => { + expect(ariaSnapshotFromAXNodes(axNodes, 1)).to.equal( + ['- heading "Checkout"', '- form:', ' - textbox "Promo code"', ' - button:', ' - text: "Redeem"', '- text: "Total: $20"'].join('\n'), + ) + expect(ariaSnapshotFromAXNodes(axNodes, 4)).to.equal(['- form:', ' - textbox "Promo code"', ' - button:', ' - text: "Redeem"'].join('\n')) + expect(ariaSnapshotFromAXNodes(axNodes, 999)).to.equal('') }) - describe('#ariaSnapshotFromTree', () => { - it('renders a Puppeteer accessibility snapshot', () => { - const tree = { - role: 'RootWebArea', - name: 'Login', - children: [ - { role: 'heading', name: 'Sign "in"', level: 1, children: [{ role: 'StaticText', name: 'Sign "in"', children: [{ role: 'InlineTextBox', name: 'Sign "in"' }] }] }, - { role: 'textbox', name: 'Email', value: '' }, - { role: 'checkbox', name: 'Remember me', checked: 'mixed' }, - { role: 'button', name: 'Submit', disabled: true }, - { role: 'image', name: 'Logo' }, - ], - } - expect(ariaSnapshotFromTree(tree)).to.equal( - ['- heading "Sign \\"in\\"" [level=1]', '- textbox "Email"', '- checkbox "Remember me" [checked=mixed]', '- button "Submit" [disabled]', '- img "Logo"'].join('\n'), - ) - }) + it('renders a Puppeteer accessibility tree', () => { + const tree = { role: 'RootWebArea', children: [{ role: 'button', name: 'Submit', children: [{ role: 'StaticText', name: 'Submit' }] }] } + expect(ariaSnapshotFromTree(tree)).to.equal('- button "Submit"') + expect(ariaSnapshotFromTree(null)).to.equal('') + }) - it('returns an empty snapshot for an empty tree', () => { - expect(ariaSnapshotFromTree(null)).to.equal('') - }) + it('produces snapshots that compact and diff', () => { + const before = ariaSnapshotFromAXNodes(axNodes, 1) + const after = ariaSnapshotFromAXNodes(axNodes.filter(node => node.nodeId !== '5'), 1) + expect(compactAriaSnapshot(before)).to.include('textbox "Promo code"') + expect(diffAriaSnapshots(before, after)).to.include('removed: 1 interactive elements') }) })