Skip to content

Commit 6a00a6a

Browse files
authored
chore: make AriaNode isomorphic (#38639)
1 parent f7beb16 commit 6a00a6a

File tree

5 files changed

+150
-139
lines changed

5 files changed

+150
-139
lines changed

packages/injected/src/ariaSnapshot.ts

Lines changed: 61 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -14,40 +14,15 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { ariaPropsEqual } from '@isomorphic/ariaSnapshot';
17+
import * as aria from '@isomorphic/ariaSnapshot';
1818
import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace } from '@isomorphic/stringUtils';
1919

2020
import { computeBox, getElementComputedStyle, isElementVisible } from './domUtils';
2121
import * as roleUtils from './roleUtils';
2222
import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from './yaml';
2323

24-
import type { AriaProps, AriaRegex, AriaTextValue, AriaRole, AriaTemplateNode } from '@isomorphic/ariaSnapshot';
25-
import type { Box } from './domUtils';
26-
27-
// Note: please keep in sync with ariaNodesEqual() below.
28-
export type AriaNode = AriaProps & {
29-
role: AriaRole | 'fragment' | 'iframe';
30-
name: string;
31-
ref?: string;
32-
children: (AriaNode | string)[];
33-
element: Element;
34-
box: Box;
35-
receivesPointerEvents: boolean;
36-
props: Record<string, string>;
37-
};
38-
39-
function ariaNodesEqual(a: AriaNode, b: AriaNode): boolean {
40-
if (a.role !== b.role || a.name !== b.name)
41-
return false;
42-
if (!ariaPropsEqual(a, b) || hasPointerCursor(a) !== hasPointerCursor(b))
43-
return false;
44-
const aKeys = Object.keys(a.props);
45-
const bKeys = Object.keys(b.props);
46-
return aKeys.length === bKeys.length && aKeys.every(k => a.props[k] === b.props[k]);
47-
}
48-
4924
export type AriaSnapshot = {
50-
root: AriaNode;
25+
root: aria.AriaNode;
5126
elements: Map<string, Element>;
5227
refs: Map<Element, string>;
5328
iframeRefs: string[];
@@ -105,13 +80,14 @@ export function generateAriaTree(rootElement: Element, publicOptions: AriaTreeOp
10580
const visited = new Set<Node>();
10681

10782
const snapshot: AriaSnapshot = {
108-
root: { role: 'fragment', name: '', children: [], element: rootElement, props: {}, box: computeBox(rootElement), receivesPointerEvents: true },
83+
root: { role: 'fragment', name: '', children: [], props: {}, box: computeBox(rootElement), receivesPointerEvents: true },
10984
elements: new Map<string, Element>(),
11085
refs: new Map<Element, string>(),
11186
iframeRefs: [],
11287
};
88+
setAriaNodeElement(snapshot.root, rootElement);
11389

114-
const visit = (ariaNode: AriaNode, node: Node, parentElementVisible: boolean) => {
90+
const visit = (ariaNode: aria.AriaNode, node: Node, parentElementVisible: boolean) => {
11591
if (visited.has(node))
11692
return;
11793
visited.add(node);
@@ -166,7 +142,7 @@ export function generateAriaTree(rootElement: Element, publicOptions: AriaTreeOp
166142
processElement(childAriaNode || ariaNode, element, ariaChildren, visible);
167143
};
168144

169-
function processElement(ariaNode: AriaNode, element: Element, ariaChildren: Element[], parentElementVisible: boolean) {
145+
function processElement(ariaNode: aria.AriaNode, element: Element, ariaChildren: Element[], parentElementVisible: boolean) {
170146
// Surround every element with spaces for the sake of concatenated text nodes.
171147
const display = getElementComputedStyle(element)?.display || 'inline';
172148
const treatAsBlock = (display !== 'inline' || element.nodeName === 'BR') ? ' ' : '';
@@ -223,34 +199,34 @@ export function generateAriaTree(rootElement: Element, publicOptions: AriaTreeOp
223199
return snapshot;
224200
}
225201

226-
function computeAriaRef(ariaNode: AriaNode, options: InternalOptions) {
202+
function computeAriaRef(ariaNode: aria.AriaNode, options: InternalOptions) {
227203
if (options.refs === 'none')
228204
return;
229205
if (options.refs === 'interactable' && (!ariaNode.box.visible || !ariaNode.receivesPointerEvents))
230206
return;
231207

232-
let ariaRef: AriaRef | undefined;
233-
ariaRef = (ariaNode.element as any)._ariaRef;
208+
const element = ariaNodeElement(ariaNode);
209+
let ariaRef = (element as any)._ariaRef as AriaRef | undefined;
234210
if (!ariaRef || ariaRef.role !== ariaNode.role || ariaRef.name !== ariaNode.name) {
235211
ariaRef = { role: ariaNode.role, name: ariaNode.name, ref: (options.refPrefix ?? '') + 'e' + (++lastRef) };
236-
(ariaNode.element as any)._ariaRef = ariaRef;
212+
(element as any)._ariaRef = ariaRef;
237213
}
238214
ariaNode.ref = ariaRef.ref;
239215
}
240216

241-
function toAriaNode(element: Element, options: InternalOptions): AriaNode | null {
217+
function toAriaNode(element: Element, options: InternalOptions): aria.AriaNode | null {
242218
const active = element.ownerDocument.activeElement === element;
243219
if (element.nodeName === 'IFRAME') {
244-
const ariaNode: AriaNode = {
220+
const ariaNode: aria.AriaNode = {
245221
role: 'iframe',
246222
name: '',
247223
children: [],
248224
props: {},
249-
element,
250225
box: computeBox(element),
251226
receivesPointerEvents: true,
252227
active
253228
};
229+
setAriaNodeElement(ariaNode, element);
254230
computeAriaRef(ariaNode, options);
255231
return ariaNode;
256232
}
@@ -267,16 +243,16 @@ function toAriaNode(element: Element, options: InternalOptions): AriaNode | null
267243
if (role === 'generic' && box.inline && element.childNodes.length === 1 && element.childNodes[0].nodeType === Node.TEXT_NODE)
268244
return null;
269245

270-
const result: AriaNode = {
246+
const result: aria.AriaNode = {
271247
role,
272248
name,
273249
children: [],
274250
props: {},
275-
element,
276251
box,
277252
receivesPointerEvents,
278253
active
279254
};
255+
setAriaNodeElement(result, element);
280256
computeAriaRef(result, options);
281257

282258
if (roleUtils.kAriaCheckedRoles.includes(role))
@@ -305,9 +281,9 @@ function toAriaNode(element: Element, options: InternalOptions): AriaNode | null
305281
return result;
306282
}
307283

308-
function normalizeGenericRoles(node: AriaNode) {
309-
const normalizeChildren = (node: AriaNode) => {
310-
const result: (AriaNode | string)[] = [];
284+
function normalizeGenericRoles(node: aria.AriaNode) {
285+
const normalizeChildren = (node: aria.AriaNode) => {
286+
const result: (aria.AriaNode | string)[] = [];
311287
for (const child of node.children || []) {
312288
if (typeof child === 'string') {
313289
result.push(child);
@@ -328,8 +304,8 @@ function normalizeGenericRoles(node: AriaNode) {
328304
normalizeChildren(node);
329305
}
330306

331-
function normalizeStringChildren(rootA11yNode: AriaNode) {
332-
const flushChildren = (buffer: string[], normalizedChildren: (AriaNode | string)[]) => {
307+
function normalizeStringChildren(rootA11yNode: aria.AriaNode) {
308+
const flushChildren = (buffer: string[], normalizedChildren: (aria.AriaNode | string)[]) => {
333309
if (!buffer.length)
334310
return;
335311
const text = normalizeWhiteSpace(buffer.join(''));
@@ -338,8 +314,8 @@ function normalizeStringChildren(rootA11yNode: AriaNode) {
338314
buffer.length = 0;
339315
};
340316

341-
const visit = (ariaNode: AriaNode) => {
342-
const normalizedChildren: (AriaNode | string)[] = [];
317+
const visit = (ariaNode: aria.AriaNode) => {
318+
const normalizedChildren: (aria.AriaNode | string)[] = [];
343319
const buffer: string[] = [];
344320
for (const child of ariaNode.children || []) {
345321
if (typeof child === 'string') {
@@ -358,7 +334,7 @@ function normalizeStringChildren(rootA11yNode: AriaNode) {
358334
visit(rootA11yNode);
359335
}
360336

361-
function matchesStringOrRegex(text: string, template: AriaRegex | string | undefined): boolean {
337+
function matchesStringOrRegex(text: string, template: aria.AriaRegex | string | undefined): boolean {
362338
if (!template)
363339
return true;
364340
if (!text)
@@ -368,7 +344,7 @@ function matchesStringOrRegex(text: string, template: AriaRegex | string | undef
368344
return !!text.match(new RegExp(template.pattern));
369345
}
370346

371-
function matchesTextValue(text: string, template: AriaTextValue | undefined) {
347+
function matchesTextValue(text: string, template: aria.AriaTextValue | undefined) {
372348
if (!template?.normalized)
373349
return true;
374350
if (!text)
@@ -387,7 +363,7 @@ function matchesTextValue(text: string, template: AriaTextValue | undefined) {
387363

388364
const cachedRegexSymbol = Symbol('cachedRegex');
389365

390-
function cachedRegex(template: AriaTextValue): RegExp | null {
366+
function cachedRegex(template: aria.AriaTextValue): RegExp | null {
391367
if ((template as any)[cachedRegexSymbol] !== undefined)
392368
return (template as any)[cachedRegexSymbol];
393369

@@ -408,7 +384,7 @@ export type MatcherReceived = {
408384
regex: string;
409385
};
410386

411-
export function matchesExpectAriaTemplate(rootElement: Element, template: AriaTemplateNode): { matches: AriaNode[], received: MatcherReceived } {
387+
export function matchesExpectAriaTemplate(rootElement: Element, template: aria.AriaTemplateNode): { matches: aria.AriaNode[], received: MatcherReceived } {
412388
const snapshot = generateAriaTree(rootElement, { mode: 'expect' });
413389
const matches = matchesNodeDeep(snapshot.root, template, false, false);
414390
return {
@@ -420,13 +396,13 @@ export function matchesExpectAriaTemplate(rootElement: Element, template: AriaTe
420396
};
421397
}
422398

423-
export function getAllElementsMatchingExpectAriaTemplate(rootElement: Element, template: AriaTemplateNode): Element[] {
399+
export function getAllElementsMatchingExpectAriaTemplate(rootElement: Element, template: aria.AriaTemplateNode): Element[] {
424400
const root = generateAriaTree(rootElement, { mode: 'expect' }).root;
425401
const matches = matchesNodeDeep(root, template, true, false);
426-
return matches.map(n => n.element);
402+
return matches.map(n => ariaNodeElement(n));
427403
}
428404

429-
function matchesNode(node: AriaNode | string, template: AriaTemplateNode, isDeepEqual: boolean): boolean {
405+
function matchesNode(node: aria.AriaNode | string, template: aria.AriaTemplateNode, isDeepEqual: boolean): boolean {
430406
if (typeof node === 'string' && template.kind === 'text')
431407
return matchesTextValue(node, template.text);
432408

@@ -462,7 +438,7 @@ function matchesNode(node: AriaNode | string, template: AriaTemplateNode, isDeep
462438
return containsList(node.children || [], template.children || []);
463439
}
464440

465-
function listEqual(children: (AriaNode | string)[], template: AriaTemplateNode[], isDeepEqual: boolean): boolean {
441+
function listEqual(children: (aria.AriaNode | string)[], template: aria.AriaTemplateNode[], isDeepEqual: boolean): boolean {
466442
if (template.length !== children.length)
467443
return false;
468444
for (let i = 0; i < template.length; ++i) {
@@ -472,7 +448,7 @@ function listEqual(children: (AriaNode | string)[], template: AriaTemplateNode[]
472448
return true;
473449
}
474450

475-
function containsList(children: (AriaNode | string)[], template: AriaTemplateNode[]): boolean {
451+
function containsList(children: (aria.AriaNode | string)[], template: aria.AriaTemplateNode[]): boolean {
476452
if (template.length > children.length)
477453
return false;
478454
const cc = children.slice();
@@ -490,9 +466,9 @@ function containsList(children: (AriaNode | string)[], template: AriaTemplateNod
490466
return true;
491467
}
492468

493-
function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll: boolean, isDeepEqual: boolean): AriaNode[] {
494-
const results: AriaNode[] = [];
495-
const visit = (node: AriaNode | string, parent: AriaNode | null): boolean => {
469+
function matchesNodeDeep(root: aria.AriaNode, template: aria.AriaTemplateNode, collectAll: boolean, isDeepEqual: boolean): aria.AriaNode[] {
470+
const results: aria.AriaNode[] = [];
471+
const visit = (node: aria.AriaNode | string, parent: aria.AriaNode | null): boolean => {
496472
if (matchesNode(node, template, isDeepEqual)) {
497473
const result = typeof node === 'string' ? parent : node;
498474
if (result)
@@ -511,7 +487,7 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll:
511487
return results;
512488
}
513489

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

524-
function compareSnapshots(ariaSnapshot: AriaSnapshot, previousSnapshot: AriaSnapshot | undefined): Map<AriaNode, 'skip' | 'same' | 'changed'> {
500+
function compareSnapshots(ariaSnapshot: AriaSnapshot, previousSnapshot: AriaSnapshot | undefined): Map<aria.AriaNode, 'skip' | 'same' | 'changed'> {
525501
const previousByRef = buildByRefMap(previousSnapshot?.root);
526-
const result = new Map<AriaNode, 'skip' | 'same' | 'changed'>();
502+
const result = new Map<aria.AriaNode, 'skip' | 'same' | 'changed'>();
527503

528504
// Returns whether ariaNode is the same as previousNode.
529-
const visit = (ariaNode: AriaNode, previousNode: AriaNode | undefined): boolean => {
530-
let same: boolean = ariaNode.children.length === previousNode?.children.length && ariaNodesEqual(ariaNode, previousNode);
505+
const visit = (ariaNode: aria.AriaNode, previousNode: aria.AriaNode | undefined): boolean => {
506+
let same: boolean = ariaNode.children.length === previousNode?.children.length && aria.ariaNodesEqual(ariaNode, previousNode);
531507
let canBeSkipped = same;
532508

533509
for (let childIndex = 0 ; childIndex < ariaNode.children.length; childIndex++) {
@@ -558,10 +534,10 @@ function compareSnapshots(ariaSnapshot: AriaSnapshot, previousSnapshot: AriaSnap
558534
}
559535

560536
// Chooses only the changed parts of the snapshot and returns them as new roots.
561-
function filterSnapshotDiff(nodes: (AriaNode | string)[], statusMap: Map<AriaNode, 'skip' | 'same' | 'changed'>): (AriaNode | string)[] {
562-
const result: (AriaNode | string)[] = [];
537+
function filterSnapshotDiff(nodes: (aria.AriaNode | string)[], statusMap: Map<aria.AriaNode, 'skip' | 'same' | 'changed'>): (aria.AriaNode | string)[] {
538+
const result: (aria.AriaNode | string)[] = [];
563539

564-
const visit = (ariaNode: AriaNode) => {
540+
const visit = (ariaNode: aria.AriaNode) => {
565541
const status = statusMap.get(ariaNode);
566542
if (status === 'same') {
567543
// No need to render unchanged root at all.
@@ -605,7 +581,7 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
605581
lines.push(indent + '- text: ' + escaped);
606582
};
607583

608-
const createKey = (ariaNode: AriaNode, renderCursorPointer: boolean): string => {
584+
const createKey = (ariaNode: aria.AriaNode, renderCursorPointer: boolean): string => {
609585
let key = ariaNode.role;
610586
// Yaml has a limit of 1024 characters per key, and we leave some space for role and attributes.
611587
if (ariaNode.name && ariaNode.name.length <= 900) {
@@ -636,17 +612,17 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
636612

637613
if (ariaNode.ref) {
638614
key += ` [ref=${ariaNode.ref}]`;
639-
if (renderCursorPointer && hasPointerCursor(ariaNode))
615+
if (renderCursorPointer && aria.hasPointerCursor(ariaNode))
640616
key += ' [cursor=pointer]';
641617
}
642618
return key;
643619
};
644620

645-
const getSingleInlinedTextChild = (ariaNode: AriaNode | undefined): string | undefined => {
621+
const getSingleInlinedTextChild = (ariaNode: aria.AriaNode | undefined): string | undefined => {
646622
return ariaNode?.children.length === 1 && typeof ariaNode.children[0] === 'string' && !Object.keys(ariaNode.props).length ? ariaNode.children[0] : undefined;
647623
};
648624

649-
const visit = (ariaNode: AriaNode, indent: string, renderCursorPointer: boolean) => {
625+
const visit = (ariaNode: aria.AriaNode, indent: string, renderCursorPointer: boolean) => {
650626
// Replace the whole subtree with a single reference when possible.
651627
if (statusMap.get(ariaNode) === 'same' && ariaNode.ref) {
652628
lines.push(indent + `- ref=${ariaNode.ref} [unchanged]`);
@@ -675,7 +651,7 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
675651
lines.push(indent + ' - /' + name + ': ' + yamlEscapeValueIfNeeded(value));
676652

677653
const childIndent = indent + ' ';
678-
const inCursorPointer = !!ariaNode.ref && renderCursorPointer && hasPointerCursor(ariaNode);
654+
const inCursorPointer = !!ariaNode.ref && renderCursorPointer && aria.hasPointerCursor(ariaNode);
679655
for (const child of ariaNode.children) {
680656
if (typeof child === 'string')
681657
visitText(includeText(ariaNode, child) ? child : '', childIndent);
@@ -734,7 +710,7 @@ function convertToBestGuessRegex(text: string): string {
734710
return String(new RegExp(pattern));
735711
}
736712

737-
function textContributesInfo(node: AriaNode, text: string): boolean {
713+
function textContributesInfo(node: aria.AriaNode, text: string): boolean {
738714
if (!text.length)
739715
return false;
740716

@@ -752,6 +728,17 @@ function textContributesInfo(node: AriaNode, text: string): boolean {
752728
return filtered.trim().length / text.length > 0.1;
753729
}
754730

755-
function hasPointerCursor(ariaNode: AriaNode): boolean {
756-
return ariaNode.box.cursor === 'pointer';
731+
const elementSymbol = Symbol('element');
732+
733+
function ariaNodeElement(ariaNode: aria.AriaNode): Element {
734+
return (ariaNode as any)[elementSymbol];
735+
}
736+
737+
function setAriaNodeElement(ariaNode: aria.AriaNode, element: Element) {
738+
(ariaNode as any)[elementSymbol] = element;
739+
}
740+
741+
export function findNewElement(from: aria.AriaNode | undefined, to: aria.AriaNode): Element | undefined {
742+
const node = aria.findNewNode(from, to);
743+
return node ? ariaNodeElement(node) : undefined;
757744
}

packages/injected/src/domUtils.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -108,17 +108,7 @@ export function isElementStyleVisibilityVisible(element: Element, style?: CSSSty
108108
return true;
109109
}
110110

111-
export type Box = {
112-
visible: boolean;
113-
inline: boolean;
114-
rect?: DOMRect;
115-
// Note: we do not store the CSSStyleDeclaration object, because it is a live object
116-
// and changes values over time. This does not work for caching or comparing to the
117-
// old values. Instead, store all the properties separately.
118-
cursor?: CSSStyleDeclaration['cursor'];
119-
};
120-
121-
export function computeBox(element: Element): Box {
111+
export function computeBox(element: Element) {
122112
// Note: this logic should be similar to waitForDisplayedAtStablePosition() to avoid surprises.
123113
const style = getElementComputedStyle(element);
124114
if (!style)
@@ -137,7 +127,7 @@ export function computeBox(element: Element): Box {
137127
if (!isElementStyleVisibilityVisible(element, style))
138128
return { cursor, visible: false, inline: false };
139129
const rect = element.getBoundingClientRect();
140-
return { rect, cursor, visible: rect.width > 0 && rect.height > 0, inline: style.display === 'inline' };
130+
return { cursor, visible: rect.width > 0 && rect.height > 0, inline: style.display === 'inline' };
141131
}
142132

143133
export function isElementVisible(element: Element): boolean {

0 commit comments

Comments
 (0)