Skip to content

Commit 1a06851

Browse files
authored
chore(aria): extract compareSnapshots (#37987)
1 parent 2d770a5 commit 1a06851

File tree

3 files changed

+89
-52
lines changed

3 files changed

+89
-52
lines changed

packages/injected/src/ariaSnapshot.ts

Lines changed: 63 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17+
import { ariaPropsEqual } from '@isomorphic/ariaSnapshot';
1718
import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace } from '@isomorphic/stringUtils';
1819

1920
import { computeBox, getElementComputedStyle, isElementVisible } from './domUtils';
@@ -23,6 +24,7 @@ import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from './yaml';
2324
import type { AriaProps, AriaRegex, AriaTextValue, AriaRole, AriaTemplateNode } from '@isomorphic/ariaSnapshot';
2425
import type { Box } from './domUtils';
2526

27+
// Note: please keep in sync with ariaNodesEqual() below.
2628
export type AriaNode = AriaProps & {
2729
role: AriaRole | 'fragment' | 'iframe';
2830
name: string;
@@ -34,6 +36,16 @@ export type AriaNode = AriaProps & {
3436
props: Record<string, string>;
3537
};
3638

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+
3749
export type AriaSnapshot = {
3850
root: AriaNode;
3951
elements: Map<string, Element>;
@@ -495,7 +507,7 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll:
495507
return results;
496508
}
497509

498-
function buildByRefMap(root: AriaNode | undefined, map: Map<string, AriaNode> = new Map()): Map<string, AriaNode> {
510+
function buildByRefMap(root: AriaNode | undefined, map: Map<string | undefined, AriaNode> = new Map()): Map<string | undefined, AriaNode> {
499511
if (root?.ref)
500512
map.set(root.ref, root);
501513
for (const child of root?.children || []) {
@@ -505,27 +517,44 @@ function buildByRefMap(root: AriaNode | undefined, map: Map<string, AriaNode> =
505517
return map;
506518
}
507519

508-
function hasIframeNodes(root: AriaNode): boolean {
509-
if (root.role === 'iframe')
510-
return true;
511-
return (root.children || []).some(child => typeof child !== 'string' && hasIframeNodes(child));
512-
}
520+
function compareSnapshots(ariaSnapshot: AriaSnapshot, previousSnapshot: AriaSnapshot | undefined): Map<AriaNode, 'skip' | 'same' | 'changed'> {
521+
const previousByRef = buildByRefMap(previousSnapshot?.root);
522+
const result = new Map<AriaNode, 'same' | 'changed'>();
513523

514-
function arePropsEqual(a: AriaNode, b: AriaNode): boolean {
515-
const aKeys = Object.keys(a.props);
516-
const bKeys = Object.keys(b.props);
517-
return aKeys.length === bKeys.length && aKeys.every(k => a.props[k] === b.props[k]);
518-
}
524+
// Returns whether ariaNode is the same as previousNode.
525+
const visit = (ariaNode: AriaNode, previousNode: AriaNode | undefined): boolean => {
526+
let same: boolean = ariaNode.children.length === previousNode?.children.length && ariaNodesEqual(ariaNode, previousNode);
527+
if (ariaNode.role === 'iframe')
528+
same = false;
519529

520-
export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions, previous?: AriaSnapshot): string {
521-
if (hasIframeNodes(ariaSnapshot.root))
522-
previous = undefined;
530+
for (let childIndex = 0 ; childIndex < ariaNode.children.length; childIndex++) {
531+
const child = ariaNode.children[childIndex];
532+
const previousChild = previousNode?.children[childIndex];
533+
if (typeof child === 'string') {
534+
same &&= child === previousChild;
535+
} else {
536+
let previous = typeof previousChild !== 'string' ? previousChild : undefined;
537+
if (child.ref)
538+
previous = previousByRef.get(child.ref);
539+
const sameChild = visit(child, previous);
540+
same &&= (sameChild && previous === previousChild);
541+
}
542+
}
523543

544+
result.set(ariaNode, same ? 'same' : 'changed');
545+
return same;
546+
};
547+
548+
visit(ariaSnapshot.root, previousByRef.get(previousSnapshot?.root?.ref));
549+
return result;
550+
}
551+
552+
export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions, previousSnapshot?: AriaSnapshot): string {
524553
const options = toInternalOptions(publicOptions);
525554
const lines: string[] = [];
526555
const includeText = options.renderStringsAsRegex ? textContributesInfo : () => true;
527556
const renderString = options.renderStringsAsRegex ? convertToBestGuessRegex : (str: string) => str;
528-
const previousByRef = buildByRefMap(previous?.root);
557+
const statusMap = compareSnapshots(ariaSnapshot, previousSnapshot);
529558

530559
const visitText = (text: string, indent: string) => {
531560
const escaped = yamlEscapeValueIfNeeded(renderString(text));
@@ -574,27 +603,23 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
574603
return ariaNode?.children.length === 1 && typeof ariaNode.children[0] === 'string' && !Object.keys(ariaNode.props).length ? ariaNode.children[0] : undefined;
575604
};
576605

577-
const visit = (ariaNode: AriaNode, indent: string, renderCursorPointer: boolean, previousNode: AriaNode | undefined): { unchanged: boolean } => {
578-
if (ariaNode.ref)
579-
previousNode = previousByRef.get(ariaNode.ref);
606+
const visit = (ariaNode: AriaNode, indent: string, renderCursorPointer: boolean) => {
607+
const status = statusMap.get(ariaNode);
580608

581-
const linesBefore = lines.length;
582-
const key = createKey(ariaNode, renderCursorPointer);
583-
const escapedKey = indent + '- ' + yamlEscapeKeyIfNeeded(key);
584-
const inCursorPointer = renderCursorPointer && !!ariaNode.ref && hasPointerCursor(ariaNode);
585-
const singleInlinedTextChild = getSingleInlinedTextChild(ariaNode);
609+
// Replace the whole subtree with a single reference when possible.
610+
if (status === 'same' && ariaNode.ref) {
611+
lines.push(indent + `- ref=${ariaNode.ref} [unchanged]`);
612+
return;
613+
}
586614

587-
// Whether ariaNode's subtree is the same as previousNode's, and can be replaced with just a ref.
588-
let unchanged = !!previousNode && key === createKey(previousNode, renderCursorPointer) && arePropsEqual(ariaNode, previousNode);
615+
const escapedKey = indent + '- ' + yamlEscapeKeyIfNeeded(createKey(ariaNode, renderCursorPointer));
616+
const singleInlinedTextChild = getSingleInlinedTextChild(ariaNode);
589617

590618
if (!ariaNode.children.length && !Object.keys(ariaNode.props).length) {
591619
// Leaf node without children.
592620
lines.push(escapedKey);
593621
} else if (singleInlinedTextChild !== undefined) {
594622
// Leaf node with just some text inside.
595-
// Unchanged when the previous node also had the same single text child.
596-
unchanged = unchanged && getSingleInlinedTextChild(previousNode) === singleInlinedTextChild;
597-
598623
const shouldInclude = includeText(ariaNode, singleInlinedTextChild);
599624
if (shouldInclude)
600625
lines.push(escapedKey + ': ' + yamlEscapeValueIfNeeded(renderString(singleInlinedTextChild)));
@@ -605,32 +630,18 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
605630
lines.push(escapedKey + ':');
606631
for (const [name, value] of Object.entries(ariaNode.props))
607632
lines.push(indent + ' - /' + name + ': ' + yamlEscapeValueIfNeeded(value));
608-
609-
// All children must be the same.
610-
unchanged = unchanged && previousNode?.children.length === ariaNode.children.length;
611-
612-
const childIndent = indent + ' ';
613-
for (let childIndex = 0 ; childIndex < ariaNode.children.length; childIndex++) {
614-
const child = ariaNode.children[childIndex];
615-
if (typeof child === 'string') {
616-
unchanged = unchanged && previousNode?.children[childIndex] === child;
617-
if (includeText(ariaNode, child))
618-
visitText(child, childIndent);
619-
} else {
620-
const previousChild = previousNode?.children[childIndex];
621-
const childResult = visit(child, childIndent, renderCursorPointer && !inCursorPointer, typeof previousChild !== 'string' ? previousChild : undefined);
622-
unchanged = unchanged && childResult.unchanged;
623-
}
624-
}
625633
}
626634

627-
if (unchanged && ariaNode.ref) {
628-
// Replace the whole subtree with a single reference.
629-
lines.splice(linesBefore);
630-
lines.push(indent + `- ref=${ariaNode.ref} [unchanged]`);
635+
indent += ' ';
636+
if (singleInlinedTextChild === undefined) {
637+
const inCursorPointer = !!ariaNode.ref && renderCursorPointer && hasPointerCursor(ariaNode);
638+
for (const child of ariaNode.children) {
639+
if (typeof child === 'string')
640+
visitText(includeText(ariaNode, child) ? child : '', indent);
641+
else
642+
visit(child, indent, renderCursorPointer && !inCursorPointer);
643+
}
631644
}
632-
633-
return { unchanged };
634645
};
635646

636647
// Do not render the root fragment, just its children.
@@ -639,7 +650,7 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
639650
if (typeof nodeToRender === 'string')
640651
visitText(nodeToRender, '');
641652
else
642-
visit(nodeToRender, '', !!options.renderCursorPointer, undefined);
653+
visit(nodeToRender, '', !!options.renderCursorPointer);
643654
}
644655
return lines.join('\n');
645656
}

packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type AriaRole = 'alert' | 'alertdialog' | 'application' | 'article' | 'ba
2424
'spinbutton' | 'status' | 'strong' | 'subscript' | 'superscript' | 'switch' | 'tab' | 'table' | 'tablist' | 'tabpanel' | 'term' | 'textbox' | 'time' | 'timer' |
2525
'toolbar' | 'tooltip' | 'tree' | 'treegrid' | 'treeitem';
2626

27+
// Note: please keep in sync with ariaPropsEqual() below.
2728
export type AriaProps = {
2829
checked?: boolean | 'mixed';
2930
disabled?: boolean;
@@ -34,6 +35,10 @@ export type AriaProps = {
3435
selected?: boolean;
3536
};
3637

38+
export function ariaPropsEqual(a: AriaProps, b: AriaProps): boolean {
39+
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;
40+
}
41+
3742
// We pass parsed template between worlds using JSON, make it easy.
3843
export type AriaRegex = { pattern: string };
3944

tests/page/page-aria-snapshot-ai.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,3 +670,24 @@ it('should not create incremental snapshots without tracks', async ({ page }) =>
670670
- listitem [ref=e5]: a span
671671
`);
672672
});
673+
674+
it('should create incremental snapshot for children swap', async ({ page }) => {
675+
await page.setContent(`
676+
<ul>
677+
<li>item 1</li>
678+
<li>item 2</li>
679+
</ul>
680+
`);
681+
expect(await snapshotForAI(page, { track: 'track', mode: 'incremental' })).toContainYaml(`
682+
- list [ref=e2]:
683+
- listitem [ref=e3]: item 1
684+
- listitem [ref=e4]: item 2
685+
`);
686+
687+
await page.evaluate(() => document.querySelector('ul').appendChild(document.querySelector('li')));
688+
expect(await snapshotForAI(page, { track: 'track', mode: 'incremental' })).toContainYaml(`
689+
- list [ref=e2]:
690+
- ref=e4 [unchanged]
691+
- ref=e3 [unchanged]
692+
`);
693+
});

0 commit comments

Comments
 (0)