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 type { Page, Locator, ElementHandle } from 'playwright-core'
|
||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
@@ -8,33 +11,12 @@ import { Sema } from 'async-sema'
|
|||||||
import type { ICDPSession } from './cdp-session.js'
|
import type { ICDPSession } from './cdp-session.js'
|
||||||
import { getCDPSessionForPage } from './cdp-session.js'
|
import { getCDPSessionForPage } from './cdp-session.js'
|
||||||
|
|
||||||
|
|
||||||
// Import sharp at module level - resolves to null if not available
|
// Import sharp at module level - resolves to null if not available
|
||||||
const sharpPromise = import('sharp')
|
const sharpPromise = import('sharp')
|
||||||
.then((m) => { return m.default })
|
.then((m) => { return m.default })
|
||||||
.catch(() => { return null })
|
.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
|
// Snapshot Format Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -279,7 +261,7 @@ function getAxRole(node: Protocol.Accessibility.AXNode): string {
|
|||||||
return role.toLowerCase()
|
return role.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
type SnapshotLine = {
|
export type SnapshotLine = {
|
||||||
text: string
|
text: string
|
||||||
baseLocator?: string
|
baseLocator?: string
|
||||||
hasChildren?: boolean
|
hasChildren?: boolean
|
||||||
@@ -288,12 +270,14 @@ type SnapshotLine = {
|
|||||||
indent?: number
|
indent?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type SnapshotNode = {
|
export type SnapshotNode = {
|
||||||
role: string
|
role: string
|
||||||
name: string
|
name: string
|
||||||
baseLocator?: string
|
baseLocator?: string
|
||||||
ref?: string
|
ref?: string
|
||||||
backendNodeId?: Protocol.DOM.BackendNodeId
|
backendNodeId?: Protocol.DOM.BackendNodeId
|
||||||
|
indentOffset?: number
|
||||||
|
ignored?: boolean
|
||||||
children: SnapshotNode[]
|
children: SnapshotNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,14 +303,266 @@ function buildTextLine(text: string, indent: number): SnapshotLine {
|
|||||||
return { text: `${prefix}- text: "${escaped}"` }
|
return { text: `${prefix}- text: "${escaped}"` }
|
||||||
}
|
}
|
||||||
|
|
||||||
function unindentLines(lines: SnapshotLine[]): SnapshotLine[] {
|
export function buildSnapshotLines(nodes: SnapshotNode[], indent = 0): SnapshotLine[] {
|
||||||
return lines.map((line) => {
|
return nodes.flatMap((node) => {
|
||||||
return line.text.startsWith(' ')
|
const nodeIndent = indent + (node.indentOffset ?? 0)
|
||||||
? { ...line, text: line.text.slice(2) }
|
const line = node.role === 'text'
|
||||||
: line
|
? 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 {
|
function buildLocatorLineText({ line, locator }: { line: SnapshotLine; locator: string }): string {
|
||||||
const prefix = ' '.repeat(line.indent ?? 0)
|
const prefix = ' '.repeat(line.indent ?? 0)
|
||||||
const role = line.role ?? ''
|
const role = line.role ?? ''
|
||||||
@@ -348,7 +584,7 @@ function buildLocatorLineText({ line, locator }: { line: SnapshotLine; locator:
|
|||||||
return `${base} ${locator}`
|
return `${base} ${locator}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function finalizeSnapshotOutput(
|
export function finalizeSnapshotOutput(
|
||||||
lines: SnapshotLine[],
|
lines: SnapshotLine[],
|
||||||
nodes: SnapshotNode[],
|
nodes: SnapshotNode[],
|
||||||
shortRefMap: Map<string, string>,
|
shortRefMap: Map<string, string>,
|
||||||
@@ -592,12 +828,16 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
|
|||||||
let fallbackCounter = 0
|
let fallbackCounter = 0
|
||||||
const refs: AriaRefDraft[] = []
|
const refs: AriaRefDraft[] = []
|
||||||
|
|
||||||
const createRefForNode = (node: Protocol.Accessibility.AXNode, role: string, name: string): string | null => {
|
const createRefForNode = (options: {
|
||||||
if (!INTERACTIVE_ROLES.has(role)) {
|
backendNodeId?: Protocol.DOM.BackendNodeId
|
||||||
|
role: string
|
||||||
|
name: string
|
||||||
|
}): string | null => {
|
||||||
|
if (!INTERACTIVE_ROLES.has(options.role)) {
|
||||||
return null
|
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
|
const stable = domInfo ? getStableRefFromAttributes(domInfo.attributes) : null
|
||||||
let baseRef = stable?.value
|
let baseRef = stable?.value
|
||||||
if (!baseRef) {
|
if (!baseRef) {
|
||||||
@@ -614,7 +854,7 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
|
|||||||
selector = buildLocatorFromStable(stable)
|
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
|
return ref
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,155 +868,41 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
|
|||||||
return allowedBackendIds.has(node.backendDOMNodeId)
|
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[] = []
|
let snapshotNodes: SnapshotNode[] = []
|
||||||
if (rootAxNodeId) {
|
if (rootAxNodeId) {
|
||||||
const rootNode = axById.get(rootAxNodeId)
|
const rootNode = axById.get(rootAxNodeId)
|
||||||
const rootRole = rootNode ? getAxRole(rootNode) : ''
|
const rootRole = rootNode ? getAxRole(rootNode) : ''
|
||||||
if (rootNode && (rootRole === 'rootwebarea' || rootRole === 'webarea') && rootNode.childIds) {
|
const rawRoots = rootNode && (rootRole === 'rootwebarea' || rootRole === 'webarea') && rootNode.childIds
|
||||||
const childResults = rootNode.childIds.map((childId) => {
|
? rootNode.childIds.map((childId) => {
|
||||||
return buildLines(childId, 0, [], false)
|
return buildRawSnapshotTree({ nodeId: childId, axById, isNodeInScope })
|
||||||
})
|
}).filter(isTruthy)
|
||||||
snapshotLines = childResults.flatMap((result) => {
|
: [buildRawSnapshotTree({ nodeId: rootAxNodeId, axById, isNodeInScope })].filter(isTruthy)
|
||||||
return result.lines
|
|
||||||
})
|
const filtered = rawRoots.flatMap((rawNode) => {
|
||||||
snapshotNodes = childResults.flatMap((result) => {
|
if (interactiveOnly) {
|
||||||
return result.nodes
|
return filterInteractiveSnapshotTree({
|
||||||
})
|
node: rawNode,
|
||||||
} else {
|
ancestorNames: [],
|
||||||
const result = buildLines(rootAxNodeId, 0, [], false)
|
labelContext: false,
|
||||||
snapshotLines = result.lines
|
refFilter,
|
||||||
snapshotNodes = result.nodes
|
domByBackendId,
|
||||||
}
|
createRefForNode,
|
||||||
|
}).nodes
|
||||||
|
}
|
||||||
|
return filterFullSnapshotTree({
|
||||||
|
node: rawNode,
|
||||||
|
ancestorNames: [],
|
||||||
|
refFilter,
|
||||||
|
domByBackendId,
|
||||||
|
createRefForNode,
|
||||||
|
}).nodes
|
||||||
|
})
|
||||||
|
snapshotNodes = filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const snapshotLines = buildSnapshotLines(snapshotNodes)
|
||||||
|
|
||||||
const shortRefMap = buildShortRefMap({ refs })
|
const shortRefMap = buildShortRefMap({ refs })
|
||||||
const finalized = finalizeSnapshotOutput(snapshotLines, snapshotNodes, shortRefMap)
|
const finalized = finalizeSnapshotOutput(snapshotLines, snapshotNodes, shortRefMap)
|
||||||
const refsWithShortRef: Array<AriaRef & { selector?: string }> = refs.map((entry) => {
|
const refsWithShortRef: Array<AriaRef & { selector?: string }> = refs.map((entry) => {
|
||||||
@@ -1008,7 +1134,7 @@ async function getLabelBoxesForRefs({
|
|||||||
* await page.locator('[data-testid="submit-btn"]').click()
|
* 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
|
page: Page
|
||||||
locator?: Locator
|
locator?: Locator
|
||||||
interactiveOnly?: boolean
|
interactiveOnly?: boolean
|
||||||
@@ -1119,7 +1245,7 @@ export async function hideAriaRefLabels({ page }: { page: Page }): Promise<void>
|
|||||||
* await page.locator('[data-testid="submit-btn"]').click()
|
* 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
|
page: Page
|
||||||
locator?: Locator
|
locator?: Locator
|
||||||
interactiveOnly?: boolean
|
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