feat: add snapshot tree output

This commit is contained in:
Tommy D. Rossi
2026-02-02 22:10:46 +01:00
parent 9786ba01cd
commit 4081146ad4
+88 -24
View File
@@ -75,6 +75,13 @@ export interface AriaRef {
ref: string ref: string
} }
export type AriaSnapshotNode = {
role: string
name: string
locator?: string
children: AriaSnapshotNode[]
}
export interface ScreenshotResult { export interface ScreenshotResult {
path: string path: string
base64: string base64: string
@@ -85,6 +92,7 @@ export interface ScreenshotResult {
export interface AriaSnapshotResult { export interface AriaSnapshotResult {
snapshot: string snapshot: string
tree: AriaSnapshotNode[]
refToElement: Map<string, { role: string; name: string }> refToElement: Map<string, { role: string; name: string }>
refToSelector: Map<string, string> refToSelector: Map<string, string>
/** /**
@@ -243,6 +251,13 @@ type SnapshotLine = {
hasChildren?: boolean hasChildren?: boolean
} }
type SnapshotNode = {
role: string
name: string
baseLocator?: string
children: SnapshotNode[]
}
function buildSnapshotLine({ role, name, baseLocator, indent, hasChildren }: { function buildSnapshotLine({ role, name, baseLocator, indent, hasChildren }: {
role: string role: string
name: string name: string
@@ -273,7 +288,7 @@ function unindentLines(lines: SnapshotLine[]): SnapshotLine[] {
}) })
} }
function finalizeSnapshotLines(lines: SnapshotLine[]): string { function finalizeSnapshotOutput(lines: SnapshotLine[], nodes: SnapshotNode[]): { snapshot: string; tree: AriaSnapshotNode[] } {
const locatorCounts = lines.reduce<Map<string, number>>((acc, line) => { const locatorCounts = lines.reduce<Map<string, number>>((acc, line) => {
if (!line.baseLocator) { if (!line.baseLocator) {
return acc return acc
@@ -283,13 +298,24 @@ function finalizeSnapshotLines(lines: SnapshotLine[]): string {
}, new Map<string, number>()) }, new Map<string, number>())
const locatorIndices = new Map<string, number>() const locatorIndices = new Map<string, number>()
return lines.map((line) => { const locatorSequence = lines.reduce<string[]>((acc, line) => {
if (!line.baseLocator) {
return acc
}
const count = locatorCounts.get(line.baseLocator) ?? 0
const index = locatorIndices.get(line.baseLocator) ?? 0
locatorIndices.set(line.baseLocator, index + 1)
const locator = count > 1 ? `${line.baseLocator} >> nth=${index}` : line.baseLocator
acc.push(locator)
return acc
}, [])
let lineLocatorIndex = 0
const snapshot = lines.map((line) => {
let text = line.text let text = line.text
if (line.baseLocator) { if (line.baseLocator) {
const count = locatorCounts.get(line.baseLocator) ?? 0 const locator = locatorSequence[lineLocatorIndex]
const index = locatorIndices.get(line.baseLocator) ?? 0 lineLocatorIndex += 1
locatorIndices.set(line.baseLocator, index + 1)
const locator = count > 1 ? `${line.baseLocator} >> nth=${index}` : line.baseLocator
text = `${text} ${locator}` text = `${text} ${locator}`
} }
if (line.hasChildren) { if (line.hasChildren) {
@@ -297,6 +323,22 @@ function finalizeSnapshotLines(lines: SnapshotLine[]): string {
} }
return text return text
}).join('\n') }).join('\n')
let nodeLocatorIndex = 0
const applyLocators = (items: SnapshotNode[]): AriaSnapshotNode[] => {
return items.map((item) => {
const locator = item.baseLocator ? locatorSequence[nodeLocatorIndex++] : undefined
const children = applyLocators(item.children)
return {
role: item.role,
name: item.name,
locator,
children,
}
})
}
return { snapshot, tree: applyLocators(nodes) }
} }
function buildDomIndex(nodes: Protocol.DOM.Node[]): { function buildDomIndex(nodes: Protocol.DOM.Node[]): {
@@ -514,10 +556,10 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
indent: number, indent: number,
ancestorNames: string[], ancestorNames: string[],
labelContext: boolean labelContext: boolean
): { lines: SnapshotLine[]; included: boolean; names: Set<string> } => { ): { lines: SnapshotLine[]; nodes: SnapshotNode[]; included: boolean; names: Set<string> } => {
const node = axById.get(nodeId) const node = axById.get(nodeId)
if (!node) { if (!node) {
return { lines: [], included: false, names: new Set() } return { lines: [], nodes: [], included: false, names: new Set() }
} }
const role = getAxRole(node) const role = getAxRole(node)
@@ -534,6 +576,9 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
const childLines = childResults.flatMap((result) => { const childLines = childResults.flatMap((result) => {
return result.lines return result.lines
}) })
const childNodes = childResults.flatMap((result) => {
return result.nodes
})
const childIncluded = childResults.some((result) => { const childIncluded = childResults.some((result) => {
return result.included return result.included
}) })
@@ -546,29 +591,31 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
const inScope = isNodeInScope(node) || childIncluded const inScope = isNodeInScope(node) || childIncluded
if (!inScope) { if (!inScope) {
return { lines: [], included: false, names: new Set() } return { lines: [], nodes: [], included: false, names: new Set() }
} }
if (node.ignored) { if (node.ignored) {
return { lines: childLines, included: true, names: childNames } return { lines: childLines, nodes: childNodes, included: true, names: childNames }
} }
if (isTextRole(role)) { if (isTextRole(role)) {
if (!hasName) { if (!hasName) {
return { lines: childLines, included: true, names: childNames } return { lines: childLines, nodes: childNodes, included: true, names: childNames }
} }
if (interactiveOnly && !labelContext) { if (interactiveOnly && !labelContext) {
return { lines: childLines, included: true, names: childNames } return { lines: childLines, nodes: childNodes, included: true, names: childNames }
} }
const isRedundantText = ancestorNames.some((ancestor) => { const isRedundantText = ancestorNames.some((ancestor) => {
return ancestor.includes(name) || name.includes(ancestor) return ancestor.includes(name) || name.includes(ancestor)
}) })
if (isRedundantText) { if (isRedundantText) {
return { lines: childLines, included: true, names: childNames } return { lines: childLines, nodes: childNodes, included: true, names: childNames }
} }
const names = new Set(childNames) const names = new Set(childNames)
names.add(name) names.add(name)
return { lines: [buildTextLine(name, indent)], included: true, names } 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 hasChildren = childLines.length > 0
@@ -583,21 +630,21 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
? includeInteractive || isLabel || isContext || hasChildren ? includeInteractive || isLabel || isContext || hasChildren
: includeInteractive || hasNameToUse || hasChildren : includeInteractive || hasNameToUse || hasChildren
if (!shouldInclude) { if (!shouldInclude) {
return { lines: childLines, included: true, names: childNames } return { lines: childLines, nodes: childNodes, included: true, names: childNames }
} }
if (interactiveOnly && !includeInteractive && !isLabel && !isContext) { if (interactiveOnly && !includeInteractive && !isLabel && !isContext) {
if (!hasChildren) { if (!hasChildren) {
return { lines: [], included: true, names: childNames } return { lines: [], nodes: [], included: true, names: childNames }
} }
return { lines: unindentLines(childLines), included: true, names: childNames } return { lines: unindentLines(childLines), nodes: childNodes, included: true, names: childNames }
} }
if (isWrapper && !hasNameToUse) { if (isWrapper && !hasNameToUse) {
if (!hasChildren) { if (!hasChildren) {
return { lines: [], included: true, names: childNames } return { lines: [], nodes: [], included: true, names: childNames }
} }
return { lines: unindentLines(childLines), included: true, names: childNames } return { lines: unindentLines(childLines), nodes: childNodes, included: true, names: childNames }
} }
let baseLocator: string | undefined let baseLocator: string | undefined
@@ -615,27 +662,43 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
indent, indent,
hasChildren, hasChildren,
}) })
const nodeEntry: SnapshotNode = {
role,
name: nameToUse,
baseLocator,
children: childNodes,
}
const names = new Set(childNames) const names = new Set(childNames)
if (hasNameToUse) { if (hasNameToUse) {
names.add(nameToUse) names.add(nameToUse)
} }
return { lines: [line, ...childLines], included: true, names } return { lines: [line, ...childLines], nodes: [nodeEntry], included: true, names }
} }
let snapshotLines: SnapshotLine[] = [] let snapshotLines: SnapshotLine[] = []
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) { if (rootNode && (rootRole === 'rootwebarea' || rootRole === 'webarea') && rootNode.childIds) {
snapshotLines = rootNode.childIds.flatMap((childId) => { const childResults = rootNode.childIds.map((childId) => {
return buildLines(childId, 0, [], false).lines return buildLines(childId, 0, [], false)
})
snapshotLines = childResults.flatMap((result) => {
return result.lines
})
snapshotNodes = childResults.flatMap((result) => {
return result.nodes
}) })
} else { } else {
snapshotLines = buildLines(rootAxNodeId, 0, [], false).lines const result = buildLines(rootAxNodeId, 0, [], false)
snapshotLines = result.lines
snapshotNodes = result.nodes
} }
} }
const result = { snapshot: finalizeSnapshotLines(snapshotLines), refs } const finalized = finalizeSnapshotOutput(snapshotLines, snapshotNodes)
const result = { snapshot: finalized.snapshot, tree: finalized.tree, refs }
// Build refToElement map // Build refToElement map
const refToElement = new Map<string, { role: string; name: string }>() const refToElement = new Map<string, { role: string; name: string }>()
@@ -735,6 +798,7 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
return { return {
snapshot, snapshot,
tree: result.tree,
refToElement, refToElement,
refToSelector, refToSelector,
getSelectorForRef, getSelectorForRef,