From ba650161a81235d26786a649a160ed70352cc87d Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 29 Jan 2025 18:47:20 +0000 Subject: [PATCH] feat: per-assertion snapshot path template in config (#34537) --- docs/src/api/class-locatorassertions.md | 22 +-- docs/src/api/params.md | 28 +++- docs/src/release-notes-js.md | 3 +- docs/src/test-api/class-testconfig.md | 3 + docs/src/test-api/class-testproject.md | 3 + docs/src/test-snapshots-js.md | 2 +- packages/playwright/src/common/config.ts | 5 +- .../src/matchers/toMatchAriaSnapshot.ts | 6 +- .../src/matchers/toMatchSnapshot.ts | 3 +- packages/playwright/src/worker/testInfo.ts | 10 +- packages/playwright/types/test.d.ts | 119 ++++++++++++++--- .../aria-snapshot-file.spec.ts | 126 ++++++++++-------- .../to-have-screenshot.spec.ts | 19 +++ 13 files changed, 239 insertions(+), 110 deletions(-) diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index 8f8233fae4..005596e56c 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -2245,27 +2245,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. + **Usage** ```js await expect(page.locator('body')).toMatchAriaSnapshot(); -await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'snapshot' }); -``` - -```python async -await expect(page.locator('body')).to_match_aria_snapshot(path='/path/to/snapshot.yml') -``` - -```python sync -expect(page.locator('body')).to_match_aria_snapshot(path='/path/to/snapshot.yml') -``` - -```csharp -await Expect(page.Locator("body")).ToMatchAriaSnapshotAsync(new { Path = "/path/to/snapshot.yml" }); -``` - -```java -assertThat(page.locator("body")).matchesAriaSnapshot(new LocatorAssertions.MatchesAriaSnapshotOptions().setPath("/path/to/snapshot.yml")); +await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.yml' }); ``` ### option: LocatorAssertions.toMatchAriaSnapshot#2.name @@ -2273,7 +2259,7 @@ assertThat(page.locator("body")).matchesAriaSnapshot(new LocatorAssertions.Match * langs: js - `name` <[string]> -Name of the snapshot to store in the snapshot (screenshot) folder corresponding to this test. +Name of the snapshot to store in the snapshot folder corresponding to this test. Generates sequential names if not specified. ### option: LocatorAssertions.toMatchAriaSnapshot#2.timeout = %%-js-assertions-timeout-%% diff --git a/docs/src/api/params.md b/docs/src/api/params.md index a1f909a4fb..d161f91066 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -1758,7 +1758,9 @@ await Expect(Page.GetByTitle("Issues count")).toHaveText("25 issues"); - `type` ?<[string]> * langs: js -This option configures a template controlling location of snapshots generated by [`method: PageAssertions.toHaveScreenshot#1`] and [`method: SnapshotAssertions.toMatchSnapshot#1`]. +This option configures a template controlling location of snapshots generated by [`method: PageAssertions.toHaveScreenshot#1`], [`method: LocatorAssertions.toMatchAriaSnapshot#2`] and [`method: SnapshotAssertions.toMatchSnapshot#1`]. + +You can configure templates for each assertion separately in [`property: TestConfig.expect`]. **Usage** @@ -1767,7 +1769,19 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests', + + // Single template for all assertions snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}', + + // Assertion-specific templates + expect: { + toHaveScreenshot: { + pathTemplate: '{testDir}/__screenshots__{/projectName}/{testFilePath}/{arg}{ext}', + }, + toMatchAriaSnapshot: { + pathTemplate: '{testDir}/__snapshots__/{testFilePath}/{arg}{ext}', + }, + }, }); ``` @@ -1798,22 +1812,22 @@ test.describe('suite', () => { The list of supported tokens: -* `{arg}` - Relative snapshot path **without extension**. These come from the arguments passed to the `toHaveScreenshot()` and `toMatchSnapshot()` calls; if called without arguments, this will be an auto-generated snapshot name. +* `{arg}` - Relative snapshot path **without extension**. This comes from the arguments passed to `toHaveScreenshot()`, `toMatchAriaSnapshot()` or `toMatchSnapshot()`; if called without arguments, this will be an auto-generated snapshot name. * Value: `foo/bar/baz` -* `{ext}` - snapshot extension (with dots) +* `{ext}` - Snapshot extension (with the leading dot). * Value: `.png` * `{platform}` - The value of `process.platform`. * `{projectName}` - Project's file-system-sanitized name, if any. * Value: `''` (empty string). -* `{snapshotDir}` - Project's [`property: TestConfig.snapshotDir`]. +* `{snapshotDir}` - Project's [`property: TestProject.snapshotDir`]. * Value: `/home/playwright/tests` (since `snapshotDir` is not provided in config, it defaults to `testDir`) -* `{testDir}` - Project's [`property: TestConfig.testDir`]. - * Value: `/home/playwright/tests` (absolute path is since `testDir` is resolved relative to directory with config) +* `{testDir}` - Project's [`property: TestProject.testDir`]. + * Value: `/home/playwright/tests` (absolute path since `testDir` is resolved relative to directory with config) * `{testFileDir}` - Directories in relative path from `testDir` to **test file**. * Value: `page` * `{testFileName}` - Test file name with extension. * Value: `page-click.spec.ts` -* `{testFilePath}` - Relative path from `testDir` to **test file** +* `{testFilePath}` - Relative path from `testDir` to **test file**. * Value: `page/page-click.spec.ts` * `{testName}` - File-system-sanitized test title, including parent describes but excluding file name. * Value: `suite-test-should-work` diff --git a/docs/src/release-notes-js.md b/docs/src/release-notes-js.md index d3760a7112..04e08149df 100644 --- a/docs/src/release-notes-js.md +++ b/docs/src/release-notes-js.md @@ -21,7 +21,7 @@ import LiteYouTube from '@site/src/components/LiteYouTube'; ``` * New method [`method: Test.step.skip`] to disable execution of a test step. - + ```js test('some test', async ({ page }) => { await test.step('before running step', async () => { @@ -49,6 +49,7 @@ import LiteYouTube from '@site/src/components/LiteYouTube'; * Option [`property: TestConfig.webServer`] added a `gracefulShutdown` field for specifying a process kill signal other than the default `SIGKILL`. * Exposed [`property: TestStep.attachments`] from the reporter API to allow retrieval of all attachments created by that step. +* New option `pathTemplate` for `toHaveScreenshot` and `toMatchAriaSnapshot` assertions in the [`property: TestConfig.expect`] configuration. ### UI updates diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index 2b19a176ac..c57789a82b 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -48,6 +48,9 @@ export default defineConfig({ - `scale` ?<[ScreenshotScale]<"css"|"device">> See [`option: Page.screenshot.scale`] in [`method: Page.screenshot`]. Defaults to `"css"`. - `stylePath` ?<[string]|[Array]<[string]>> See [`option: Page.screenshot.style`] in [`method: Page.screenshot`]. - `threshold` ?<[float]> An acceptable perceived color difference between the same pixel in compared images, ranging from `0` (strict) and `1` (lax). `"pixelmatch"` comparator computes color difference in [YIQ color space](https://en.wikipedia.org/wiki/YIQ) and defaults `threshold` value to `0.2`. + - `pathTemplate` ?<[string]> A template controlling location of the screenshots. See [`property: TestConfig.snapshotPathTemplate`] for details. + - `toMatchAriaSnapshot` ?<[Object]> Configuration for the [`method: LocatorAssertions.toMatchAriaSnapshot#2`] method. + - `pathTemplate` ?<[string]> A template controlling location of the aria snapshots. See [`property: TestConfig.snapshotPathTemplate`] for details. - `toMatchSnapshot` ?<[Object]> Configuration for the [`method: SnapshotAssertions.toMatchSnapshot#1`] method. - `maxDiffPixels` ?<[int]> An acceptable amount of pixels that could be different, unset by default. - `maxDiffPixelRatio` ?<[float]> An acceptable ratio of pixels that are different to the total amount of pixels, between `0` and `1` , unset by default. diff --git a/docs/src/test-api/class-testproject.md b/docs/src/test-api/class-testproject.md index d93286fa26..d9814708ef 100644 --- a/docs/src/test-api/class-testproject.md +++ b/docs/src/test-api/class-testproject.md @@ -98,6 +98,9 @@ export default defineConfig({ - `caret` ?<[ScreenshotCaret]<"hide"|"initial">> See [`option: Page.screenshot.caret`] in [`method: Page.screenshot`]. Defaults to `"hide"`. - `scale` ?<[ScreenshotScale]<"css"|"device">> See [`option: Page.screenshot.scale`] in [`method: Page.screenshot`]. Defaults to `"css"`. - `stylePath` ?<[string]|[Array]<[string]>> See [`option: Page.screenshot.style`] in [`method: Page.screenshot`]. + - `pathTemplate` ?<[string]> A template controlling location of the screenshots. See [`property: TestProject.snapshotPathTemplate`] for details. + - `toMatchAriaSnapshot` ?<[Object]> Configuration for the [`method: LocatorAssertions.toMatchAriaSnapshot#2`] method. + - `pathTemplate` ?<[string]> A template controlling location of the aria snapshots. See [`property: TestProject.snapshotPathTemplate`] for details. - `toMatchSnapshot` ?<[Object]> Configuration for the [`method: SnapshotAssertions.toMatchSnapshot#1`] method. - `threshold` ?<[float]> an acceptable perceived color difference between the same pixel in compared images, ranging from `0` (strict) and `1` (lax). `"pixelmatch"` comparator computes color difference in [YIQ color space](https://en.wikipedia.org/wiki/YIQ) and defaults `threshold` value to `0.2`. - `maxDiffPixels` ?<[int]> an acceptable amount of pixels that could be different, unset by default. diff --git a/docs/src/test-snapshots-js.md b/docs/src/test-snapshots-js.md index a1ece1a6e5..6a8d42e886 100644 --- a/docs/src/test-snapshots-js.md +++ b/docs/src/test-snapshots-js.md @@ -48,7 +48,7 @@ The snapshot name `example-test-1-chromium-darwin.png` consists of a few parts: - `chromium-darwin` - the browser name and the platform. Screenshots differ between browsers and platforms due to different rendering, fonts and more, so you will need different snapshots for them. If you use multiple projects in your [configuration file](./test-configuration.md), project name will be used instead of `chromium`. -The snapshot name and path can be configured with [`snapshotPathTemplate`](./api/class-testproject#test-project-snapshot-path-template) in the playwright config. +The snapshot name and path can be configured with [`property: TestConfig.snapshotPathTemplate`] in the playwright config. ## Updating screenshots diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index c2dc2d6edf..b55c3d6527 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -170,7 +170,7 @@ export class FullProjectInternal { readonly fullyParallel: boolean; readonly expect: Project['expect']; readonly respectGitIgnore: boolean; - readonly snapshotPathTemplate: string; + readonly snapshotPathTemplate: string | undefined; readonly ignoreSnapshots: boolean; id = ''; deps: FullProjectInternal[] = []; @@ -179,8 +179,7 @@ export class FullProjectInternal { constructor(configDir: string, config: Config, fullConfig: FullConfigInternal, projectConfig: Project, configCLIOverrides: ConfigCLIOverrides, packageJsonDir: string) { this.fullConfig = fullConfig; const testDir = takeFirst(pathResolve(configDir, projectConfig.testDir), pathResolve(configDir, config.testDir), fullConfig.configDir); - const defaultSnapshotPathTemplate = '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}'; - this.snapshotPathTemplate = takeFirst(projectConfig.snapshotPathTemplate, config.snapshotPathTemplate, defaultSnapshotPathTemplate); + this.snapshotPathTemplate = takeFirst(projectConfig.snapshotPathTemplate, config.snapshotPathTemplate); this.project = { grep: takeFirst(projectConfig.grep, config.grep, defaultGrep), diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index e4944fbfc1..d5dbb5a666 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -49,6 +49,8 @@ export async function toMatchAriaSnapshot( return { pass: !this.isNot, message: () => '', name: 'toMatchAriaSnapshot', expected: '' }; const updateSnapshots = testInfo.config.updateSnapshots; + const pathTemplate = testInfo._projectInternal.expect?.toMatchAriaSnapshot?.pathTemplate; + const defaultTemplate = '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}'; const matcherOptions = { isNot: this.isNot, @@ -63,7 +65,7 @@ export async function toMatchAriaSnapshot( timeout = options.timeout ?? this.timeout; } else { if (expectedParam?.name) { - expectedPath = testInfo.snapshotPath(sanitizeFilePathBeforeExtension(expectedParam.name)); + expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeFilePathBeforeExtension(expectedParam.name)]); } else { let snapshotNames = (testInfo as any)[snapshotNamesSymbol] as SnapshotNames; if (!snapshotNames) { @@ -71,7 +73,7 @@ export async function toMatchAriaSnapshot( (testInfo as any)[snapshotNamesSymbol] = snapshotNames; } const fullTitleWithoutSpec = [...testInfo.titlePath.slice(1), ++snapshotNames.anonymousSnapshotIndex].join(' '); - expectedPath = testInfo.snapshotPath(sanitizeForFilePath(trimLongString(fullTitleWithoutSpec)) + '.yml'); + expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeForFilePath(trimLongString(fullTitleWithoutSpec)) + '.yml']); } expected = await fs.promises.readFile(expectedPath, 'utf8').catch(() => ''); timeout = expectedParam?.timeout ?? this.timeout; diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts index 8d16f11fc2..193b57058f 100644 --- a/packages/playwright/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchSnapshot.ts @@ -148,7 +148,8 @@ class SnapshotHelper { outputBasePath = testInfo._getOutputPath(sanitizedName); this.attachmentBaseName = sanitizedName; } - this.expectedPath = testInfo.snapshotPath(...expectedPathSegments); + const defaultTemplate = '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}'; + this.expectedPath = testInfo._resolveSnapshotPath(configOptions.pathTemplate, defaultTemplate, expectedPathSegments); 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 6e6ab4660c..3efd3b3750 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -454,14 +454,15 @@ export class TestInfoImpl implements TestInfo { return sanitizeForFilePath(trimLongString(fullTitleWithoutSpec)); } - snapshotPath(...pathSegments: string[]) { + _resolveSnapshotPath(template: string | undefined, defaultTemplate: string, pathSegments: string[]) { const subPath = path.join(...pathSegments); const parsedSubPath = path.parse(subPath); const relativeTestFilePath = path.relative(this.project.testDir, this._requireFile); const parsedRelativeTestFilePath = path.parse(relativeTestFilePath); const projectNamePathSegment = sanitizeForFilePath(this.project.name); - const snapshotPath = (this._projectInternal.snapshotPathTemplate || '') + const actualTemplate = (template || this._projectInternal.snapshotPathTemplate || defaultTemplate); + const snapshotPath = actualTemplate .replace(/\{(.)?testDir\}/g, '$1' + this.project.testDir) .replace(/\{(.)?snapshotDir\}/g, '$1' + this.project.snapshotDir) .replace(/\{(.)?snapshotSuffix\}/g, this.snapshotSuffix ? '$1' + this.snapshotSuffix : '') @@ -477,6 +478,11 @@ export class TestInfoImpl implements TestInfo { return path.normalize(path.resolve(this._configInternal.configDir, snapshotPath)); } + snapshotPath(...pathSegments: string[]) { + const legacyTemplate = '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}'; + return this._resolveSnapshotPath(undefined, legacyTemplate, pathSegments); + } + skip(...args: [arg?: any, description?: string]) { this._modifier('skip', args); } diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index c4433e58e2..b1d38068e8 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -214,6 +214,27 @@ interface TestProject { * [page.screenshot([options])](https://playwright.dev/docs/api/class-page#page-screenshot). */ stylePath?: string|Array; + + /** + * A template controlling location of the screenshots. See + * [testProject.snapshotPathTemplate](https://playwright.dev/docs/api/class-testproject#test-project-snapshot-path-template) + * for details. + */ + pathTemplate?: string; + }; + + /** + * Configuration for the + * [expect(locator).toMatchAriaSnapshot([options])](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-match-aria-snapshot-2) + * method. + */ + toMatchAriaSnapshot?: { + /** + * A template controlling location of the aria snapshots. See + * [testProject.snapshotPathTemplate](https://playwright.dev/docs/api/class-testproject#test-project-snapshot-path-template) + * for details. + */ + pathTemplate?: string; }; /** @@ -404,10 +425,14 @@ interface TestProject { /** * This option configures a template controlling location of snapshots generated by - * [expect(page).toHaveScreenshot(name[, options])](https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-screenshot-1) + * [expect(page).toHaveScreenshot(name[, options])](https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-screenshot-1), + * [expect(locator).toMatchAriaSnapshot([options])](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-match-aria-snapshot-2) * and * [expect(value).toMatchSnapshot(name[, options])](https://playwright.dev/docs/api/class-snapshotassertions#snapshot-assertions-to-match-snapshot-1). * + * You can configure templates for each assertion separately in + * [testConfig.expect](https://playwright.dev/docs/api/class-testconfig#test-config-expect). + * * **Usage** * * ```js @@ -416,7 +441,19 @@ interface TestProject { * * export default defineConfig({ * testDir: './tests', + * + * // Single template for all assertions * snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}', + * + * // Assertion-specific templates + * expect: { + * toHaveScreenshot: { + * pathTemplate: '{testDir}/__screenshots__{/projectName}/{testFilePath}/{arg}{ext}', + * }, + * toMatchAriaSnapshot: { + * pathTemplate: '{testDir}/__snapshots__/{testFilePath}/{arg}{ext}', + * }, + * }, * }); * ``` * @@ -447,27 +484,27 @@ interface TestProject { * ``` * * The list of supported tokens: - * - `{arg}` - Relative snapshot path **without extension**. These come from the arguments passed to the - * `toHaveScreenshot()` and `toMatchSnapshot()` calls; if called without arguments, this will be an auto-generated - * snapshot name. + * - `{arg}` - Relative snapshot path **without extension**. This comes from the arguments passed to + * `toHaveScreenshot()`, `toMatchAriaSnapshot()` or `toMatchSnapshot()`; if called without arguments, this will be + * an auto-generated snapshot name. * - Value: `foo/bar/baz` - * - `{ext}` - snapshot extension (with dots) + * - `{ext}` - Snapshot extension (with the leading dot). * - Value: `.png` * - `{platform}` - The value of `process.platform`. * - `{projectName}` - Project's file-system-sanitized name, if any. * - Value: `''` (empty string). * - `{snapshotDir}` - Project's - * [testConfig.snapshotDir](https://playwright.dev/docs/api/class-testconfig#test-config-snapshot-dir). + * [testProject.snapshotDir](https://playwright.dev/docs/api/class-testproject#test-project-snapshot-dir). * - Value: `/home/playwright/tests` (since `snapshotDir` is not provided in config, it defaults to `testDir`) * - `{testDir}` - Project's - * [testConfig.testDir](https://playwright.dev/docs/api/class-testconfig#test-config-test-dir). - * - Value: `/home/playwright/tests` (absolute path is since `testDir` is resolved relative to directory with + * [testProject.testDir](https://playwright.dev/docs/api/class-testproject#test-project-test-dir). + * - Value: `/home/playwright/tests` (absolute path since `testDir` is resolved relative to directory with * config) * - `{testFileDir}` - Directories in relative path from `testDir` to **test file**. * - Value: `page` * - `{testFileName}` - Test file name with extension. * - Value: `page-click.spec.ts` - * - `{testFilePath}` - Relative path from `testDir` to **test file** + * - `{testFilePath}` - Relative path from `testDir` to **test file**. * - Value: `page/page-click.spec.ts` * - `{testName}` - File-system-sanitized test title, including parent describes but excluding file name. * - Value: `suite-test-should-work` @@ -991,6 +1028,27 @@ interface TestConfig { * [YIQ color space](https://en.wikipedia.org/wiki/YIQ) and defaults `threshold` value to `0.2`. */ threshold?: number; + + /** + * A template controlling location of the screenshots. See + * [testConfig.snapshotPathTemplate](https://playwright.dev/docs/api/class-testconfig#test-config-snapshot-path-template) + * for details. + */ + pathTemplate?: string; + }; + + /** + * Configuration for the + * [expect(locator).toMatchAriaSnapshot([options])](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-match-aria-snapshot-2) + * method. + */ + toMatchAriaSnapshot?: { + /** + * A template controlling location of the aria snapshots. See + * [testConfig.snapshotPathTemplate](https://playwright.dev/docs/api/class-testconfig#test-config-snapshot-path-template) + * for details. + */ + pathTemplate?: string; }; /** @@ -1494,10 +1552,14 @@ interface TestConfig { /** * This option configures a template controlling location of snapshots generated by - * [expect(page).toHaveScreenshot(name[, options])](https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-screenshot-1) + * [expect(page).toHaveScreenshot(name[, options])](https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-screenshot-1), + * [expect(locator).toMatchAriaSnapshot([options])](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-match-aria-snapshot-2) * and * [expect(value).toMatchSnapshot(name[, options])](https://playwright.dev/docs/api/class-snapshotassertions#snapshot-assertions-to-match-snapshot-1). * + * You can configure templates for each assertion separately in + * [testConfig.expect](https://playwright.dev/docs/api/class-testconfig#test-config-expect). + * * **Usage** * * ```js @@ -1506,7 +1568,19 @@ interface TestConfig { * * export default defineConfig({ * testDir: './tests', + * + * // Single template for all assertions * snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}', + * + * // Assertion-specific templates + * expect: { + * toHaveScreenshot: { + * pathTemplate: '{testDir}/__screenshots__{/projectName}/{testFilePath}/{arg}{ext}', + * }, + * toMatchAriaSnapshot: { + * pathTemplate: '{testDir}/__snapshots__/{testFilePath}/{arg}{ext}', + * }, + * }, * }); * ``` * @@ -1537,27 +1611,27 @@ interface TestConfig { * ``` * * The list of supported tokens: - * - `{arg}` - Relative snapshot path **without extension**. These come from the arguments passed to the - * `toHaveScreenshot()` and `toMatchSnapshot()` calls; if called without arguments, this will be an auto-generated - * snapshot name. + * - `{arg}` - Relative snapshot path **without extension**. This comes from the arguments passed to + * `toHaveScreenshot()`, `toMatchAriaSnapshot()` or `toMatchSnapshot()`; if called without arguments, this will be + * an auto-generated snapshot name. * - Value: `foo/bar/baz` - * - `{ext}` - snapshot extension (with dots) + * - `{ext}` - Snapshot extension (with the leading dot). * - Value: `.png` * - `{platform}` - The value of `process.platform`. * - `{projectName}` - Project's file-system-sanitized name, if any. * - Value: `''` (empty string). * - `{snapshotDir}` - Project's - * [testConfig.snapshotDir](https://playwright.dev/docs/api/class-testconfig#test-config-snapshot-dir). + * [testProject.snapshotDir](https://playwright.dev/docs/api/class-testproject#test-project-snapshot-dir). * - Value: `/home/playwright/tests` (since `snapshotDir` is not provided in config, it defaults to `testDir`) * - `{testDir}` - Project's - * [testConfig.testDir](https://playwright.dev/docs/api/class-testconfig#test-config-test-dir). - * - Value: `/home/playwright/tests` (absolute path is since `testDir` is resolved relative to directory with + * [testProject.testDir](https://playwright.dev/docs/api/class-testproject#test-project-test-dir). + * - Value: `/home/playwright/tests` (absolute path since `testDir` is resolved relative to directory with * config) * - `{testFileDir}` - Directories in relative path from `testDir` to **test file**. * - Value: `page` * - `{testFileName}` - Test file name with extension. * - Value: `page-click.spec.ts` - * - `{testFilePath}` - Relative path from `testDir` to **test file** + * - `{testFilePath}` - Relative path from `testDir` to **test file**. * - Value: `page/page-click.spec.ts` * - `{testName}` - File-system-sanitized test title, including parent describes but excluding file name. * - Value: `suite-test-should-work` @@ -8711,19 +8785,22 @@ 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. + * * **Usage** * * ```js * await expect(page.locator('body')).toMatchAriaSnapshot(); - * await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'snapshot' }); + * await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.yml' }); * ``` * * @param options */ toMatchAriaSnapshot(options?: { /** - * Name of the snapshot to store in the snapshot (screenshot) folder corresponding to this test. Generates sequential - * names if not specified. + * Name of the snapshot to store in the snapshot folder corresponding to this test. Generates sequential names if not + * specified. */ name?: string; diff --git a/tests/playwright-test/aria-snapshot-file.spec.ts b/tests/playwright-test/aria-snapshot-file.spec.ts index f9c2563f37..c121d623d1 100644 --- a/tests/playwright-test/aria-snapshot-file.spec.ts +++ b/tests/playwright-test/aria-snapshot-file.spec.ts @@ -22,12 +22,7 @@ test.describe.configure({ mode: 'parallel' }); test('should match snapshot with name', async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({ - 'playwright.config.ts': ` - export default { - snapshotPathTemplate: '__snapshots__/{testFilePath}/{arg}{ext}', - }; - `, - '__snapshots__/a.spec.ts/test.yml': ` + 'a.spec.ts-snapshots/test.yml': ` - heading "hello world" `, 'a.spec.ts': ` @@ -44,11 +39,6 @@ test('should match snapshot with name', async ({ runInlineTest }, testInfo) => { test('should generate multiple missing', async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({ - 'playwright.config.ts': ` - export default { - snapshotPathTemplate: '__snapshots__/{testFilePath}/{arg}{ext}', - }; - `, 'a.spec.ts': ` import { test, expect } from '@playwright/test'; test('test', async ({ page }) => { @@ -61,25 +51,20 @@ test('should generate multiple missing', async ({ runInlineTest }, testInfo) => }); expect(result.exitCode).toBe(1); - expect(result.output).toContain(`A snapshot doesn't exist at __snapshots__${path.sep}a.spec.ts${path.sep}test-1.yml, writing actual`); - expect(result.output).toContain(`A snapshot doesn't exist at __snapshots__${path.sep}a.spec.ts${path.sep}test-2.yml, writing actual`); - const snapshot1 = await fs.promises.readFile(testInfo.outputPath('__snapshots__/a.spec.ts/test-1.yml'), 'utf8'); + 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(snapshot1).toBe('- heading "hello world" [level=1]'); - const snapshot2 = await fs.promises.readFile(testInfo.outputPath('__snapshots__/a.spec.ts/test-2.yml'), 'utf8'); + const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-2.yml'), 'utf8'); expect(snapshot2).toBe('- heading "hello world 2" [level=1]'); }); test('should rebaseline all', async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({ - 'playwright.config.ts': ` - export default { - snapshotPathTemplate: '__snapshots__/{testFilePath}/{arg}{ext}', - }; - `, - '__snapshots__/a.spec.ts/test-1.yml': ` + 'a.spec.ts-snapshots/test-1.yml': ` - heading "foo" `, - '__snapshots__/a.spec.ts/test-2.yml': ` + 'a.spec.ts-snapshots/test-2.yml': ` - heading "bar" `, 'a.spec.ts': ` @@ -94,22 +79,17 @@ test('should rebaseline all', async ({ runInlineTest }, testInfo) => { }, { 'update-snapshots': 'all' }); expect(result.exitCode).toBe(0); - expect(result.output).toContain(`A snapshot is generated at __snapshots__${path.sep}a.spec.ts${path.sep}test-1.yml`); - expect(result.output).toContain(`A snapshot is generated at __snapshots__${path.sep}a.spec.ts${path.sep}test-2.yml`); - const snapshot1 = await fs.promises.readFile(testInfo.outputPath('__snapshots__/a.spec.ts/test-1.yml'), 'utf8'); + 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(snapshot1).toBe('- heading "hello world" [level=1]'); - const snapshot2 = await fs.promises.readFile(testInfo.outputPath('__snapshots__/a.spec.ts/test-2.yml'), 'utf8'); + const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-2.yml'), 'utf8'); expect(snapshot2).toBe('- heading "hello world 2" [level=1]'); }); test('should not rebaseline matching', async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({ - 'playwright.config.ts': ` - export default { - snapshotPathTemplate: '__snapshots__/{testFilePath}/{arg}{ext}', - }; - `, - '__snapshots__/a.spec.ts/test.yml': ` + 'a.spec.ts-snapshots/test.yml': ` - heading "hello world" `, 'a.spec.ts': ` @@ -122,17 +102,12 @@ test('should not rebaseline matching', async ({ runInlineTest }, testInfo) => { }, { 'update-snapshots': 'changed' }); expect(result.exitCode).toBe(0); - const snapshot1 = await fs.promises.readFile(testInfo.outputPath('__snapshots__/a.spec.ts/test.yml'), 'utf8'); + const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test.yml'), 'utf8'); expect(snapshot1.trim()).toBe('- heading "hello world"'); }); test('should generate snapshot name', async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({ - 'playwright.config.ts': ` - export default { - snapshotPathTemplate: '__snapshots__/{testFilePath}/{arg}{ext}', - }; - `, 'a.spec.ts': ` import { test, expect } from '@playwright/test'; test('test name', async ({ page }) => { @@ -145,11 +120,11 @@ test('should generate snapshot name', async ({ runInlineTest }, testInfo) => { }); expect(result.exitCode).toBe(1); - expect(result.output).toContain(`A snapshot doesn't exist at __snapshots__${path.sep}a.spec.ts${path.sep}test-name-1.yml, writing actual`); - expect(result.output).toContain(`A snapshot doesn't exist at __snapshots__${path.sep}a.spec.ts${path.sep}test-name-2.yml, writing actual`); - const snapshot1 = await fs.promises.readFile(testInfo.outputPath('__snapshots__/a.spec.ts/test-name-1.yml'), 'utf8'); + 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(snapshot1).toBe('- heading "hello world" [level=1]'); - const snapshot2 = await fs.promises.readFile(testInfo.outputPath('__snapshots__/a.spec.ts/test-name-2.yml'), 'utf8'); + const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-name-2.yml'), 'utf8'); expect(snapshot2).toBe('- heading "hello world 2" [level=1]'); }); @@ -158,7 +133,6 @@ for (const updateSnapshots of ['all', 'changed', 'missing', 'none']) { const result = await runInlineTest({ 'playwright.config.ts': ` export default { - snapshotPathTemplate: '__snapshots__/{testFilePath}/{arg}{ext}', updateSnapshots: '${updateSnapshots}', }; `, @@ -169,13 +143,13 @@ for (const updateSnapshots of ['all', 'changed', 'missing', 'none']) { await expect(page.locator('body')).toMatchAriaSnapshot({ timeout: 1 }); }); `, - '__snapshots__/a.spec.ts/test-1.yml': '- heading "Old content" [level=1]', + 'a.spec.ts-snapshots/test-1.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('__snapshots__/a.spec.ts/test-1.yml'); + const snapshotOutputPath = testInfo.outputPath('a.spec.ts-snapshots/test-1.yml'); expect(result.output).toContain(`A snapshot is generated at`); const data = fs.readFileSync(snapshotOutputPath); expect(data.toString()).toBe('- heading "New content" [level=1]'); @@ -187,14 +161,6 @@ for (const updateSnapshots of ['all', 'changed', 'missing', 'none']) { test('should respect timeout', async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({ - 'playwright.config.ts': ` - export default { - snapshotPathTemplate: '__snapshots__/{testFilePath}/{arg}{ext}', - }; - `, - 'test.yml': ` - - heading "hello world" - `, 'a.spec.ts': ` import { test, expect } from '@playwright/test'; import path from 'path'; @@ -203,9 +169,61 @@ test('should respect timeout', async ({ runInlineTest }, testInfo) => { await expect(page.locator('body')).toMatchAriaSnapshot({ timeout: 1 }); }); `, - '__snapshots__/a.spec.ts/test-1.yml': '- heading "new world" [level=1]', + 'a.spec.ts-snapshots/test-1.yml': '- heading "new world" [level=1]', }); expect(result.exitCode).toBe(1); expect(result.output).toContain(`Timed out 1ms waiting for`); }); + +test('should respect config.snapshotPathTemplate', async ({ runInlineTest }, testInfo) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + snapshotPathTemplate: 'my-snapshots/{testFilePath}/{arg}{ext}', + }; + `, + 'my-snapshots/dir/a.spec.ts/test.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' }); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should respect config.expect.toMatchAriaSnapshot.pathTemplate', async ({ runInlineTest }, testInfo) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + snapshotPathTemplate: 'my-snapshots/{testFilePath}/{arg}{ext}', + expect: { + toMatchAriaSnapshot: { + pathTemplate: 'actual-snapshots/{testFilePath}/{arg}{ext}', + }, + }, + }; + `, + 'my-snapshots/dir/a.spec.ts/test.yml': ` + - heading "wrong one" + `, + 'actual-snapshots/dir/a.spec.ts/test.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' }); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); diff --git a/tests/playwright-test/to-have-screenshot.spec.ts b/tests/playwright-test/to-have-screenshot.spec.ts index 3afa7a8d90..65e348cc52 100644 --- a/tests/playwright-test/to-have-screenshot.spec.ts +++ b/tests/playwright-test/to-have-screenshot.spec.ts @@ -740,6 +740,25 @@ test('should update snapshot with the update-snapshots flag', async ({ runInline expect(comparePNGs(fs.readFileSync(snapshotOutputPath), whiteImage)).toBe(null); }); +test('should respect config.expect.toHaveScreenshot.pathTemplate', async ({ runInlineTest }, testInfo) => { + const result = await runInlineTest({ + ...playwrightConfig({ + snapshotPathTemplate: '__screenshots__/{testFilePath}/{arg}{ext}', + expect: { toHaveScreenshot: { pathTemplate: 'actual-screenshots/{testFilePath}/{arg}{ext}' } }, + }), + '__screenshots__/a.spec.js/snapshot.png': blueImage, + 'actual-screenshots/a.spec.js/snapshot.png': whiteImage, + 'a.spec.js': ` + const { test, expect } = require('@playwright/test'); + test('is a test', async ({ page }) => { + await expect(page).toHaveScreenshot('snapshot.png'); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + test('shouldn\'t update snapshot with the update-snapshots flag for negated matcher', async ({ runInlineTest }, testInfo) => { const EXPECTED_SNAPSHOT = blueImage; const result = await runInlineTest({