diff --git a/lib/aria.js b/lib/aria.js index f4a05fe31..4535ddf7d 100644 --- a/lib/aria.js +++ b/lib/aria.js @@ -257,4 +257,39 @@ function diffAriaSnapshots(previous, current) { return formatDiff(added, removed) } -export { diffAriaSnapshots, compactAriaSnapshot } +// ───────────────────────────────────────────────────────────────── +// Build: accessibility tree → YAML snapshot +// ───────────────────────────────────────────────────────────────── + +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] +} + +// Nested tree shaped like Puppeteer's SerializedAXNode: { role, name, children } +function ariaSnapshotFromTree(root) { + return renderAXTree(root).join('\n') +} + +// 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 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/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..105c4b420 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`, `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. @@ -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)}, ${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) + 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/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 diff --git a/test/unit/aria_test.js b/test/unit/aria_test.js new file mode 100644 index 000000000..fb3661465 --- /dev/null +++ b/test/unit/aria_test.js @@ -0,0 +1,38 @@ +import { expect } from 'chai' +import { ariaSnapshotFromAXNodes, ariaSnapshotFromTree, compactAriaSnapshot, diffAriaSnapshots } from '../../lib/aria.js' + +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', () => { + 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('') + }) + + 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('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') + }) +}) 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' } +}