Repository: microsoft/playwright
Pull request #35709: fix(test runner): TestInfo.snapshotPath() should sanitize
Author: login: dgozman
Created: 2025-04-23T14:22:46Z | Merged: 2025-04-24T09:36:39Z

To be exactly the same as `toHaveScreenshot()`.

References #35669.


Diff:
diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts
index ea38793882d0e..003b92ab83753 100644
--- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts
+++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts
@@ -18,11 +18,11 @@
 import fs from 'fs';
 import path from 'path';
 
-import { escapeTemplateString, isString, sanitizeForFilePath } from 'playwright-core/lib/utils';
+import { escapeTemplateString, isString } from 'playwright-core/lib/utils';
 
 import {  kNoElementsFoundError, matcherHint } from './matcherHint';
 import { EXPECTED_COLOR } from '../common/expectBundle';
-import { callLogText, fileExistsAsync, sanitizeFilePathBeforeExtension, trimLongString } from '../util';
+import { callLogText, fileExistsAsync, trimLongString } from '../util';
 import { printReceivedStringContainExpectedSubstring } from './expect';
 import { currentTestInfo } from '../common/globals';
 
@@ -70,8 +70,8 @@ export async function toMatchAriaSnapshot(
     timeout = options.timeout ?? this.timeout;
   } else {
     if (expectedParam?.name) {
-      const ext = expectedParam.name!.endsWith('.aria.yml') ? '.aria.yml' : undefined;
-      expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeFilePathBeforeExtension(expectedParam.name, ext)]);
+      const ext = expectedParam.name.endsWith('.aria.yml') ? '.aria.yml' : undefined;
+      expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [expectedParam.name], true, ext);
     } else {
       let snapshotNames = (testInfo as any)[snapshotNamesSymbol] as SnapshotNames;
       if (!snapshotNames) {
@@ -79,11 +79,11 @@ export async function toMatchAriaSnapshot(
         (testInfo as any)[snapshotNamesSymbol] = snapshotNames;
       }
       const fullTitleWithoutSpec = [...testInfo.titlePath.slice(1), ++snapshotNames.anonymousSnapshotIndex].join(' ');
-      expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeForFilePath(trimLongString(fullTitleWithoutSpec))], '.aria.yml');
+      expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [trimLongString(fullTitleWithoutSpec) + '.aria.yml'], true, '.aria.yml');
       // in 1.51, we changed the default template to use .aria.yml extension
       // for backwards compatibility, we check for the legacy .yml extension
       if (!(await fileExistsAsync(expectedPath))) {
-        const legacyPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeForFilePath(trimLongString(fullTitleWithoutSpec))], '.yml');
+        const legacyPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [trimLongString(fullTitleWithoutSpec) + '.yml'], true, '.yml');
         if (await fileExistsAsync(legacyPath))
           expectedPath = legacyPath;
       }
diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts
index 6cefb48f1eaf7..fb05e4ce38e10 100644
--- a/packages/playwright/src/matchers/toMatchSnapshot.ts
+++ b/packages/playwright/src/matchers/toMatchSnapshot.ts
@@ -122,6 +122,7 @@ class SnapshotHelper {
     }
 
     let expectedPathSegments: string[];
+    let sanitizeFilePath = true;
     let outputBasePath: string;
     if (!name) {
       // Consider the use case below. We should save actual to different paths.
@@ -135,17 +136,24 @@ class SnapshotHelper {
         ++snapshotNames.anonymousSnapshotIndex,
       ].join(' ');
       // Note: expected path must not ever change for backwards compatibility.
-      expectedPathSegments = [sanitizeForFilePath(trimLongString(fullTitleWithoutSpec)) + '.' + anonymousSnapshotExtension];
+      expectedPathSegments = [trimLongString(fullTitleWithoutSpec) + '.' + anonymousSnapshotExtension];
       // Trim the output file paths more aggressively to avoid hitting Windows filesystem limits.
       const sanitizedName = sanitizeForFilePath(trimLongString(fullTitleWithoutSpec, windowsFilesystemFriendlyLength)) + '.' + anonymousSnapshotExtension;
       outputBasePath = testInfo._getOutputPath(sanitizedName);
       this.attachmentBaseName = sanitizedName;
     } else {
-      // We intentionally do not sanitize user-provided array of segments, assuming
-      // it is a file system path. See https://github.com/microsoft/playwright/pull/9156.
       // Note: expected path must not ever change for backwards compatibility.
-      expectedPathSegments = Array.isArray(name) ? name : [sanitizeFilePathBeforeExtension(name)];
-      const joinedName = Array.isArray(name) ? name.join(path.sep) : sanitizeFilePathBeforeExtension(trimLongString(name, windowsFilesystemFriendlyLength));
+      let joinedName: string;
+      if (Array.isArray(name)) {
+        // We intentionally do not sanitize user-provided array of segments, assuming
+        // it is a file system path. See https://github.com/microsoft/playwright/pull/9156.
+        sanitizeFilePath = false;
+        expectedPathSegments = name;
+        joinedName = name.join(path.sep);
+      } else {
+        expectedPathSegments = [name];
+        joinedName = sanitizeFilePathBeforeExtension(trimLongString(name, windowsFilesystemFriendlyLength));
+      }
       snapshotNames.namedSnapshotIndex[joinedName] = (snapshotNames.namedSnapshotIndex[joinedName] || 0) + 1;
       const index = snapshotNames.namedSnapshotIndex[joinedName];
       const sanitizedName = index > 1 ? addSuffixToFilePath(joinedName, `-${index - 1}`) : joinedName;
@@ -153,7 +161,7 @@ class SnapshotHelper {
       this.attachmentBaseName = sanitizedName;
     }
     const defaultTemplate = '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}';
-    this.expectedPath = testInfo._resolveSnapshotPath(configOptions.pathTemplate, defaultTemplate, expectedPathSegments);
+    this.expectedPath = testInfo._resolveSnapshotPath(configOptions.pathTemplate, defaultTemplate, expectedPathSegments, sanitizeFilePath);
     this.legacyExpectedPath = addSuffixToFilePath(outputBasePath, '-expected');
     this.previousPath = addSuffixToFilePath(outputBasePath, '-previous');
     this.actualPath = addSuffixToFilePath(outputBasePath, '-actual');
diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts
index b8e462339b396..1e7011761a8da 100644
--- a/packages/playwright/src/worker/testInfo.ts
+++ b/packages/playwright/src/worker/testInfo.ts
@@ -20,7 +20,7 @@ import path from 'path';
 import { captureRawStack, monotonicTime, sanitizeForFilePath, stringifyStackFrames, currentZone } from 'playwright-core/lib/utils';
 
 import { TimeoutManager, TimeoutManagerError, kMaxDeadline } from './timeoutManager';
-import { filteredStackTrace, getContainedPath, normalizeAndSaveAttachment, trimLongString, windowsFilesystemFriendlyLength } from '../util';
+import { filteredStackTrace, getContainedPath, normalizeAndSaveAttachment, sanitizeFilePathBeforeExtension, trimLongString, windowsFilesystemFriendlyLength } from '../util';
 import { TestTracing } from './testTracing';
 import { testInfoError } from './util';
 
@@ -448,8 +448,11 @@ export class TestInfoImpl implements TestInfo {
     return sanitizeForFilePath(trimLongString(fullTitleWithoutSpec));
   }
 
-  _resolveSnapshotPath(template: string | undefined, defaultTemplate: string, pathSegments: string[], extension?: string) {
-    const subPath = path.join(...pathSegments);
+  _resolveSnapshotPath(template: string | undefined, defaultTemplate: string, pathSegments: string[], sanitizeFilePath: boolean, extension?: string) {
+    let subPath = path.join(...pathSegments);
+    if (sanitizeFilePath)
+      subPath = sanitizeFilePathBeforeExtension(subPath, extension);
+
     const dir = path.dirname(subPath);
     const ext = extension ?? path.extname(subPath);
     const name = path.basename(subPath, ext);
@@ -475,8 +478,11 @@ export class TestInfoImpl implements TestInfo {
   }
 
   snapshotPath(...pathSegments: string[]) {
+    // Assume a single path segment corresponds to `toHaveScreenshot(name)` and sanitize it,
+    // like we do in SnapshotHelper. See https://github.com/microsoft/playwright/pull/9156 for history.
+    const sanitizeFilePath = pathSegments.length === 1;
     const legacyTemplate = '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}';
-    return this._resolveSnapshotPath(undefined, legacyTemplate, pathSegments);
+    return this._resolveSnapshotPath(undefined, legacyTemplate, pathSegments, sanitizeFilePath);
   }
 
   skip(...args: [arg?: any, description?: string]) {
diff --git a/tests/playwright-test/to-have-screenshot.spec.ts b/tests/playwright-test/to-have-screenshot.spec.ts
index a2f3cfc728988..aedfb477e9e6a 100644
--- a/tests/playwright-test/to-have-screenshot.spec.ts
+++ b/tests/playwright-test/to-have-screenshot.spec.ts
@@ -742,6 +742,37 @@ test('should update snapshot with the update-snapshots flag', async ({ runInline
   expect(comparePNGs(fs.readFileSync(snapshotOutputPath), whiteImage)).toBe(null);
 });
 
+test('should respect config.snapshotPathTemplate and sanitize the name', {
+  annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/35669' },
+}, async ({ runInlineTest }) => {
+  const result = await runInlineTest({
+    ...playwrightConfig({
+      snapshotPathTemplate: '__screenshots__/{testFilePath}/{arg}{ext}',
+    }),
+    '__screenshots__/a.spec.js/my-name.png': whiteImage,
+    '__screenshots__/a.spec.js/my_name/bar.png': whiteImage,
+    'a.spec.js': `
+      const path = require('path');
+      const { test, expect } = require('@playwright/test');
+      test('is a test', async ({ page }) => {
+        const testDir = test.info().project.testDir;
+
+        // A single name is sanitized to a valid file name.
+        const expectedPath1 = path.join(testDir, '__screenshots__/a.spec.js/my-name.png');
+        expect(test.info().snapshotPath('my_name.png')).toBe(expectedPath1);
+        await expect(page).toHaveScreenshot('my_name.png');
+
+        // An array is not sanitized - see https://github.com/microsoft/playwright/pull/9156.
+        const expectedPath2 = path.join(testDir, '__screenshots__/a.spec.js/my_name/bar.png');
+        expect(test.info().snapshotPath('my_name', 'bar.png')).toBe(expectedPath2);
+        await expect(page).toHaveScreenshot(['my_name', 'bar.png']);
+      });
+    `
+  });
+  expect(result.exitCode).toBe(0);
+  expect(result.passed).toBe(1);
+});
+
 test('should respect config.expect.toHaveScreenshot.pathTemplate', async ({ runInlineTest }, testInfo) => {
   const result = await runInlineTest({
     ...playwrightConfig({


Reviews:
user: login: yury-s
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-23T17:20:31Z
user: login: dgozman
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-23T18:47:41Z
user: login: yury-s
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-23T18:51:16Z
user: login: yury-s
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-23T19:07:24Z
user: login: yury-s
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-23T19:20:43Z

Review comments:
diff_hunk: @@ -476,7 +484,9 @@ export class TestInfoImpl implements TestInfo {
 
   snapshotPath(...pathSegments: string[]) {
     const legacyTemplate = '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}';
-    return this._resolveSnapshotPath(undefined, legacyTemplate, pathSegments);
+    // Assume a single path segment corresponds to `toHaveScreenshot(name)` so pass it as a string,
path: packages/playwright/src/worker/testInfo.ts
user: login: yury-s
body: What if it is `testInfo.snapshotPath('foo')`, don't we want to sanitize the only segment?
created_at: 2025-04-23T17:20:23Z
author_association: MEMBER
diff_hunk: @@ -476,7 +484,9 @@ export class TestInfoImpl implements TestInfo {
 
   snapshotPath(...pathSegments: string[]) {
     const legacyTemplate = '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}';
-    return this._resolveSnapshotPath(undefined, legacyTemplate, pathSegments);
+    // Assume a single path segment corresponds to `toHaveScreenshot(name)` so pass it as a string,
path: packages/playwright/src/worker/testInfo.ts
user: login: dgozman
body: Updated for better clarity.
created_at: 2025-04-23T18:47:41Z
author_association: COLLABORATOR
diff_hunk: @@ -742,6 +742,37 @@ test('should update snapshot with the update-snapshots flag', async ({ runInline
   expect(comparePNGs(fs.readFileSync(snapshotOutputPath), whiteImage)).toBe(null);
 });
 
+test('should respect config.snapshotPathTemplate and sanitize the name', {
+  annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/35669' },
+}, async ({ runInlineTest }) => {
+  const result = await runInlineTest({
+    ...playwrightConfig({
+      snapshotPathTemplate: '__screenshots__/{testFilePath}/{arg}{ext}',
+    }),
+    '__screenshots__/a.spec.js/my-name.png': whiteImage,
+    '__screenshots__/a.spec.js/foo/bar.png': whiteImage,
+    'a.spec.js': `
+      const path = require('path');
+      const { test, expect } = require('@playwright/test');
+      test('is a test', async ({ page }) => {
+        const testDir = test.info().project.testDir;
+
+        // A single name is sanitized to a valid file name.
+        const expectedPath1 = path.join(testDir, '__screenshots__/a.spec.js/my-name.png');
+        expect(test.info().snapshotPath('my_name.png')).toBe(expectedPath1);
+        await expect(page).toHaveScreenshot('my_name.png');
+
+        // An array is not sanitized - see https://github.com/microsoft/playwright/pull/9156.
+        const expectedPath2 = path.join(testDir, '__screenshots__/a.spec.js/foo/bar.png');
+        expect(test.info().snapshotPath('foo', 'bar.png')).toBe(expectedPath2);
path: tests/playwright-test/to-have-screenshot.spec.ts
user: login: yury-s
body: add a segment with a symbol that could be sanitized?
created_at: 2025-04-23T18:51:09Z
author_association: MEMBER
diff_hunk: @@ -448,8 +448,12 @@ export class TestInfoImpl implements TestInfo {
     return sanitizeForFilePath(trimLongString(fullTitleWithoutSpec));
   }
 
-  _resolveSnapshotPath(template: string | undefined, defaultTemplate: string, pathSegments: string[], extension?: string) {
-    const subPath = path.join(...pathSegments);
+  _resolveSnapshotPath(template: string | undefined, defaultTemplate: string, pathSegments: string[], sanitizeFilePath: boolean, extension?: string) {
+    let segments = pathSegments;
+    if (sanitizeFilePath)
+      segments = pathSegments.map((segment, index) => sanitizeFilePathBeforeExtension(segment, index === pathSegments.length - 1 ? extension : undefined));
path: packages/playwright/src/worker/testInfo.ts
user: login: yury-s
body: why not join first and sanitize after? you'd not need the conditional extension.
created_at: 2025-04-23T19:06:41Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-23T15:12:44Z
body: <!-- Generated by Playwright markdown reporter for tests 1 in job merge-reports -->
### [Test results](https://mspwblobreport.z1.web.core.windows.net/run-14620603266-1-69ffff95e06cc3e3a0c05f1cc7b2776b6301db5f/index.html) for "tests 1"
<details>
<summary><b>5 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: [chromium-library] › library/client-certificates.spec.ts:400:3 › browser › should not hang on tls errors during TLS 1.2 handshake @ubuntu-22.04-chromium-tip-of-tree <br/>
:warning: [chromium-library] › library/video.spec.ts:379:5 › screencast › should capture navigation @ubuntu-22.04-chromium-tip-of-tree <br/>
:warning: [webkit-library] › library/browsercontext-viewport-mobile.spec.ts:116:5 › mobile viewport › default mobile viewports to 980 width @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>

**39069 passed, 803 skipped**
:heavy_check_mark::heavy_check_mark::heavy_check_mark:


Merge [workflow run](https://github.com/microsoft/playwright/actions/runs/14621690086).
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-23T19:04:51Z
body: <!-- Generated by Playwright markdown reporter for tests 1 in job merge-reports -->
### [Test results](https://mspwblobreport.z1.web.core.windows.net/run-14625761912-1-400f4669579fa0ccb6b9018f0ec4d94325cf6871/index.html) for "tests 1"
**17930 passed, 246 skipped**
:heavy_check_mark::heavy_check_mark::heavy_check_mark:


Merge [workflow run](https://github.com/microsoft/playwright/actions/runs/14626046681).
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-23T19:20:55Z
body: <!-- Generated by Playwright markdown reporter for tests 1 in job merge-reports -->
### [Test results](https://mspwblobreport.z1.web.core.windows.net/run-14626039012-1-400f4669579fa0ccb6b9018f0ec4d94325cf6871/index.html) for "tests 1"
<details>
<summary><b>1 flaky</b></summary>
:warning: [chromium-library] › library/chromium/oopif.spec.ts:284:3 › should click @chromium-ubuntu-22.04-node20 <br/>

</details>

**17929 passed, 246 skipped**
:heavy_check_mark::heavy_check_mark::heavy_check_mark:


Merge [workflow run](https://github.com/microsoft/playwright/actions/runs/14626330635).
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-23T20:10:08Z
body: <!-- Generated by Playwright markdown reporter for tests 1 in job merge-reports -->
### [Test results](https://mspwblobreport.z1.web.core.windows.net/run-14626323153-1-400f4669579fa0ccb6b9018f0ec4d94325cf6871/index.html) for "tests 1"
**1 failed**
:x: [webkit-library] › library/browsercontext-pages.spec.ts:82:3 › should click the button with offset with page scale @webkit-ubuntu-22.04-node18

<details>
<summary><b>4 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-pages.spec.ts:105:3 › should return bounding box with page scale @webkit-ubuntu-22.04-node18 <br/>
:warning: [webkit-page] › page/page-screenshot.spec.ts:345:5 › page screenshot › should work while navigating @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/14627137964).
author_association: CONTRIBUTOR