Repository: microsoft/playwright
Pull request #35705: fix(trace viewer): encode some urls inside style tags
Author: login: dgozman
Created: 2025-04-23T10:17:57Z | Merged: 2025-04-24T09:53:48Z

When url inside a style tag contains `</style>`, it messes up with html parsing.

Fixes #35681.

Linked issue #35681: [Bug]: CSS rendered as text in playwright test runner
### Version

1.52.0

### Steps to reproduce

1. Clone repo at https://github.com/BrightNight-Energy/debug-css-playwright.git
2. Install deps: `npm install`
3. Run dev server: `npm run dev`
4. Open test runner: `npx playwright test --ui`
5. You should see the issue:

![Image](https://github.com/user-attachments/assets/791a9364-aadb-48f1-a7da-14ad4370712b)

### Expected behavior

I expect the CSS to get inject, not rendered as text.

![Image](https://github.com/user-attachments/assets/72e78f1d-859b-43dd-8704-8a65e949c858)

### Actual behavior

Rendered as text, rather than injected as CSS.

![Image](https://github.com/user-attachments/assets/791a9364-aadb-48f1-a7da-14ad4370712b)

### Additional context

This issue does not exist when viewing in the browser normally. Nor have I seen this issue when using cypress. Therefore I'm thinking this is a playwright issue but do let me know if anything else comes to mind.



### Environment

```shell
System:
    OS: macOS 15.3.2
    CPU: (16) arm64 Apple M4 Max
    Memory: 654.33 MB / 64.00 GB
  Binaries:
    Node: 20.18.1 - ~/.local/share/nvm/v20.18.1/bin/node
    npm: 11.0.0 - ~/.local/share/nvm/v20.18.1/bin/npm
    bun: 1.1.45 - ~/.bun/bin/bun
  Languages:
    Bash: 5.2.37 - /opt/homebrew/bin/bash
  npmPackages:
    @playwright/test: ^1.52.0 => 1.52.0
```
Linked issue comments: user: login: marc-niclas
created_at: 2025-04-28T16:25:06Z
body: @dgozman thanks for the fix! When are you planning to release 1.53?
author_association: NONE
user: login: dgozman
created_at: 2025-04-28T16:54:31Z
body: @marc-at-brightnight Not too soon, since the last release was just 2 weeks ago. You can use a [canary release](https://playwright.dev/docs/canary-releases) for now.

author_association: COLLABORATOR

Diff:
diff --git a/packages/trace-viewer/src/sw/snapshotRenderer.ts b/packages/trace-viewer/src/sw/snapshotRenderer.ts
index c2b014ac3f0e3..a0980ac7b1bd4 100644
--- a/packages/trace-viewer/src/sw/snapshotRenderer.ts
+++ b/packages/trace-viewer/src/sw/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(rewriteURLsInStyleSheetForCustomProtocol(n));
+          result.push(escapeURLsInStyleSheet(rewriteURLsInStyleSheetForCustomProtocol(n)));
         else
           result.push(escapeHTML(n));
         return;
@@ -618,6 +618,21 @@ function rewriteURLsInStyleSheetForCustomProtocol(text: string): string {
   });
 }
 
+// url() inside a <style> tag can mess up with html parsing, so we encode some of them.
+// As an example, the following url will close the </style> tag:
+// url('data:image/svg+xml,<svg><defs><style>.a{fill:none}</style></defs><g class="a"></g></svg>')
+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('</'))
+      return match.replace(url, encodeURI(url));
+    return match;
+  };
+  return text.replace(urlToEscapeRegex1, replacer).replace(urlToEscapeRegex2, replacer);
+}
+
 // <base>/snapshot.html?r=<snapshotUrl> is used for "pop out snapshot" feature.
 export function unwrapPopoutUrl(url: string) {
   const u = new URL(url);
diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts
index 5e795b8913280..039d9cbc05fd5 100644
--- a/tests/library/trace-viewer.spec.ts
+++ b/tests/library/trace-viewer.spec.ts
@@ -1840,3 +1840,39 @@ test("shouldn't render not-blob trace received from message", async ({ showTrace
   await expect(traceViewer.page.locator('.drop-target')).toBeVisible();
   await expect(traceViewer.actionTitles).not.toBeVisible();
 });
+
+test('should not trip over complex urls in style tags', {
+  annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/35681' },
+}, async ({ runAndTrace, page }) => {
+  const traceViewer = await runAndTrace(async () => {
+    await page.setContent(`
+      <html>
+        <head>
+          <style>
+          </style>
+        </head>
+        <body>
+          <div>hello</div>
+          <span>world</span>
+        </body>
+      </html>
+    `);
+    const css = `
+      div {
+        background-image: url(   'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><style>.cls-1{fill:none}</style></defs><title>Custom Title</title><polyline class="cls-1" points="1 2 3 4"/></svg>');
+        color: rgb(1, 2, 3);
+      }
+      span {
+        color: rgb(4, 5, 6);
+        background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><defs><style>.cls-1{fill:none}</style></defs><title>Custom Title</title><polyline class='cls-1' points='1 2 3 4'/></svg>" );
+      }
+    `;
+    await page.evaluate(css => document.querySelector('style').textContent = css, css);
+  });
+
+  const snapshot = await traceViewer.snapshotFrame('page.evaluate');
+  // Just "hello world", no gibberish from the <style>.
+  await expect(snapshot.locator('body')).toHaveText('hello world');
+  await expect(snapshot.locator('div')).toHaveCSS('color', 'rgb(1, 2, 3)');
+  await expect(snapshot.locator('span')).toHaveCSS('color', 'rgb(4, 5, 6)');
+});


Reviews:
user: login: agg23
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T12:33:51Z
user: login: dgozman
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-23T12:39:21Z
user: login: pavelfeldman
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-23T20:07:17Z
user: login: mxschmitt
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T09:42:31Z

Review comments:
diff_hunk: @@ -618,6 +618,21 @@ function rewriteURLsInStyleSheetForCustomProtocol(text: string): string {
   });
 }
 
+// url() inside a <style> tag can mess up with html parsing, so we encode some of them.
+// As an example, the following url will close the </style> tag:
+// url('data:image/svg+xml,<svg><defs><style>.a{fill:none}</style></defs><g class="a"></g></svg>')
+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.
path: packages/trace-viewer/src/sw/snapshotRenderer.ts
user: login: agg23
body: Why conservatively? Either the encoding works inside of `<style>` or it doesn't.
created_at: 2025-04-23T12:31:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1840,3 +1840,40 @@ test("shouldn't render not-blob trace received from message", async ({ showTrace
   await expect(traceViewer.page.locator('.drop-target')).toBeVisible();
   await expect(traceViewer.actionTitles).not.toBeVisible();
 });
+
+test('should not trip over complex urls in style tags', {
+  annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/35681' },
+}, async ({ runAndTrace, page }) => {
+  const traceViewer = await runAndTrace(async () => {
+    await page.setContent(`
+      <html>
+        <head>
+          <style>
+          </style>
+        </head>
+        <body>
+          <div>hello</div>
+          <span>world</span>
+        </body>
+      </html>
+    `);
+    const css = `
+      div {
+        background-image: url(   'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><style>.cls-1{fill:none}</style></defs><title>Custom Title</title><polyline class="cls-1" points="1 2 3 4"/></svg>');
+        color: rgb(1, 2, 3);
+      }
+      span {
+        color: rgb(4, 5, 6);
+        background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><defs><style>.cls-1{fill:none}</style></defs><title>Custom Title</title><polyline class='cls-1' points='1 2 3 4'/></svg>" );
+      }
+    `;
+    await page.evaluate(css => document.querySelector('style').textContent = css, css);
path: tests/library/trace-viewer.spec.ts
user: login: agg23
body: Is there significance to applying the style through JS instead of inline in the HTML?
created_at: 2025-04-23T12:33:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -618,6 +618,21 @@ function rewriteURLsInStyleSheetForCustomProtocol(text: string): string {
   });
 }
 
+// url() inside a <style> tag can mess up with html parsing, so we encode some of them.
+// As an example, the following url will close the </style> tag:
+// url('data:image/svg+xml,<svg><defs><style>.a{fill:none}</style></defs><g class="a"></g></svg>')
+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.
path: packages/trace-viewer/src/sw/snapshotRenderer.ts
user: login: dgozman
body: I'd like to be on the safe side and alter the actual content as less a possible. We know that `</` urls are broken anyway, so fixing them is definitely a win.
created_at: 2025-04-23T12:38:36Z
author_association: COLLABORATOR
diff_hunk: @@ -1840,3 +1840,40 @@ test("shouldn't render not-blob trace received from message", async ({ showTrace
   await expect(traceViewer.page.locator('.drop-target')).toBeVisible();
   await expect(traceViewer.actionTitles).not.toBeVisible();
 });
+
+test('should not trip over complex urls in style tags', {
+  annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/35681' },
+}, async ({ runAndTrace, page }) => {
+  const traceViewer = await runAndTrace(async () => {
+    await page.setContent(`
+      <html>
+        <head>
+          <style>
+          </style>
+        </head>
+        <body>
+          <div>hello</div>
+          <span>world</span>
+        </body>
+      </html>
+    `);
+    const css = `
+      div {
+        background-image: url(   'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><style>.cls-1{fill:none}</style></defs><title>Custom Title</title><polyline class="cls-1" points="1 2 3 4"/></svg>');
+        color: rgb(1, 2, 3);
+      }
+      span {
+        color: rgb(4, 5, 6);
+        background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><defs><style>.cls-1{fill:none}</style></defs><title>Custom Title</title><polyline class='cls-1' points='1 2 3 4'/></svg>" );
+      }
+    `;
+    await page.evaluate(css => document.querySelector('style').textContent = css, css);
path: tests/library/trace-viewer.spec.ts
user: login: dgozman
body: Yes, in the html I'd have to escape them like the actual fix does, and then the fix will not be even required.
created_at: 2025-04-23T12:39:18Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-23T11:07:40Z
body: <!-- Generated by Playwright markdown reporter for tests 1 in job merge-reports -->
### [Test results](https://mspwblobreport.z1.web.core.windows.net/run-14615704825-1-108419aa1efe75d3f3e6b9484fc53731de784f66/index.html) for "tests 1"
**3 failed**
:x: [playwright-test] › runner.spec.ts:118:5 › should ignore subprocess creation error because of SIGINT @macos-latest-node18-1
:x: [playwright-test] › ui-mode-test-network-tab.spec.ts:99:5 › should format JSON request body @macos-latest-node18-1
:x: [playwright-test] › ui-mode-test-network-tab.spec.ts:99:5 › should format JSON request body @ubuntu-latest-node22-1

<details>
<summary><b>2 flaky</b></summary>
:warning: [firefox-page] › page/page-evaluate.spec.ts:424:3 › should throw for too deep reference chain @firefox-ubuntu-22.04-node18 <br/>
:warning: [webkit-library] › library/browsercontext-viewport-mobile.spec.ts:124:5 › mobile viewport › respect meta viewport tag @webkit-ubuntu-22.04-node18 <br/>

</details>

**39070 passed, 803 skipped**
:heavy_check_mark::heavy_check_mark::heavy_check_mark:


Merge [workflow run](https://github.com/microsoft/playwright/actions/runs/14616588852).
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-23T20:59:21Z
body: <!-- Generated by Playwright markdown reporter for tests 1 in job merge-reports -->
### [Test results](https://mspwblobreport.z1.web.core.windows.net/run-14627133860-1-400f4669579fa0ccb6b9018f0ec4d94325cf6871/index.html) for "tests 1"
**3 failed**
:x: [playwright-test] › test-step.spec.ts:1447:5 › reading network request / response should not be listed as step @macos-latest-node18-1
:x: [legacy] › run-tests.spec.ts:1153:5 › should produce output twice @vscode-extension
:x: [webkit-library] › library/browsercontext-device.spec.ts:45:5 › device › should scroll to click @webkit-ubuntu-22.04-node18

<details>
<summary><b>3 flaky</b></summary>
:warning: [firefox-page] › page/page-evaluate.spec.ts:424:3 › should throw for too deep reference chain @firefox-ubuntu-22.04-node18 <br/>
:warning: [webkit-library] › library/video.spec.ts:475:5 › screencast › should scale frames down to the requested size  @webkit-ubuntu-22.04-node18 <br/>
:warning: [playwright-test] › ui-mode-test-watch.spec.ts:145:5 › should watch all @windows-latest-node18-1 <br/>

</details>

**39075 passed, 803 skipped**
:heavy_check_mark::heavy_check_mark::heavy_check_mark:


Merge [workflow run](https://github.com/microsoft/playwright/actions/runs/14627973254).
author_association: CONTRIBUTOR