diff --git a/playwriter/src/aria-snapshot.ts b/playwriter/src/aria-snapshot.ts index e221d31..4a95429 100644 --- a/playwriter/src/aria-snapshot.ts +++ b/playwriter/src/aria-snapshot.ts @@ -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 + 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 + createRefForNode: (options: { backendNodeId?: Protocol.DOM.BackendNodeId; role: string; name: string }) => string | null +}): { nodes: SnapshotNode[]; names: Set } { + 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()) + + 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 + createRefForNode: (options: { backendNodeId?: Protocol.DOM.BackendNodeId; role: string; name: string }) => string | null +}): { nodes: SnapshotNode[]; names: Set } { + 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()) + + 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, @@ -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 } => { - 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()) - - 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 = 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 * 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 diff --git a/playwriter/src/aria-snapshot.unit.test.ts b/playwriter/src/aria-snapshot.unit.test.ts new file mode 100644 index 0000000..31127d8 --- /dev/null +++ b/playwriter/src/aria-snapshot.unit.test.ts @@ -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([ + [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([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 + }>([ + [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 + }>([ + [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 + }>() + + 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 + }>([ + [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", + }, + ], + } + `) + }) +})