Skip to content
Merged
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
37 changes: 36 additions & 1 deletion lib/aria.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
2 changes: 1 addition & 1 deletion lib/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 36 additions & 7 deletions lib/helper/CDPBrowser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
}
Expand Down Expand Up @@ -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<string>} 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()) {
Expand Down Expand Up @@ -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'))
}

/**
Expand All @@ -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'))
}

/**
Expand Down
4 changes: 2 additions & 2 deletions lib/helper/Decision.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
}
Expand Down
2 changes: 2 additions & 0 deletions lib/helper/Playwright.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions lib/helper/Puppeteer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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<string>} 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.
*
Expand Down
2 changes: 2 additions & 0 deletions lib/helper/WebDriver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/helper/clientscripts/cdpBrowserClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion lib/plugin/aiTrace.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion lib/plugin/pause.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
9 changes: 3 additions & 6 deletions lib/plugin/screencast.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions lib/plugin/screenshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions lib/utils/trace.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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`
Expand Down
21 changes: 21 additions & 0 deletions test/helper/webapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions test/unit/aria_test.js
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading
Loading