Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 61 additions & 74 deletions packages/injected/src/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
};

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<string, Element>;
refs: Map<Element, string>;
iframeRefs: string[];
Expand Down Expand Up @@ -105,13 +80,14 @@ export function generateAriaTree(rootElement: Element, publicOptions: AriaTreeOp
const visited = new Set<Node>();

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<string, Element>(),
refs: new Map<Element, string>(),
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);
Expand Down Expand Up @@ -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') ? ' ' : '';
Expand Down Expand Up @@ -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;
}
Expand All @@ -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))
Expand Down Expand Up @@ -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);
Expand All @@ -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(''));
Expand All @@ -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') {
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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];

Expand All @@ -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 {
Expand All @@ -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);

Expand Down Expand Up @@ -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) {
Expand All @@ -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();
Expand All @@ -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)
Expand All @@ -511,7 +487,7 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll:
return results;
}

function buildByRefMap(root: AriaNode | undefined, map: Map<string | undefined, AriaNode> = new Map()): Map<string | undefined, AriaNode> {
function buildByRefMap(root: aria.AriaNode | undefined, map: Map<string | undefined, aria.AriaNode> = new Map()): Map<string | undefined, aria.AriaNode> {
if (root?.ref)
map.set(root.ref, root);
for (const child of root?.children || []) {
Expand All @@ -521,13 +497,13 @@ function buildByRefMap(root: AriaNode | undefined, map: Map<string | undefined,
return map;
}

function compareSnapshots(ariaSnapshot: AriaSnapshot, previousSnapshot: AriaSnapshot | undefined): Map<AriaNode, 'skip' | 'same' | 'changed'> {
function compareSnapshots(ariaSnapshot: AriaSnapshot, previousSnapshot: AriaSnapshot | undefined): Map<aria.AriaNode, 'skip' | 'same' | 'changed'> {
const previousByRef = buildByRefMap(previousSnapshot?.root);
const result = new Map<AriaNode, 'skip' | 'same' | 'changed'>();
const result = new Map<aria.AriaNode, 'skip' | 'same' | 'changed'>();

// 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++) {
Expand Down Expand Up @@ -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, 'skip' | 'same' | 'changed'>): (AriaNode | string)[] {
const result: (AriaNode | string)[] = [];
function filterSnapshotDiff(nodes: (aria.AriaNode | string)[], statusMap: Map<aria.AriaNode, 'skip' | 'same' | 'changed'>): (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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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]`);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Expand All @@ -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;
}
14 changes: 2 additions & 12 deletions packages/injected/src/domUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
Loading
Loading