From ecf07fff2276584259570244691b80ac5929fec5 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 25 Aug 2026 10:28:38 -0700 Subject: [PATCH 1/7] fix(trace-viewer): escape "" in snapshot stylesheet text Stylesheet text nodes were emitted into the snapshot unescaped, so a literal "" inside style content would close the `); diff --git a/tests/library/snapshot-renderer.spec.ts b/tests/library/snapshot-renderer.spec.ts index f512a6933b57c..29db180dfa611 100644 --- a/tests/library/snapshot-renderer.spec.ts +++ b/tests/library/snapshot-renderer.spec.ts @@ -48,6 +48,18 @@ for (const [name, overrides] of [ }); } +test('snapshot renderer escapes closing style tag in style text', () => { + const renderer = new SnapshotRenderer(new LRUCache(1_000_000), [], [makeSnapshot({ + html: ['HTML', {}, ['BODY', {}, ['STYLE', {}, 'body::after{content:""}']]], + })], [], 0); + const { html } = renderer.render(); + // The literal must not survive - it would terminate the element and + // let the trailing markup be parsed as live HTML on the trace viewer origin. + expect(html).not.toContain(''); +}); + test('snapshot renderer strips event handler attributes', () => { const renderer = new SnapshotRenderer(new LRUCache(1_000_000), [], [makeSnapshot({ html: ['HTML', {}, ['BODY', {}, ['IMG', { 'onerror': 'alert(1)', 'src': 'x' }]]], From a36ac7aa4af80f202a94235d4f7727d05768a902 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 25 Aug 2026 14:20:04 -0700 Subject: [PATCH 2/7] fix(trace-viewer): escape closing style tag over full style content Escape the fully serialized " split across adjacent text nodes cannot reassemble after join and terminate the element. Add an end-to-end trace-viewer test that records a real trace and asserts the style text stays contained. --- packages/isomorphic/trace/snapshotRenderer.ts | 10 +++++++++- tests/library/snapshot-renderer.spec.ts | 11 ++++++----- tests/library/trace-viewer.spec.ts | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/isomorphic/trace/snapshotRenderer.ts b/packages/isomorphic/trace/snapshotRenderer.ts index 73bc7d818d3c7..588f12322e383 100644 --- a/packages/isomorphic/trace/snapshotRenderer.ts +++ b/packages/isomorphic/trace/snapshotRenderer.ts @@ -82,7 +82,7 @@ export class SnapshotRenderer { // Best-effort Electron support: rewrite custom protocol in url() links in stylesheets. // Old snapshotter was sending lower-case. if (parentTag === 'STYLE' || parentTag === 'style') - result.push(escapeClosingStyleTag(escapeURLsInStyleSheet(rewriteURLsInStyleSheetForCustomProtocol(n)))); + result.push(escapeURLsInStyleSheet(rewriteURLsInStyleSheetForCustomProtocol(n))); else result.push(escapeHTML(n)); return; @@ -164,8 +164,16 @@ export class SnapshotRenderer { result.push(' ', attrName, '="', escapeHTMLAttribute(attrValue), '"'); } result.push('>'); + const styleContentStart = upperName === 'STYLE' ? result.length : -1; for (const child of children) visit(child, snapshotIndex, nodeName, attrs); + if (styleContentStart !== -1) { + // Escape the fully serialized " across adjacent text nodes so no single node contains it, yet joining + // the result would reassemble it and terminate the element early. + const styleContent = result.splice(styleContentStart).join(''); + result.push(escapeClosingStyleTag(styleContent)); + } if (!autoClosing.has(nodeName)) result.push(''); return; diff --git a/tests/library/snapshot-renderer.spec.ts b/tests/library/snapshot-renderer.spec.ts index 29db180dfa611..fabce696d6183 100644 --- a/tests/library/snapshot-renderer.spec.ts +++ b/tests/library/snapshot-renderer.spec.ts @@ -48,15 +48,16 @@ for (const [name, overrides] of [ }); } -test('snapshot renderer escapes closing style tag in style text', () => { +test('snapshot renderer escapes closing style tag split across text nodes', () => { + // A " across adjacent text nodes so that no single node contains it, yet joining + // the rendered result would reassemble it and terminate the element early. const renderer = new SnapshotRenderer(new LRUCache(1_000_000), [], [makeSnapshot({ - html: ['HTML', {}, ['BODY', {}, ['STYLE', {}, 'body::after{content:""}']]], + html: ['HTML', {}, ['BODY', {}, ['STYLE', {}, 'body{}<', '/style>']]], })], [], 0); const { html } = renderer.render(); - // The literal must not survive - it would terminate the element and - // let the trailing markup be parsed as live HTML on the trace viewer origin. expect(html).not.toContain(' stays inside the stylesheet, neutralized with a CSS backslash. expect(html).toContain('<\\/style>'); }); diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index fcc9323add279..64757a80648fb 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -869,6 +869,25 @@ test('should capture data-url svg iframe', async ({ page, server, runAndTrace }) expect(content).toContain(`d="M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3zm-4.4 15.55l-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05z"`); }); +test('should not let style text break out of the style element', async ({ page, runAndTrace, server }) => { + const traceViewer = await runAndTrace(async () => { + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + const style = document.createElement('style'); + style.textContent = 'body{}'; + document.body.appendChild(style); + }); + await page.locator('body').click(); + }); + + const frame = await traceViewer.snapshotFrame('Click'); + // If the "" broke out of the element, this would be a real node. + await expect(frame.locator('img')).toHaveCount(0); + // The stylesheet text is preserved verbatim, not dropped. + await expect(frame.locator('body style')).toHaveCount(1); + expect(await frame.locator('body style').evaluate(el => el.textContent)).toContain('onerror='); +}); + test('should contain adopted style sheets', async ({ page, runAndTrace, browserName }) => { const traceViewer = await runAndTrace(async () => { await page.setContent(''); From 8bc0ec7c13e6d41d471bc9dd9a3205210faf1d16 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 25 Aug 2026 15:31:17 -0700 Subject: [PATCH 3/7] chore(trace-viewer): trim style escaping comments, drop unit test Keep the end-to-end coverage only. --- packages/isomorphic/trace/snapshotRenderer.ts | 8 ++------ tests/library/snapshot-renderer.spec.ts | 13 ------------- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/packages/isomorphic/trace/snapshotRenderer.ts b/packages/isomorphic/trace/snapshotRenderer.ts index 588f12322e383..d0d2c502dfe7d 100644 --- a/packages/isomorphic/trace/snapshotRenderer.ts +++ b/packages/isomorphic/trace/snapshotRenderer.ts @@ -168,9 +168,7 @@ export class SnapshotRenderer { for (const child of children) visit(child, snapshotIndex, nodeName, attrs); if (styleContentStart !== -1) { - // Escape the fully serialized " across adjacent text nodes so no single node contains it, yet joining - // the result would reassemble it and terminate the element early. + // Escape the joined text - "" can be split across adjacent text nodes. const styleContent = result.splice(styleContentStart).join(''); result.push(escapeClosingStyleTag(styleContent)); } @@ -688,9 +686,7 @@ function escapeURLsInStyleSheet(text: string): string { return text.replace(urlToEscapeRegex1, replacer).replace(urlToEscapeRegex2, replacer); } -// A literal " element. +// A literal " { - // A " across adjacent text nodes so that no single node contains it, yet joining - // the rendered result would reassemble it and terminate the element early. - const renderer = new SnapshotRenderer(new LRUCache(1_000_000), [], [makeSnapshot({ - html: ['HTML', {}, ['BODY', {}, ['STYLE', {}, 'body{}<', '/style>']]], - })], [], 0); - const { html } = renderer.render(); - expect(html).not.toContain(' stays inside the stylesheet, neutralized with a CSS backslash. - expect(html).toContain('<\\/style>'); -}); - test('snapshot renderer strips event handler attributes', () => { const renderer = new SnapshotRenderer(new LRUCache(1_000_000), [], [makeSnapshot({ html: ['HTML', {}, ['BODY', {}, ['IMG', { 'onerror': 'alert(1)', 'src': 'x' }]]], From c15ddb140b47853abc80467c0b5980b83e4f0046 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 27 Aug 2026 12:26:04 -0700 Subject: [PATCH 4/7] fix(trace-viewer): store snapshot style text in an attribute Render stylesheet text into __playwright_style_content__ on the " can be split across adjacent text nodes. - const styleContent = result.splice(styleContentStart).join(''); - result.push(escapeClosingStyleTag(styleContent)); - } if (!autoClosing.has(nodeName)) result.push(''); return; @@ -340,6 +341,11 @@ function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefine for (const e of root.querySelectorAll(`[__playwright_scroll_left_]`)) scrollLefts.push(e); + for (const element of root.querySelectorAll(`style[__playwright_style_content__]`)) { + element.textContent = element.getAttribute('__playwright_style_content__'); + element.removeAttribute('__playwright_style_content__'); + } + for (const element of root.querySelectorAll(`[__playwright_value_]`)) { const inputElement = element as HTMLInputElement | HTMLTextAreaElement; if (inputElement.type !== 'file') @@ -671,24 +677,4 @@ function rewriteURLsInStyleSheetForCustomProtocol(text: string): string { }); } -// url() inside a tag: -// url('data:image/svg+xml,') -const urlToEscapeRegex1 = /url\(\s*'([^']*)'\s*\)/ig; -const urlToEscapeRegex2 = /url\(\s*"([^"]*)"\s*\)/ig; -function escapeURLsInStyleSheet(text: string): string { - const replacer = (match: string, url: string) => { - // Conservatively encode only urls with a closing tag. - if (url.includes('`); diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 64757a80648fb..95fc6e16c5d71 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -874,8 +874,8 @@ test('should not let style text break out of the style element', async ({ page, await page.goto(server.EMPTY_PAGE); await page.evaluate(() => { const style = document.createElement('style'); - style.textContent = 'body{}'; - document.body.appendChild(style); + style.textContent = 'div{color:rgb(1, 2, 3)}'; + document.body.append(style, document.createElement('div')); }); await page.locator('body').click(); }); @@ -883,8 +883,8 @@ test('should not let style text break out of the style element', async ({ page, const frame = await traceViewer.snapshotFrame('Click'); // If the "" broke out of the element, this would be a real node. await expect(frame.locator('img')).toHaveCount(0); - // The stylesheet text is preserved verbatim, not dropped. - await expect(frame.locator('body style')).toHaveCount(1); + // The stylesheet is preserved verbatim and still applies. + await expect(frame.locator('div')).toHaveCSS('color', 'rgb(1, 2, 3)'); expect(await frame.locator('body style').evaluate(el => el.textContent)).toContain('onerror='); }); From 56ec18053c54d53ea7a8187bbf734cdd33a2984c Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 27 Aug 2026 13:13:41 -0700 Subject: [PATCH 5/7] chore(trace-viewer): read style text directly from the single child node The snapshotter always emits STYLE as [name, {}, cssText], so there is no need to render children and join them back. Fall back to empty text when a malformed trace has no text child, to keep rendering non-throwing. --- packages/isomorphic/trace/snapshotRenderer.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/isomorphic/trace/snapshotRenderer.ts b/packages/isomorphic/trace/snapshotRenderer.ts index e6798221e0be3..4bc9403da6f5e 100644 --- a/packages/isomorphic/trace/snapshotRenderer.ts +++ b/packages/isomorphic/trace/snapshotRenderer.ts @@ -79,11 +79,7 @@ export class SnapshotRenderer { const visit = (n: NodeSnapshot, snapshotIndex: number, parentTag: string | undefined, parentAttrs: [string, string][] | undefined) => { // Text node. if (typeof n === 'string') { - // Style text is hoisted into an attribute below, so emit it verbatim. - if (parentTag?.toUpperCase() === 'STYLE') - result.push(n); - else - result.push(escapeHTML(n)); + result.push(escapeHTML(n)); return; } @@ -163,11 +159,9 @@ export class SnapshotRenderer { result.push(' ', attrName, '="', escapeHTMLAttribute(attrValue), '"'); } if (upperName === 'STYLE') { - const contentStart = result.length; - for (const child of children) - visit(child, snapshotIndex, nodeName, attrs); - const styleContent = rewriteURLsInStyleSheetForCustomProtocol(result.splice(contentStart).join('')); - result.push(' __playwright_style_content__="', escapeHTMLAttribute(styleContent), '">'); + // Style has always exactly one child which is a text node. + const styleContent = typeof children[0] === 'string' ? children[0] : ''; + result.push(' __playwright_style_content__="', escapeHTMLAttribute(rewriteURLsInStyleSheetForCustomProtocol(styleContent)), '">'); return; } result.push('>'); From 2991e4a7a3aa3a1fb03f34d7785f4abeaad5bde5 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 27 Aug 2026 13:16:53 -0700 Subject: [PATCH 6/7] chore(trace-viewer): push style content attribute like other attributes Pass the attribute name and value as separate parts, matching how the attribute loop above emits every other attribute. --- packages/isomorphic/trace/snapshotRenderer.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/isomorphic/trace/snapshotRenderer.ts b/packages/isomorphic/trace/snapshotRenderer.ts index 4bc9403da6f5e..433ee074c70bd 100644 --- a/packages/isomorphic/trace/snapshotRenderer.ts +++ b/packages/isomorphic/trace/snapshotRenderer.ts @@ -161,7 +161,8 @@ export class SnapshotRenderer { if (upperName === 'STYLE') { // Style has always exactly one child which is a text node. const styleContent = typeof children[0] === 'string' ? children[0] : ''; - result.push(' __playwright_style_content__="', escapeHTMLAttribute(rewriteURLsInStyleSheetForCustomProtocol(styleContent)), '">'); + result.push(' ', '__playwright_style_content__', '="', escapeHTMLAttribute(rewriteURLsInStyleSheetForCustomProtocol(styleContent)), '"'); + result.push('>'); return; } result.push('>'); From f4fbdb5a867b9c936ac75ad995152d5c155ad82b Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 27 Aug 2026 14:32:25 -0700 Subject: [PATCH 7/7] test(trace-viewer): give the style breakout test a visible body The body only held a display:none '; - document.body.append(style, document.createElement('div')); + const div = document.createElement('div'); + div.textContent = 'hello'; + document.body.append(style, div); }); await page.locator('body').click(); });