diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index d7b4463265..26b487d0ff 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -2263,13 +2263,13 @@ assertThat(page.locator("body")).matchesAriaSnapshot(""" Asserts that the target element matches the given [accessibility snapshot](../aria-snapshots.md). -Snapshot is stored in a separate `.yml` file in a location configured by `expect.toMatchAriaSnapshot.pathTemplate` and/or `snapshotPathTemplate` properties in the configuration file. +Snapshot is stored in a separate `.snapshot.yml` file in a location configured by `expect.toMatchAriaSnapshot.pathTemplate` and/or `snapshotPathTemplate` properties in the configuration file. **Usage** ```js await expect(page.locator('body')).toMatchAriaSnapshot(); -await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.yml' }); +await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.snapshot.yml' }); ``` ### option: LocatorAssertions.toMatchAriaSnapshot#2.name diff --git a/docs/src/aria-snapshots.md b/docs/src/aria-snapshots.md index 25b05164ad..0467cf3878 100644 --- a/docs/src/aria-snapshots.md +++ b/docs/src/aria-snapshots.md @@ -339,10 +339,10 @@ npx playwright test --update-snapshots --update-source-mode=3way #### Snapshots as separate files -To store your snapshots in a separate file, use the `toMatchAriaSnapshot` method with the `name` option, specifying a `.yml` file extension. +To store your snapshots in a separate file, use the `toMatchAriaSnapshot` method with the `name` option, specifying a `.snapshot.yml` file extension. ```js -await expect(page.getByRole('main')).toMatchAriaSnapshot({ name: 'main-snapshot.yml' }); +await expect(page.getByRole('main')).toMatchAriaSnapshot({ name: 'main.snapshot.yml' }); ``` By default, snapshots from a test file `example.spec.ts` are placed in the `example.spec.ts-snapshots` directory. As snapshots should be the same across browsers, only one snapshot is saved even if testing with multiple browsers. Should you wish, you can customize the [snapshot path template](./api/class-testconfig#test-config-snapshot-path-template) using the following configuration: diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index ad1855a88e..0c76a97c68 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -22,7 +22,7 @@ import { escapeTemplateString, isString, sanitizeForFilePath } from 'playwright- import { kNoElementsFoundError, matcherHint } from './matcherHint'; import { EXPECTED_COLOR } from '../common/expectBundle'; -import { callLogText, sanitizeFilePathBeforeExtension, trimLongString } from '../util'; +import { callLogText, fileExistsAsync, sanitizeFilePathBeforeExtension, trimLongString } from '../util'; import { printReceivedStringContainExpectedSubstring } from './expect'; import { currentTestInfo } from '../common/globals'; @@ -70,7 +70,8 @@ export async function toMatchAriaSnapshot( timeout = options.timeout ?? this.timeout; } else { if (expectedParam?.name) { - expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeFilePathBeforeExtension(expectedParam.name)]); + const ext = expectedParam.name!.endsWith('.snapshot.yml') ? '.snapshot.yml' : undefined; + expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeFilePathBeforeExtension(expectedParam.name, ext)]); } else { let snapshotNames = (testInfo as any)[snapshotNamesSymbol] as SnapshotNames; if (!snapshotNames) { @@ -78,7 +79,14 @@ 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)) + '.yml']); + expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeForFilePath(trimLongString(fullTitleWithoutSpec))], '.snapshot.yml'); + // in 1.51, we changed the default template to use .snapshot.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'); + if (await fileExistsAsync(legacyPath)) + expectedPath = legacyPath; + } } expected = await fs.promises.readFile(expectedPath, 'utf8').catch(() => ''); timeout = expectedParam?.timeout ?? this.timeout; diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index 53760970bd..c992313e06 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -206,8 +206,8 @@ export function addSuffixToFilePath(filePath: string, suffix: string): string { return base + suffix + ext; } -export function sanitizeFilePathBeforeExtension(filePath: string): string { - const ext = path.extname(filePath); +export function sanitizeFilePathBeforeExtension(filePath: string, ext?: string): string { + ext ??= path.extname(filePath); const base = filePath.substring(0, filePath.length - ext.length); return sanitizeForFilePath(base) + ext; } @@ -391,6 +391,15 @@ function fileExists(resolved: string) { return fs.statSync(resolved, { throwIfNoEntry: false })?.isFile(); } +export async function fileExistsAsync(resolved: string) { + try { + const stat = await fs.promises.stat(resolved); + return stat.isFile(); + } catch { + return false; + } +} + function dirExists(resolved: string) { return fs.statSync(resolved, { throwIfNoEntry: false })?.isDirectory(); } diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 2a0557d0b8..72bb9fb025 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -452,9 +452,11 @@ export class TestInfoImpl implements TestInfo { return sanitizeForFilePath(trimLongString(fullTitleWithoutSpec)); } - _resolveSnapshotPath(template: string | undefined, defaultTemplate: string, pathSegments: string[]) { + _resolveSnapshotPath(template: string | undefined, defaultTemplate: string, pathSegments: string[], extension?: string) { const subPath = path.join(...pathSegments); - const parsedSubPath = path.parse(subPath); + const dir = path.dirname(subPath); + const ext = extension ?? path.extname(subPath); + const name = path.basename(subPath, ext); const relativeTestFilePath = path.relative(this.project.testDir, this._requireFile); const parsedRelativeTestFilePath = path.parse(relativeTestFilePath); const projectNamePathSegment = sanitizeForFilePath(this.project.name); @@ -470,8 +472,8 @@ export class TestInfoImpl implements TestInfo { .replace(/\{(.)?testName\}/g, '$1' + this._fsSanitizedTestName()) .replace(/\{(.)?testFileName\}/g, '$1' + parsedRelativeTestFilePath.base) .replace(/\{(.)?testFilePath\}/g, '$1' + relativeTestFilePath) - .replace(/\{(.)?arg\}/g, '$1' + path.join(parsedSubPath.dir, parsedSubPath.name)) - .replace(/\{(.)?ext\}/g, parsedSubPath.ext ? '$1' + parsedSubPath.ext : ''); + .replace(/\{(.)?arg\}/g, '$1' + path.join(dir, name)) + .replace(/\{(.)?ext\}/g, ext ? '$1' + ext : ''); return path.normalize(path.resolve(this._configInternal.configDir, snapshotPath)); } diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 76d2b31ffd..8653327f0e 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -8791,14 +8791,14 @@ interface LocatorAssertions { /** * Asserts that the target element matches the given [accessibility snapshot](https://playwright.dev/docs/aria-snapshots). * - * Snapshot is stored in a separate `.yml` file in a location configured by `expect.toMatchAriaSnapshot.pathTemplate` - * and/or `snapshotPathTemplate` properties in the configuration file. + * Snapshot is stored in a separate `.snapshot.yml` file in a location configured by + * `expect.toMatchAriaSnapshot.pathTemplate` and/or `snapshotPathTemplate` properties in the configuration file. * * **Usage** * * ```js * await expect(page.locator('body')).toMatchAriaSnapshot(); - * await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.yml' }); + * await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.snapshot.yml' }); * ``` * * @param options diff --git a/tests/playwright-test/aria-snapshot-file.spec.ts b/tests/playwright-test/aria-snapshot-file.spec.ts index c121d623d1..f02ae3b26b 100644 --- a/tests/playwright-test/aria-snapshot-file.spec.ts +++ b/tests/playwright-test/aria-snapshot-file.spec.ts @@ -22,14 +22,14 @@ test.describe.configure({ mode: 'parallel' }); test('should match snapshot with name', async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({ - 'a.spec.ts-snapshots/test.yml': ` + 'a.spec.ts-snapshots/test.snapshot.yml': ` - heading "hello world" `, 'a.spec.ts': ` import { test, expect } from '@playwright/test'; test('test', async ({ page }) => { await page.setContent(\`

hello world

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.snapshot.yml' }); }); ` }); @@ -43,66 +43,66 @@ test('should generate multiple missing', async ({ runInlineTest }, testInfo) => import { test, expect } from '@playwright/test'; test('test', async ({ page }) => { await page.setContent(\`

hello world

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.snapshot.yml' }); await page.setContent(\`

hello world 2

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-2.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-2.snapshot.yml' }); }); ` }); expect(result.exitCode).toBe(1); - expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-1.yml, writing actual`); - expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-2.yml, writing actual`); - const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-1.yml'), 'utf8'); + expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-1.snapshot.yml, writing actual`); + expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-2.snapshot.yml, writing actual`); + const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-1.snapshot.yml'), 'utf8'); expect(snapshot1).toBe('- heading "hello world" [level=1]'); - const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-2.yml'), 'utf8'); + const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-2.snapshot.yml'), 'utf8'); expect(snapshot2).toBe('- heading "hello world 2" [level=1]'); }); test('should rebaseline all', async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({ - 'a.spec.ts-snapshots/test-1.yml': ` + 'a.spec.ts-snapshots/test-1.snapshot.yml': ` - heading "foo" `, - 'a.spec.ts-snapshots/test-2.yml': ` + 'a.spec.ts-snapshots/test-2.snapshot.yml': ` - heading "bar" `, 'a.spec.ts': ` import { test, expect } from '@playwright/test'; test('test', async ({ page }) => { await page.setContent(\`

hello world

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.snapshot.yml' }); await page.setContent(\`

hello world 2

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-2.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-2.snapshot.yml' }); }); ` }, { 'update-snapshots': 'all' }); expect(result.exitCode).toBe(0); - expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-1.yml`); - expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-2.yml`); - const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-1.yml'), 'utf8'); + expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-1.snapshot.yml`); + expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-2.snapshot.yml`); + const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-1.snapshot.yml'), 'utf8'); expect(snapshot1).toBe('- heading "hello world" [level=1]'); - const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-2.yml'), 'utf8'); + const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-2.snapshot.yml'), 'utf8'); expect(snapshot2).toBe('- heading "hello world 2" [level=1]'); }); test('should not rebaseline matching', async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({ - 'a.spec.ts-snapshots/test.yml': ` + 'a.spec.ts-snapshots/test.snapshot.yml': ` - heading "hello world" `, 'a.spec.ts': ` import { test, expect } from '@playwright/test'; test('test', async ({ page }) => { await page.setContent(\`

hello world

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.snapshot.yml' }); }); ` }, { 'update-snapshots': 'changed' }); expect(result.exitCode).toBe(0); - const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test.yml'), 'utf8'); + const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test.snapshot.yml'), 'utf8'); expect(snapshot1.trim()).toBe('- heading "hello world"'); }); @@ -120,14 +120,32 @@ test('should generate snapshot name', async ({ runInlineTest }, testInfo) => { }); expect(result.exitCode).toBe(1); - expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-name-1.yml, writing actual`); - expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-name-2.yml, writing actual`); - const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-name-1.yml'), 'utf8'); + expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-name-1.snapshot.yml, writing actual`); + expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-name-2.snapshot.yml, writing actual`); + const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-name-1.snapshot.yml'), 'utf8'); expect(snapshot1).toBe('- heading "hello world" [level=1]'); - const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-name-2.yml'), 'utf8'); + const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-name-2.snapshot.yml'), 'utf8'); expect(snapshot2).toBe('- heading "hello world 2" [level=1]'); }); +test('backwards compat with .yml extension', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts-snapshots/test-1.yml': ` + - heading "hello old world" + `, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('test', async ({ page }) => { + await page.setContent(\`

hello new world

\`); + await expect(page.locator('body')).toMatchAriaSnapshot(); + }); + ` + }, { 'update-snapshots': 'changed' }); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-1.yml.`); +}); + for (const updateSnapshots of ['all', 'changed', 'missing', 'none']) { test(`should update snapshot with the update-snapshots=${updateSnapshots} (config)`, async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({ @@ -143,13 +161,13 @@ for (const updateSnapshots of ['all', 'changed', 'missing', 'none']) { await expect(page.locator('body')).toMatchAriaSnapshot({ timeout: 1 }); }); `, - 'a.spec.ts-snapshots/test-1.yml': '- heading "Old content" [level=1]', + 'a.spec.ts-snapshots/test-1.snapshot.yml': '- heading "Old content" [level=1]', }); const rebase = updateSnapshots === 'all' || updateSnapshots === 'changed'; expect(result.exitCode).toBe(rebase ? 0 : 1); if (rebase) { - const snapshotOutputPath = testInfo.outputPath('a.spec.ts-snapshots/test-1.yml'); + const snapshotOutputPath = testInfo.outputPath('a.spec.ts-snapshots/test-1.snapshot.yml'); expect(result.output).toContain(`A snapshot is generated at`); const data = fs.readFileSync(snapshotOutputPath); expect(data.toString()).toBe('- heading "New content" [level=1]'); @@ -169,7 +187,7 @@ test('should respect timeout', async ({ runInlineTest }, testInfo) => { await expect(page.locator('body')).toMatchAriaSnapshot({ timeout: 1 }); }); `, - 'a.spec.ts-snapshots/test-1.yml': '- heading "new world" [level=1]', + 'a.spec.ts-snapshots/test-1.snapshot.yml': '- heading "new world" [level=1]', }); expect(result.exitCode).toBe(1); @@ -183,14 +201,14 @@ test('should respect config.snapshotPathTemplate', async ({ runInlineTest }, tes snapshotPathTemplate: 'my-snapshots/{testFilePath}/{arg}{ext}', }; `, - 'my-snapshots/dir/a.spec.ts/test.yml': ` + 'my-snapshots/dir/a.spec.ts/test.snapshot.yml': ` - heading "hello world" `, 'dir/a.spec.ts': ` import { test, expect } from '@playwright/test'; test('test', async ({ page }) => { await page.setContent(\`

hello world

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.snapshot.yml' }); }); ` }); @@ -210,17 +228,17 @@ test('should respect config.expect.toMatchAriaSnapshot.pathTemplate', async ({ r }, }; `, - 'my-snapshots/dir/a.spec.ts/test.yml': ` + 'my-snapshots/dir/a.spec.ts/test.snapshot.yml': ` - heading "wrong one" `, - 'actual-snapshots/dir/a.spec.ts/test.yml': ` + 'actual-snapshots/dir/a.spec.ts/test.snapshot.yml': ` - heading "hello world" `, 'dir/a.spec.ts': ` import { test, expect } from '@playwright/test'; test('test', async ({ page }) => { await page.setContent(\`

hello world

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.snapshot.yml' }); }); ` });