From e966b82cd01273c5ca1a68e7d4bb131f7faf9e64 Mon Sep 17 00:00:00 2001
From: Max Schmitt <max@schmitt.mx>
Date: Mon, 26 Jun 2023 14:37:05 +0200
Subject: [PATCH] fix: do not collide with other test when test name has
 special chars

---
 .../src/matchers/toMatchSnapshot.ts           | 13 +++-
 packages/playwright-test/src/util.ts          | 22 ++++---
 .../playwright.artifacts.spec.ts              |  2 +-
 .../reporter-attachment.spec.ts               |  2 +-
 tests/playwright-test/reporter-raw.spec.ts    |  6 +-
 tests/playwright-test/test-output-dir.spec.ts | 66 +++++++++++++++++--
 6 files changed, 88 insertions(+), 23 deletions(-)

diff --git a/packages/playwright-test/src/matchers/toMatchSnapshot.ts b/packages/playwright-test/src/matchers/toMatchSnapshot.ts
index bbcb5afdbb9b6..87ad7d3e93630 100644
--- a/packages/playwright-test/src/matchers/toMatchSnapshot.ts
+++ b/packages/playwright-test/src/matchers/toMatchSnapshot.ts
@@ -22,7 +22,7 @@ import type { ImageComparatorOptions, Comparator } from 'playwright-core/lib/uti
 import { getComparator } from 'playwright-core/lib/utils';
 import type { PageScreenshotOptions } from 'playwright-core/types/types';
 import {
-  addSuffixToFilePath, serializeError, sanitizeForFilePath,
+  serializeError, sanitizeForFilePath,
   trimLongString, callLogText,
   expectTypes  } from '../util';
 import { colors } from 'playwright-core/lib/utilsBundle';
@@ -440,3 +440,14 @@ function determineFileExtension(file: string | Buffer): string {
 function compareMagicBytes(file: Buffer, magicBytes: number[]): boolean {
   return Buffer.compare(Buffer.from(magicBytes), file.slice(0, magicBytes.length)) === 0;
 }
+
+function addSuffixToFilePath(filePath: string, suffix: string, customExtension?: string, sanitize = false): string {
+  const dirname = path.dirname(filePath);
+  const ext = path.extname(filePath);
+  const name = path.basename(filePath, ext);
+  const base = path.join(dirname, name);
+  const sanitizeForSnapshotFilePath = (s: string) => {
+    return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
+  };
+  return (sanitize ? sanitizeForSnapshotFilePath(base) : base) + suffix + (customExtension || ext);
+}
diff --git a/packages/playwright-test/src/util.ts b/packages/playwright-test/src/util.ts
index e67ca4719db89..30f7158c80783 100644
--- a/packages/playwright-test/src/util.ts
+++ b/packages/playwright-test/src/util.ts
@@ -195,8 +195,18 @@ export function expectTypes(receiver: any, types: string[], matcherName: string)
   }
 }
 
-export function sanitizeForFilePath(s: string) {
-  return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
+export function sanitizeForFilePath(input: string) {
+  let nonTrivialSubstitute = false;
+  let sanitized = input.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F\x2A\-\*]+/g, substring => {
+    if (substring !== ' ')
+      nonTrivialSubstitute = true;
+    return '-';
+  });
+  if (!nonTrivialSubstitute)
+    return sanitized;
+  // If we sanitized the beginning or end, remove it for cosmetic reasons.
+  sanitized = sanitized.replace(/^-/, '').replace(/-$/, '');
+  return sanitized + '-' + calculateSha1(input).substring(0, 6);
 }
 
 export function trimLongString(s: string, length = 100) {
@@ -209,14 +219,6 @@ export function trimLongString(s: string, length = 100) {
   return s.substring(0, start) + middle + s.slice(-end);
 }
 
-export function addSuffixToFilePath(filePath: string, suffix: string, customExtension?: string, sanitize = false): string {
-  const dirname = path.dirname(filePath);
-  const ext = path.extname(filePath);
-  const name = path.basename(filePath, ext);
-  const base = path.join(dirname, name);
-  return (sanitize ? sanitizeForFilePath(base) : base) + suffix + (customExtension || ext);
-}
-
 /**
  * Returns absolute path contained within parent directory.
  */
diff --git a/tests/playwright-test/playwright.artifacts.spec.ts b/tests/playwright-test/playwright.artifacts.spec.ts
index 62a2dcf0d2f9e..f952b35a837b2 100644
--- a/tests/playwright-test/playwright.artifacts.spec.ts
+++ b/tests/playwright-test/playwright.artifacts.spec.ts
@@ -54,7 +54,7 @@ const testFiles = {
         await page.click('text=Click me');
       });
 
-      test('shared  failing', async ({ }) => {
+      test('shared failing', async ({ }) => {
         await page.click('text=And me');
         expect(1).toBe(2);
       });
diff --git a/tests/playwright-test/reporter-attachment.spec.ts b/tests/playwright-test/reporter-attachment.spec.ts
index d761c0a4da975..37fffd33d67fb 100644
--- a/tests/playwright-test/reporter-attachment.spec.ts
+++ b/tests/playwright-test/reporter-attachment.spec.ts
@@ -235,7 +235,7 @@ test(`testInfo.attach name should be sanitized`, async ({ runInlineTest }) => {
   expect(result.failed).toBe(1);
 
   expect(result.output).toContain('attachment #1: ../../../test (text/plain)');
-  expect(result.output).toContain(`attachments${path.sep}-test`);
+  expect(result.output).toContain(`attachments${path.sep}test-8d909b-`);
 });
 
 test(`testInfo.attach name can be empty string`, async ({ runInlineTest }) => {
diff --git a/tests/playwright-test/reporter-raw.spec.ts b/tests/playwright-test/reporter-raw.spec.ts
index 9b38d96db746a..194fcfbc1caa1 100644
--- a/tests/playwright-test/reporter-raw.spec.ts
+++ b/tests/playwright-test/reporter-raw.spec.ts
@@ -48,7 +48,7 @@ test('should use project name', async ({ runInlineTest }, testInfo) => {
       test('passes', async ({ page }, testInfo) => {});
     `,
   }, { reporter: 'dot,' + kRawReporterPath });
-  const json = JSON.parse(fs.readFileSync(testInfo.outputPath('output', 'report', 'project-name.report'), 'utf-8'));
+  const json = JSON.parse(fs.readFileSync(testInfo.outputPath('output', 'report', 'project-name-656a9b.report'), 'utf-8'));
   expect(json.project.name).toBe('project-name');
   expect(result.exitCode).toBe(0);
 });
@@ -173,7 +173,7 @@ test(`testInfo.attach should save attachments via path`, async ({ runInlineTest
     expect(result.attachments[0].name).toBe('example.png');
     expect(result.attachments[0].contentType).toBe('x-playwright/custom');
     const p = result.attachments[0].path;
-    expect(p).toMatch(/[/\\]attachments[/\\]example-png-[0-9a-f]+\.json$/);
+    expect(p).toMatch(/[/\\]attachments[/\\]example-png-[0-9a-f]{6}-[0-9a-f]+\.json$/);
     const contents = fs.readFileSync(p);
     expect(contents.toString()).toBe('We <3 Playwright!');
   }
@@ -255,5 +255,5 @@ test('dupe project names', async ({ runInlineTest }, testInfo) => {
     `,
   }, { reporter: 'dot,' + kRawReporterPath });
   const files = fs.readdirSync(testInfo.outputPath('test-results', 'report'));
-  expect(new Set(files)).toEqual(new Set(['project-name.report', 'project-name-1.report', 'project-name-2.report']));
+  expect(new Set(files)).toEqual(new Set(['project-name-656a9b.report', 'project-name-656a9b-1.report', 'project-name-656a9b-2.report']));
 });
diff --git a/tests/playwright-test/test-output-dir.spec.ts b/tests/playwright-test/test-output-dir.spec.ts
index 07a5f5be15923..203542559bd04 100644
--- a/tests/playwright-test/test-output-dir.spec.ts
+++ b/tests/playwright-test/test-output-dir.spec.ts
@@ -194,10 +194,10 @@ test('should include the project name', async ({ runInlineTest }) => {
   expect(result.output).toContain('my-test.spec.js-snapshots/bar-foo.txt');
 
   // test1, run with bar
-  expect(result.output).toContain('test-results/my-test-test-1-Bar-space-/bar.txt');
-  expect(result.output).toContain('my-test.spec.js-snapshots/bar-Bar-space-.txt');
-  expect(result.output).toContain('test-results/my-test-test-1-Bar-space--retry1/bar.txt');
-  expect(result.output).toContain('my-test.spec.js-snapshots/bar-Bar-space-.txt');
+  expect(result.output).toContain('test-results/my-test-test-1-Bar-space-dc1461/bar.txt');
+  expect(result.output).toContain('my-test.spec.js-snapshots/bar-Bar-space-dc1461.txt');
+  expect(result.output).toContain('test-results/my-test-test-1-Bar-space-dc1461-retry1/bar.txt');
+  expect(result.output).toContain('my-test.spec.js-snapshots/bar-Bar-space-dc1461.txt');
 
   // test2, run with empty
   expect(result.output).toContain('test-results/my-test-test-2/bar.txt');
@@ -212,8 +212,8 @@ test('should include the project name', async ({ runInlineTest }) => {
   expect(result.output).toContain('my-test.spec.js-snapshots/bar-foo-suffix.txt');
 
   // test2, run with bar
-  expect(result.output).toContain('test-results/my-test-test-2-Bar-space-/bar.txt');
-  expect(result.output).toContain('my-test.spec.js-snapshots/bar-Bar-space--suffix.txt');
+  expect(result.output).toContain('test-results/my-test-test-2-Bar-space-dc1461/bar.txt');
+  expect(result.output).toContain('my-test.spec.js-snapshots/bar-Bar-space-dc1461-suffix.txt');
 });
 
 test('should include path option in snapshot', async ({ runInlineTest }) => {
@@ -401,6 +401,58 @@ test('should allow nonAscii characters in the output dir', async ({ runInlineTes
   expect(outputDir).toBe(path.join(testInfo.outputDir, 'test-results', 'my-test-こんにちは世界'));
 });
 
+test('should not collide with other tests when nonAscii characters are replaced', async ({ runInlineTest }) => {
+  test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/23386' });
+  const result = await runInlineTest({
+    'my-test.spec.js': `
+      import { test, expect } from '@playwright/test';
+      const specialChars = ['>', '=', '<', '+', '#', '-', '.', '!', '$', '%', '&', '\\'', '*', '/', '?', '^', '_', '\`', '{', '|', '}', '~', '(', ')', '[', ']', '@'];
+      for (const char of specialChars) {
+        test('test' + char, async ({}, testInfo) => {
+          console.log('\\n%%' + testInfo.outputDir);
+        });
+      }
+    `,
+  });
+  expect(result.exitCode).toBe(0);
+  const outputDirs = result.outputLines;
+  expect(outputDirs.length).toBe(27);
+  expect(new Set(outputDirs).size).toBe(outputDirs.length);
+  const forbiddenCharacters = ['\\', '/', ':', '*', '?', '"', '<', '>', '|', '%'];
+  for (const outputDir of outputDirs) {
+    const relativePath = path.relative(path.join(test.info().outputDir, 'test-results'), outputDir);
+    for (const forbiddenCharacter of forbiddenCharacters)
+      expect(relativePath).not.toContain(forbiddenCharacter);
+  }
+});
+
+test('should generate expected output dir names', async ({ runInlineTest }, testInfo) => {
+  const runTests = async (fileName: string, testNames: string[]) => {
+    const result = await runInlineTest({
+      [fileName]: `
+        import { test, expect } from '@playwright/test';
+        ${testNames.map(name => `test('${name}', async ({}, testInfo) => console.log('\\n%%' + testInfo.outputDir));`).join('\n')}
+      `,
+    });
+    expect(result.exitCode).toBe(0);
+    const outputDirs = result.outputLines.map(line => path.relative(path.join(testInfo.outputDir, 'test-results'), line));
+    return outputDirs;
+  };
+  expect(await runTests('filename.spec.js', [
+    'testing > foo',
+    'testing multiple spaces',
+    'testing    multiple   spaces',
+    '!!!hello!!!',
+    'dashes-are-used',
+  ])).toEqual([
+    'filename-testing-foo-574286',
+    'filename-testing-multiple-spaces',
+    'filename-testing-multiple-spaces-f5d359',
+    'filename-hello-8eb257',
+    'filename-dashes-are-used-c67e31',
+  ]);
+});
+
 test('should allow shorten long output dirs characters in the output dir', async ({ runInlineTest }, testInfo) => {
   const result = await runInlineTest({
     'very/deep/and/long/file/name/that/i/want/to/be/trimmed/my-test.spec.js': `
@@ -426,7 +478,7 @@ test('should not mangle double dashes', async ({ runInlineTest }, testInfo) => {
     `,
   });
   const outputDir = result.outputLines[0];
-  expect(outputDir).toBe(path.join(testInfo.outputDir, 'test-results', 'my--file-my--test'));
+  expect(outputDir).toBe(path.join(testInfo.outputDir, 'test-results', 'my--file-my-test-68b7dd'));
 });
 
 test('should allow include the describe name the output dir', async ({ runInlineTest }, testInfo) => {
