Skip to content

Commit 8aa97ea

Browse files
committed
chore(aria): derive yaml aria snapshots from the JSON snapshot
1 parent 4c0f879 commit 8aa97ea

15 files changed

Lines changed: 253 additions & 389 deletions

File tree

docs/src/api/class-locator.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -255,10 +255,10 @@ await page.getByRole('list').ariaSnapshotJSON();
255255
**Details**
256256

257257
This method returns the same tree as [`method: Locator.ariaSnapshot`], serialized as a JSON value instead of YAML markup.
258-
The result is a list of nodes, each node being either a plain string with static text, or an object with the following properties:
259-
* `role` Aria role of the element.
258+
The result is a list of nodes, each node being an object with the following properties:
259+
* `role` Aria role of the element, or `"text"` for a static text fragment.
260260
* `name` Accessible name of the element, if any.
261-
* `text` Text content of the element, when it is the only child.
261+
* `text` Text content of the element when it is the only child, or the content of a static text fragment.
262262
* `children` Child nodes and text fragments.
263263
* Boolean and value properties for element state flags: `checked`, `disabled`, `expanded`, `active`, `invalid`, `level`, `pressed` and `selected`.
264264
* Additional element properties, for example `url` for links and `placeholder` for text boxes.

packages/injected/src/ariaSnapshot.ts

Lines changed: 13 additions & 192 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
*/
1616

1717
import * as aria from '@isomorphic/ariaSnapshot';
18-
import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace, truncateDataUrl } from '@isomorphic/stringUtils';
19-
import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from '@isomorphic/yaml';
18+
import { renderAriaSnapshotAsYaml } from '@isomorphic/ariaSnapshotRenderer';
19+
import { normalizeWhiteSpace, truncateDataUrl } from '@isomorphic/stringUtils';
2020

2121
import { distillAriaSnapshot } from './ariaSnapshotDistiller';
2222
import { computeBox, getElementComputedStyle, isElementVisible } from './domUtils';
@@ -52,7 +52,6 @@ type InternalOptions = {
5252
includeGenericRole?: boolean,
5353
renderCursorPointer?: boolean,
5454
renderActive?: boolean,
55-
renderStringsAsRegex?: boolean,
5655
renderBoxes?: boolean,
5756
};
5857

@@ -74,11 +73,8 @@ function toInternalOptions(options: AriaTreeOptions): InternalOptions {
7473
// To auto-generate assertions on visible elements.
7574
return { visibility: 'ariaAndVisible', refs: 'none', renderBoxes };
7675
}
77-
if (options.mode === 'codegen') {
78-
// To generate aria assertion with regex heurisitcs.
79-
return { visibility: 'aria', refs: 'none', renderStringsAsRegex: true, renderBoxes };
80-
}
81-
// To match aria snapshot.
76+
// To match aria snapshot. In 'codegen' mode, the generated tree is the same,
77+
// strings are converted to regexes when serializing to yaml.
8278
return { visibility: 'aria', refs: 'none', renderBoxes };
8379
}
8480

@@ -360,11 +356,12 @@ export type MatcherReceived = {
360356
export function matchesExpectAriaTemplate(rootElement: Element, template: aria.AriaTemplateNode): { matches: aria.AriaNode[], received: MatcherReceived } {
361357
const snapshot = generateAriaTree(rootElement, { mode: 'default' });
362358
const matches = matchesNodeDeep(snapshot.root, template, false, false);
359+
const { json } = renderAriaTreeAsJSON(snapshot, { mode: 'default' });
363360
return {
364361
matches,
365362
received: {
366-
raw: renderAriaTree(snapshot, { mode: 'default' }).text,
367-
regex: renderAriaTree(snapshot, { mode: 'codegen' }).text,
363+
raw: renderAriaSnapshotAsYaml(json),
364+
regex: renderAriaSnapshotAsYaml(json, { convertStringsToRegex: true }),
368365
}
369366
};
370367
}
@@ -462,127 +459,6 @@ function matchesNodeDeep(root: aria.AriaNode, template: aria.AriaTemplateNode, c
462459
return results;
463460
}
464461

465-
function indent(depth: number): string {
466-
return ' '.repeat(depth);
467-
}
468-
469-
export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions): { text: string, iframeDepths: Record<string, number> } {
470-
const options = toInternalOptions(publicOptions);
471-
const lines: string[] = [];
472-
const iframeDepths: Record<string, number> = {};
473-
const includeText = options.renderStringsAsRegex ? textContributesInfo : () => true;
474-
const renderString = options.renderStringsAsRegex ? convertToBestGuessRegex : (str: string) => str;
475-
476-
// Do not render the root fragment, just its children.
477-
const nodesToRender = ariaSnapshot.root.role === 'fragment' ? ariaSnapshot.root.children : [ariaSnapshot.root];
478-
479-
const visitText = (text: string, depth: number) => {
480-
if (publicOptions.depth && depth > publicOptions.depth)
481-
return;
482-
const escaped = yamlEscapeValueIfNeeded(renderString(text));
483-
if (escaped)
484-
lines.push(indent(depth) + '- text: ' + escaped);
485-
};
486-
487-
const createKey = (ariaNode: aria.AriaNode, renderCursorPointer: boolean): string => {
488-
let key = ariaNode.role;
489-
// Yaml has a limit of 1024 characters per key, and we leave some space for role and attributes.
490-
if (ariaNode.name && ariaNode.name.length <= 900) {
491-
const name = renderString(ariaNode.name);
492-
if (name) {
493-
const stringifiedName = name.startsWith('/') && name.endsWith('/') ? name : JSON.stringify(name);
494-
key += ' ' + stringifiedName;
495-
}
496-
}
497-
if (ariaNode.checked === 'mixed')
498-
key += ` [checked=mixed]`;
499-
if (ariaNode.checked === true)
500-
key += ` [checked]`;
501-
if (ariaNode.disabled)
502-
key += ` [disabled]`;
503-
if (ariaNode.expanded)
504-
key += ` [expanded]`;
505-
if (ariaNode.active && options.renderActive)
506-
key += ` [active]`;
507-
if (ariaNode.invalid === 'grammar' || ariaNode.invalid === 'spelling')
508-
key += ` [invalid=${ariaNode.invalid}]`;
509-
if (ariaNode.invalid === true)
510-
key += ` [invalid]`;
511-
if (ariaNode.level)
512-
key += ` [level=${ariaNode.level}]`;
513-
if (ariaNode.pressed === 'mixed')
514-
key += ` [pressed=mixed]`;
515-
if (ariaNode.pressed === true)
516-
key += ` [pressed]`;
517-
if (ariaNode.selected === true)
518-
key += ` [selected]`;
519-
520-
if (ariaNode.ref) {
521-
key += ` [ref=${ariaNode.ref}]`;
522-
if (renderCursorPointer && aria.hasPointerCursor(ariaNode))
523-
key += ' [cursor=pointer]';
524-
}
525-
if (options.renderBoxes) {
526-
const element = ariaNodeElement(ariaNode);
527-
if (element) {
528-
const r = element.getBoundingClientRect();
529-
key += ` [box=${Math.round(r.x)},${Math.round(r.y)},${Math.round(r.width)},${Math.round(r.height)}]`;
530-
}
531-
}
532-
return key;
533-
};
534-
535-
const getSingleTextChild = (ariaNode: aria.AriaNode): string | undefined => {
536-
return ariaNode.children.length === 1 && typeof ariaNode.children[0] === 'string' && !Object.keys(ariaNode.props).length ? ariaNode.children[0] : undefined;
537-
};
538-
539-
const visit = (ariaNode: aria.AriaNode, depth: number, renderCursorPointer: boolean) => {
540-
if (publicOptions.depth && depth > publicOptions.depth)
541-
return;
542-
543-
if (ariaNode.role === 'iframe' && ariaNode.ref)
544-
iframeDepths[ariaNode.ref] = depth;
545-
546-
const escapedKey = indent(depth) + '- ' + yamlEscapeKeyIfNeeded(createKey(ariaNode, renderCursorPointer));
547-
const singleTextChild = getSingleTextChild(ariaNode);
548-
const isAtDepthLimit = !!publicOptions.depth && depth === publicOptions.depth;
549-
const hasNoChildren = !singleTextChild && (!ariaNode.children.length || isAtDepthLimit);
550-
551-
if (hasNoChildren && !Object.keys(ariaNode.props).length) {
552-
// Leaf node without children.
553-
lines.push(escapedKey);
554-
} else if (singleTextChild !== undefined) {
555-
// Leaf node with just some text inside.
556-
const shouldInclude = includeText(ariaNode, singleTextChild);
557-
if (shouldInclude)
558-
lines.push(escapedKey + ': ' + yamlEscapeValueIfNeeded(renderString(singleTextChild)));
559-
else
560-
lines.push(escapedKey);
561-
} else {
562-
// Node with (optional) props and some children.
563-
lines.push(escapedKey + ':');
564-
for (const [name, value] of Object.entries(ariaNode.props))
565-
lines.push(indent(depth + 1) + '- /' + name + ': ' + yamlEscapeValueIfNeeded(value));
566-
567-
const inCursorPointer = !!ariaNode.ref && renderCursorPointer && aria.hasPointerCursor(ariaNode);
568-
for (const child of ariaNode.children) {
569-
if (typeof child === 'string')
570-
visitText(includeText(ariaNode, child) ? child : '', depth + 1);
571-
else
572-
visit(child, depth + 1, renderCursorPointer && !inCursorPointer);
573-
}
574-
}
575-
};
576-
577-
for (const nodeToRender of nodesToRender) {
578-
if (typeof nodeToRender === 'string')
579-
visitText(nodeToRender, 0);
580-
else
581-
visit(nodeToRender, 0, !!options.renderCursorPointer);
582-
}
583-
return { text: lines.join('\n'), iframeDepths };
584-
}
585-
586462
export function renderAriaTreeAsJSON(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions): { json: aria.AriaSnapshotJSON, iframeDepths: Record<string, number> } {
587463
const options = toInternalOptions(publicOptions);
588464
const iframeDepths: Record<string, number> = {};
@@ -591,7 +467,7 @@ export function renderAriaTreeAsJSON(ariaSnapshot: AriaSnapshot, publicOptions:
591467
if (ariaNode.role === 'iframe' && ariaNode.ref)
592468
iframeDepths[ariaNode.ref] = depth;
593469

594-
const node: aria.AriaNodeJSON = { role: ariaNode.role };
470+
const node: aria.AriaNodeJSON = { role: ariaNode.role as aria.AriaNodeJSON['role'] };
595471
if (ariaNode.name)
596472
node.name = ariaNode.name;
597473
if (ariaNode.checked === 'mixed' || ariaNode.checked === true)
@@ -622,8 +498,10 @@ export function renderAriaTreeAsJSON(ariaSnapshot: AriaSnapshot, publicOptions:
622498
node.box = { x: Math.round(r.x), y: Math.round(r.y), width: Math.round(r.width), height: Math.round(r.height) };
623499
}
624500
}
625-
for (const [name, value] of Object.entries(ariaNode.props))
626-
node[name] = value;
501+
if (ariaNode.props.url !== undefined)
502+
node.url = ariaNode.props.url;
503+
if (ariaNode.props.placeholder !== undefined)
504+
node.placeholder = ariaNode.props.placeholder;
627505

628506
const singleTextChild = ariaNode.children.length === 1 && typeof ariaNode.children[0] === 'string' ? ariaNode.children[0] : undefined;
629507
const isAtDepthLimit = !!publicOptions.depth && depth === publicOptions.depth;
@@ -644,70 +522,13 @@ export function renderAriaTreeAsJSON(ariaSnapshot: AriaSnapshot, publicOptions:
644522
const nodesToRender = ariaSnapshot.root.role === 'fragment' ? ariaSnapshot.root.children : [ariaSnapshot.root];
645523
for (const nodeToRender of nodesToRender) {
646524
if (typeof nodeToRender === 'string')
647-
json.push(nodeToRender);
525+
json.push({ role: 'text', text: nodeToRender });
648526
else
649527
json.push(visit(nodeToRender, 0, !!options.renderCursorPointer));
650528
}
651529
return { json, iframeDepths };
652530
}
653531

654-
function convertToBestGuessRegex(text: string): string {
655-
const dynamicContent = [
656-
// 550e8400-e29b-41d4-a716-446655440000
657-
{ regex: /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/, replacement: '[0-9a-fA-F-]+' },
658-
// 2mb
659-
{ regex: /\b[\d,.]+[bkmBKM]+\b/, replacement: '[\\d,.]+[bkmBKM]+' },
660-
// 2ms, 20s
661-
{ regex: /\b\d+[hmsp]+\b/, replacement: '\\d+[hmsp]+' },
662-
{ regex: /\b[\d,.]+[hmsp]+\b/, replacement: '[\\d,.]+[hmsp]+' },
663-
// Do not replace single digits with regex by default.
664-
// 2+ digits: [Issue 22, 22.3, 2.33, 2,333]
665-
{ regex: /\b\d+,\d+\b/, replacement: '\\d+,\\d+' },
666-
{ regex: /\b\d+\.\d{2,}\b/, replacement: '\\d+\\.\\d+' },
667-
{ regex: /\b\d{2,}\.\d+\b/, replacement: '\\d+\\.\\d+' },
668-
{ regex: /\b\d{2,}\b/, replacement: '\\d+' },
669-
];
670-
671-
let pattern = '';
672-
let lastIndex = 0;
673-
674-
const combinedRegex = new RegExp(dynamicContent.map(r => '(' + r.regex.source + ')').join('|'), 'g');
675-
text.replace(combinedRegex, (match, ...args) => {
676-
const offset = args[args.length - 2];
677-
const groups = args.slice(0, -2);
678-
pattern += escapeRegExp(text.slice(lastIndex, offset));
679-
for (let i = 0; i < groups.length; i++) {
680-
if (groups[i]) {
681-
const { replacement } = dynamicContent[i];
682-
pattern += replacement;
683-
break;
684-
}
685-
}
686-
lastIndex = offset + match.length;
687-
return match;
688-
});
689-
if (!pattern)
690-
return text;
691-
692-
pattern += escapeRegExp(text.slice(lastIndex));
693-
return String(new RegExp(pattern));
694-
}
695-
696-
function textContributesInfo(node: aria.AriaNode, text: string): boolean {
697-
if (!text.length)
698-
return false;
699-
700-
if (!node.name)
701-
return true;
702-
703-
// Figure out if text adds any value. "longestCommonSubstring" is expensive, so limit strings length.
704-
const substr = (text.length <= 200 && node.name.length <= 200) ? longestCommonSubstring(text, node.name) : '';
705-
let filtered = text;
706-
while (substr && filtered.includes(substr))
707-
filtered = filtered.replace(substr, '');
708-
return filtered.trim().length / text.length > 0.1;
709-
}
710-
711532
const elementSymbol = Symbol('element');
712533

713534
function ariaNodeElement(ariaNode: aria.AriaNode): Element {

packages/injected/src/injectedScript.ts

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@
1515
*/
1616

1717
import { parseAriaSnapshot } from '@isomorphic/ariaSnapshot';
18+
import { renderAriaSnapshotAsYaml } from '@isomorphic/ariaSnapshotRenderer';
1819
import { asLocator } from '@isomorphic/locatorGenerators';
1920
import { splitTestIdAttributeNames } from '@isomorphic/locatorUtils';
2021
import { parseAttributeSelector, parseSelector, stringifySelector, visitAllSelectorParts } from '@isomorphic/selectorParser';
2122
import { cacheNormalizedWhitespaces, normalizeWhiteSpace, trimStringWithEllipsis } from '@isomorphic/stringUtils';
2223

23-
import { generateAriaTree, getAllElementsMatchingExpectAriaTemplate, matchesExpectAriaTemplate, renderAriaTree, renderAriaTreeAsJSON, findNewElement } from './ariaSnapshot';
24+
import { generateAriaTree, getAllElementsMatchingExpectAriaTemplate, matchesExpectAriaTemplate, renderAriaTreeAsJSON, findNewElement } from './ariaSnapshot';
2425
import { beginDOMCaches, enclosingShadowRootOrDocument, endDOMCaches, isElementVisible, isInsideScope, parentElementOrShadowHost, setGlobalOptions } from './domUtils';
2526
import { Highlight } from './highlight';
2627
import { kLayoutSelectorNames, layoutSelectorScore } from './layoutSelectorUtils';
@@ -315,17 +316,8 @@ export class InjectedScript {
315316
}
316317

317318
ariaSnapshot(node: Node, options: AriaTreeOptions): string {
318-
return this.ariaSnapshotWithRefs(node, options).text;
319-
}
320-
321-
ariaSnapshotWithRefs(node: Node, options: AriaTreeOptions & { depth?: number }): { text: string, iframeRefs: string[], iframeDepths: Record<string, number> } {
322-
if (node.nodeType !== Node.ELEMENT_NODE)
323-
throw this.createStacklessError('Can only capture aria snapshot of Element nodes.');
324-
options = { ...options, refPrefix: this._frameSeq && options.mode === 'ai' ? 'f' + this._frameSeq : '' };
325-
const ariaSnapshot = generateAriaTree(node as Element, options);
326-
const rendered = renderAriaTree(ariaSnapshot, options);
327-
this._lastAriaSnapshotForQuery = ariaSnapshot;
328-
return { text: rendered.text, iframeRefs: ariaSnapshot.iframeRefs, iframeDepths: rendered.iframeDepths };
319+
const { json } = this.ariaSnapshotJSON(node, options);
320+
return renderAriaSnapshotAsYaml(json, { convertStringsToRegex: options.mode === 'codegen' });
329321
}
330322

331323
ariaSnapshotJSON(node: Node, options: AriaTreeOptions & { depth?: number }): { json: AriaSnapshotJSON, iframeRefs: string[], iframeDepths: Record<string, number> } {
@@ -340,14 +332,15 @@ export class InjectedScript {
340332

341333
ariaSnapshotForRecorder(): { ariaSnapshot: string, refs: Map<Element, string> } {
342334
const tree = generateAriaTree(this.document.body, { mode: 'ai' });
343-
const { text: ariaSnapshot } = renderAriaTree(tree, { mode: 'ai' });
344-
return { ariaSnapshot, refs: tree.refs };
335+
const { json } = renderAriaTreeAsJSON(tree, { mode: 'ai' });
336+
return { ariaSnapshot: renderAriaSnapshotAsYaml(json), refs: tree.refs };
345337
}
346338

347339
ariaSnapshotForExpectFailure(element: Element, options: AriaTreeOptions): string {
348340
// Bypass _lastAriaSnapshotForQuery — that cache is reserved for explicit
349341
// ariaSnapshot() calls used by the aria-ref selector engine.
350-
return renderAriaTree(generateAriaTree(element, options), options).text;
342+
const { json } = renderAriaTreeAsJSON(generateAriaTree(element, options), options);
343+
return renderAriaSnapshotAsYaml(json);
351344
}
352345

353346
getAllElementsMatchingExpectAriaTemplate(document: Document, template: AriaTemplateNode): Element[] {

0 commit comments

Comments
 (0)