Skip to content

fix(aria): Improved Aria snapshot diffing #36101

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Draft
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
130 changes: 93 additions & 37 deletions packages/injected/src/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ export type AriaSnapshot = {
elements: Map<string, Element>;
};

export type AriaMatchEntry = {
templateLineNumber: number | undefined;
actualLineNumber: number;
};

type InternalMatchEntry = {
templateLineNumber: number;
node: AriaNode | string;
};

type AriaRef = {
role: string;
name: string;
Expand Down Expand Up @@ -293,29 +303,64 @@ function matchesName(text: string, template: AriaTemplateRoleNode) {
}

export type MatcherReceived = {
matchEntries: AriaMatchEntry[];
raw: string;
regex: string;
};

export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: AriaNode[], received: MatcherReceived } {
const snapshot = generateAriaTree(rootElement);
const matches = matchesNodeDeep(snapshot.root, template, false, false);
const internalMatchEntries: InternalMatchEntry[] = [];
const matches = matchesNodeDeep(snapshot.root, template, false, false, internalMatchEntries);
const rawTree = renderAriaTree(snapshot, { mode: 'raw' });
const matchEntries = internalMatchEntries.flatMap(entry => {
const index = rawTree.findIndex(({ ariaNode }) => ariaNode === entry.node);
if (index === -1)
return [];
return {
templateLineNumber: entry.templateLineNumber - 1,
actualLineNumber: index
};
});
return {
matches,
received: {
raw: renderAriaTree(snapshot, { mode: 'raw' }),
regex: renderAriaTree(snapshot, { mode: 'regex' }),
matchEntries,
raw: rawTree.map(({ line }) => line).join('\n'),
regex: renderAriaTree(snapshot, { mode: 'regex' }).map(({ line }) => line).join('\n'),
}
};
}

export function getAllByAria(rootElement: Element, template: AriaTemplateNode): Element[] {
const root = generateAriaTree(rootElement).root;
const matches = matchesNodeDeep(root, template, true, false);
const matchEntries: InternalMatchEntry[] = [];
const matches = matchesNodeDeep(root, template, true, false, matchEntries);
return matches.map(n => n.element);
}

function matchesNode(node: AriaNode | string, template: AriaTemplateNode, isDeepEqual: boolean): boolean {
function matchesNode(
node: AriaNode | string,
template: AriaTemplateNode,
isDeepEqual: boolean,
foundMatchEntries: InternalMatchEntry[],
): boolean {
const didMatch = _doesMatchNode(node, template, isDeepEqual, foundMatchEntries);
if (didMatch) {
foundMatchEntries.push({
templateLineNumber: template.lineNumber,
node
});
}
return didMatch;
}

function _doesMatchNode(
node: AriaNode | string,
template: AriaTemplateNode,
isDeepEqual: boolean,
foundMatchEntries: InternalMatchEntry[],
): boolean {
if (typeof node === 'string' && template.kind === 'text')
return matchesTextNode(node, template);

Expand Down Expand Up @@ -343,46 +388,57 @@ function matchesNode(node: AriaNode | string, template: AriaTemplateNode, isDeep

// Proceed based on the container mode.
if (template.containerMode === 'contain')
return containsList(node.children || [], template.children || []);
if (template.containerMode === 'equal')
return listEqual(node.children || [], template.children || [], false);
if (template.containerMode === 'deep-equal' || isDeepEqual)
return listEqual(node.children || [], template.children || [], true);
return containsList(node.children || [], template.children || []);
return containsList(node.children || [], template.children || [], foundMatchEntries);
else if (template.containerMode === 'equal')
return listEqual(node.children || [], template.children || [], false, foundMatchEntries);
else if (template.containerMode === 'deep-equal' || isDeepEqual)
return listEqual(node.children || [], template.children || [], true, foundMatchEntries);
return containsList(node.children || [], template.children || [], foundMatchEntries);
}

function listEqual(children: (AriaNode | string)[], template: AriaTemplateNode[], isDeepEqual: boolean): boolean {
if (template.length !== children.length)
return false;
for (let i = 0; i < template.length; ++i) {
if (!matchesNode(children[i], template[i], isDeepEqual))
return false;
function listEqual(children: (AriaNode | string)[], template: AriaTemplateNode[], isDeepEqual: boolean, foundMatchEntries: InternalMatchEntry[]): boolean {
let match = true;
const length = Math.min(children.length, template.length);
for (let i = 0; i < length; ++i) {
if (!matchesNode(children[i], template[i], isDeepEqual, foundMatchEntries))
match = false;
}
return true;
return template.length === children.length && match;
}

function containsList(children: (AriaNode | string)[], template: AriaTemplateNode[]): boolean {
if (template.length > children.length)
return false;
const cc = children.slice();
function containsList(children: (AriaNode | string)[], template: AriaTemplateNode[], foundMatchEntries: InternalMatchEntry[]): boolean {
let cc = children.slice();
const tt = template.slice();
let match = true;
let childrenAtStartOfTemplate = [];
for (const t of tt) {
// At start of template node store where we are in the children list
childrenAtStartOfTemplate = cc.slice();
let c = cc.shift();
while (c) {
if (matchesNode(c, t, false))
if (matchesNode(c, t, false, foundMatchEntries))
break;
c = cc.shift();
}
if (!c)
return false;
if (!c) {
// Restore children location after we finished matching against this template node
cc = childrenAtStartOfTemplate;
match = false;
}
}
return true;
return match;
}

function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll: boolean, isDeepEqual: boolean): AriaNode[] {
function matchesNodeDeep(
root: AriaNode,
template: AriaTemplateNode,
collectAll: boolean,
isDeepEqual: boolean,
foundMatchEntries: InternalMatchEntry[],
): AriaNode[] {
const results: AriaNode[] = [];
const visit = (node: AriaNode | string, parent: AriaNode | null): boolean => {
if (matchesNode(node, template, isDeepEqual)) {
if (matchesNode(node, template, isDeepEqual, foundMatchEntries)) {
const result = typeof node === 'string' ? parent : node;
if (result)
results.push(result);
Expand All @@ -400,8 +456,8 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll:
return results;
}

export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'raw' | 'regex', forAI?: boolean }): string {
const lines: string[] = [];
export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'raw' | 'regex', forAI?: boolean }): Array<{ ariaNode: AriaNode | string, line: string }> {
const lines: Array<{ ariaNode: AriaNode | string, line: string }> = [];
const includeText = options?.mode === 'regex' ? textContributesInfo : () => true;
const renderString = options?.mode === 'regex' ? convertToBestGuessRegex : (str: string) => str;
const visit = (ariaNode: AriaNode | string, parentAriaNode: AriaNode | null, indent: string) => {
Expand All @@ -410,7 +466,7 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'r
return;
const text = yamlEscapeValueIfNeeded(renderString(ariaNode));
if (text)
lines.push(indent + '- text: ' + text);
lines.push({ ariaNode, line: indent + '- text: ' + text });
return;
}

Expand Down Expand Up @@ -449,17 +505,17 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'r
const escapedKey = indent + '- ' + yamlEscapeKeyIfNeeded(key);
const hasProps = !!Object.keys(ariaNode.props).length;
if (!ariaNode.children.length && !hasProps) {
lines.push(escapedKey);
lines.push({ ariaNode, line: escapedKey });
} else if (ariaNode.children.length === 1 && typeof ariaNode.children[0] === 'string' && !hasProps) {
const text = includeText(ariaNode, ariaNode.children[0]) ? renderString(ariaNode.children[0] as string) : null;
if (text)
lines.push(escapedKey + ': ' + yamlEscapeValueIfNeeded(text));
lines.push({ ariaNode, line: escapedKey + ': ' + yamlEscapeValueIfNeeded(text) });
else
lines.push(escapedKey);
lines.push({ ariaNode, line: escapedKey });
} else {
lines.push(escapedKey + ':');
lines.push({ ariaNode, line: escapedKey + ':' });
for (const [name, value] of Object.entries(ariaNode.props))
lines.push(indent + ' - /' + name + ': ' + yamlEscapeValueIfNeeded(value));
lines.push({ ariaNode, line: indent + ' - /' + name + ': ' + yamlEscapeValueIfNeeded(value) });
for (const child of ariaNode.children || [])
visit(child, ariaNode, indent + ' ');
}
Expand All @@ -473,7 +529,7 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'r
} else {
visit(ariaNode, null, '');
}
return lines.join('\n');
return lines;
}

function convertToBestGuessRegex(text: string): string {
Expand Down
2 changes: 1 addition & 1 deletion packages/injected/src/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ export class InjectedScript {
if (node.nodeType !== Node.ELEMENT_NODE)
throw this.createStacklessError('Can only capture aria snapshot of Element nodes.');
this._lastAriaSnapshot = generateAriaTree(node as Element, options);
return renderAriaTree(this._lastAriaSnapshot, options);
return renderAriaTree(this._lastAriaSnapshot, options).map(({ line }) => line).join('\n');
}

getAllByAria(document: Document, template: AriaTemplateNode): Element[] {
Expand Down
28 changes: 17 additions & 11 deletions packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,13 @@ export type AriaRegex = { pattern: string };
export type AriaTemplateTextNode = {
kind: 'text';
text: AriaRegex | string;
lineNumber: number;
};

export type AriaTemplateRoleNode = AriaProps & {
kind: 'role';
role: AriaRole | 'fragment';
lineNumber: number;
name?: AriaRegex | string;
children?: AriaTemplateNode[];
props?: Record<string, string | AriaRegex>;
Expand Down Expand Up @@ -101,7 +103,7 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: yaml
for (const item of seq.items) {
const itemIsString = item instanceof yaml.Scalar && typeof item.value === 'string';
if (itemIsString) {
const childNode = KeyParser.parse(item, parseOptions, errors);
const childNode = KeyParser.parse(item, parseOptions, errors, lineCounter.linePos(item.range![0]).line);
if (childNode) {
container.children = container.children || [];
container.children.push(childNode);
Expand Down Expand Up @@ -148,7 +150,8 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: yaml
}
container.children.push({
kind: 'text',
text: valueOrRegex(value.value)
text: valueOrRegex(value.value),
lineNumber: lineCounter.linePos(key.range![0]).line
});
continue;
}
Expand Down Expand Up @@ -183,7 +186,7 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: yaml
}

// role "name": ...
const childNode = KeyParser.parse(key, parseOptions, errors);
const childNode = KeyParser.parse(key, parseOptions, errors, lineCounter.linePos(key.range![0]).line);
if (!childNode)
continue;

Expand All @@ -198,12 +201,13 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: yaml
});
continue;
}

const textChildLineNumber = value.range ? lineCounter.linePos(value.range[0]).line : childNode.lineNumber;
container.children.push({
...childNode,
children: [{
kind: 'text',
text: valueOrRegex(String(value.value))
text: valueOrRegex(String(value.value)),
lineNumber: textChildLineNumber // Line number of the text value itself
}]
});
continue;
Expand All @@ -225,7 +229,7 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: yaml
}
};

const fragment: AriaTemplateNode = { kind: 'role', role: 'fragment' };
const fragment: AriaTemplateNode = { kind: 'role', role: 'fragment', lineNumber: 0 };

yamlDoc.errors.forEach(addError);
if (errors.length)
Expand All @@ -248,7 +252,7 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: yaml
return { fragment, errors };
}

const emptyFragment: AriaTemplateRoleNode = { kind: 'role', role: 'fragment' };
const emptyFragment: AriaTemplateRoleNode = { kind: 'role', role: 'fragment', lineNumber: 0 };

function normalizeWhitespace(text: string) {
// TODO: why is this different from normalizeWhitespace in stringUtils.ts?
Expand All @@ -263,10 +267,11 @@ export class KeyParser {
private _input: string;
private _pos: number;
private _length: number;
private _lineNumber: number;

static parse(text: yamlTypes.Scalar<string>, options: yamlTypes.ParseOptions, errors: ParsedYamlError[]): AriaTemplateRoleNode | null {
static parse(text: yamlTypes.Scalar<string>, options: yamlTypes.ParseOptions, errors: ParsedYamlError[], lineNumber: number): AriaTemplateRoleNode | null {
try {
return new KeyParser(text.value)._parse();
return new KeyParser(text.value, lineNumber)._parse();
} catch (e) {
if (e instanceof ParserError) {
const message = options.prettyErrors === false ? e.message : e.message + ':\n\n' + text.value + '\n' + ' '.repeat(e.pos) + '^\n';
Expand All @@ -280,10 +285,11 @@ export class KeyParser {
}
}

constructor(input: string) {
constructor(input: string, lineNumber: number) {
this._input = input;
this._pos = 0;
this._length = input.length;
this._lineNumber = lineNumber;
}

private _peek() {
Expand Down Expand Up @@ -419,7 +425,7 @@ export class KeyParser {
const role = this._readIdentifier('role') as AriaTemplateRoleNode['role'];
this._skipWhitespace();
const name = this._readStringOrRegex() || '';
const result: AriaTemplateRoleNode = { kind: 'role', role, name };
const result: AriaTemplateRoleNode = { kind: 'role', role, name, lineNumber: this._lineNumber };
this._readAttributes(result);
this._skipWhitespace();
if (!this._eof())
Expand Down
11 changes: 10 additions & 1 deletion packages/playwright/src/matchers/toMatchAriaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import type { LocatorEx } from './matchers';
import type { ExpectMatcherState } from '../../types/test';
import type { MatcherReceived } from '@injected/ariaSnapshot';


type ToMatchAriaSnapshotExpected = {
name?: string;
path?: string;
Expand Down Expand Up @@ -103,7 +102,17 @@ export async function toMatchAriaSnapshot(
};
}

const expectedLines = expected.split('\n');
const actualLines = typedReceived.raw.split('\n');

for (const { templateLineNumber, actualLineNumber } of typedReceived.matchEntries) {
if (templateLineNumber === undefined)
continue;
expectedLines[templateLineNumber] = actualLines[actualLineNumber];
}

const receivedText = typedReceived.raw;
expected = expectedLines.join('\n');
const message = () => {
if (pass) {
if (notFound)
Expand Down
Loading
Loading