Refactor aria snapshot filtering and add unit tests
This commit is contained in:
+304
-178
@@ -1,3 +1,6 @@
|
||||
// Accessibility snapshot pipeline: build raw AX tree, filter to a
|
||||
// tree (interactive-only, labels/contexts, wrapper hoisting, ignored
|
||||
// indent preservation), then render lines and locators.
|
||||
import type { Page, Locator, ElementHandle } from 'playwright-core'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
@@ -8,33 +11,12 @@ import { Sema } from 'async-sema'
|
||||
import type { ICDPSession } from './cdp-session.js'
|
||||
import { getCDPSessionForPage } from './cdp-session.js'
|
||||
|
||||
|
||||
// Import sharp at module level - resolves to null if not available
|
||||
const sharpPromise = import('sharp')
|
||||
.then((m) => { return m.default })
|
||||
.catch(() => { return null })
|
||||
|
||||
// ============================================================================
|
||||
// Aria Snapshot Format Documentation
|
||||
// ============================================================================
|
||||
//
|
||||
// This module generates accessibility snapshots using the browser's
|
||||
// Accessibility.getFullAXTree (CDP) and maps nodes to stable DOM refs
|
||||
// via DOM.getFlattenedDocument (no per-node CDP calls).
|
||||
// The output format is:
|
||||
//
|
||||
// ```
|
||||
// - role "accessible name" locator
|
||||
// - button "Submit" [id="submit-btn"]
|
||||
// - link "Home" [data-testid="nav-home"]
|
||||
// - textbox "Search" role=textbox[name="Search"]
|
||||
// ```
|
||||
//
|
||||
// The locator is a Playwright selector that points to a unique element.
|
||||
// - Stable attributes: [id="..."] or [data-testid="..."]
|
||||
// - Fallback: role=... with accessible name, e.g. role=button[name="Submit"]
|
||||
// - Duplicates add >> nth=N (0-based) to make the locator unique
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// Snapshot Format Types
|
||||
// ============================================================================
|
||||
@@ -279,7 +261,7 @@ function getAxRole(node: Protocol.Accessibility.AXNode): string {
|
||||
return role.toLowerCase()
|
||||
}
|
||||
|
||||
type SnapshotLine = {
|
||||
export type SnapshotLine = {
|
||||
text: string
|
||||
baseLocator?: string
|
||||
hasChildren?: boolean
|
||||
@@ -288,12 +270,14 @@ type SnapshotLine = {
|
||||
indent?: number
|
||||
}
|
||||
|
||||
type SnapshotNode = {
|
||||
export type SnapshotNode = {
|
||||
role: string
|
||||
name: string
|
||||
baseLocator?: string
|
||||
ref?: string
|
||||
backendNodeId?: Protocol.DOM.BackendNodeId
|
||||
indentOffset?: number
|
||||
ignored?: boolean
|
||||
children: SnapshotNode[]
|
||||
}
|
||||
|
||||
@@ -319,14 +303,266 @@ function buildTextLine(text: string, indent: number): SnapshotLine {
|
||||
return { text: `${prefix}- text: "${escaped}"` }
|
||||
}
|
||||
|
||||
function unindentLines(lines: SnapshotLine[]): SnapshotLine[] {
|
||||
return lines.map((line) => {
|
||||
return line.text.startsWith(' ')
|
||||
? { ...line, text: line.text.slice(2) }
|
||||
: line
|
||||
export function buildSnapshotLines(nodes: SnapshotNode[], indent = 0): SnapshotLine[] {
|
||||
return nodes.flatMap((node) => {
|
||||
const nodeIndent = indent + (node.indentOffset ?? 0)
|
||||
const line = node.role === 'text'
|
||||
? buildTextLine(node.name, nodeIndent)
|
||||
: buildSnapshotLine({
|
||||
role: node.role,
|
||||
name: node.name,
|
||||
baseLocator: node.baseLocator,
|
||||
indent: nodeIndent,
|
||||
hasChildren: node.children.length > 0,
|
||||
})
|
||||
return [line, ...buildSnapshotLines(node.children, nodeIndent + 1)]
|
||||
})
|
||||
}
|
||||
|
||||
function shiftIndent(nodes: SnapshotNode[], offset: number): SnapshotNode[] {
|
||||
return nodes.map((node) => {
|
||||
return { ...node, indentOffset: (node.indentOffset ?? 0) + offset }
|
||||
})
|
||||
}
|
||||
|
||||
export function buildRawSnapshotTree(options: {
|
||||
nodeId: Protocol.Accessibility.AXNodeId
|
||||
axById: Map<Protocol.Accessibility.AXNodeId, Protocol.Accessibility.AXNode>
|
||||
isNodeInScope: (node: Protocol.Accessibility.AXNode) => boolean
|
||||
}): SnapshotNode | null {
|
||||
const node = options.axById.get(options.nodeId)
|
||||
if (!node) {
|
||||
return null
|
||||
}
|
||||
|
||||
const role = getAxRole(node)
|
||||
const name = getAxValueString(node.name).trim()
|
||||
const children = (node.childIds ?? []).map((childId) => {
|
||||
return buildRawSnapshotTree({
|
||||
nodeId: childId,
|
||||
axById: options.axById,
|
||||
isNodeInScope: options.isNodeInScope,
|
||||
})
|
||||
}).filter(isTruthy)
|
||||
|
||||
const inScope = options.isNodeInScope(node) || children.length > 0
|
||||
if (!inScope) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
role,
|
||||
name,
|
||||
backendNodeId: node.backendDOMNodeId,
|
||||
ignored: node.ignored,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
export function filterInteractiveSnapshotTree(options: {
|
||||
node: SnapshotNode
|
||||
ancestorNames: string[]
|
||||
labelContext: boolean
|
||||
refFilter?: (entry: { role: string; name: string }) => boolean
|
||||
domByBackendId: Map<Protocol.DOM.BackendNodeId, DomNodeInfo>
|
||||
createRefForNode: (options: { backendNodeId?: Protocol.DOM.BackendNodeId; role: string; name: string }) => string | null
|
||||
}): { nodes: SnapshotNode[]; names: Set<string> } {
|
||||
const role = options.node.role
|
||||
const name = options.node.name
|
||||
const hasName = name.length > 0
|
||||
const nextAncestors = hasName ? [...options.ancestorNames, name] : options.ancestorNames
|
||||
|
||||
const isLabel = LABEL_ROLES.has(role)
|
||||
const nextLabelContext = options.labelContext || isLabel
|
||||
|
||||
const childResults = options.node.children.map((child) => {
|
||||
return filterInteractiveSnapshotTree({
|
||||
node: child,
|
||||
ancestorNames: nextAncestors,
|
||||
labelContext: nextLabelContext,
|
||||
refFilter: options.refFilter,
|
||||
domByBackendId: options.domByBackendId,
|
||||
createRefForNode: options.createRefForNode,
|
||||
})
|
||||
})
|
||||
const childNodes = childResults.flatMap((result) => {
|
||||
return result.nodes
|
||||
})
|
||||
const childNames = childResults.reduce((acc, result) => {
|
||||
result.names.forEach((childName) => {
|
||||
acc.add(childName)
|
||||
})
|
||||
return acc
|
||||
}, new Set<string>())
|
||||
|
||||
if (options.node.ignored) {
|
||||
return { nodes: shiftIndent(childNodes, 1), names: childNames }
|
||||
}
|
||||
|
||||
if (isTextRole(role)) {
|
||||
if (!hasName) {
|
||||
return { nodes: childNodes, names: childNames }
|
||||
}
|
||||
if (!options.labelContext) {
|
||||
return { nodes: childNodes, names: childNames }
|
||||
}
|
||||
const isRedundantText = options.ancestorNames.some((ancestor) => {
|
||||
return ancestor.includes(name) || name.includes(ancestor)
|
||||
})
|
||||
if (isRedundantText) {
|
||||
return { nodes: childNodes, names: childNames }
|
||||
}
|
||||
const names = new Set(childNames)
|
||||
names.add(name)
|
||||
const textNode: SnapshotNode = { role: 'text', name, children: [] }
|
||||
return { nodes: [textNode], names }
|
||||
}
|
||||
|
||||
const hasChildren = childNodes.length > 0
|
||||
const nameToUse = hasName && (childNames.has(name) || isSubstringOfAny(name, childNames)) ? '' : name
|
||||
const hasNameToUse = nameToUse.length > 0
|
||||
const isWrapper = SKIP_WRAPPER_ROLES.has(role)
|
||||
const isInteractive = INTERACTIVE_ROLES.has(role)
|
||||
const isContext = CONTEXT_ROLES.has(role)
|
||||
const passesRefFilter = !options.refFilter || options.refFilter({ role, name })
|
||||
const includeInteractive = isInteractive && passesRefFilter
|
||||
const shouldInclude = includeInteractive || isLabel || isContext || hasChildren
|
||||
if (!shouldInclude) {
|
||||
return { nodes: childNodes, names: childNames }
|
||||
}
|
||||
|
||||
if (!includeInteractive && !isLabel && !isContext) {
|
||||
if (!hasChildren) {
|
||||
return { nodes: [], names: childNames }
|
||||
}
|
||||
return { nodes: childNodes, names: childNames }
|
||||
}
|
||||
|
||||
if (isWrapper && !hasNameToUse) {
|
||||
if (!hasChildren) {
|
||||
return { nodes: [], names: childNames }
|
||||
}
|
||||
return { nodes: childNodes, names: childNames }
|
||||
}
|
||||
|
||||
let baseLocator: string | undefined
|
||||
let ref: string | null = null
|
||||
if (includeInteractive) {
|
||||
const domInfo = options.node.backendNodeId ? options.domByBackendId.get(options.node.backendNodeId) : undefined
|
||||
const stable = domInfo ? getStableRefFromAttributes(domInfo.attributes) : null
|
||||
baseLocator = buildBaseLocator({ role, name, stable })
|
||||
ref = options.createRefForNode({ backendNodeId: options.node.backendNodeId, role, name })
|
||||
}
|
||||
|
||||
const nodeEntry: SnapshotNode = {
|
||||
role,
|
||||
name: nameToUse,
|
||||
baseLocator,
|
||||
ref: ref ?? undefined,
|
||||
backendNodeId: options.node.backendNodeId,
|
||||
children: childNodes,
|
||||
}
|
||||
const names = new Set(childNames)
|
||||
if (hasNameToUse) {
|
||||
names.add(nameToUse)
|
||||
}
|
||||
return { nodes: [nodeEntry], names }
|
||||
}
|
||||
|
||||
export function filterFullSnapshotTree(options: {
|
||||
node: SnapshotNode
|
||||
ancestorNames: string[]
|
||||
refFilter?: (entry: { role: string; name: string }) => boolean
|
||||
domByBackendId: Map<Protocol.DOM.BackendNodeId, DomNodeInfo>
|
||||
createRefForNode: (options: { backendNodeId?: Protocol.DOM.BackendNodeId; role: string; name: string }) => string | null
|
||||
}): { nodes: SnapshotNode[]; names: Set<string> } {
|
||||
const role = options.node.role
|
||||
const name = options.node.name
|
||||
const hasName = name.length > 0
|
||||
const nextAncestors = hasName ? [...options.ancestorNames, name] : options.ancestorNames
|
||||
|
||||
const childResults = options.node.children.map((child) => {
|
||||
return filterFullSnapshotTree({
|
||||
node: child,
|
||||
ancestorNames: nextAncestors,
|
||||
refFilter: options.refFilter,
|
||||
domByBackendId: options.domByBackendId,
|
||||
createRefForNode: options.createRefForNode,
|
||||
})
|
||||
})
|
||||
const childNodes = childResults.flatMap((result) => {
|
||||
return result.nodes
|
||||
})
|
||||
const childNames = childResults.reduce((acc, result) => {
|
||||
result.names.forEach((childName) => {
|
||||
acc.add(childName)
|
||||
})
|
||||
return acc
|
||||
}, new Set<string>())
|
||||
|
||||
if (options.node.ignored) {
|
||||
return { nodes: shiftIndent(childNodes, 1), names: childNames }
|
||||
}
|
||||
|
||||
if (isTextRole(role)) {
|
||||
if (!hasName) {
|
||||
return { nodes: childNodes, names: childNames }
|
||||
}
|
||||
const isRedundantText = options.ancestorNames.some((ancestor) => {
|
||||
return ancestor.includes(name) || name.includes(ancestor)
|
||||
})
|
||||
if (isRedundantText) {
|
||||
return { nodes: childNodes, names: childNames }
|
||||
}
|
||||
const names = new Set(childNames)
|
||||
names.add(name)
|
||||
const textNode: SnapshotNode = { role: 'text', name, children: [] }
|
||||
return { nodes: [textNode], names }
|
||||
}
|
||||
|
||||
const hasChildren = childNodes.length > 0
|
||||
const nameToUse = hasName && (childNames.has(name) || isSubstringOfAny(name, childNames)) ? '' : name
|
||||
const hasNameToUse = nameToUse.length > 0
|
||||
const isWrapper = SKIP_WRAPPER_ROLES.has(role)
|
||||
const isInteractive = INTERACTIVE_ROLES.has(role)
|
||||
const passesRefFilter = !options.refFilter || options.refFilter({ role, name })
|
||||
const includeInteractive = isInteractive && passesRefFilter
|
||||
const shouldInclude = includeInteractive || hasNameToUse || hasChildren
|
||||
if (!shouldInclude) {
|
||||
return { nodes: childNodes, names: childNames }
|
||||
}
|
||||
|
||||
if (isWrapper && !hasNameToUse) {
|
||||
if (!hasChildren) {
|
||||
return { nodes: [], names: childNames }
|
||||
}
|
||||
return { nodes: childNodes, names: childNames }
|
||||
}
|
||||
|
||||
let baseLocator: string | undefined
|
||||
let ref: string | null = null
|
||||
if (includeInteractive) {
|
||||
const domInfo = options.node.backendNodeId ? options.domByBackendId.get(options.node.backendNodeId) : undefined
|
||||
const stable = domInfo ? getStableRefFromAttributes(domInfo.attributes) : null
|
||||
baseLocator = buildBaseLocator({ role, name, stable })
|
||||
ref = options.createRefForNode({ backendNodeId: options.node.backendNodeId, role, name })
|
||||
}
|
||||
|
||||
const nodeEntry: SnapshotNode = {
|
||||
role,
|
||||
name: nameToUse,
|
||||
baseLocator,
|
||||
ref: ref ?? undefined,
|
||||
backendNodeId: options.node.backendNodeId,
|
||||
children: childNodes,
|
||||
}
|
||||
const names = new Set(childNames)
|
||||
if (hasNameToUse) {
|
||||
names.add(nameToUse)
|
||||
}
|
||||
return { nodes: [nodeEntry], names }
|
||||
}
|
||||
|
||||
function buildLocatorLineText({ line, locator }: { line: SnapshotLine; locator: string }): string {
|
||||
const prefix = ' '.repeat(line.indent ?? 0)
|
||||
const role = line.role ?? ''
|
||||
@@ -348,7 +584,7 @@ function buildLocatorLineText({ line, locator }: { line: SnapshotLine; locator:
|
||||
return `${base} ${locator}`
|
||||
}
|
||||
|
||||
function finalizeSnapshotOutput(
|
||||
export function finalizeSnapshotOutput(
|
||||
lines: SnapshotLine[],
|
||||
nodes: SnapshotNode[],
|
||||
shortRefMap: Map<string, string>,
|
||||
@@ -592,12 +828,16 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
|
||||
let fallbackCounter = 0
|
||||
const refs: AriaRefDraft[] = []
|
||||
|
||||
const createRefForNode = (node: Protocol.Accessibility.AXNode, role: string, name: string): string | null => {
|
||||
if (!INTERACTIVE_ROLES.has(role)) {
|
||||
const createRefForNode = (options: {
|
||||
backendNodeId?: Protocol.DOM.BackendNodeId
|
||||
role: string
|
||||
name: string
|
||||
}): string | null => {
|
||||
if (!INTERACTIVE_ROLES.has(options.role)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const domInfo = node.backendDOMNodeId ? domByBackendId.get(node.backendDOMNodeId) : undefined
|
||||
const domInfo = options.backendNodeId ? domByBackendId.get(options.backendNodeId) : undefined
|
||||
const stable = domInfo ? getStableRefFromAttributes(domInfo.attributes) : null
|
||||
let baseRef = stable?.value
|
||||
if (!baseRef) {
|
||||
@@ -614,7 +854,7 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
|
||||
selector = buildLocatorFromStable(stable)
|
||||
}
|
||||
|
||||
refs.push({ ref, role, name, selector, backendNodeId: node.backendDOMNodeId })
|
||||
refs.push({ ref, role: options.role, name: options.name, selector, backendNodeId: options.backendNodeId })
|
||||
return ref
|
||||
}
|
||||
|
||||
@@ -628,155 +868,41 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
|
||||
return allowedBackendIds.has(node.backendDOMNodeId)
|
||||
}
|
||||
|
||||
const buildLines = (
|
||||
nodeId: Protocol.Accessibility.AXNodeId,
|
||||
indent: number,
|
||||
ancestorNames: string[],
|
||||
labelContext: boolean
|
||||
): { lines: SnapshotLine[]; nodes: SnapshotNode[]; included: boolean; names: Set<string> } => {
|
||||
const node = axById.get(nodeId)
|
||||
if (!node) {
|
||||
return { lines: [], nodes: [], included: false, names: new Set() }
|
||||
}
|
||||
|
||||
const role = getAxRole(node)
|
||||
const name = getAxValueString(node.name).trim()
|
||||
const hasName = name.length > 0
|
||||
const nextAncestors = hasName ? [...ancestorNames, name] : ancestorNames
|
||||
|
||||
const isLabel = LABEL_ROLES.has(role)
|
||||
const nextLabelContext = labelContext || isLabel
|
||||
|
||||
const childResults = (node.childIds ?? []).map((childId) => {
|
||||
return buildLines(childId, indent + 1, nextAncestors, nextLabelContext)
|
||||
})
|
||||
const childLines = childResults.flatMap((result) => {
|
||||
return result.lines
|
||||
})
|
||||
const childNodes = childResults.flatMap((result) => {
|
||||
return result.nodes
|
||||
})
|
||||
const childIncluded = childResults.some((result) => {
|
||||
return result.included
|
||||
})
|
||||
const childNames = childResults.reduce((acc, result) => {
|
||||
for (const childName of result.names) {
|
||||
acc.add(childName)
|
||||
}
|
||||
return acc
|
||||
}, new Set<string>())
|
||||
|
||||
const inScope = isNodeInScope(node) || childIncluded
|
||||
if (!inScope) {
|
||||
return { lines: [], nodes: [], included: false, names: new Set() }
|
||||
}
|
||||
|
||||
if (node.ignored) {
|
||||
return { lines: childLines, nodes: childNodes, included: true, names: childNames }
|
||||
}
|
||||
|
||||
if (isTextRole(role)) {
|
||||
if (!hasName) {
|
||||
return { lines: childLines, nodes: childNodes, included: true, names: childNames }
|
||||
}
|
||||
if (interactiveOnly && !labelContext) {
|
||||
return { lines: childLines, nodes: childNodes, included: true, names: childNames }
|
||||
}
|
||||
const isRedundantText = ancestorNames.some((ancestor) => {
|
||||
return ancestor.includes(name) || name.includes(ancestor)
|
||||
})
|
||||
if (isRedundantText) {
|
||||
return { lines: childLines, nodes: childNodes, included: true, names: childNames }
|
||||
}
|
||||
const names = new Set(childNames)
|
||||
names.add(name)
|
||||
const textLine = buildTextLine(name, indent)
|
||||
const textNode: SnapshotNode = { role: 'text', name, children: [] }
|
||||
return { lines: [textLine], nodes: [textNode], included: true, names }
|
||||
}
|
||||
|
||||
const hasChildren = childLines.length > 0
|
||||
const nameToUse = hasName && (childNames.has(name) || isSubstringOfAny(name, childNames)) ? '' : name
|
||||
const hasNameToUse = nameToUse.length > 0
|
||||
const isWrapper = SKIP_WRAPPER_ROLES.has(role)
|
||||
const isInteractive = INTERACTIVE_ROLES.has(role)
|
||||
const isContext = CONTEXT_ROLES.has(role)
|
||||
const passesRefFilter = !refFilter || refFilter({ role, name })
|
||||
const includeInteractive = isInteractive && passesRefFilter
|
||||
const shouldInclude = interactiveOnly
|
||||
? includeInteractive || isLabel || isContext || hasChildren
|
||||
: includeInteractive || hasNameToUse || hasChildren
|
||||
if (!shouldInclude) {
|
||||
return { lines: childLines, nodes: childNodes, included: true, names: childNames }
|
||||
}
|
||||
|
||||
if (interactiveOnly && !includeInteractive && !isLabel && !isContext) {
|
||||
if (!hasChildren) {
|
||||
return { lines: [], nodes: [], included: true, names: childNames }
|
||||
}
|
||||
return { lines: unindentLines(childLines), nodes: childNodes, included: true, names: childNames }
|
||||
}
|
||||
|
||||
if (isWrapper && !hasNameToUse) {
|
||||
if (!hasChildren) {
|
||||
return { lines: [], nodes: [], included: true, names: childNames }
|
||||
}
|
||||
return { lines: unindentLines(childLines), nodes: childNodes, included: true, names: childNames }
|
||||
}
|
||||
|
||||
let baseLocator: string | undefined
|
||||
let ref: string | null = null
|
||||
if (includeInteractive) {
|
||||
const domInfo = node.backendDOMNodeId ? domByBackendId.get(node.backendDOMNodeId) : undefined
|
||||
const stable = domInfo ? getStableRefFromAttributes(domInfo.attributes) : null
|
||||
baseLocator = buildBaseLocator({ role, name, stable })
|
||||
ref = createRefForNode(node, role, name)
|
||||
}
|
||||
|
||||
const line = buildSnapshotLine({
|
||||
role,
|
||||
name: nameToUse,
|
||||
baseLocator,
|
||||
indent,
|
||||
hasChildren,
|
||||
})
|
||||
const nodeEntry: SnapshotNode = {
|
||||
role,
|
||||
name: nameToUse,
|
||||
baseLocator,
|
||||
ref: ref ?? undefined,
|
||||
backendNodeId: node.backendDOMNodeId,
|
||||
children: childNodes,
|
||||
}
|
||||
const names = new Set(childNames)
|
||||
if (hasNameToUse) {
|
||||
names.add(nameToUse)
|
||||
}
|
||||
return { lines: [line, ...childLines], nodes: [nodeEntry], included: true, names }
|
||||
}
|
||||
|
||||
let snapshotLines: SnapshotLine[] = []
|
||||
let snapshotNodes: SnapshotNode[] = []
|
||||
if (rootAxNodeId) {
|
||||
const rootNode = axById.get(rootAxNodeId)
|
||||
const rootRole = rootNode ? getAxRole(rootNode) : ''
|
||||
if (rootNode && (rootRole === 'rootwebarea' || rootRole === 'webarea') && rootNode.childIds) {
|
||||
const childResults = rootNode.childIds.map((childId) => {
|
||||
return buildLines(childId, 0, [], false)
|
||||
})
|
||||
snapshotLines = childResults.flatMap((result) => {
|
||||
return result.lines
|
||||
})
|
||||
snapshotNodes = childResults.flatMap((result) => {
|
||||
return result.nodes
|
||||
})
|
||||
} else {
|
||||
const result = buildLines(rootAxNodeId, 0, [], false)
|
||||
snapshotLines = result.lines
|
||||
snapshotNodes = result.nodes
|
||||
}
|
||||
const rawRoots = rootNode && (rootRole === 'rootwebarea' || rootRole === 'webarea') && rootNode.childIds
|
||||
? rootNode.childIds.map((childId) => {
|
||||
return buildRawSnapshotTree({ nodeId: childId, axById, isNodeInScope })
|
||||
}).filter(isTruthy)
|
||||
: [buildRawSnapshotTree({ nodeId: rootAxNodeId, axById, isNodeInScope })].filter(isTruthy)
|
||||
|
||||
const filtered = rawRoots.flatMap((rawNode) => {
|
||||
if (interactiveOnly) {
|
||||
return filterInteractiveSnapshotTree({
|
||||
node: rawNode,
|
||||
ancestorNames: [],
|
||||
labelContext: false,
|
||||
refFilter,
|
||||
domByBackendId,
|
||||
createRefForNode,
|
||||
}).nodes
|
||||
}
|
||||
return filterFullSnapshotTree({
|
||||
node: rawNode,
|
||||
ancestorNames: [],
|
||||
refFilter,
|
||||
domByBackendId,
|
||||
createRefForNode,
|
||||
}).nodes
|
||||
})
|
||||
snapshotNodes = filtered
|
||||
}
|
||||
|
||||
const snapshotLines = buildSnapshotLines(snapshotNodes)
|
||||
|
||||
const shortRefMap = buildShortRefMap({ refs })
|
||||
const finalized = finalizeSnapshotOutput(snapshotLines, snapshotNodes, shortRefMap)
|
||||
const refsWithShortRef: Array<AriaRef & { selector?: string }> = refs.map((entry) => {
|
||||
@@ -1008,7 +1134,7 @@ async function getLabelBoxesForRefs({
|
||||
* await page.locator('[data-testid="submit-btn"]').click()
|
||||
* ```
|
||||
*/
|
||||
export async function showAriaRefLabels({ page, locator, interactiveOnly = true, wsUrl, logger }: {
|
||||
export async function showAriaRefLabels({ page, locator, interactiveOnly = false, wsUrl, logger }: {
|
||||
page: Page
|
||||
locator?: Locator
|
||||
interactiveOnly?: boolean
|
||||
@@ -1119,7 +1245,7 @@ export async function hideAriaRefLabels({ page }: { page: Page }): Promise<void>
|
||||
* await page.locator('[data-testid="submit-btn"]').click()
|
||||
* ```
|
||||
*/
|
||||
export async function screenshotWithAccessibilityLabels({ page, locator, interactiveOnly = true, wsUrl, collector, logger }: {
|
||||
export async function screenshotWithAccessibilityLabels({ page, locator, interactiveOnly = false, wsUrl, collector, logger }: {
|
||||
page: Page
|
||||
locator?: Locator
|
||||
interactiveOnly?: boolean
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Protocol } from 'devtools-protocol'
|
||||
import {
|
||||
buildRawSnapshotTree,
|
||||
buildSnapshotLines,
|
||||
filterFullSnapshotTree,
|
||||
filterInteractiveSnapshotTree,
|
||||
finalizeSnapshotOutput,
|
||||
type SnapshotNode,
|
||||
} from './aria-snapshot.js'
|
||||
|
||||
const roleValue = (value: string): Protocol.Accessibility.AXValue => {
|
||||
return { type: 'role', value }
|
||||
}
|
||||
|
||||
const nameValue = (value: string): Protocol.Accessibility.AXValue => {
|
||||
return { type: 'string', value }
|
||||
}
|
||||
|
||||
describe('aria-snapshot tree filters', () => {
|
||||
it('builds a raw snapshot tree with scope pruning', () => {
|
||||
const rootId = '1' as Protocol.Accessibility.AXNodeId
|
||||
const mainId = '2' as Protocol.Accessibility.AXNodeId
|
||||
const navId = '3' as Protocol.Accessibility.AXNodeId
|
||||
const listId = '4' as Protocol.Accessibility.AXNodeId
|
||||
const listItemId = '5' as Protocol.Accessibility.AXNodeId
|
||||
const linkId = '6' as Protocol.Accessibility.AXNodeId
|
||||
const headingId = '7' as Protocol.Accessibility.AXNodeId
|
||||
const buttonId = '8' as Protocol.Accessibility.AXNodeId
|
||||
|
||||
const axById = new Map<Protocol.Accessibility.AXNodeId, Protocol.Accessibility.AXNode>([
|
||||
[rootId, {
|
||||
nodeId: rootId,
|
||||
ignored: false,
|
||||
role: roleValue('rootwebarea'),
|
||||
childIds: [mainId, navId],
|
||||
}],
|
||||
[mainId, {
|
||||
nodeId: mainId,
|
||||
ignored: false,
|
||||
role: roleValue('main'),
|
||||
childIds: [headingId, buttonId],
|
||||
backendDOMNodeId: 200 as Protocol.DOM.BackendNodeId,
|
||||
}],
|
||||
[navId, {
|
||||
nodeId: navId,
|
||||
ignored: false,
|
||||
role: roleValue('navigation'),
|
||||
childIds: [listId],
|
||||
backendDOMNodeId: 201 as Protocol.DOM.BackendNodeId,
|
||||
}],
|
||||
[listId, {
|
||||
nodeId: listId,
|
||||
ignored: false,
|
||||
role: roleValue('list'),
|
||||
childIds: [listItemId],
|
||||
backendDOMNodeId: 202 as Protocol.DOM.BackendNodeId,
|
||||
}],
|
||||
[listItemId, {
|
||||
nodeId: listItemId,
|
||||
ignored: false,
|
||||
role: roleValue('listitem'),
|
||||
childIds: [linkId],
|
||||
backendDOMNodeId: 203 as Protocol.DOM.BackendNodeId,
|
||||
}],
|
||||
[linkId, {
|
||||
nodeId: linkId,
|
||||
ignored: false,
|
||||
role: roleValue('link'),
|
||||
name: nameValue('Docs'),
|
||||
backendDOMNodeId: 204 as Protocol.DOM.BackendNodeId,
|
||||
}],
|
||||
[headingId, {
|
||||
nodeId: headingId,
|
||||
ignored: false,
|
||||
role: roleValue('heading'),
|
||||
name: nameValue('Title'),
|
||||
backendDOMNodeId: 205 as Protocol.DOM.BackendNodeId,
|
||||
}],
|
||||
[buttonId, {
|
||||
nodeId: buttonId,
|
||||
ignored: false,
|
||||
role: roleValue('button'),
|
||||
name: nameValue('Submit'),
|
||||
backendDOMNodeId: 206 as Protocol.DOM.BackendNodeId,
|
||||
}],
|
||||
])
|
||||
|
||||
const allowed = new Set<Protocol.DOM.BackendNodeId>([204 as Protocol.DOM.BackendNodeId])
|
||||
const isNodeInScope = (node: Protocol.Accessibility.AXNode): boolean => {
|
||||
return Boolean(node.backendDOMNodeId && allowed.has(node.backendDOMNodeId))
|
||||
}
|
||||
|
||||
const rawTree = buildRawSnapshotTree({ nodeId: rootId, axById, isNodeInScope })
|
||||
expect(rawTree).toMatchInlineSnapshot(`
|
||||
{
|
||||
"backendNodeId": undefined,
|
||||
"children": [
|
||||
{
|
||||
"backendNodeId": 201,
|
||||
"children": [
|
||||
{
|
||||
"backendNodeId": 202,
|
||||
"children": [
|
||||
{
|
||||
"backendNodeId": 203,
|
||||
"children": [
|
||||
{
|
||||
"backendNodeId": 204,
|
||||
"children": [],
|
||||
"ignored": false,
|
||||
"name": "Docs",
|
||||
"role": "link",
|
||||
},
|
||||
],
|
||||
"ignored": false,
|
||||
"name": "",
|
||||
"role": "listitem",
|
||||
},
|
||||
],
|
||||
"ignored": false,
|
||||
"name": "",
|
||||
"role": "list",
|
||||
},
|
||||
],
|
||||
"ignored": false,
|
||||
"name": "",
|
||||
"role": "navigation",
|
||||
},
|
||||
],
|
||||
"ignored": false,
|
||||
"name": "",
|
||||
"role": "rootwebarea",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('filters interactive-only trees with labels and wrapper hoisting', () => {
|
||||
const rawTree: SnapshotNode = {
|
||||
role: 'main',
|
||||
name: '',
|
||||
ignored: false,
|
||||
children: [
|
||||
{
|
||||
role: 'navigation',
|
||||
name: '',
|
||||
ignored: false,
|
||||
children: [
|
||||
{ role: 'link', name: 'Home', backendNodeId: 2 as Protocol.DOM.BackendNodeId, children: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'labeltext',
|
||||
name: '',
|
||||
ignored: false,
|
||||
children: [
|
||||
{ role: 'statictext', name: 'Email', ignored: false, children: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'generic',
|
||||
name: '',
|
||||
ignored: false,
|
||||
children: [
|
||||
{ role: 'button', name: 'Save', backendNodeId: 1 as Protocol.DOM.BackendNodeId, children: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'generic',
|
||||
name: 'Wrapper',
|
||||
ignored: false,
|
||||
children: [
|
||||
{ role: 'statictext', name: 'Wrapper', ignored: false, children: [] },
|
||||
{ role: 'statictext', name: 'Hint', ignored: false, children: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'generic',
|
||||
name: '',
|
||||
ignored: true,
|
||||
children: [
|
||||
{ role: 'button', name: 'Ignored Action', backendNodeId: 3 as Protocol.DOM.BackendNodeId, children: [] },
|
||||
],
|
||||
},
|
||||
{ role: 'heading', name: 'Settings', ignored: false, children: [] },
|
||||
],
|
||||
}
|
||||
|
||||
const domByBackendId = new Map<Protocol.DOM.BackendNodeId, {
|
||||
nodeId: Protocol.DOM.NodeId
|
||||
parentId?: Protocol.DOM.NodeId
|
||||
backendNodeId: Protocol.DOM.BackendNodeId
|
||||
nodeName: string
|
||||
attributes: Map<string, string>
|
||||
}>([
|
||||
[1 as Protocol.DOM.BackendNodeId, {
|
||||
nodeId: 10 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 1 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'BUTTON',
|
||||
attributes: new Map([['id', 'save-btn']]),
|
||||
}],
|
||||
[2 as Protocol.DOM.BackendNodeId, {
|
||||
nodeId: 11 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 2 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'A',
|
||||
attributes: new Map([['data-testid', 'nav-home']]),
|
||||
}],
|
||||
[3 as Protocol.DOM.BackendNodeId, {
|
||||
nodeId: 12 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 3 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'BUTTON',
|
||||
attributes: new Map([['id', 'ignored-action']]),
|
||||
}],
|
||||
])
|
||||
|
||||
let refCounter = 0
|
||||
const createRefForNode = (options: { backendNodeId?: Protocol.DOM.BackendNodeId; role: string; name: string }): string => {
|
||||
refCounter += 1
|
||||
return `${options.role}-${options.name}-${refCounter}`
|
||||
}
|
||||
|
||||
const filtered = filterInteractiveSnapshotTree({
|
||||
node: rawTree,
|
||||
ancestorNames: [],
|
||||
labelContext: false,
|
||||
domByBackendId,
|
||||
createRefForNode,
|
||||
})
|
||||
|
||||
expect(filtered).toMatchInlineSnapshot(`
|
||||
{
|
||||
"names": Set {
|
||||
"Home",
|
||||
"Email",
|
||||
"Save",
|
||||
"Ignored Action",
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"backendNodeId": undefined,
|
||||
"baseLocator": undefined,
|
||||
"children": [
|
||||
{
|
||||
"backendNodeId": undefined,
|
||||
"baseLocator": undefined,
|
||||
"children": [
|
||||
{
|
||||
"backendNodeId": 2,
|
||||
"baseLocator": "[data-testid="nav-home"]",
|
||||
"children": [],
|
||||
"name": "Home",
|
||||
"ref": "link-Home-1",
|
||||
"role": "link",
|
||||
},
|
||||
],
|
||||
"name": "",
|
||||
"ref": undefined,
|
||||
"role": "navigation",
|
||||
},
|
||||
{
|
||||
"backendNodeId": undefined,
|
||||
"baseLocator": undefined,
|
||||
"children": [
|
||||
{
|
||||
"children": [],
|
||||
"name": "Email",
|
||||
"role": "text",
|
||||
},
|
||||
],
|
||||
"name": "",
|
||||
"ref": undefined,
|
||||
"role": "labeltext",
|
||||
},
|
||||
{
|
||||
"backendNodeId": 1,
|
||||
"baseLocator": "[id="save-btn"]",
|
||||
"children": [],
|
||||
"name": "Save",
|
||||
"ref": "button-Save-2",
|
||||
"role": "button",
|
||||
},
|
||||
{
|
||||
"backendNodeId": 3,
|
||||
"baseLocator": "[id="ignored-action"]",
|
||||
"children": [],
|
||||
"indentOffset": 1,
|
||||
"name": "Ignored Action",
|
||||
"ref": "button-Ignored Action-3",
|
||||
"role": "button",
|
||||
},
|
||||
],
|
||||
"name": "",
|
||||
"ref": undefined,
|
||||
"role": "main",
|
||||
},
|
||||
],
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('generates locator output for full snapshot trees', () => {
|
||||
const rawTree: SnapshotNode = {
|
||||
role: 'form',
|
||||
name: 'Account',
|
||||
ignored: false,
|
||||
children: [
|
||||
{ role: 'textbox', name: 'Email', backendNodeId: 2 as Protocol.DOM.BackendNodeId, children: [] },
|
||||
{
|
||||
role: 'group',
|
||||
name: '',
|
||||
ignored: false,
|
||||
children: [
|
||||
{ role: 'button', name: 'Save', backendNodeId: 3 as Protocol.DOM.BackendNodeId, children: [] },
|
||||
{ role: 'button', name: 'Save', backendNodeId: 4 as Protocol.DOM.BackendNodeId, children: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const domByBackendId = new Map<Protocol.DOM.BackendNodeId, {
|
||||
nodeId: Protocol.DOM.NodeId
|
||||
parentId?: Protocol.DOM.NodeId
|
||||
backendNodeId: Protocol.DOM.BackendNodeId
|
||||
nodeName: string
|
||||
attributes: Map<string, string>
|
||||
}>([
|
||||
[2 as Protocol.DOM.BackendNodeId, {
|
||||
nodeId: 20 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 2 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'INPUT',
|
||||
attributes: new Map([['data-testid', 'email-input']]),
|
||||
}],
|
||||
[3 as Protocol.DOM.BackendNodeId, {
|
||||
nodeId: 21 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 3 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'BUTTON',
|
||||
attributes: new Map([['id', 'save-primary']]),
|
||||
}],
|
||||
[4 as Protocol.DOM.BackendNodeId, {
|
||||
nodeId: 22 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 4 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'BUTTON',
|
||||
attributes: new Map([['id', 'save-secondary']]),
|
||||
}],
|
||||
])
|
||||
|
||||
let refCounter = 0
|
||||
const createRefForNode = (): string => {
|
||||
refCounter += 1
|
||||
return `e${refCounter}`
|
||||
}
|
||||
|
||||
const filtered = filterFullSnapshotTree({
|
||||
node: rawTree,
|
||||
ancestorNames: [],
|
||||
domByBackendId,
|
||||
createRefForNode,
|
||||
})
|
||||
|
||||
const lines = buildSnapshotLines(filtered.nodes)
|
||||
const result = finalizeSnapshotOutput(lines, filtered.nodes, new Map())
|
||||
expect(result.snapshot).toMatchInlineSnapshot(`
|
||||
"- form "Account":
|
||||
- textbox "Email" [data-testid="email-input"]
|
||||
- button "Save" [id="save-primary"]
|
||||
- button "Save" [id="save-secondary"]"
|
||||
`)
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
{
|
||||
"snapshot": "- form "Account":
|
||||
- textbox "Email" [data-testid="email-input"]
|
||||
- button "Save" [id="save-primary"]
|
||||
- button "Save" [id="save-secondary"]",
|
||||
"tree": [
|
||||
{
|
||||
"backendNodeId": undefined,
|
||||
"children": [
|
||||
{
|
||||
"backendNodeId": 2,
|
||||
"children": [],
|
||||
"locator": "[data-testid="email-input"]",
|
||||
"name": "Email",
|
||||
"ref": "e1",
|
||||
"role": "textbox",
|
||||
"shortRef": "e1",
|
||||
},
|
||||
{
|
||||
"backendNodeId": 3,
|
||||
"children": [],
|
||||
"locator": "[id="save-primary"]",
|
||||
"name": "Save",
|
||||
"ref": "e2",
|
||||
"role": "button",
|
||||
"shortRef": "e2",
|
||||
},
|
||||
{
|
||||
"backendNodeId": 4,
|
||||
"children": [],
|
||||
"locator": "[id="save-secondary"]",
|
||||
"name": "Save",
|
||||
"ref": "e3",
|
||||
"role": "button",
|
||||
"shortRef": "e3",
|
||||
},
|
||||
],
|
||||
"locator": undefined,
|
||||
"name": "Account",
|
||||
"ref": undefined,
|
||||
"role": "form",
|
||||
"shortRef": undefined,
|
||||
},
|
||||
],
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('drops redundant text and preserves named wrappers in full snapshots', () => {
|
||||
const rawTree: SnapshotNode = {
|
||||
role: 'section',
|
||||
name: 'Billing',
|
||||
ignored: false,
|
||||
children: [
|
||||
{
|
||||
role: 'generic',
|
||||
name: 'Card',
|
||||
ignored: false,
|
||||
children: [
|
||||
{ role: 'statictext', name: 'Card', ignored: false, children: [] },
|
||||
{ role: 'statictext', name: 'Card number', ignored: false, children: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const domByBackendId = new Map<Protocol.DOM.BackendNodeId, {
|
||||
nodeId: Protocol.DOM.NodeId
|
||||
parentId?: Protocol.DOM.NodeId
|
||||
backendNodeId: Protocol.DOM.BackendNodeId
|
||||
nodeName: string
|
||||
attributes: Map<string, string>
|
||||
}>()
|
||||
|
||||
const createRefForNode = (): string | null => {
|
||||
return null
|
||||
}
|
||||
|
||||
const filtered = filterFullSnapshotTree({
|
||||
node: rawTree,
|
||||
ancestorNames: [],
|
||||
domByBackendId,
|
||||
createRefForNode,
|
||||
})
|
||||
|
||||
expect(filtered).toMatchInlineSnapshot(`
|
||||
{
|
||||
"names": Set {
|
||||
"Card",
|
||||
"Billing",
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"backendNodeId": undefined,
|
||||
"baseLocator": undefined,
|
||||
"children": [
|
||||
{
|
||||
"backendNodeId": undefined,
|
||||
"baseLocator": undefined,
|
||||
"children": [],
|
||||
"name": "Card",
|
||||
"ref": undefined,
|
||||
"role": "generic",
|
||||
},
|
||||
],
|
||||
"name": "Billing",
|
||||
"ref": undefined,
|
||||
"role": "section",
|
||||
},
|
||||
],
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('respects refFilter in interactive-only snapshots', () => {
|
||||
const rawTree: SnapshotNode = {
|
||||
role: 'main',
|
||||
name: '',
|
||||
ignored: false,
|
||||
children: [
|
||||
{ role: 'button', name: 'Delete', backendNodeId: 5 as Protocol.DOM.BackendNodeId, children: [] },
|
||||
{ role: 'button', name: 'Save', backendNodeId: 6 as Protocol.DOM.BackendNodeId, children: [] },
|
||||
],
|
||||
}
|
||||
|
||||
const domByBackendId = new Map<Protocol.DOM.BackendNodeId, {
|
||||
nodeId: Protocol.DOM.NodeId
|
||||
parentId?: Protocol.DOM.NodeId
|
||||
backendNodeId: Protocol.DOM.BackendNodeId
|
||||
nodeName: string
|
||||
attributes: Map<string, string>
|
||||
}>([
|
||||
[5 as Protocol.DOM.BackendNodeId, {
|
||||
nodeId: 30 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 5 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'BUTTON',
|
||||
attributes: new Map([['id', 'delete']]),
|
||||
}],
|
||||
[6 as Protocol.DOM.BackendNodeId, {
|
||||
nodeId: 31 as Protocol.DOM.NodeId,
|
||||
backendNodeId: 6 as Protocol.DOM.BackendNodeId,
|
||||
nodeName: 'BUTTON',
|
||||
attributes: new Map([['id', 'save']]),
|
||||
}],
|
||||
])
|
||||
|
||||
let refCounter = 0
|
||||
const createRefForNode = (): string => {
|
||||
refCounter += 1
|
||||
return `e${refCounter}`
|
||||
}
|
||||
|
||||
const filtered = filterInteractiveSnapshotTree({
|
||||
node: rawTree,
|
||||
ancestorNames: [],
|
||||
labelContext: false,
|
||||
domByBackendId,
|
||||
createRefForNode,
|
||||
refFilter: ({ name }) => name !== 'Delete',
|
||||
})
|
||||
|
||||
expect(filtered).toMatchInlineSnapshot(`
|
||||
{
|
||||
"names": Set {
|
||||
"Save",
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"backendNodeId": undefined,
|
||||
"baseLocator": undefined,
|
||||
"children": [
|
||||
{
|
||||
"backendNodeId": 6,
|
||||
"baseLocator": "[id="save"]",
|
||||
"children": [],
|
||||
"name": "Save",
|
||||
"ref": "e1",
|
||||
"role": "button",
|
||||
},
|
||||
],
|
||||
"name": "",
|
||||
"ref": undefined,
|
||||
"role": "main",
|
||||
},
|
||||
],
|
||||
}
|
||||
`)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user