Skip to content

Commit

Permalink
chore: suggest aria snapshots w/ regex (#33334)
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelfeldman authored Oct 29, 2024
1 parent 3b18834 commit 9ce401d
Show file tree
Hide file tree
Showing 7 changed files with 357 additions and 50 deletions.
124 changes: 92 additions & 32 deletions packages/playwright-core/src/server/injected/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
import * as roleUtils from './roleUtils';
import { getElementComputedStyle } from './domUtils';
import type { AriaRole } from './roleUtils';
import { escapeRegExp, longestCommonSubstring } from '@isomorphic/stringUtils';
import { yamlEscapeStringIfNeeded, yamlQuoteFragment } from './yaml';

type AriaProps = {
checked?: boolean | 'mixed';
Expand Down Expand Up @@ -89,7 +91,7 @@ export function generateAriaTree(rootElement: Element): AriaNode {
if (treatAsBlock)
ariaNode.children.push(treatAsBlock);

if (ariaNode.children.length === 1 && ariaNode.name === ariaNode.children[0])
if (ariaNode.children.length === 1 && ariaNode.name === ariaNode.children[0])
ariaNode.children = [];
}

Expand Down Expand Up @@ -180,10 +182,19 @@ function matchesText(text: string | undefined, template: RegExp | string | undef
return !!text.match(template);
}

export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: boolean, received: string } {
export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: boolean, received: { raw: string, regex: string } } {
const root = generateAriaTree(rootElement);
const matches = matchesNodeDeep(root, template);
return { matches, received: renderAriaTree(root) };
return {
matches,
received: {
raw: renderAriaTree(root),
regex: renderAriaTree(root, {
includeText,
renderString: convertToBestGuessRegex
}),
}
};
}

function matchesNode(node: AriaNode | string, template: AriaTemplateNode | RegExp | string, depth: number): boolean {
Expand Down Expand Up @@ -251,62 +262,111 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode): boolean {
return !!results.length;
}

export function renderAriaTree(ariaNode: AriaNode, options?: { noText?: boolean }): string {
type RenderAriaTreeOptions = {
includeText?: (node: AriaNode, text: string) => boolean;
renderString?: (text: string) => string | null;
};

export function renderAriaTree(ariaNode: AriaNode, options?: RenderAriaTreeOptions): string {
const lines: string[] = [];
const visit = (ariaNode: AriaNode | string, indent: string) => {
const includeText = options?.includeText || (() => true);
const renderString = options?.renderString || (str => str);
const visit = (ariaNode: AriaNode | string, parentAriaNode: AriaNode | null, indent: string) => {
if (typeof ariaNode === 'string') {
if (!options?.noText)
lines.push(indent + '- text: ' + quoteYamlString(ariaNode));
if (parentAriaNode && !includeText(parentAriaNode, ariaNode))
return;
const text = renderString(ariaNode);
if (text)
lines.push(indent + '- text: ' + text);
return;
}
let line = `${indent}- ${ariaNode.role}`;
if (ariaNode.name)
line += ` ${quoteYamlString(ariaNode.name)}`;

let key = ariaNode.role;
if (ariaNode.name) {
const name = renderString(ariaNode.name);
if (name)
key += ' ' + yamlQuoteFragment(name);
}
if (ariaNode.checked === 'mixed')
line += ` [checked=mixed]`;
key += ` [checked=mixed]`;
if (ariaNode.checked === true)
line += ` [checked]`;
key += ` [checked]`;
if (ariaNode.disabled)
line += ` [disabled]`;
key += ` [disabled]`;
if (ariaNode.expanded)
line += ` [expanded]`;
key += ` [expanded]`;
if (ariaNode.level)
line += ` [level=${ariaNode.level}]`;
key += ` [level=${ariaNode.level}]`;
if (ariaNode.pressed === 'mixed')
line += ` [pressed=mixed]`;
key += ` [pressed=mixed]`;
if (ariaNode.pressed === true)
line += ` [pressed]`;
key += ` [pressed]`;
if (ariaNode.selected === true)
line += ` [selected]`;
key += ` [selected]`;

const escapedKey = indent + '- ' + yamlEscapeStringIfNeeded(key, '\'');
if (!ariaNode.children.length) {
lines.push(line);
lines.push(escapedKey);
} else if (ariaNode.children.length === 1 && typeof ariaNode.children[0] === 'string') {
if (!options?.noText)
line += ': ' + quoteYamlString(ariaNode.children[0]);
lines.push(line);
const text = includeText(ariaNode, ariaNode.children[0]) ? renderString(ariaNode.children[0] as string) : null;
if (text)
lines.push(escapedKey + ': ' + yamlEscapeStringIfNeeded(text, '"'));
else
lines.push(escapedKey);
} else {
lines.push(line + ':');
lines.push(escapedKey + ':');
for (const child of ariaNode.children || [])
visit(child, indent + ' ');
visit(child, ariaNode, indent + ' ');
}
};

if (ariaNode.role === 'fragment') {
// Render fragment.
for (const child of ariaNode.children || [])
visit(child, '');
visit(child, ariaNode, '');
} else {
visit(ariaNode, '');
visit(ariaNode, null, '');
}
return lines.join('\n');
}

function quoteYamlString(str: string) {
return `"${str
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')}"`;
function convertToBestGuessRegex(text: string): string {
const dynamicContent = [
// Do not replace single digits with regex by default.
// 2+ digits: [Issue 22, 22.3, 2.33, 2,333]
{ regex: /\b\d{2,}\b/g, replacement: '\\d+' },
{ regex: /\b\{2,}\.\d+\b/g, replacement: '\\d+\\.\\d+' },
{ regex: /\b\d+\.\d{2,}\b/g, replacement: '\\d+\\.\\d+' },
{ regex: /\b\d+,\d+\b/g, replacement: '\\d+,\\d+' },
// 2ms, 20s
{ regex: /\b\d+[hms]+\b/g, replacement: '\\d+[hms]+' },
{ regex: /\b[\d,.]+[hms]+\b/g, replacement: '[\\d,.]+[hms]+' },
];

let result = escapeRegExp(text);
let hasDynamicContent = false;

for (const { regex, replacement } of dynamicContent) {
if (regex.test(result)) {
result = result.replace(regex, replacement);
hasDynamicContent = true;
}
}

return hasDynamicContent ? String(new RegExp(result)) : text;
}

function includeText(node: AriaNode, text: string): boolean {
if (!text.length)
return false;

if (!node.name)
return true;

// Figure out if text adds any value.
const substr = longestCommonSubstring(text, node.name);
let filtered = text;
while (substr && filtered.includes(substr))
filtered = filtered.replace(substr, '');
return filtered.trim().length / text.length > 0.1;
}
107 changes: 107 additions & 0 deletions packages/playwright-core/src/server/injected/yaml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

export function yamlEscapeStringIfNeeded(str: string, quote = '"'): string {
if (!yamlStringNeedsQuotes(str))
return str;
return yamlEscapeString(str, quote);
}

export function yamlEscapeString(str: string, quote = '"'): string {
return quote + str.replace(/[\\"\x00-\x1f\x7f-\x9f]/g, c => {
switch (c) {
case '\\':
return '\\\\';
case '"':
return quote === '"' ? '\\"' : '"';
case '\'':
return quote === '\'' ? '\\\'' : '\'';
case '\b':
return '\\b';
case '\f':
return '\\f';
case '\n':
return '\\n';
case '\r':
return '\\r';
case '\t':
return '\\t';
default:
const code = c.charCodeAt(0);
return '\\x' + code.toString(16).padStart(2, '0');
}
}) + quote;
}

export function yamlQuoteFragment(str: string, quote = '"'): string {
return quote + str.replace(/['"]/g, c => {
switch (c) {
case '"':
return quote === '"' ? '\\"' : '"';
case '\'':
return quote === '\'' ? '\\\'' : '\'';
default:
return c;
}
}) + quote;
}

function yamlStringNeedsQuotes(str: string): boolean {
if (str.length === 0)
return true;

// Strings with leading or trailing whitespace need quotes
if (/^\s|\s$/.test(str))
return true;

// Strings containing control characters need quotes
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(str))
return true;

// Strings starting with '-' followed by a space need quotes
if (/^-\s/.test(str))
return true;

// Strings that start with a special indicator character need quotes
if (/^[&*].*/.test(str))
return true;

// Strings containing ':' followed by a space or at the end need quotes
if (/:(\s|$)/.test(str))
return true;

// Strings containing '#' preceded by a space need quotes (comment indicator)
if (/\s#/.test(str))
return true;

// Strings that contain line breaks need quotes
if (/[\n\r]/.test(str))
return true;

// Strings starting with '?' or '!' (directives) need quotes
if (/^[?!]/.test(str))
return true;

// Strings starting with '>' or '|' (block scalar indicators) need quotes
if (/^[>|]/.test(str))
return true;

// Strings containing special characters that could cause ambiguity
if (/[{}`]/.test(str))
return true;

return false;
}
29 changes: 29 additions & 0 deletions packages/playwright-core/src/utils/isomorphic/stringUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,32 @@ export function escapeHTMLAttribute(s: string): string {
export function escapeHTML(s: string): string {
return s.replace(/[&<]/ug, char => (escaped as any)[char]);
}

export function longestCommonSubstring(s1: string, s2: string): string {
const n = s1.length;
const m = s2.length;
let maxLen = 0;
let endingIndex = 0;

// Initialize a 2D array with zeros
const dp = Array(n + 1)
.fill(null)
.map(() => Array(m + 1).fill(0));

// Build the dp table
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
if (s1[i - 1] === s2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;

if (dp[i][j] > maxLen) {
maxLen = dp[i][j];
endingIndex = i;
}
}
}
}

// Extract the longest common substring
return s1.slice(endingIndex - maxLen, endingIndex);
}
20 changes: 17 additions & 3 deletions packages/playwright/src/matchers/toMatchAriaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,25 @@ export async function toMatchAriaSnapshot(
const timeout = options.timeout ?? this.timeout;
expected = unshift(expected);
const { matches: pass, received, log, timedOut } = await receiver._expect('to.match.aria', { expectedValue: expected, isNot: this.isNot, timeout });
const typedReceived = received as {
raw: string;
noText: string;
regex: string;
} | typeof kNoElementsFoundError;

const messagePrefix = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined);
const notFound = received === kNoElementsFoundError;
const notFound = typedReceived === kNoElementsFoundError;
if (notFound) {
return {
pass: this.isNot,
message: () => messagePrefix + `Expected: ${this.utils.printExpected(expected)}\nReceived: ${EXPECTED_COLOR('not found')}` + callLogText(log),
name: 'toMatchAriaSnapshot',
expected,
};
}

const escapedExpected = escapePrivateUsePoints(expected);
const escapedReceived = escapePrivateUsePoints(received);
const escapedReceived = escapePrivateUsePoints(typedReceived.raw);
const message = () => {
if (pass) {
if (notFound)
Expand All @@ -91,7 +105,7 @@ export async function toMatchAriaSnapshot(

if (!this.isNot && pass === this.isNot && generateNewBaseline) {
// Only rebaseline failed snapshots.
const suggestedRebaseline = `toMatchAriaSnapshot(\`\n${indent(received, '${indent} ')}\n\${indent}\`)`;
const suggestedRebaseline = `toMatchAriaSnapshot(\`\n${indent(typedReceived.regex, '${indent} ')}\n\${indent}\`)`;
return { pass: this.isNot, message: () => '', name: 'toMatchAriaSnapshot', suggestedRebaseline };
}

Expand Down
Loading

0 comments on commit 9ce401d

Please sign in to comment.