From 1a1c47ada3c7231585e1cdeed15fcd3b00ba8590 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Fri, 16 May 2025 11:25:29 -0700 Subject: [PATCH 01/10] Began exploring redesign of toMatchAriaSnapshot diff --- .../src/matchers/toMatchAriaSnapshot.ts | 68 ++++++++++++++++++- tests/page/to-match-aria-snapshot.spec.ts | 59 ++++++++++++++++ 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index 7cc50e772fd2f..127fe226b2b8d 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -31,13 +31,19 @@ import type { LocatorEx } from './matchers'; import type { ExpectMatcherState } from '../../types/test'; import type { MatcherReceived } from '@injected/ariaSnapshot'; - type ToMatchAriaSnapshotExpected = { name?: string; path?: string; timeout?: number; } | string; +type AriaProperty = { + key: string; + value?: string; +}; + +const ARIA_PROPERTY_REGEX = /\[(\w+)(=(\w+))?\]/g; + export async function toMatchAriaSnapshot( this: ExpectMatcherState, receiver: LocatorEx, @@ -103,7 +109,53 @@ export async function toMatchAriaSnapshot( }; } - const receivedText = typedReceived.raw; + const expectedLines = expected.split('\n'); + const actualLines = typedReceived.raw.split('\n'); + + const length = Math.min(actualLines.length, expectedLines.length); + + for (let i = 0; i < length; i++) { + const expectedLine = expectedLines[i]; + const actualLine = actualLines[i]; + + const expectedMatch = expectedLine.match(/\/(.*)\//); + if (expectedMatch && expectedMatch.index !== undefined) { + try { + const regex = new RegExp(expectedMatch[1]); + + const actualMatch = actualLine.match(regex); + if (actualMatch) + expectedLines[i] = expectedLine.slice(0, expectedMatch.index) + actualMatch[0] + expectedLine.slice(expectedMatch.index + expectedMatch[0].length); + } catch (e) { + // Skip invalid regex + } + } + + const expectedProps = extractProperties(expectedLine); + const actualProps = extractProperties(actualLine); + const actualPropsMap = new Map(actualProps.map(({ key, value }) => [key, value])); + + let propsMatch = true; + + for (const { key, value } of expectedProps) { + const actualValue = actualPropsMap.get(key); + if (!actualValue || actualValue !== value) { + propsMatch = false; + break; + } + } + + if (propsMatch) { + actualLines[i] = actualLines[i].split(ARIA_PROPERTY_REGEX)[0].trimEnd(); + if (expectedProps.length > 0) { + const actualPropsString = expectedProps.map(({ key, value }) => `[${key}${value ? '=' + value : ''}]`).join(' '); + actualLines[i] += ` ${actualPropsString}`; + } + } + } + + const receivedText = actualLines.join('\n'); + expected = expectedLines.join('\n'); const message = () => { if (pass) { if (notFound) @@ -178,3 +230,15 @@ function unshift(snapshot: string): string { function indent(snapshot: string, indent: string): string { return snapshot.split('\n').map(line => indent + line).join('\n'); } + +function extractProperties(line: string): AriaProperty[] { + const props: AriaProperty[] = []; + + for (const match of line.matchAll(ARIA_PROPERTY_REGEX)) { + const key = match[1]; + const value = match[3]; + props.push({ key, value }); + } + + return props; +} diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts index f929f1b11a8d5..0f947102c2580 100644 --- a/tests/page/to-match-aria-snapshot.spec.ts +++ b/tests/page/to-match-aria-snapshot.spec.ts @@ -791,3 +791,62 @@ test('should allow restoring contain mode inside deep-equal', async ({ page }) = - listitem: 1.1 `); }); + +test(`should only highlight regex patterns that don't match`, async ({ page }) => { + await test.step('simple regex', async () => { + await page.setContent(` +

Title 123

+
Content with value 456
+ `); + + const error = await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading /Title \\d+/ + - text: /Content with value \\d+/ + - text: "This text doesn't exist" + `, { timeout: 1000 }).catch(e => e); + + expect(stripAnsi(error.message)).toContain(` +- - heading Title 123 ++ - heading "Title 123" + - text: Content with value 456 +- - text: "This text doesn't exist"`); + }); + + await test.step('nested regex', async () => { + await page.setContent(` + + `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - list: + - listitem: + - link /Link \\d+/: + - /url: about:blank + - listitem: "One more row" + `, { timeout: 1000 }); + + const error = await expect(page.locator('body')).toMatchAriaSnapshot(` + - list: + - listitem: + - link /Link2 \\d+/: + - /url: about:blank + - listitem: "One more row" + `, { timeout: 1000 }).catch(e => e); + + expect(stripAnsi(error.message)).toContain(` + - list: + - listitem: +- - link /Link2 \\d+/: ++ - link "Link 123": + - /url: about:blank +- - listitem: "One more row" ++ - listitem: Another row ++ - listitem: One more row`); + }); +}); From d52210a1cb940cf63e110a7a123e20e4b730864d Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Thu, 22 May 2025 10:45:47 -0700 Subject: [PATCH 02/10] More reasonable match index calculation --- packages/injected/src/ariaSnapshot.ts | 100 ++++++++++++++---- .../src/utils/isomorphic/ariaSnapshot.ts | 14 ++- .../src/matchers/toMatchAriaSnapshot.ts | 88 ++++++++------- tests/page/to-match-aria-snapshot.spec.ts | 3 +- 4 files changed, 141 insertions(+), 64 deletions(-) diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index a75a5cd185d6c..014f47ac18aca 100644 --- a/packages/injected/src/ariaSnapshot.ts +++ b/packages/injected/src/ariaSnapshot.ts @@ -39,6 +39,16 @@ export type AriaSnapshot = { elements: Map; }; +export interface AriaMatchEntry { + templateLineNumber: number | undefined; // Line number from the original template string + ariaNodeDFSIndex: number; // DFS index in the live ARIA tree +} + +export interface AriaMatch { + templateLineNumber?: number; + isFromTemplateRegex?: boolean; // True if this failure was due to a regex in the template name/prop +} + type AriaRef = { role: string; name: string; @@ -293,16 +303,21 @@ 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 matchEntries: AriaMatchEntry[] = []; + const dfsIndexState = { currentIndex: 0 }; + console.log('matchesAriaTree'); + const matches = matchesNodeDeep(snapshot.root, template, false, false, matchEntries, dfsIndexState); return { matches, received: { + matchEntries, raw: renderAriaTree(snapshot, { mode: 'raw' }), regex: renderAriaTree(snapshot, { mode: 'regex' }), } @@ -311,13 +326,32 @@ export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode export function getAllByAria(rootElement: Element, template: AriaTemplateNode): Element[] { const root = generateAriaTree(rootElement).root; - const matches = matchesNodeDeep(root, template, true, false); + const matchEntries: AriaMatchEntry[] = []; // Required for matchesNodeDeep signature + const dfsIndexState = { currentIndex: 0 }; // Required for matchesNodeDeep signature + const matches = matchesNodeDeep(root, template, true, false, matchEntries, dfsIndexState); return matches.map(n => n.element); } -function matchesNode(node: AriaNode | string, template: AriaTemplateNode, isDeepEqual: boolean): boolean { - if (typeof node === 'string' && template.kind === 'text') - return matchesTextNode(node, template); +function matchesNode( + node: AriaNode | string, + template: AriaTemplateNode, + isDeepEqual: boolean, + matchEntriesCollector: AriaMatchEntry[], + dfsIndexState: { currentIndex: number }, + currentAriaNodeDFSIndex?: number +): boolean { + console.log('matchesNode', node, template, dfsIndexState.currentIndex, currentAriaNodeDFSIndex); + if (typeof node === 'string' && template.kind === 'text') { + const didMatch = matchesTextNode(node, template); + if (didMatch && currentAriaNodeDFSIndex !== undefined) { + console.log('Matching', template.lineNumber, currentAriaNodeDFSIndex); + matchEntriesCollector.push({ + templateLineNumber: template.lineNumber, + ariaNodeDFSIndex: currentAriaNodeDFSIndex, + }); + } + return didMatch; + } if (node === null || typeof node !== 'object' || template.kind !== 'role') return false; @@ -341,35 +375,55 @@ function matchesNode(node: AriaNode | string, template: AriaTemplateNode, isDeep if (!matchesText(node.props.url, template.props?.url)) return false; + let childrenMatch: boolean; // 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 || []); + childrenMatch = containsList(node.children || [], template.children || [], matchEntriesCollector, dfsIndexState); + else if (template.containerMode === 'equal') + childrenMatch = listEqual(node.children || [], template.children || [], false, matchEntriesCollector, dfsIndexState); + else if (template.containerMode === 'deep-equal' || isDeepEqual) + childrenMatch = listEqual(node.children || [], template.children || [], true, matchEntriesCollector, dfsIndexState); + else + childrenMatch = containsList(node.children || [], template.children || [], matchEntriesCollector, dfsIndexState); + + if (childrenMatch && currentAriaNodeDFSIndex !== undefined) { + console.log('Matching', template.lineNumber, currentAriaNodeDFSIndex); + matchEntriesCollector.push({ + templateLineNumber: template.lineNumber, + ariaNodeDFSIndex: currentAriaNodeDFSIndex, + }); + } + return childrenMatch; } -function listEqual(children: (AriaNode | string)[], template: AriaTemplateNode[], isDeepEqual: boolean): boolean { +function listEqual( + children: (AriaNode | string)[], + template: AriaTemplateNode[], + isDeepEqual: boolean, + matchEntriesCollector: AriaMatchEntry[], + dfsIndexState: { currentIndex: number } +): boolean { if (template.length !== children.length) return false; for (let i = 0; i < template.length; ++i) { - if (!matchesNode(children[i], template[i], isDeepEqual)) + const childDFSIndex = dfsIndexState.currentIndex++; + if (!matchesNode(children[i], template[i], isDeepEqual, matchEntriesCollector, dfsIndexState, childDFSIndex)) return false; } return true; } -function containsList(children: (AriaNode | string)[], template: AriaTemplateNode[]): boolean { - if (template.length > children.length) - return false; +function containsList(children: (AriaNode | string)[], template: AriaTemplateNode[], matchEntriesCollector: AriaMatchEntry[], dfsIndexState: { currentIndex: number }): boolean { + // if (template.length > children.length) + // return false; + console.log('containsList', children, template); const cc = children.slice(); const tt = template.slice(); for (const t of tt) { let c = cc.shift(); while (c) { - if (matchesNode(c, t, false)) + const childDFSIndex = dfsIndexState ? dfsIndexState.currentIndex++ : -1; + if (matchesNode(c, t, false, matchEntriesCollector, dfsIndexState, childDFSIndex)) break; c = cc.shift(); } @@ -379,10 +433,18 @@ function containsList(children: (AriaNode | string)[], template: AriaTemplateNod return true; } -function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll: boolean, isDeepEqual: boolean): AriaNode[] { +function matchesNodeDeep( + root: AriaNode, + template: AriaTemplateNode, + collectAll: boolean, + isDeepEqual: boolean, + matchEntriesCollector: AriaMatchEntry[], + dfsIndexState: { currentIndex: number } +): AriaNode[] { const results: AriaNode[] = []; const visit = (node: AriaNode | string, parent: AriaNode | null): boolean => { - if (matchesNode(node, template, isDeepEqual)) { + const currentDFSIndex = dfsIndexState.currentIndex++; + if (matchesNode(node, template, isDeepEqual, matchEntriesCollector, dfsIndexState, currentDFSIndex)) { const result = typeof node === 'string' ? parent : node; if (result) results.push(result); diff --git a/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts b/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts index ffbd50ddef416..bedd477810577 100644 --- a/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts +++ b/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts @@ -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; @@ -103,6 +105,7 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: yaml if (itemIsString) { const childNode = KeyParser.parse(item, parseOptions, errors); if (childNode) { + childNode.lineNumber = lineCounter.linePos(item.range![0]).line; container.children = container.children || []; container.children.push(childNode); } @@ -148,7 +151,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; } @@ -186,6 +190,7 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: yaml const childNode = KeyParser.parse(key, parseOptions, errors); if (!childNode) continue; + childNode.lineNumber = lineCounter.linePos(key.range![0]).line; // - role "name": "text" const valueIsScalar = value instanceof yaml.Scalar; @@ -198,12 +203,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; @@ -214,7 +220,7 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: yaml const valueIsSequence = value instanceof yaml.YAMLSeq; if (valueIsSequence) { container.children.push(childNode); - convertSeq(childNode, value as yamlTypes.YAMLSeq); + convertSeq(childNode, value as yamlTypes.YAMLSeq); // convertSeq will handle line numbers for its children continue; } diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index 127fe226b2b8d..b93b42f0391a7 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -98,6 +98,8 @@ export async function toMatchAriaSnapshot( const { matches: pass, received, log, timedOut } = await receiver._expect('to.match.aria', { expectedValue: expected, isNot: this.isNot, timeout }); const typedReceived = received as MatcherReceived | typeof kNoElementsFoundError; + console.log(typedReceived); + const messagePrefix = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined); const notFound = typedReceived === kNoElementsFoundError; if (notFound) { @@ -112,46 +114,54 @@ export async function toMatchAriaSnapshot( const expectedLines = expected.split('\n'); const actualLines = typedReceived.raw.split('\n'); - const length = Math.min(actualLines.length, expectedLines.length); - - for (let i = 0; i < length; i++) { - const expectedLine = expectedLines[i]; - const actualLine = actualLines[i]; - - const expectedMatch = expectedLine.match(/\/(.*)\//); - if (expectedMatch && expectedMatch.index !== undefined) { - try { - const regex = new RegExp(expectedMatch[1]); - - const actualMatch = actualLine.match(regex); - if (actualMatch) - expectedLines[i] = expectedLine.slice(0, expectedMatch.index) + actualMatch[0] + expectedLine.slice(expectedMatch.index + expectedMatch[0].length); - } catch (e) { - // Skip invalid regex - } - } - - const expectedProps = extractProperties(expectedLine); - const actualProps = extractProperties(actualLine); - const actualPropsMap = new Map(actualProps.map(({ key, value }) => [key, value])); - - let propsMatch = true; - - for (const { key, value } of expectedProps) { - const actualValue = actualPropsMap.get(key); - if (!actualValue || actualValue !== value) { - propsMatch = false; - break; - } - } - - if (propsMatch) { - actualLines[i] = actualLines[i].split(ARIA_PROPERTY_REGEX)[0].trimEnd(); - if (expectedProps.length > 0) { - const actualPropsString = expectedProps.map(({ key, value }) => `[${key}${value ? '=' + value : ''}]`).join(' '); - actualLines[i] += ` ${actualPropsString}`; - } + // const length = Math.min(actualLines.length, expectedLines.length); + + // for (let i = 0; i < length; i++) { + // const expectedLine = expectedLines[i]; + // const actualLine = actualLines[i]; + + // const expectedMatch = expectedLine.match(/\/(.*)\//); + // if (expectedMatch && expectedMatch.index !== undefined) { + // try { + // const regex = new RegExp(expectedMatch[1]); + + // const actualMatch = actualLine.match(regex); + // if (actualMatch) + // expectedLines[i] = expectedLine.slice(0, expectedMatch.index) + actualMatch[0] + expectedLine.slice(expectedMatch.index + expectedMatch[0].length); + // } catch (e) { + // // Skip invalid regex + // } + // } + + // const expectedProps = extractProperties(expectedLine); + // const actualProps = extractProperties(actualLine); + // const actualPropsMap = new Map(actualProps.map(({ key, value }) => [key, value])); + + // let propsMatch = true; + + // for (const { key, value } of expectedProps) { + // const actualValue = actualPropsMap.get(key); + // if (!actualValue || actualValue !== value) { + // propsMatch = false; + // break; + // } + // } + + // if (propsMatch) { + // actualLines[i] = actualLines[i].split(ARIA_PROPERTY_REGEX)[0].trimEnd(); + // if (expectedProps.length > 0) { + // const actualPropsString = expectedProps.map(({ key, value }) => `[${key}${value ? '=' + value : ''}]`).join(' '); + // actualLines[i] += ` ${actualPropsString}`; + // } + // } + // } + for (const entry of typedReceived.matchEntries) { + if (entry.templateLineNumber === undefined) { + console.log(`No template line number for ${entry.ariaNodeDFSIndex}`); + continue; } + console.log(`Copying "${actualLines[entry.ariaNodeDFSIndex - 1]}" to ${expectedLines[entry.templateLineNumber - 1]}`); + expectedLines[entry.templateLineNumber - 1] = actualLines[entry.ariaNodeDFSIndex - 1]; } const receivedText = actualLines.join('\n'); diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts index 0f947102c2580..a6421c4b17841 100644 --- a/tests/page/to-match-aria-snapshot.spec.ts +++ b/tests/page/to-match-aria-snapshot.spec.ts @@ -806,8 +806,7 @@ test(`should only highlight regex patterns that don't match`, async ({ page }) = `, { timeout: 1000 }).catch(e => e); expect(stripAnsi(error.message)).toContain(` -- - heading Title 123 -+ - heading "Title 123" + - heading "Title 123" [level=1] - text: Content with value 456 - - text: "This text doesn't exist"`); }); From 93b5fe319c8e0eb8b604714e3cfc96801dd2b2c4 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Thu, 22 May 2025 12:12:01 -0700 Subject: [PATCH 03/10] Look up lines via their nodes --- packages/injected/src/ariaSnapshot.ts | 101 ++++++++++-------- .../src/matchers/toMatchAriaSnapshot.ts | 10 +- 2 files changed, 60 insertions(+), 51 deletions(-) diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index 014f47ac18aca..9d7a2035dc5d2 100644 --- a/packages/injected/src/ariaSnapshot.ts +++ b/packages/injected/src/ariaSnapshot.ts @@ -39,10 +39,15 @@ export type AriaSnapshot = { elements: Map; }; -export interface AriaMatchEntry { - templateLineNumber: number | undefined; // Line number from the original template string - ariaNodeDFSIndex: number; // DFS index in the live ARIA tree -} +export type AriaMatchEntry = { + templateLineNumber: number | undefined; + actualLineNumber: number; +}; + +type InternalMatchEntry = { + templateLineNumber: number | undefined; + node: AriaNode | string; +}; export interface AriaMatch { templateLineNumber?: number; @@ -310,25 +315,36 @@ export type MatcherReceived = { export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: AriaNode[], received: MatcherReceived } { const snapshot = generateAriaTree(rootElement); - const matchEntries: AriaMatchEntry[] = []; - const dfsIndexState = { currentIndex: 0 }; + const internalMatchEntries: InternalMatchEntry[] = []; console.log('matchesAriaTree'); - const matches = matchesNodeDeep(snapshot.root, template, false, false, matchEntries, dfsIndexState); + const matches = matchesNodeDeep(snapshot.root, template, false, false, internalMatchEntries); + console.log('allmatches', internalMatchEntries); + const rawTree = renderAriaTree(snapshot, { mode: 'raw' }); + const matchEntries = internalMatchEntries.flatMap(entry => { + const index = rawTree.findIndex(({ ariaNode }) => ariaNode === entry.node); + if (index !== -1) { + return { + templateLineNumber: entry.templateLineNumber !== undefined ? entry.templateLineNumber - 1 : undefined, + actualLineNumber: index + }; + } else { + return []; + } + }); return { matches, received: { matchEntries, - raw: renderAriaTree(snapshot, { mode: 'raw' }), - regex: renderAriaTree(snapshot, { mode: 'regex' }), + 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 matchEntries: AriaMatchEntry[] = []; // Required for matchesNodeDeep signature - const dfsIndexState = { currentIndex: 0 }; // Required for matchesNodeDeep signature - const matches = matchesNodeDeep(root, template, true, false, matchEntries, dfsIndexState); + const matchEntries: InternalMatchEntry[] = []; // Required for matchesNodeDeep signature + const matches = matchesNodeDeep(root, template, true, false, matchEntries); return matches.map(n => n.element); } @@ -336,18 +352,16 @@ function matchesNode( node: AriaNode | string, template: AriaTemplateNode, isDeepEqual: boolean, - matchEntriesCollector: AriaMatchEntry[], - dfsIndexState: { currentIndex: number }, - currentAriaNodeDFSIndex?: number + matchEntriesCollector: InternalMatchEntry[], ): boolean { - console.log('matchesNode', node, template, dfsIndexState.currentIndex, currentAriaNodeDFSIndex); + console.log('matchesNode', node, template); if (typeof node === 'string' && template.kind === 'text') { const didMatch = matchesTextNode(node, template); - if (didMatch && currentAriaNodeDFSIndex !== undefined) { - console.log('Matching', template.lineNumber, currentAriaNodeDFSIndex); + if (didMatch) { + console.log('Matching', template.lineNumber); matchEntriesCollector.push({ templateLineNumber: template.lineNumber, - ariaNodeDFSIndex: currentAriaNodeDFSIndex, + node }); } return didMatch; @@ -378,19 +392,19 @@ function matchesNode( let childrenMatch: boolean; // Proceed based on the container mode. if (template.containerMode === 'contain') - childrenMatch = containsList(node.children || [], template.children || [], matchEntriesCollector, dfsIndexState); + childrenMatch = containsList(node.children || [], template.children || [], matchEntriesCollector); else if (template.containerMode === 'equal') - childrenMatch = listEqual(node.children || [], template.children || [], false, matchEntriesCollector, dfsIndexState); + childrenMatch = listEqual(node.children || [], template.children || [], false, matchEntriesCollector); else if (template.containerMode === 'deep-equal' || isDeepEqual) - childrenMatch = listEqual(node.children || [], template.children || [], true, matchEntriesCollector, dfsIndexState); + childrenMatch = listEqual(node.children || [], template.children || [], true, matchEntriesCollector); else - childrenMatch = containsList(node.children || [], template.children || [], matchEntriesCollector, dfsIndexState); + childrenMatch = containsList(node.children || [], template.children || [], matchEntriesCollector); - if (childrenMatch && currentAriaNodeDFSIndex !== undefined) { - console.log('Matching', template.lineNumber, currentAriaNodeDFSIndex); + if (childrenMatch) { + console.log('Matching', template.lineNumber); matchEntriesCollector.push({ templateLineNumber: template.lineNumber, - ariaNodeDFSIndex: currentAriaNodeDFSIndex, + node }); } return childrenMatch; @@ -400,20 +414,18 @@ function listEqual( children: (AriaNode | string)[], template: AriaTemplateNode[], isDeepEqual: boolean, - matchEntriesCollector: AriaMatchEntry[], - dfsIndexState: { currentIndex: number } + matchEntriesCollector: InternalMatchEntry[], ): boolean { if (template.length !== children.length) return false; for (let i = 0; i < template.length; ++i) { - const childDFSIndex = dfsIndexState.currentIndex++; - if (!matchesNode(children[i], template[i], isDeepEqual, matchEntriesCollector, dfsIndexState, childDFSIndex)) + if (!matchesNode(children[i], template[i], isDeepEqual, matchEntriesCollector)) return false; } return true; } -function containsList(children: (AriaNode | string)[], template: AriaTemplateNode[], matchEntriesCollector: AriaMatchEntry[], dfsIndexState: { currentIndex: number }): boolean { +function containsList(children: (AriaNode | string)[], template: AriaTemplateNode[], matchEntriesCollector: InternalMatchEntry[]): boolean { // if (template.length > children.length) // return false; console.log('containsList', children, template); @@ -422,8 +434,7 @@ function containsList(children: (AriaNode | string)[], template: AriaTemplateNod for (const t of tt) { let c = cc.shift(); while (c) { - const childDFSIndex = dfsIndexState ? dfsIndexState.currentIndex++ : -1; - if (matchesNode(c, t, false, matchEntriesCollector, dfsIndexState, childDFSIndex)) + if (matchesNode(c, t, false, matchEntriesCollector)) break; c = cc.shift(); } @@ -438,13 +449,11 @@ function matchesNodeDeep( template: AriaTemplateNode, collectAll: boolean, isDeepEqual: boolean, - matchEntriesCollector: AriaMatchEntry[], - dfsIndexState: { currentIndex: number } + matchEntriesCollector: InternalMatchEntry[], ): AriaNode[] { const results: AriaNode[] = []; const visit = (node: AriaNode | string, parent: AriaNode | null): boolean => { - const currentDFSIndex = dfsIndexState.currentIndex++; - if (matchesNode(node, template, isDeepEqual, matchEntriesCollector, dfsIndexState, currentDFSIndex)) { + if (matchesNode(node, template, isDeepEqual, matchEntriesCollector)) { const result = typeof node === 'string' ? parent : node; if (result) results.push(result); @@ -462,8 +471,8 @@ function matchesNodeDeep( 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) => { @@ -472,7 +481,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; } @@ -511,17 +520,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 + ' '); } @@ -535,7 +544,7 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'r } else { visit(ariaNode, null, ''); } - return lines.join('\n'); + return lines; } function convertToBestGuessRegex(text: string): string { diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index b93b42f0391a7..8d98d27e3adf5 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -155,13 +155,13 @@ export async function toMatchAriaSnapshot( // } // } // } - for (const entry of typedReceived.matchEntries) { - if (entry.templateLineNumber === undefined) { - console.log(`No template line number for ${entry.ariaNodeDFSIndex}`); + for (const { templateLineNumber, actualLineNumber } of typedReceived.matchEntries) { + if (templateLineNumber === undefined) { + console.log(`No template line number for ${actualLineNumber}`); continue; } - console.log(`Copying "${actualLines[entry.ariaNodeDFSIndex - 1]}" to ${expectedLines[entry.templateLineNumber - 1]}`); - expectedLines[entry.templateLineNumber - 1] = actualLines[entry.ariaNodeDFSIndex - 1]; + console.log(`Copying "${actualLines[actualLineNumber]}" to ${expectedLines[templateLineNumber]}`); + expectedLines[templateLineNumber] = actualLines[actualLineNumber]; } const receivedText = actualLines.join('\n'); From 087a62de3c62fc1c73eb5c1d45302a0469a77183 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Fri, 23 May 2025 06:01:34 -0700 Subject: [PATCH 04/10] Minor test fixes --- packages/injected/src/injectedScript.ts | 2 +- tests/page/to-match-aria-snapshot.spec.ts | 32 +++++++++++++++++++---- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index f276fdec5e44f..e95e08f0f5a88 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -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[] { diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts index a6421c4b17841..19bd25c9a1e9e 100644 --- a/tests/page/to-match-aria-snapshot.spec.ts +++ b/tests/page/to-match-aria-snapshot.spec.ts @@ -416,13 +416,12 @@ test('expected formatter', async ({ page }) => { expect(stripAnsi(error.message)).toContain(` Locator: locator('body') -- Expected - 2 -+ Received + 3 +- Expected - 1 ++ Received + 2 -- - heading "todos" -- - textbox "Wrong text" + - banner: -+ - heading "todos" [level=1] + - heading "todos" [level=1] +- - textbox "Wrong text" + - textbox "What needs to be done?"`); }); @@ -848,4 +847,27 @@ test(`should only highlight regex patterns that don't match`, async ({ page }) = + - listitem: Another row + - listitem: One more row`); }); + + await test.step('regex with attributes', async () => { + await page.setContent(` +

Title 123

+

Heading 2 456

+ `); + + const error = await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading /Title \\d+/ [level=1] + - heading /Heading 2 \\d+/ [level=2] + - text: "This text doesn't exist" + `, { timeout: 1000 }).catch(e => e); + + expect(stripAnsi(error.message)).toContain(` + - heading "Title 123" [level=1] + - heading "Heading 2 456" [level=2] +- - text: "This text doesn't exist"`); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading /Title \\d+/ + - heading /Heading 2 \\d+/ + `, { timeout: 1000 }); + }); }); From b674e0b1bcd771d4a43180be5fe8611a03fafa83 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Fri, 23 May 2025 10:15:28 -0700 Subject: [PATCH 05/10] Further testing --- packages/injected/src/ariaSnapshot.ts | 20 ++- tests/page/to-match-aria-snapshot.spec.ts | 173 ++++++++++++++++++++++ 2 files changed, 187 insertions(+), 6 deletions(-) diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index 9d7a2035dc5d2..0fb63fcdba9d2 100644 --- a/packages/injected/src/ariaSnapshot.ts +++ b/packages/injected/src/ariaSnapshot.ts @@ -426,22 +426,30 @@ function listEqual( } function containsList(children: (AriaNode | string)[], template: AriaTemplateNode[], matchEntriesCollector: InternalMatchEntry[]): boolean { - // if (template.length > children.length) - // return false; console.log('containsList', children, template); - const cc = children.slice(); + 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, matchEntriesCollector)) break; c = cc.shift(); + console.log('Incremented children', cc); } - if (!c) - return false; + if (!c) { + console.log('Failing containsList'); + cc = childrenAtStartOfTemplate; + match = false; } - return true; + console.log('Incremented template', tt); + } + console.log('Passing containsList'); + return match; } function matchesNodeDeep( diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts index 19bd25c9a1e9e..32b72c859df92 100644 --- a/tests/page/to-match-aria-snapshot.spec.ts +++ b/tests/page/to-match-aria-snapshot.spec.ts @@ -871,3 +871,176 @@ test(`should only highlight regex patterns that don't match`, async ({ page }) = `, { timeout: 1000 }); }); }); + +test(`should handle various regex failure scenarios`, async ({ page }) => { + await test.step('regex with special characters', async () => { + await page.setContent(` +
Item A.1
+
Item B*2
+

This matches

+
Ignored
+ +

Parentheses (example)

+

Brackets [example]

+ `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - text: Item A.1 Item B*2 + `, { timeout: 1000 }); + + const error = await expect(page.locator('body')).toMatchAriaSnapshot(` + - text: /Item A\\\\.X Item B\\\\*2/ + - paragraph: This matches + - button: /Submit\\\\?Now/ + - paragraph: /Parentheses \\\\(example\\\\)/ + - paragraph: /Brackets \\\\[example\\\\]/ + `, { timeout: 1000 }).catch(e => e); + + expect(stripAnsi(error.message)).toContain(` +- - text: /Item A\\\\.X Item B\\\\*2/ ++ - text: Item A.1 Item B*2 + - paragraph: This matches +- - button: /Submit\\\\?Now/ ++ - text: Ignored ++ - button "Submit+Now" +- - paragraph: /Parentheses \\\\(example\\\\)/ ++ - paragraph: Parentheses (example) +- - paragraph: /Brackets \\\\[example\\\\]/ ++ - paragraph: Brackets [example]`); + }); + + await test.step('regex case sensitivity', async () => { + await page.setContent(` +

Hello World

+

another example

+ `); + + const error = await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading /hello world/ + - paragraph: /Another Example/ + `, { timeout: 1000 }).catch(e => e); + + expect(stripAnsi(error.message)).toContain(` +- - heading /hello world/ ++ - heading "Hello World" [level=1] +- - paragraph: /Another Example/ ++ - paragraph: another example`); + }); + + await test.step('name regex matches, attribute does not', async () => { + await page.setContent(` + + `); + const error = await expect(page.locator('body')).toMatchAriaSnapshot(` + - button /Action Button \\d+/ [pressed=false] + `, { timeout: 1000 }).catch(e => e); + + expect(stripAnsi(error.message)).toContain(` +Expected: \"- button /Action Button \\\\d+/ [pressed=false]\" +Received: \"- button \\"Action Button 007\\" [pressed]\"`); + }); + + await test.step('name regex mismatches, attribute matches', async () => { + await page.setContent(` +

Actual Section Title

+ `); + + const error = await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading /Expected Section \\d+/ [level=2] + `, { timeout: 1000 }).catch(e => e); + + expect(stripAnsi(error.message)).toContain(` +Expected: \"- heading /Expected Section \\\\d+/ [level=2]\" +Received: \"- heading \\\"Actual Section Title\\\" [level=2]\"`); + }); + + await test.step('name regex mismatches with an attribute', async () => { + await page.setContent(` + + + `); + const error = await expect(page.locator('body')).toMatchAriaSnapshot(` + - button /NonExistent Button C/ [pressed] + - button /Actual Button A/ [pressed=false] + `, { timeout: 1000 }).catch(e => e); + + expect(stripAnsi(error.message)).toContain(` +- - button /NonExistent Button C/ [pressed] ++ - button "Actual Button A" [pressed] +- - button /Actual Button A/ [pressed=false] ++ - button "Expected Button B" [pressed]`); + }); + + await test.step('missing label', async () => { + await page.setContent(` +
+
+ + + `); + + let error = await expect(page.locator('body')).toMatchAriaSnapshot(` + - status /.+/ + - row /.+/ + - button /Submit/ + - image /.*/ + `, { timeout: 1000 }).catch(e => e); + + expect(stripAnsi(error.message)).toContain(` +- - status /.+/ ++ - status + - row "some label" + - button "Submit" +- - image /.*/`); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - status + - button "Submit" + `, { timeout: 1000 }); + + error = await expect(page.locator('body')).toMatchAriaSnapshot(` + - status /^\$/ + - button /Submit/ + - image /^\$/ + `, { timeout: 1000 }).catch(e => e); + + expect(stripAnsi(error.message)).toContain(` +- - status /^\$/ ++ - status ++ - row "some label" + - button "Submit" +- - image /^\$/`); + }); + + await test.step('incorrect order', async () => { + await page.setContent(` +

Numeric 123

+

Alpha ABC

+ `); + + // TODO: This error message could be better; while "Alpha ABC" is found, it is not the first element, which causes this confusing diff + const error = await expect(page.locator('body')).toMatchAriaSnapshot(` + - paragraph: /Alpha [A-Z]+/ + - paragraph: /Numeric \\\\d+/ + `, { timeout: 1000 }).catch(e => e); + expect(stripAnsi(error.message)).toContain(` +- - paragraph: Alpha ABC ++ - paragraph: Numeric 123 +- - paragraph: /Numeric \\\\d+/ ++ - paragraph: Alpha ABC`); + }); + + await test.step('regex partially matches', async () => { + await page.setContent(` +
Product Code ABC-123-XYZ
+

Other text

+ `); + const error = await expect(page.locator('body')).toMatchAriaSnapshot(` + - text: /Product Code [A-Z]{3}-\\d{3}-[A-Z]{4}/ + `, { timeout: 1000 }).catch(e => e); + expect(stripAnsi(error.message)).toContain(` +- - text: /Product Code [A-Z]{3}-\\d{3}-[A-Z]{4}/ ++ - text: Product Code ABC-123-XYZ ++ - heading "Other text" [level=1]`); + }); +}); From 3dafef94e450b5ed6f286d5288221a75a2225694 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Fri, 23 May 2025 10:47:20 -0700 Subject: [PATCH 06/10] Test for list operations --- tests/page/to-match-aria-snapshot.spec.ts | 102 +++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts index 32b72c859df92..dcd18d2c9cb3a 100644 --- a/tests/page/to-match-aria-snapshot.spec.ts +++ b/tests/page/to-match-aria-snapshot.spec.ts @@ -872,7 +872,7 @@ test(`should only highlight regex patterns that don't match`, async ({ page }) = }); }); -test(`should handle various regex failure scenarios`, async ({ page }) => { +test('should handle various regex failure scenarios', async ({ page }) => { await test.step('regex with special characters', async () => { await page.setContent(`
Item A.1
@@ -1044,3 +1044,103 @@ Received: \"- heading \\\"Actual Section Title\\\" [level=2]\"`); + - heading "Other text" [level=1]`); }); }); + +test('should properly highlight regex failures in equal', async ({ page }) => { + await page.setContent(` +
    +
  • +
      +
    • 1.1
    • +
    • 1.2
    • +
    +
  • +
  • +
      +
    • 2.1
    • +
    • 2.2
    • +
    +
  • +
+ `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - list: + - /children: equal + - listitem: + - list: + - listitem: 1.1 + - listitem: + - list: + - listitem: 2.1 + `, { timeout: 1000 }); + + const error = await expect(page.locator('body')).toMatchAriaSnapshot(` + - list: + - /children: deep-equal + - listitem: + - list: + - listitem: /a value/ + - listitem: + - list: + - listitem: 2.1 + + `, { timeout: 1000 }).catch(e => e); + + expect(stripAnsi(error.message)).toContain(` + - list: +- - /children: deep-equal +- - listitem: +- - list: ++ - listitem: ++ - list: ++ - listitem: \"1.1\" +- - listitem: /a value/ ++ - listitem: \"1.2\" + - listitem: + - list: +- - listitem: 2.1 ++ - listitem: \"2.1\" ++ - listitem: \"2.2\"`); +}); + +test('should properly highlight regex failures in deep-equal', async ({ page }) => { + await page.setContent(` +
    +
  • +
      +
    • 1.1
    • +
    • 1.2
    • +
    +
  • +
+ `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - list: + - /children: deep-equal + - listitem: + - list: + - listitem: 1.1 + - listitem: /1\\.\\d/ + `, { timeout: 1000 }); + + const error = await expect(page.locator('body')).toMatchAriaSnapshot(` + - list: + - /children: deep-equal + - listitem: + - list: + - listitem: 1.1 + - listitem: /2\\.\\d/ + - listitem: another + `, { timeout: 1000 }).catch(e => e); + + expect(stripAnsi(error.message)).toContain(` + - list: +- - /children: deep-equal + - listitem: + - list: + - listitem: \"1.1\" +- - listitem: /2\\.\\d/ +- - listitem: another ++ - listitem: \"1.2\"`); +}); From 991c1ef2d9f8cf2249149e3fc30aafad53f146b8 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Fri, 23 May 2025 10:49:49 -0700 Subject: [PATCH 07/10] Cleaned up debugging information --- packages/injected/src/ariaSnapshot.ts | 12 +--- .../src/matchers/toMatchAriaSnapshot.ts | 69 +------------------ 2 files changed, 3 insertions(+), 78 deletions(-) diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index 0fb63fcdba9d2..41df5521e8871 100644 --- a/packages/injected/src/ariaSnapshot.ts +++ b/packages/injected/src/ariaSnapshot.ts @@ -316,9 +316,7 @@ export type MatcherReceived = { export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: AriaNode[], received: MatcherReceived } { const snapshot = generateAriaTree(rootElement); const internalMatchEntries: InternalMatchEntry[] = []; - console.log('matchesAriaTree'); const matches = matchesNodeDeep(snapshot.root, template, false, false, internalMatchEntries); - console.log('allmatches', internalMatchEntries); const rawTree = renderAriaTree(snapshot, { mode: 'raw' }); const matchEntries = internalMatchEntries.flatMap(entry => { const index = rawTree.findIndex(({ ariaNode }) => ariaNode === entry.node); @@ -354,11 +352,9 @@ function matchesNode( isDeepEqual: boolean, matchEntriesCollector: InternalMatchEntry[], ): boolean { - console.log('matchesNode', node, template); if (typeof node === 'string' && template.kind === 'text') { const didMatch = matchesTextNode(node, template); if (didMatch) { - console.log('Matching', template.lineNumber); matchEntriesCollector.push({ templateLineNumber: template.lineNumber, node @@ -401,7 +397,6 @@ function matchesNode( childrenMatch = containsList(node.children || [], template.children || [], matchEntriesCollector); if (childrenMatch) { - console.log('Matching', template.lineNumber); matchEntriesCollector.push({ templateLineNumber: template.lineNumber, node @@ -426,7 +421,6 @@ function listEqual( } function containsList(children: (AriaNode | string)[], template: AriaTemplateNode[], matchEntriesCollector: InternalMatchEntry[]): boolean { - console.log('containsList', children, template); let cc = children.slice(); const tt = template.slice(); let match = true; @@ -439,16 +433,12 @@ function containsList(children: (AriaNode | string)[], template: AriaTemplateNod if (matchesNode(c, t, false, matchEntriesCollector)) break; c = cc.shift(); - console.log('Incremented children', cc); } if (!c) { - console.log('Failing containsList'); cc = childrenAtStartOfTemplate; match = false; + } } - console.log('Incremented template', tt); - } - console.log('Passing containsList'); return match; } diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index 8d98d27e3adf5..b0f4b68015cc5 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -37,13 +37,6 @@ type ToMatchAriaSnapshotExpected = { timeout?: number; } | string; -type AriaProperty = { - key: string; - value?: string; -}; - -const ARIA_PROPERTY_REGEX = /\[(\w+)(=(\w+))?\]/g; - export async function toMatchAriaSnapshot( this: ExpectMatcherState, receiver: LocatorEx, @@ -98,8 +91,6 @@ export async function toMatchAriaSnapshot( const { matches: pass, received, log, timedOut } = await receiver._expect('to.match.aria', { expectedValue: expected, isNot: this.isNot, timeout }); const typedReceived = received as MatcherReceived | typeof kNoElementsFoundError; - console.log(typedReceived); - const messagePrefix = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined); const notFound = typedReceived === kNoElementsFoundError; if (notFound) { @@ -114,57 +105,13 @@ export async function toMatchAriaSnapshot( const expectedLines = expected.split('\n'); const actualLines = typedReceived.raw.split('\n'); - // const length = Math.min(actualLines.length, expectedLines.length); - - // for (let i = 0; i < length; i++) { - // const expectedLine = expectedLines[i]; - // const actualLine = actualLines[i]; - - // const expectedMatch = expectedLine.match(/\/(.*)\//); - // if (expectedMatch && expectedMatch.index !== undefined) { - // try { - // const regex = new RegExp(expectedMatch[1]); - - // const actualMatch = actualLine.match(regex); - // if (actualMatch) - // expectedLines[i] = expectedLine.slice(0, expectedMatch.index) + actualMatch[0] + expectedLine.slice(expectedMatch.index + expectedMatch[0].length); - // } catch (e) { - // // Skip invalid regex - // } - // } - - // const expectedProps = extractProperties(expectedLine); - // const actualProps = extractProperties(actualLine); - // const actualPropsMap = new Map(actualProps.map(({ key, value }) => [key, value])); - - // let propsMatch = true; - - // for (const { key, value } of expectedProps) { - // const actualValue = actualPropsMap.get(key); - // if (!actualValue || actualValue !== value) { - // propsMatch = false; - // break; - // } - // } - - // if (propsMatch) { - // actualLines[i] = actualLines[i].split(ARIA_PROPERTY_REGEX)[0].trimEnd(); - // if (expectedProps.length > 0) { - // const actualPropsString = expectedProps.map(({ key, value }) => `[${key}${value ? '=' + value : ''}]`).join(' '); - // actualLines[i] += ` ${actualPropsString}`; - // } - // } - // } for (const { templateLineNumber, actualLineNumber } of typedReceived.matchEntries) { - if (templateLineNumber === undefined) { - console.log(`No template line number for ${actualLineNumber}`); + if (templateLineNumber === undefined) continue; - } - console.log(`Copying "${actualLines[actualLineNumber]}" to ${expectedLines[templateLineNumber]}`); expectedLines[templateLineNumber] = actualLines[actualLineNumber]; } - const receivedText = actualLines.join('\n'); + const receivedText = typedReceived.raw; expected = expectedLines.join('\n'); const message = () => { if (pass) { @@ -240,15 +187,3 @@ function unshift(snapshot: string): string { function indent(snapshot: string, indent: string): string { return snapshot.split('\n').map(line => indent + line).join('\n'); } - -function extractProperties(line: string): AriaProperty[] { - const props: AriaProperty[] = []; - - for (const match of line.matchAll(ARIA_PROPERTY_REGEX)) { - const key = match[1]; - const value = match[3]; - props.push({ key, value }); - } - - return props; -} From a0c75835159cd633f4df7bd41954f75845fa67d4 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Fri, 23 May 2025 10:55:49 -0700 Subject: [PATCH 08/10] Remove unused type --- packages/injected/src/ariaSnapshot.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index 41df5521e8871..798794429733d 100644 --- a/packages/injected/src/ariaSnapshot.ts +++ b/packages/injected/src/ariaSnapshot.ts @@ -49,11 +49,6 @@ type InternalMatchEntry = { node: AriaNode | string; }; -export interface AriaMatch { - templateLineNumber?: number; - isFromTemplateRegex?: boolean; // True if this failure was due to a regex in the template name/prop -} - type AriaRef = { role: string; name: string; From aa0ba68ffb510c0cf6f442673fe2b73e9565de2f Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Tue, 27 May 2025 08:02:12 -0700 Subject: [PATCH 09/10] Clean up diff --- packages/injected/src/ariaSnapshot.ts | 122 ++++++++---------- .../src/utils/isomorphic/ariaSnapshot.ts | 26 ++-- tests/page/to-match-aria-snapshot.spec.ts | 13 +- 3 files changed, 75 insertions(+), 86 deletions(-) diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index 798794429733d..9b055fde32de5 100644 --- a/packages/injected/src/ariaSnapshot.ts +++ b/packages/injected/src/ariaSnapshot.ts @@ -45,7 +45,7 @@ export type AriaMatchEntry = { }; type InternalMatchEntry = { - templateLineNumber: number | undefined; + templateLineNumber: number; node: AriaNode | string; }; @@ -315,14 +315,12 @@ export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode const rawTree = renderAriaTree(snapshot, { mode: 'raw' }); const matchEntries = internalMatchEntries.flatMap(entry => { const index = rawTree.findIndex(({ ariaNode }) => ariaNode === entry.node); - if (index !== -1) { - return { - templateLineNumber: entry.templateLineNumber !== undefined ? entry.templateLineNumber - 1 : undefined, - actualLineNumber: index - }; - } else { + if (index === -1) return []; - } + return { + templateLineNumber: entry.templateLineNumber - 1, + actualLineNumber: index + }; }); return { matches, @@ -336,7 +334,7 @@ export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode export function getAllByAria(rootElement: Element, template: AriaTemplateNode): Element[] { const root = generateAriaTree(rootElement).root; - const matchEntries: InternalMatchEntry[] = []; // Required for matchesNodeDeep signature + const matchEntries: InternalMatchEntry[] = []; const matches = matchesNodeDeep(root, template, true, false, matchEntries); return matches.map(n => n.element); } @@ -345,77 +343,70 @@ function matchesNode( node: AriaNode | string, template: AriaTemplateNode, isDeepEqual: boolean, - matchEntriesCollector: InternalMatchEntry[], + foundMatchEntries: InternalMatchEntry[], ): boolean { - if (typeof node === 'string' && template.kind === 'text') { - const didMatch = matchesTextNode(node, template); - if (didMatch) { - matchEntriesCollector.push({ - templateLineNumber: template.lineNumber, - node - }); - } - return didMatch; - } + function doesMatchNode(): boolean { + if (typeof node === 'string' && template.kind === 'text') + return matchesTextNode(node, template); - if (node === null || typeof node !== 'object' || template.kind !== 'role') - return false; + if (node === null || typeof node !== 'object' || template.kind !== 'role') + return false; - if (template.role !== 'fragment' && template.role !== node.role) - return false; - if (template.checked !== undefined && template.checked !== node.checked) - return false; - if (template.disabled !== undefined && template.disabled !== node.disabled) - return false; - if (template.expanded !== undefined && template.expanded !== node.expanded) - return false; - if (template.level !== undefined && template.level !== node.level) - return false; - if (template.pressed !== undefined && template.pressed !== node.pressed) - return false; - if (template.selected !== undefined && template.selected !== node.selected) - return false; - if (!matchesName(node.name, template)) - return false; - if (!matchesText(node.props.url, template.props?.url)) - return false; + if (template.role !== 'fragment' && template.role !== node.role) + return false; + if (template.checked !== undefined && template.checked !== node.checked) + return false; + if (template.disabled !== undefined && template.disabled !== node.disabled) + return false; + if (template.expanded !== undefined && template.expanded !== node.expanded) + return false; + if (template.level !== undefined && template.level !== node.level) + return false; + if (template.pressed !== undefined && template.pressed !== node.pressed) + return false; + if (template.selected !== undefined && template.selected !== node.selected) + return false; + if (!matchesName(node.name, template)) + return false; + if (!matchesText(node.props.url, template.props?.url)) + return false; + + // Proceed based on the container mode. + if (template.containerMode === 'contain') + 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); + } - let childrenMatch: boolean; - // Proceed based on the container mode. - if (template.containerMode === 'contain') - childrenMatch = containsList(node.children || [], template.children || [], matchEntriesCollector); - else if (template.containerMode === 'equal') - childrenMatch = listEqual(node.children || [], template.children || [], false, matchEntriesCollector); - else if (template.containerMode === 'deep-equal' || isDeepEqual) - childrenMatch = listEqual(node.children || [], template.children || [], true, matchEntriesCollector); - else - childrenMatch = containsList(node.children || [], template.children || [], matchEntriesCollector); - - if (childrenMatch) { - matchEntriesCollector.push({ + const didMatch = doesMatchNode(); + if (didMatch) { + foundMatchEntries.push({ templateLineNumber: template.lineNumber, node }); } - return childrenMatch; + return didMatch; } function listEqual( children: (AriaNode | string)[], template: AriaTemplateNode[], isDeepEqual: boolean, - matchEntriesCollector: InternalMatchEntry[], + foundMatchEntries: InternalMatchEntry[], ): boolean { - if (template.length !== children.length) - return false; - for (let i = 0; i < template.length; ++i) { - if (!matchesNode(children[i], template[i], isDeepEqual, matchEntriesCollector)) - return false; + 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[], matchEntriesCollector: InternalMatchEntry[]): boolean { +function containsList(children: (AriaNode | string)[], template: AriaTemplateNode[], foundMatchEntries: InternalMatchEntry[]): boolean { let cc = children.slice(); const tt = template.slice(); let match = true; @@ -425,11 +416,12 @@ function containsList(children: (AriaNode | string)[], template: AriaTemplateNod childrenAtStartOfTemplate = cc.slice(); let c = cc.shift(); while (c) { - if (matchesNode(c, t, false, matchEntriesCollector)) + if (matchesNode(c, t, false, foundMatchEntries)) break; c = cc.shift(); } if (!c) { + // Restore children location after we finished matching against this template node cc = childrenAtStartOfTemplate; match = false; } @@ -442,11 +434,11 @@ function matchesNodeDeep( template: AriaTemplateNode, collectAll: boolean, isDeepEqual: boolean, - matchEntriesCollector: InternalMatchEntry[], + foundMatchEntries: InternalMatchEntry[], ): AriaNode[] { const results: AriaNode[] = []; const visit = (node: AriaNode | string, parent: AriaNode | null): boolean => { - if (matchesNode(node, template, isDeepEqual, matchEntriesCollector)) { + if (matchesNode(node, template, isDeepEqual, foundMatchEntries)) { const result = typeof node === 'string' ? parent : node; if (result) results.push(result); diff --git a/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts b/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts index bedd477810577..d7fb06660c3eb 100644 --- a/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts +++ b/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts @@ -39,13 +39,13 @@ export type AriaRegex = { pattern: string }; export type AriaTemplateTextNode = { kind: 'text'; text: AriaRegex | string; - lineNumber?: number; + lineNumber: number; }; export type AriaTemplateRoleNode = AriaProps & { kind: 'role'; role: AriaRole | 'fragment'; - lineNumber?: number; + lineNumber: number; name?: AriaRegex | string; children?: AriaTemplateNode[]; props?: Record; @@ -103,9 +103,8 @@ 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) { - childNode.lineNumber = lineCounter.linePos(item.range![0]).line; container.children = container.children || []; container.children.push(childNode); } @@ -187,10 +186,9 @@ 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; - childNode.lineNumber = lineCounter.linePos(key.range![0]).line; // - role "name": "text" const valueIsScalar = value instanceof yaml.Scalar; @@ -220,7 +218,7 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: yaml const valueIsSequence = value instanceof yaml.YAMLSeq; if (valueIsSequence) { container.children.push(childNode); - convertSeq(childNode, value as yamlTypes.YAMLSeq); // convertSeq will handle line numbers for its children + convertSeq(childNode, value as yamlTypes.YAMLSeq); continue; } @@ -231,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) @@ -254,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? @@ -269,10 +267,11 @@ export class KeyParser { private _input: string; private _pos: number; private _length: number; + private _lineNumber: number; - static parse(text: yamlTypes.Scalar, options: yamlTypes.ParseOptions, errors: ParsedYamlError[]): AriaTemplateRoleNode | null { + static parse(text: yamlTypes.Scalar, 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'; @@ -286,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() { @@ -425,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()) diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts index dcd18d2c9cb3a..ce2fdec795f74 100644 --- a/tests/page/to-match-aria-snapshot.spec.ts +++ b/tests/page/to-match-aria-snapshot.spec.ts @@ -843,9 +843,8 @@ test(`should only highlight regex patterns that don't match`, async ({ page }) = - - link /Link2 \\d+/: + - link "Link 123": - /url: about:blank -- - listitem: "One more row" + - listitem: Another row -+ - listitem: One more row`); + - listitem: One more row`); }); await test.step('regex with attributes', async () => { @@ -1076,30 +1075,28 @@ test('should properly highlight regex failures in equal', async ({ page }) => { const error = await expect(page.locator('body')).toMatchAriaSnapshot(` - list: - - /children: deep-equal + - /children: equal - listitem: - list: - listitem: /a value/ - listitem: - list: - listitem: 2.1 - `, { timeout: 1000 }).catch(e => e); expect(stripAnsi(error.message)).toContain(` - list: -- - /children: deep-equal +- - /children: equal - - listitem: -- - list: + - listitem: +- - list: + - list: + - listitem: \"1.1\" - - listitem: /a value/ + - listitem: \"1.2\" - listitem: - list: -- - listitem: 2.1 -+ - listitem: \"2.1\" + - listitem: \"2.1\" + - listitem: \"2.2\"`); }); From ade749bf7207bb0d07996e6b3205a4d79fc6d855 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Tue, 27 May 2025 08:05:11 -0700 Subject: [PATCH 10/10] Move inner function out for smaller diff --- packages/injected/src/ariaSnapshot.ts | 80 +++++++++++++-------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index 9b055fde32de5..234fcae160344 100644 --- a/packages/injected/src/ariaSnapshot.ts +++ b/packages/injected/src/ariaSnapshot.ts @@ -345,43 +345,7 @@ function matchesNode( isDeepEqual: boolean, foundMatchEntries: InternalMatchEntry[], ): boolean { - function doesMatchNode(): boolean { - if (typeof node === 'string' && template.kind === 'text') - return matchesTextNode(node, template); - - if (node === null || typeof node !== 'object' || template.kind !== 'role') - return false; - - if (template.role !== 'fragment' && template.role !== node.role) - return false; - if (template.checked !== undefined && template.checked !== node.checked) - return false; - if (template.disabled !== undefined && template.disabled !== node.disabled) - return false; - if (template.expanded !== undefined && template.expanded !== node.expanded) - return false; - if (template.level !== undefined && template.level !== node.level) - return false; - if (template.pressed !== undefined && template.pressed !== node.pressed) - return false; - if (template.selected !== undefined && template.selected !== node.selected) - return false; - if (!matchesName(node.name, template)) - return false; - if (!matchesText(node.props.url, template.props?.url)) - return false; - - // Proceed based on the container mode. - if (template.containerMode === 'contain') - 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); - } - - const didMatch = doesMatchNode(); + const didMatch = _doesMatchNode(node, template, isDeepEqual, foundMatchEntries); if (didMatch) { foundMatchEntries.push({ templateLineNumber: template.lineNumber, @@ -391,12 +355,48 @@ function matchesNode( return didMatch; } -function listEqual( - children: (AriaNode | string)[], - template: AriaTemplateNode[], +function _doesMatchNode( + node: AriaNode | string, + template: AriaTemplateNode, isDeepEqual: boolean, foundMatchEntries: InternalMatchEntry[], ): boolean { + if (typeof node === 'string' && template.kind === 'text') + return matchesTextNode(node, template); + + if (node === null || typeof node !== 'object' || template.kind !== 'role') + return false; + + if (template.role !== 'fragment' && template.role !== node.role) + return false; + if (template.checked !== undefined && template.checked !== node.checked) + return false; + if (template.disabled !== undefined && template.disabled !== node.disabled) + return false; + if (template.expanded !== undefined && template.expanded !== node.expanded) + return false; + if (template.level !== undefined && template.level !== node.level) + return false; + if (template.pressed !== undefined && template.pressed !== node.pressed) + return false; + if (template.selected !== undefined && template.selected !== node.selected) + return false; + if (!matchesName(node.name, template)) + return false; + if (!matchesText(node.props.url, template.props?.url)) + return false; + + // Proceed based on the container mode. + if (template.containerMode === 'contain') + 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, foundMatchEntries: InternalMatchEntry[]): boolean { let match = true; const length = Math.min(children.length, template.length); for (let i = 0; i < length; ++i) {