From e507e1112025363469d638b72568445bac4c0ec0 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 23 Dec 2025 11:57:39 -0800 Subject: [PATCH] chore: make AriaNode isomorphic --- packages/injected/src/ariaSnapshot.ts | 135 ++++++++---------- packages/injected/src/domUtils.ts | 14 +- packages/injected/src/injectedScript.ts | 3 +- packages/injected/src/recorder/recorder.ts | 55 +------ .../src/utils/isomorphic/ariaSnapshot.ts | 82 ++++++++++- 5 files changed, 150 insertions(+), 139 deletions(-) diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index 7db583e957c0a..5bf24257a571a 100644 --- a/packages/injected/src/ariaSnapshot.ts +++ b/packages/injected/src/ariaSnapshot.ts @@ -14,40 +14,15 @@ * limitations under the License. */ -import { ariaPropsEqual } from '@isomorphic/ariaSnapshot'; +import * as aria from '@isomorphic/ariaSnapshot'; import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace } from '@isomorphic/stringUtils'; import { computeBox, getElementComputedStyle, isElementVisible } from './domUtils'; import * as roleUtils from './roleUtils'; import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from './yaml'; -import type { AriaProps, AriaRegex, AriaTextValue, AriaRole, AriaTemplateNode } from '@isomorphic/ariaSnapshot'; -import type { Box } from './domUtils'; - -// Note: please keep in sync with ariaNodesEqual() below. -export type AriaNode = AriaProps & { - role: AriaRole | 'fragment' | 'iframe'; - name: string; - ref?: string; - children: (AriaNode | string)[]; - element: Element; - box: Box; - receivesPointerEvents: boolean; - props: Record; -}; - -function ariaNodesEqual(a: AriaNode, b: AriaNode): boolean { - if (a.role !== b.role || a.name !== b.name) - return false; - if (!ariaPropsEqual(a, b) || hasPointerCursor(a) !== hasPointerCursor(b)) - return false; - const aKeys = Object.keys(a.props); - const bKeys = Object.keys(b.props); - return aKeys.length === bKeys.length && aKeys.every(k => a.props[k] === b.props[k]); -} - export type AriaSnapshot = { - root: AriaNode; + root: aria.AriaNode; elements: Map; refs: Map; iframeRefs: string[]; @@ -105,13 +80,14 @@ export function generateAriaTree(rootElement: Element, publicOptions: AriaTreeOp const visited = new Set(); const snapshot: AriaSnapshot = { - root: { role: 'fragment', name: '', children: [], element: rootElement, props: {}, box: computeBox(rootElement), receivesPointerEvents: true }, + root: { role: 'fragment', name: '', children: [], props: {}, box: computeBox(rootElement), receivesPointerEvents: true }, elements: new Map(), refs: new Map(), iframeRefs: [], }; + setAriaNodeElement(snapshot.root, rootElement); - const visit = (ariaNode: AriaNode, node: Node, parentElementVisible: boolean) => { + const visit = (ariaNode: aria.AriaNode, node: Node, parentElementVisible: boolean) => { if (visited.has(node)) return; visited.add(node); @@ -166,7 +142,7 @@ export function generateAriaTree(rootElement: Element, publicOptions: AriaTreeOp processElement(childAriaNode || ariaNode, element, ariaChildren, visible); }; - function processElement(ariaNode: AriaNode, element: Element, ariaChildren: Element[], parentElementVisible: boolean) { + function processElement(ariaNode: aria.AriaNode, element: Element, ariaChildren: Element[], parentElementVisible: boolean) { // Surround every element with spaces for the sake of concatenated text nodes. const display = getElementComputedStyle(element)?.display || 'inline'; const treatAsBlock = (display !== 'inline' || element.nodeName === 'BR') ? ' ' : ''; @@ -223,34 +199,34 @@ export function generateAriaTree(rootElement: Element, publicOptions: AriaTreeOp return snapshot; } -function computeAriaRef(ariaNode: AriaNode, options: InternalOptions) { +function computeAriaRef(ariaNode: aria.AriaNode, options: InternalOptions) { if (options.refs === 'none') return; if (options.refs === 'interactable' && (!ariaNode.box.visible || !ariaNode.receivesPointerEvents)) return; - let ariaRef: AriaRef | undefined; - ariaRef = (ariaNode.element as any)._ariaRef; + const element = ariaNodeElement(ariaNode); + let ariaRef = (element as any)._ariaRef as AriaRef | undefined; if (!ariaRef || ariaRef.role !== ariaNode.role || ariaRef.name !== ariaNode.name) { ariaRef = { role: ariaNode.role, name: ariaNode.name, ref: (options.refPrefix ?? '') + 'e' + (++lastRef) }; - (ariaNode.element as any)._ariaRef = ariaRef; + (element as any)._ariaRef = ariaRef; } ariaNode.ref = ariaRef.ref; } -function toAriaNode(element: Element, options: InternalOptions): AriaNode | null { +function toAriaNode(element: Element, options: InternalOptions): aria.AriaNode | null { const active = element.ownerDocument.activeElement === element; if (element.nodeName === 'IFRAME') { - const ariaNode: AriaNode = { + const ariaNode: aria.AriaNode = { role: 'iframe', name: '', children: [], props: {}, - element, box: computeBox(element), receivesPointerEvents: true, active }; + setAriaNodeElement(ariaNode, element); computeAriaRef(ariaNode, options); return ariaNode; } @@ -267,16 +243,16 @@ function toAriaNode(element: Element, options: InternalOptions): AriaNode | null if (role === 'generic' && box.inline && element.childNodes.length === 1 && element.childNodes[0].nodeType === Node.TEXT_NODE) return null; - const result: AriaNode = { + const result: aria.AriaNode = { role, name, children: [], props: {}, - element, box, receivesPointerEvents, active }; + setAriaNodeElement(result, element); computeAriaRef(result, options); if (roleUtils.kAriaCheckedRoles.includes(role)) @@ -305,9 +281,9 @@ function toAriaNode(element: Element, options: InternalOptions): AriaNode | null return result; } -function normalizeGenericRoles(node: AriaNode) { - const normalizeChildren = (node: AriaNode) => { - const result: (AriaNode | string)[] = []; +function normalizeGenericRoles(node: aria.AriaNode) { + const normalizeChildren = (node: aria.AriaNode) => { + const result: (aria.AriaNode | string)[] = []; for (const child of node.children || []) { if (typeof child === 'string') { result.push(child); @@ -328,8 +304,8 @@ function normalizeGenericRoles(node: AriaNode) { normalizeChildren(node); } -function normalizeStringChildren(rootA11yNode: AriaNode) { - const flushChildren = (buffer: string[], normalizedChildren: (AriaNode | string)[]) => { +function normalizeStringChildren(rootA11yNode: aria.AriaNode) { + const flushChildren = (buffer: string[], normalizedChildren: (aria.AriaNode | string)[]) => { if (!buffer.length) return; const text = normalizeWhiteSpace(buffer.join('')); @@ -338,8 +314,8 @@ function normalizeStringChildren(rootA11yNode: AriaNode) { buffer.length = 0; }; - const visit = (ariaNode: AriaNode) => { - const normalizedChildren: (AriaNode | string)[] = []; + const visit = (ariaNode: aria.AriaNode) => { + const normalizedChildren: (aria.AriaNode | string)[] = []; const buffer: string[] = []; for (const child of ariaNode.children || []) { if (typeof child === 'string') { @@ -358,7 +334,7 @@ function normalizeStringChildren(rootA11yNode: AriaNode) { visit(rootA11yNode); } -function matchesStringOrRegex(text: string, template: AriaRegex | string | undefined): boolean { +function matchesStringOrRegex(text: string, template: aria.AriaRegex | string | undefined): boolean { if (!template) return true; if (!text) @@ -368,7 +344,7 @@ function matchesStringOrRegex(text: string, template: AriaRegex | string | undef return !!text.match(new RegExp(template.pattern)); } -function matchesTextValue(text: string, template: AriaTextValue | undefined) { +function matchesTextValue(text: string, template: aria.AriaTextValue | undefined) { if (!template?.normalized) return true; if (!text) @@ -387,7 +363,7 @@ function matchesTextValue(text: string, template: AriaTextValue | undefined) { const cachedRegexSymbol = Symbol('cachedRegex'); -function cachedRegex(template: AriaTextValue): RegExp | null { +function cachedRegex(template: aria.AriaTextValue): RegExp | null { if ((template as any)[cachedRegexSymbol] !== undefined) return (template as any)[cachedRegexSymbol]; @@ -408,7 +384,7 @@ export type MatcherReceived = { regex: string; }; -export function matchesExpectAriaTemplate(rootElement: Element, template: AriaTemplateNode): { matches: AriaNode[], received: MatcherReceived } { +export function matchesExpectAriaTemplate(rootElement: Element, template: aria.AriaTemplateNode): { matches: aria.AriaNode[], received: MatcherReceived } { const snapshot = generateAriaTree(rootElement, { mode: 'expect' }); const matches = matchesNodeDeep(snapshot.root, template, false, false); return { @@ -420,13 +396,13 @@ export function matchesExpectAriaTemplate(rootElement: Element, template: AriaTe }; } -export function getAllElementsMatchingExpectAriaTemplate(rootElement: Element, template: AriaTemplateNode): Element[] { +export function getAllElementsMatchingExpectAriaTemplate(rootElement: Element, template: aria.AriaTemplateNode): Element[] { const root = generateAriaTree(rootElement, { mode: 'expect' }).root; const matches = matchesNodeDeep(root, template, true, false); - return matches.map(n => n.element); + return matches.map(n => ariaNodeElement(n)); } -function matchesNode(node: AriaNode | string, template: AriaTemplateNode, isDeepEqual: boolean): boolean { +function matchesNode(node: aria.AriaNode | string, template: aria.AriaTemplateNode, isDeepEqual: boolean): boolean { if (typeof node === 'string' && template.kind === 'text') return matchesTextValue(node, template.text); @@ -462,7 +438,7 @@ function matchesNode(node: AriaNode | string, template: AriaTemplateNode, isDeep return containsList(node.children || [], template.children || []); } -function listEqual(children: (AriaNode | string)[], template: AriaTemplateNode[], isDeepEqual: boolean): boolean { +function listEqual(children: (aria.AriaNode | string)[], template: aria.AriaTemplateNode[], isDeepEqual: boolean): boolean { if (template.length !== children.length) return false; for (let i = 0; i < template.length; ++i) { @@ -472,7 +448,7 @@ function listEqual(children: (AriaNode | string)[], template: AriaTemplateNode[] return true; } -function containsList(children: (AriaNode | string)[], template: AriaTemplateNode[]): boolean { +function containsList(children: (aria.AriaNode | string)[], template: aria.AriaTemplateNode[]): boolean { if (template.length > children.length) return false; const cc = children.slice(); @@ -490,9 +466,9 @@ function containsList(children: (AriaNode | string)[], template: AriaTemplateNod return true; } -function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll: boolean, isDeepEqual: boolean): AriaNode[] { - const results: AriaNode[] = []; - const visit = (node: AriaNode | string, parent: AriaNode | null): boolean => { +function matchesNodeDeep(root: aria.AriaNode, template: aria.AriaTemplateNode, collectAll: boolean, isDeepEqual: boolean): aria.AriaNode[] { + const results: aria.AriaNode[] = []; + const visit = (node: aria.AriaNode | string, parent: aria.AriaNode | null): boolean => { if (matchesNode(node, template, isDeepEqual)) { const result = typeof node === 'string' ? parent : node; if (result) @@ -511,7 +487,7 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll: return results; } -function buildByRefMap(root: AriaNode | undefined, map: Map = new Map()): Map { +function buildByRefMap(root: aria.AriaNode | undefined, map: Map = new Map()): Map { if (root?.ref) map.set(root.ref, root); for (const child of root?.children || []) { @@ -521,13 +497,13 @@ function buildByRefMap(root: AriaNode | undefined, map: Map { +function compareSnapshots(ariaSnapshot: AriaSnapshot, previousSnapshot: AriaSnapshot | undefined): Map { const previousByRef = buildByRefMap(previousSnapshot?.root); - const result = new Map(); + const result = new Map(); // Returns whether ariaNode is the same as previousNode. - const visit = (ariaNode: AriaNode, previousNode: AriaNode | undefined): boolean => { - let same: boolean = ariaNode.children.length === previousNode?.children.length && ariaNodesEqual(ariaNode, previousNode); + const visit = (ariaNode: aria.AriaNode, previousNode: aria.AriaNode | undefined): boolean => { + let same: boolean = ariaNode.children.length === previousNode?.children.length && aria.ariaNodesEqual(ariaNode, previousNode); let canBeSkipped = same; for (let childIndex = 0 ; childIndex < ariaNode.children.length; childIndex++) { @@ -558,10 +534,10 @@ function compareSnapshots(ariaSnapshot: AriaSnapshot, previousSnapshot: AriaSnap } // Chooses only the changed parts of the snapshot and returns them as new roots. -function filterSnapshotDiff(nodes: (AriaNode | string)[], statusMap: Map): (AriaNode | string)[] { - const result: (AriaNode | string)[] = []; +function filterSnapshotDiff(nodes: (aria.AriaNode | string)[], statusMap: Map): (aria.AriaNode | string)[] { + const result: (aria.AriaNode | string)[] = []; - const visit = (ariaNode: AriaNode) => { + const visit = (ariaNode: aria.AriaNode) => { const status = statusMap.get(ariaNode); if (status === 'same') { // No need to render unchanged root at all. @@ -605,7 +581,7 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr lines.push(indent + '- text: ' + escaped); }; - const createKey = (ariaNode: AriaNode, renderCursorPointer: boolean): string => { + const createKey = (ariaNode: aria.AriaNode, renderCursorPointer: boolean): string => { let key = ariaNode.role; // Yaml has a limit of 1024 characters per key, and we leave some space for role and attributes. if (ariaNode.name && ariaNode.name.length <= 900) { @@ -636,17 +612,17 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr if (ariaNode.ref) { key += ` [ref=${ariaNode.ref}]`; - if (renderCursorPointer && hasPointerCursor(ariaNode)) + if (renderCursorPointer && aria.hasPointerCursor(ariaNode)) key += ' [cursor=pointer]'; } return key; }; - const getSingleInlinedTextChild = (ariaNode: AriaNode | undefined): string | undefined => { + const getSingleInlinedTextChild = (ariaNode: aria.AriaNode | undefined): string | undefined => { return ariaNode?.children.length === 1 && typeof ariaNode.children[0] === 'string' && !Object.keys(ariaNode.props).length ? ariaNode.children[0] : undefined; }; - const visit = (ariaNode: AriaNode, indent: string, renderCursorPointer: boolean) => { + const visit = (ariaNode: aria.AriaNode, indent: string, renderCursorPointer: boolean) => { // Replace the whole subtree with a single reference when possible. if (statusMap.get(ariaNode) === 'same' && ariaNode.ref) { lines.push(indent + `- ref=${ariaNode.ref} [unchanged]`); @@ -675,7 +651,7 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr lines.push(indent + ' - /' + name + ': ' + yamlEscapeValueIfNeeded(value)); const childIndent = indent + ' '; - const inCursorPointer = !!ariaNode.ref && renderCursorPointer && hasPointerCursor(ariaNode); + const inCursorPointer = !!ariaNode.ref && renderCursorPointer && aria.hasPointerCursor(ariaNode); for (const child of ariaNode.children) { if (typeof child === 'string') visitText(includeText(ariaNode, child) ? child : '', childIndent); @@ -734,7 +710,7 @@ function convertToBestGuessRegex(text: string): string { return String(new RegExp(pattern)); } -function textContributesInfo(node: AriaNode, text: string): boolean { +function textContributesInfo(node: aria.AriaNode, text: string): boolean { if (!text.length) return false; @@ -752,6 +728,17 @@ function textContributesInfo(node: AriaNode, text: string): boolean { return filtered.trim().length / text.length > 0.1; } -function hasPointerCursor(ariaNode: AriaNode): boolean { - return ariaNode.box.cursor === 'pointer'; +const elementSymbol = Symbol('element'); + +function ariaNodeElement(ariaNode: aria.AriaNode): Element { + return (ariaNode as any)[elementSymbol]; +} + +function setAriaNodeElement(ariaNode: aria.AriaNode, element: Element) { + (ariaNode as any)[elementSymbol] = element; +} + +export function findNewElement(from: aria.AriaNode | undefined, to: aria.AriaNode): Element | undefined { + const node = aria.findNewNode(from, to); + return node ? ariaNodeElement(node) : undefined; } diff --git a/packages/injected/src/domUtils.ts b/packages/injected/src/domUtils.ts index 78381bb409c93..efa9e505643dc 100644 --- a/packages/injected/src/domUtils.ts +++ b/packages/injected/src/domUtils.ts @@ -108,17 +108,7 @@ export function isElementStyleVisibilityVisible(element: Element, style?: CSSSty return true; } -export type Box = { - visible: boolean; - inline: boolean; - rect?: DOMRect; - // Note: we do not store the CSSStyleDeclaration object, because it is a live object - // and changes values over time. This does not work for caching or comparing to the - // old values. Instead, store all the properties separately. - cursor?: CSSStyleDeclaration['cursor']; -}; - -export function computeBox(element: Element): Box { +export function computeBox(element: Element) { // Note: this logic should be similar to waitForDisplayedAtStablePosition() to avoid surprises. const style = getElementComputedStyle(element); if (!style) @@ -137,7 +127,7 @@ export function computeBox(element: Element): Box { if (!isElementStyleVisibilityVisible(element, style)) return { cursor, visible: false, inline: false }; const rect = element.getBoundingClientRect(); - return { rect, cursor, visible: rect.width > 0 && rect.height > 0, inline: style.display === 'inline' }; + return { cursor, visible: rect.width > 0 && rect.height > 0, inline: style.display === 'inline' }; } export function isElementVisible(element: Element): boolean { diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index b25b6ccff8def..014466f4938e1 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -19,7 +19,7 @@ import { asLocator } from '@isomorphic/locatorGenerators'; import { parseAttributeSelector, parseSelector, stringifySelector, visitAllSelectorParts } from '@isomorphic/selectorParser'; import { cacheNormalizedWhitespaces, normalizeWhiteSpace, trimStringWithEllipsis } from '@isomorphic/stringUtils'; -import { generateAriaTree, getAllElementsMatchingExpectAriaTemplate, matchesExpectAriaTemplate, renderAriaTree } from './ariaSnapshot'; +import { generateAriaTree, getAllElementsMatchingExpectAriaTemplate, matchesExpectAriaTemplate, renderAriaTree, findNewElement } from './ariaSnapshot'; import { beginDOMCaches, enclosingShadowRootOrDocument, endDOMCaches, isElementVisible, isInsideScope, parentElementOrShadowHost, setGlobalOptions } from './domUtils'; import { Highlight } from './highlight'; import { kLayoutSelectorNames, layoutSelectorScore } from './layoutSelectorUtils'; @@ -110,6 +110,7 @@ export class InjectedScript { normalizeWhiteSpace, parseAriaSnapshot, generateAriaTree, + findNewElement, // Builtins protect injected code from clock emulation. builtins: null as unknown as Builtins, }; diff --git a/packages/injected/src/recorder/recorder.ts b/packages/injected/src/recorder/recorder.ts index 1d709f77abac3..35bbbf76e6448 100644 --- a/packages/injected/src/recorder/recorder.ts +++ b/packages/injected/src/recorder/recorder.ts @@ -14,16 +14,18 @@ * limitations under the License. */ +// This is the only dependency this file is allowed to have, because we are fine with a dupe. +// See DEPS.list for more details. import clipPaths from './clipPaths'; import type { Point } from '@isomorphic/types'; +import type { AriaSnapshot } from '../ariaSnapshot'; import type { Highlight, HighlightEntry } from '../highlight'; import type { InjectedScript } from '../injectedScript'; import type { ElementText } from '../selectorUtils'; import type * as actions from '@recorder/actions'; import type { ElementInfo, Mode, OverlayState, UIState } from '@recorder/recorderTypes'; import type { Language } from '@isomorphic/locatorGenerators'; -import type { AriaNode, AriaSnapshot } from '@injected/ariaSnapshot'; const HighlightColors = { multiple: '#f6b26b7f', @@ -1704,7 +1706,7 @@ export class Recorder { const previousSnapshot = this._lastActionAutoexpectSnapshot; this._lastActionAutoexpectSnapshot = this._captureAutoExpectSnapshot(); if (!isAssertAction(action) && this._lastActionAutoexpectSnapshot) { - const element = findNewElement(previousSnapshot, this._lastActionAutoexpectSnapshot); + const element = this.injectedScript.utils.findNewElement(previousSnapshot?.root, this._lastActionAutoexpectSnapshot?.root); action.preconditionSelector = element ? this.injectedScript.generateSelector(element, { testIdAttributeName: this.state.testIdAttributeName }).selector : undefined; if (action.preconditionSelector === action.selector) action.preconditionSelector = undefined; @@ -1938,52 +1940,3 @@ function createSvgElement(doc: Document, { tagName, attrs, children }: SvgJson): function isAssertAction(action: actions.Action): action is actions.AssertAction { return action.name.startsWith('assert'); } - -function findNewElement(from: AriaSnapshot | undefined, to: AriaSnapshot): Element | undefined { - type ByRoleAndName = Map>; - - function fillMap(root: AriaNode, map: ByRoleAndName, position: number) { - let size = 1; - let childPosition = position + size; - for (const child of root.children || []) { - if (typeof child === 'string') { - size++; - childPosition++; - } else { - size += fillMap(child, map, childPosition); - childPosition += size; - } - } - if (!['none', 'presentation', 'fragment', 'iframe', 'generic'].includes(root.role) && root.name) { - let byRole = map.get(root.role); - if (!byRole) { - byRole = new Map(); - map.set(root.role, byRole); - } - const existing = byRole.get(root.name); - // This heuristic prioritizes elements at the top of the page, even if somewhat smaller. - const sizeAndPosition = size * 100 - position; - if (!existing || existing.sizeAndPosition < sizeAndPosition) - byRole.set(root.name, { node: root, sizeAndPosition }); - } - return size; - } - - const fromMap: ByRoleAndName = new Map(); - if (from) - fillMap(from.root, fromMap, 0); - - const toMap: ByRoleAndName = new Map(); - fillMap(to.root, toMap, 0); - - const result: { node: AriaNode, sizeAndPosition: number }[] = []; - for (const [role, byRole] of toMap) { - for (const [name, byName] of byRole) { - const inFrom = fromMap.get(role)?.get(name); - if (!inFrom) - result.push(byName); - } - } - result.sort((a, b) => b.sizeAndPosition - a.sizeAndPosition); - return result[0]?.node.element; -} diff --git a/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts b/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts index dcf546a96b2b8..a051692bd7b56 100644 --- a/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts +++ b/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts @@ -35,7 +35,38 @@ export type AriaProps = { selected?: boolean; }; -export function ariaPropsEqual(a: AriaProps, b: AriaProps): boolean { +export type AriaBox = { + visible: boolean; + inline: boolean; + cursor?: string; +}; + +// Note: please keep in sync with ariaNodesEqual() below. +export type AriaNode = AriaProps & { + role: AriaRole | 'fragment' | 'iframe'; + name: string; + ref?: string; + children: (AriaNode | string)[]; + box: AriaBox; + receivesPointerEvents: boolean; + props: Record; +}; + +export function ariaNodesEqual(a: AriaNode, b: AriaNode): boolean { + if (a.role !== b.role || a.name !== b.name) + return false; + if (!ariaPropsEqual(a, b) || hasPointerCursor(a) !== hasPointerCursor(b)) + return false; + const aKeys = Object.keys(a.props); + const bKeys = Object.keys(b.props); + return aKeys.length === bKeys.length && aKeys.every(k => a.props[k] === b.props[k]); +} + +export function hasPointerCursor(ariaNode: AriaNode): boolean { + return ariaNode.box.cursor === 'pointer'; +} + +function ariaPropsEqual(a: AriaProps, b: AriaProps): boolean { return a.active === b.active && a.checked === b.checked && a.disabled === b.disabled && a.expanded === b.expanded && a.selected === b.selected && a.level === b.level && a.pressed === b.pressed; } @@ -497,3 +528,52 @@ export class ParserError extends Error { this.pos = pos; } } + +export function findNewNode(from: AriaNode | undefined, to: AriaNode): AriaNode | undefined { + type ByRoleAndName = Map>; + + function fillMap(root: AriaNode, map: ByRoleAndName, position: number) { + let size = 1; + let childPosition = position + size; + for (const child of root.children || []) { + if (typeof child === 'string') { + size++; + childPosition++; + } else { + size += fillMap(child, map, childPosition); + childPosition += size; + } + } + if (!['none', 'presentation', 'fragment', 'iframe', 'generic'].includes(root.role) && root.name) { + let byRole = map.get(root.role); + if (!byRole) { + byRole = new Map(); + map.set(root.role, byRole); + } + const existing = byRole.get(root.name); + // This heuristic prioritizes elements at the top of the page, even if somewhat smaller. + const sizeAndPosition = size * 100 - position; + if (!existing || existing.sizeAndPosition < sizeAndPosition) + byRole.set(root.name, { node: root, sizeAndPosition }); + } + return size; + } + + const fromMap: ByRoleAndName = new Map(); + if (from) + fillMap(from, fromMap, 0); + + const toMap: ByRoleAndName = new Map(); + fillMap(to, toMap, 0); + + const result: { node: AriaNode, sizeAndPosition: number }[] = []; + for (const [role, byRole] of toMap) { + for (const [name, byName] of byRole) { + const inFrom = fromMap.get(role)?.get(name); + if (!inFrom) + result.push(byName); + } + } + result.sort((a, b) => b.sizeAndPosition - a.sizeAndPosition); + return result[0]?.node; +}