diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index a75a5cd185d6c..234fcae160344 100644 --- a/packages/injected/src/ariaSnapshot.ts +++ b/packages/injected/src/ariaSnapshot.ts @@ -39,6 +39,16 @@ export type AriaSnapshot = { elements: Map; }; +export type AriaMatchEntry = { + templateLineNumber: number | undefined; + actualLineNumber: number; +}; + +type InternalMatchEntry = { + templateLineNumber: number; + node: AriaNode | string; +}; + type AriaRef = { role: string; name: string; @@ -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); @@ -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); @@ -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) => { @@ -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; } @@ -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 + ' '); } @@ -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 { 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/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts b/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts index ffbd50ddef416..d7fb06660c3eb 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; @@ -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); @@ -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; } @@ -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; @@ -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; @@ -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) @@ -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? @@ -263,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'; @@ -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() { @@ -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()) diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index 7cc50e772fd2f..b0f4b68015cc5 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -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; @@ -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) diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts index f929f1b11a8d5..ce2fdec795f74 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?"`); }); @@ -791,3 +790,354 @@ 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" [level=1] + - text: Content with value 456 +- - text: "This text doesn't exist"`); + }); + + await test.step('nested regex', async () => { + await page.setContent(` +
    +
  • + Link 123 +
  • +
  • Another row
  • +
  • One more row
  • +
+ `); + + 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: 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 }); + }); +}); + +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]`); + }); +}); + +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: equal + - listitem: + - list: + - listitem: /a value/ + - listitem: + - list: + - listitem: 2.1 + `, { timeout: 1000 }).catch(e => e); + + expect(stripAnsi(error.message)).toContain(` + - list: +- - /children: equal +- - listitem: ++ - listitem: +- - list: ++ - list: ++ - listitem: \"1.1\" +- - listitem: /a value/ ++ - listitem: \"1.2\" + - listitem: + - list: + - 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\"`); +});