From 48a7d21b15a26acb1fe9530fe35f7db512678217 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Sat, 23 May 2026 19:25:18 -0700 Subject: [PATCH] fix(aria-snapshot): keep matching regex lines as context in diff output When toMatchAriaSnapshot fails on a single literal text line, the diff currently marks the surrounding regex-templated expected lines as -/+ even when their regexes actually match the received text. The extra noise masks the real mismatch. This change pre-processes the received tree before handing it to the jest text differ: for each expected line that parses as a regex (via the existing /.../ syntax for full lines or text values), if the corresponding received line matches that regex, the received line is replaced with the expected line verbatim. The differ then renders those lines as unchanged context, so only the genuine mismatch surfaces in the output. Alignment between expected and received is preserved with an LCS pass over equality-or-regex-match, which stays correct when the trees differ in length. Closes #34555. --- .../src/matchers/toMatchAriaSnapshot.ts | 135 +++++++++++++++++- tests/page/to-match-aria-snapshot.spec.ts | 24 ++++ 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index 5fa1965c28fb7..bf891b5ebcb07 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -108,7 +108,8 @@ export async function toMatchAriaSnapshot( printedExpected = `Expected: not ${this.utils.printExpected(expected)}`; printedReceived = `Received: ${receivedString}`; } else { - printedDiff = this.utils.printDiffOrStringify(expected, typedReceived.raw, 'Expected', 'Received', false); + const receivedForDiff = mergeRegexMatchedLines(expected, typedReceived.raw); + printedDiff = this.utils.printDiffOrStringify(expected, receivedForDiff, 'Expected', 'Received', false); } return formatMatcherMessage(this.utils, { isNot: this.isNot, @@ -181,3 +182,135 @@ function unshift(snapshot: string): string { function indent(snapshot: string, indent: string): string { return snapshot.split('\n').map(line => indent + line).join('\n'); } + +// Rewrites `received` so that any line that the corresponding `expected` +// line's regexes match is replaced with the expected line verbatim. The +// downstream jest text differ then treats those lines as unchanged context +// rather than -/+ noise, which keeps the real mismatch visible. See #34555. +export function mergeRegexMatchedLines(expected: string, received: string): string { + const expectedLines = expected.split('\n'); + const receivedLines = received.split('\n'); + const expectedRegexes = expectedLines.map(toFullLineRegex); + + const linesMatch = (i: number, j: number): boolean => { + if (expectedLines[i] === receivedLines[j]) + return true; + const regex = expectedRegexes[i]; + return !!(regex && regex.test(receivedLines[j])); + }; + + // LCS over equality-or-regex-match keeps alignment robust when lengths drift. + const n = expectedLines.length; + const m = receivedLines.length; + const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) + dp[i][j] = linesMatch(i, j) ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + + const result: string[] = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (linesMatch(i, j)) { + result.push(expectedLines[i]); + i++; + j++; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + i++; + } else { + result.push(receivedLines[j]); + j++; + } + } + while (j < m) + result.push(receivedLines[j++]); + return result.join('\n'); +} + +// Builds a full-line RegExp from an expected aria-snapshot line. Any `/.../` +// segments outside of quoted strings become regex bodies; everything else is +// matched as plain text. Returns null when the line contains no regex. +function toFullLineRegex(line: string): RegExp | null { + const escapeMeta = (s: string) => s.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); + let pattern = ''; + let i = 0; + let inString = false; + let foundRegex = false; + while (i < line.length) { + const ch = line[i]; + if (inString) { + if (ch === '\\' && i + 1 < line.length) { + pattern += escapeMeta(ch + line[i + 1]); + i += 2; + continue; + } + if (ch === '"') + inString = false; + pattern += escapeMeta(ch); + i++; + continue; + } + if (ch === '"') { + inString = true; + pattern += escapeMeta(ch); + i++; + continue; + } + if (ch === '/') { + const body = readRegexBody(line, i + 1); + if (body) { + // Role names render quoted in received output even when the + // expected line uses the regex form, so allow optional quotes. + pattern += '"?(?:' + body.source + ')"?'; + i = body.endIndex; + foundRegex = true; + continue; + } + } + pattern += escapeMeta(ch); + i++; + } + if (!foundRegex) + return null; + try { + return new RegExp('^' + pattern + '$'); + } catch { + return null; + } +} + +// Reads a `/.../` body starting just after the opening slash, returning the +// regex source and the index just past the closing slash, or null on no match. +function readRegexBody(line: string, start: number): { source: string, endIndex: number } | null { + let source = ''; + let escaped = false; + let inClass = false; + for (let j = start; j < line.length; j++) { + const c = line[j]; + if (escaped) { + source += c; + escaped = false; + continue; + } + if (c === '\\') { + source += c; + escaped = true; + continue; + } + if (c === '[') { + inClass = true; + source += c; + continue; + } + if (c === ']' && inClass) { + inClass = false; + source += c; + continue; + } + if (c === '/' && !inClass) + return { source, endIndex: j + 1 }; + source += c; + } + return null; +} diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts index 0236178bba053..849206f969699 100644 --- a/tests/page/to-match-aria-snapshot.spec.ts +++ b/tests/page/to-match-aria-snapshot.spec.ts @@ -934,3 +934,27 @@ test('treat bad regex as a string', async ({ page }) => { expect(stripAnsi(error.message)).toContain('- - /url: /[a/'); expect(stripAnsi(error.message)).toContain('+ - /url: /foo'); }); + +test('regex lines that match are not shown as diff (issue 34555)', async ({ page }) => { + await page.setContent(` +
+

34 % match

+ +

Tanta memoria 256 GB

+
+ `); + const error = await expect(page).toMatchAriaSnapshot(` + - paragraph: /\\d+ % match/ + - button "Colore neutro + 1 EUR" + - paragraph: /Tanta memoria \\d+ GB/ + `, { timeout: 1000 }).catch(e => e); + const message = stripAnsi(error.message); + expect(message).toContain('expect(page).toMatchAriaSnapshot(expected) failed'); + // The literal mismatch should appear as -/+ lines in the diff. + expect(message).toContain('- - button "Colore neutro + 1 EUR"'); + expect(message).toContain('+ - button "Colore neutro + 0 EUR"'); + // Regex lines that actually match the received text should be rendered as + // unchanged context, so they must NOT appear as -/+ entries. + expect(message).not.toMatch(/^-\s+- paragraph: \/\\d\+ % match\//m); + expect(message).not.toMatch(/^-\s+- paragraph: \/Tanta memoria \\d\+ GB\//m); +});