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
115 changes: 63 additions & 52 deletions packages/injected/src/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* limitations under the License.
*/

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

import { computeBox, getElementComputedStyle, isElementVisible } from './domUtils';
Expand All @@ -23,6 +24,7 @@ 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;
Expand All @@ -34,6 +36,16 @@ export type AriaNode = AriaProps & {
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;
elements: Map<string, Element>;
Expand Down Expand Up @@ -495,7 +507,7 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll:
return results;
}

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

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

function arePropsEqual(a: AriaNode, b: AriaNode): boolean {
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]);
}
// Returns whether ariaNode is the same as previousNode.
const visit = (ariaNode: AriaNode, previousNode: AriaNode | undefined): boolean => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm growing increasingly unsympathetic towards rendering via delta. I think we should have a simple code path that does a simple thing and a complex code path that does complex things. If we need helper functions for it, let's introduce them beforehand.

let same: boolean = ariaNode.children.length === previousNode?.children.length && ariaNodesEqual(ariaNode, previousNode);
if (ariaNode.role === 'iframe')
same = false;

export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions, previous?: AriaSnapshot): string {
if (hasIframeNodes(ariaSnapshot.root))
previous = undefined;
for (let childIndex = 0 ; childIndex < ariaNode.children.length; childIndex++) {
const child = ariaNode.children[childIndex];
const previousChild = previousNode?.children[childIndex];
if (typeof child === 'string') {
same &&= child === previousChild;
} else {
let previous = typeof previousChild !== 'string' ? previousChild : undefined;
if (child.ref)
previous = previousByRef.get(child.ref);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way I read it, I can reorder child nodes and it'll yield the truthy result, yet you have it tested. Why does it work?

const sameChild = visit(child, previous);
same &&= (sameChild && previous === previousChild);
}
}

result.set(ariaNode, same ? 'same' : 'changed');
return same;
};

visit(ariaSnapshot.root, previousByRef.get(previousSnapshot?.root?.ref));
return result;
}

export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions, previousSnapshot?: AriaSnapshot): string {
const options = toInternalOptions(publicOptions);
const lines: string[] = [];
const includeText = options.renderStringsAsRegex ? textContributesInfo : () => true;
const renderString = options.renderStringsAsRegex ? convertToBestGuessRegex : (str: string) => str;
const previousByRef = buildByRefMap(previous?.root);
const statusMap = compareSnapshots(ariaSnapshot, previousSnapshot);

const visitText = (text: string, indent: string) => {
const escaped = yamlEscapeValueIfNeeded(renderString(text));
Expand Down Expand Up @@ -574,27 +603,23 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
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, previousNode: AriaNode | undefined): { unchanged: boolean } => {
if (ariaNode.ref)
previousNode = previousByRef.get(ariaNode.ref);
const visit = (ariaNode: AriaNode, indent: string, renderCursorPointer: boolean) => {
const status = statusMap.get(ariaNode);

const linesBefore = lines.length;
const key = createKey(ariaNode, renderCursorPointer);
const escapedKey = indent + '- ' + yamlEscapeKeyIfNeeded(key);
const inCursorPointer = renderCursorPointer && !!ariaNode.ref && hasPointerCursor(ariaNode);
const singleInlinedTextChild = getSingleInlinedTextChild(ariaNode);
// Replace the whole subtree with a single reference when possible.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a part of compareSnapshots?

if (status === 'same' && ariaNode.ref) {
lines.push(indent + `- ref=${ariaNode.ref} [unchanged]`);
return;
}

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

if (!ariaNode.children.length && !Object.keys(ariaNode.props).length) {
// Leaf node without children.
lines.push(escapedKey);
} else if (singleInlinedTextChild !== undefined) {
// Leaf node with just some text inside.
// Unchanged when the previous node also had the same single text child.
unchanged = unchanged && getSingleInlinedTextChild(previousNode) === singleInlinedTextChild;

const shouldInclude = includeText(ariaNode, singleInlinedTextChild);
if (shouldInclude)
lines.push(escapedKey + ': ' + yamlEscapeValueIfNeeded(renderString(singleInlinedTextChild)));
Expand All @@ -605,32 +630,18 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
lines.push(escapedKey + ':');
for (const [name, value] of Object.entries(ariaNode.props))
lines.push(indent + ' - /' + name + ': ' + yamlEscapeValueIfNeeded(value));

// All children must be the same.
unchanged = unchanged && previousNode?.children.length === ariaNode.children.length;

const childIndent = indent + ' ';
for (let childIndex = 0 ; childIndex < ariaNode.children.length; childIndex++) {
const child = ariaNode.children[childIndex];
if (typeof child === 'string') {
unchanged = unchanged && previousNode?.children[childIndex] === child;
if (includeText(ariaNode, child))
visitText(child, childIndent);
} else {
const previousChild = previousNode?.children[childIndex];
const childResult = visit(child, childIndent, renderCursorPointer && !inCursorPointer, typeof previousChild !== 'string' ? previousChild : undefined);
unchanged = unchanged && childResult.unchanged;
}
}
}

if (unchanged && ariaNode.ref) {
// Replace the whole subtree with a single reference.
lines.splice(linesBefore);
lines.push(indent + `- ref=${ariaNode.ref} [unchanged]`);
indent += ' ';
if (singleInlinedTextChild === undefined) {
const inCursorPointer = !!ariaNode.ref && renderCursorPointer && hasPointerCursor(ariaNode);
for (const child of ariaNode.children) {
if (typeof child === 'string')
visitText(includeText(ariaNode, child) ? child : '', indent);
else
visit(child, indent, renderCursorPointer && !inCursorPointer);
}
}

return { unchanged };
};

// Do not render the root fragment, just its children.
Expand All @@ -639,7 +650,7 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
if (typeof nodeToRender === 'string')
visitText(nodeToRender, '');
else
visit(nodeToRender, '', !!options.renderCursorPointer, undefined);
visit(nodeToRender, '', !!options.renderCursorPointer);
}
return lines.join('\n');
}
Expand Down
5 changes: 5 additions & 0 deletions packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type AriaRole = 'alert' | 'alertdialog' | 'application' | 'article' | 'ba
'spinbutton' | 'status' | 'strong' | 'subscript' | 'superscript' | 'switch' | 'tab' | 'table' | 'tablist' | 'tabpanel' | 'term' | 'textbox' | 'time' | 'timer' |
'toolbar' | 'tooltip' | 'tree' | 'treegrid' | 'treeitem';

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

export 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;
}

// We pass parsed template between worlds using JSON, make it easy.
export type AriaRegex = { pattern: string };

Expand Down
21 changes: 21 additions & 0 deletions tests/page/page-aria-snapshot-ai.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,3 +670,24 @@ it('should not create incremental snapshots without tracks', async ({ page }) =>
- listitem [ref=e5]: a span
`);
});

it('should create incremental snapshot for children swap', async ({ page }) => {
await page.setContent(`
<ul>
<li>item 1</li>
<li>item 2</li>
</ul>
`);
expect(await snapshotForAI(page, { track: 'track', mode: 'incremental' })).toContainYaml(`
- list [ref=e2]:
- listitem [ref=e3]: item 1
- listitem [ref=e4]: item 2
`);

await page.evaluate(() => document.querySelector('ul').appendChild(document.querySelector('li')));
expect(await snapshotForAI(page, { track: 'track', mode: 'incremental' })).toContainYaml(`
- list [ref=e2]:
- ref=e4 [unchanged]
- ref=e3 [unchanged]
`);
});