diff --git a/docs/src/test-advanced-js.md b/docs/src/test-advanced-js.md index cef14e8525..ca61db62bc 100644 --- a/docs/src/test-advanced-js.md +++ b/docs/src/test-advanced-js.md @@ -15,6 +15,7 @@ These options define your test suite: - `metadata: any` - Any JSON-serializable metadata that will be put directly to the test report. - `name: string` - Project name, useful when defining multiple [test projects](#projects). - `outputDir: string` - Output directory for files created during the test run. +- `snapshotDir: string` - Base output directory for snapshot files. - `repeatEach: number` - The number of times to repeat each test, useful for debugging flaky tests. - `retries: number` - The maximum number of retry attempts given to failed tests. If not specified, failing tests are not retried. - `testDir: string` - Directory that will be recursively scanned for test files. diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index 1349cc0eb3..a12ebdd306 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -286,6 +286,15 @@ test('example test', async ({}, testInfo) => { }); ``` +## property: TestConfig.snapshotDir +- type: <[string]> + +The base directory, relative to the config file, for snapshot files created with `toMatchSnapshot`. Defaults to [`property: TestConfig.testDir`]. + +The directory for each test can be accessed by [`property: TestInfo.snapshotDir`] and [`method: TestInfo.snapshotPath`]. + +This path will serve as the base directory for each test file snapshot directory. Setting `snapshotDir` to `'snapshots'`, the [`property: TestInfo.snapshotDir`] would resolve to `snapshots/a.spec.js-snapshots`. + ## property: TestConfig.preserveOutput - type: <[PreserveOutput]<"always"|"never"|"failures-only">> diff --git a/docs/src/test-api/class-testinfo.md b/docs/src/test-api/class-testinfo.md index b21ca1db8d..4bb3d010ae 100644 --- a/docs/src/test-api/class-testinfo.md +++ b/docs/src/test-api/class-testinfo.md @@ -163,6 +163,11 @@ Test function as passed to `test(title, testFunction)`. Line number where the currently running test is declared. +## property: TestInfo.snapshotDir +- type: <[string]> + +Absolute path to the snapshot output directory for this specific test. Each test suite gets its own directory so they cannot conflict. + ## property: TestInfo.outputDir - type: <[string]> diff --git a/docs/src/test-api/class-testproject.md b/docs/src/test-api/class-testproject.md index 1bbba68074..3fc8c7c1ef 100644 --- a/docs/src/test-api/class-testproject.md +++ b/docs/src/test-api/class-testproject.md @@ -124,6 +124,15 @@ Any JSON-serializable metadata that will be put directly to the test report. Project name is visible in the report and during test execution. +## property: TestProject.snapshotDir +- type: <[string]> + +The base directory, relative to the config file, for snapshot files created with `toMatchSnapshot`. Defaults to [`property: TestProject.testDir`]. + +The directory for each test can be accessed by [`property: TestInfo.snapshotDir`] and [`method: TestInfo.snapshotPath`]. + +This path will serve as the base directory for each test file snapshot directory. Setting `snapshotDir` to `'snapshots'`, the [`property: TestInfo.snapshotDir`] would resolve to `snapshots/a.spec.js-snapshots`. + ## property: TestProject.outputDir - type: <[string]> diff --git a/packages/playwright-test/src/loader.ts b/packages/playwright-test/src/loader.ts index 7ee45cac46..32ef13d363 100644 --- a/packages/playwright-test/src/loader.ts +++ b/packages/playwright-test/src/loader.ts @@ -169,6 +169,9 @@ export class Loader { let outputDir = takeFirst(this._configOverrides.outputDir, projectConfig.outputDir, this._config.outputDir, path.resolve(process.cwd(), 'test-results')); if (!path.isAbsolute(outputDir)) outputDir = path.resolve(configDir, outputDir); + let snapshotDir = takeFirst(this._configOverrides.snapshotDir, projectConfig.snapshotDir, this._config.snapshotDir, testDir); + if (!path.isAbsolute(snapshotDir)) + snapshotDir = path.resolve(configDir, snapshotDir); const fullProject: FullProject = { define: takeFirst(this._configOverrides.define, projectConfig.define, this._config.define, []), expect: takeFirst(this._configOverrides.expect, projectConfig.expect, this._config.expect, undefined), @@ -178,6 +181,7 @@ export class Loader { metadata: takeFirst(this._configOverrides.metadata, projectConfig.metadata, this._config.metadata, undefined), name: takeFirst(this._configOverrides.name, projectConfig.name, this._config.name, ''), testDir, + snapshotDir, testIgnore: takeFirst(this._configOverrides.testIgnore, projectConfig.testIgnore, this._config.testIgnore, []), testMatch: takeFirst(this._configOverrides.testMatch, projectConfig.testMatch, this._config.testMatch, '**/?(*.)@(spec|test).@(ts|js|mjs)'), timeout: takeFirst(this._configOverrides.timeout, projectConfig.timeout, this._config.timeout, 10000), diff --git a/packages/playwright-test/src/workerRunner.ts b/packages/playwright-test/src/workerRunner.ts index 955b7835b5..61e5e12383 100644 --- a/packages/playwright-test/src/workerRunner.ts +++ b/packages/playwright-test/src/workerRunner.ts @@ -241,6 +241,11 @@ export class WorkerRunner extends EventEmitter { return path.join(this._project.config.outputDir, testOutputDir); })(); + const snapshotDir = (() => { + const relativeTestFilePath = path.relative(this._project.config.testDir, test._requireFile); + return path.join(this._project.config.snapshotDir, relativeTestFilePath + '-snapshots'); + })(); + let testFinishedCallback = () => {}; let lastStepId = 0; const testInfo: TestInfoImpl = { @@ -266,13 +271,13 @@ export class WorkerRunner extends EventEmitter { timeout: this._project.config.timeout, snapshotSuffix: '', outputDir: baseOutputDir, + snapshotDir, outputPath: (...pathSegments: string[]): string => { fs.mkdirSync(baseOutputDir, { recursive: true }); const joinedPath = path.join(...pathSegments); const outputPath = getContainedPath(baseOutputDir, joinedPath); if (outputPath) return outputPath; throw new Error(`The outputPath is not allowed outside of the parent directory. Please fix the defined path.\n\n\toutputPath: ${joinedPath}`); - }, snapshotPath: (...pathSegments: string[]): string => { let suffix = ''; @@ -280,11 +285,8 @@ export class WorkerRunner extends EventEmitter { suffix += '-' + this._projectNamePathSegment; if (testInfo.snapshotSuffix) suffix += '-' + testInfo.snapshotSuffix; - - const baseSnapshotPath = test._requireFile + '-snapshots'; const subPath = addSuffixToFilePath(path.join(...pathSegments), suffix); - const snapshotPath = getContainedPath(baseSnapshotPath, subPath); - + const snapshotPath = getContainedPath(snapshotDir, subPath); if (snapshotPath) return snapshotPath; throw new Error(`The snapshotPath is not allowed outside of the parent directory. Please fix the defined path.\n\n\tsnapshotPath: ${subPath}`); }, diff --git a/packages/playwright-test/types/test.d.ts b/packages/playwright-test/types/test.d.ts index 1cd4336df2..b25096948f 100644 --- a/packages/playwright-test/types/test.d.ts +++ b/packages/playwright-test/types/test.d.ts @@ -123,6 +123,19 @@ interface TestProject { * Project name is visible in the report and during test execution. */ name?: string; + /** + * The base directory, relative to the config file, for snapshot files created with `toMatchSnapshot`. Defaults to + * [testProject.testDir](https://playwright.dev/docs/api/class-testproject#test-project-test-dir). + * + * The directory for each test can be accessed by + * [testInfo.snapshotDir](https://playwright.dev/docs/api/class-testinfo#test-info-snapshot-dir) and + * [testInfo.snapshotPath(pathSegments)](https://playwright.dev/docs/api/class-testinfo#test-info-snapshot-path). + * + * This path will serve as the base directory for each test file snapshot directory. Setting `snapshotDir` to + * `'snapshots'`, the [testInfo.snapshotDir](https://playwright.dev/docs/api/class-testinfo#test-info-snapshot-dir) would + * resolve to `snapshots/a.spec.js-snapshots`. + */ + snapshotDir?: string; /** * The output directory for files created during test execution. Defaults to `test-results`. * @@ -603,6 +616,19 @@ interface TestConfig { */ metadata?: any; name?: string; + /** + * The base directory, relative to the config file, for snapshot files created with `toMatchSnapshot`. Defaults to + * [testConfig.testDir](https://playwright.dev/docs/api/class-testconfig#test-config-test-dir). + * + * The directory for each test can be accessed by + * [testInfo.snapshotDir](https://playwright.dev/docs/api/class-testinfo#test-info-snapshot-dir) and + * [testInfo.snapshotPath(pathSegments)](https://playwright.dev/docs/api/class-testinfo#test-info-snapshot-path). + * + * This path will serve as the base directory for each test file snapshot directory. Setting `snapshotDir` to + * `'snapshots'`, the [testInfo.snapshotDir](https://playwright.dev/docs/api/class-testinfo#test-info-snapshot-dir) would + * resolve to `snapshots/a.spec.js-snapshots`. + */ + snapshotDir?: string; /** * The output directory for files created during test execution. Defaults to `test-results`. * @@ -1342,6 +1368,11 @@ export interface TestInfo { * [snapshots](https://playwright.dev/docs/test-snapshots). */ snapshotSuffix: string; + /** + * Absolute path to the snapshot output directory for this specific test. Each test suite gets its own directory so they + * cannot conflict. + */ + snapshotDir: string; /** * Absolute path to the output directory for this specific test run. Each test run gets its own directory so they cannot * conflict. diff --git a/tests/playwright-test/golden.spec.ts b/tests/playwright-test/golden.spec.ts index 9526cceb99..cb7d0b0210 100644 --- a/tests/playwright-test/golden.spec.ts +++ b/tests/playwright-test/golden.spec.ts @@ -488,12 +488,101 @@ test('should write missing expectations with sanitized snapshot name', async ({ expect(data.toString()).toBe('Hello world'); }); -test('should join array of snapshot path segments without sanitizing ', async ({ runInlineTest }) => { +test('should join array of snapshot path segments without sanitizing', async ({ runInlineTest }) => { const result = await runInlineTest({ ...files, 'a.spec.js-snapshots/test/path/snapshot.txt': `Hello world`, 'a.spec.js': ` - const { test } = require('./helper');; + const { test } = require('./helper'); + test('is a test', ({}) => { + expect('Hello world').toMatchSnapshot(['test', 'path', 'snapshot.txt']); + }); + ` + }); + expect(result.exitCode).toBe(0); +}); + +test('should use snapshotDir as snapshot base directory', async ({ runInlineTest }) => { + const result = await runInlineTest({ + ...files, + 'playwright.config.ts': ` + module.exports = { + snapshotDir: 'snaps', + }; + `, + 'snaps/a.spec.js-snapshots/snapshot.txt': `Hello world`, + 'a.spec.js': ` + const { test } = require('./helper'); + test('is a test', ({}) => { + expect('Hello world').toMatchSnapshot('snapshot.txt'); + }); + ` + }); + expect(result.exitCode).toBe(0); +}); + +test('should use snapshotDir with path segments as snapshot directory', async ({ runInlineTest }) => { + const result = await runInlineTest({ + ...files, + 'playwright.config.ts': ` + module.exports = { + snapshotDir: 'snaps', + }; + `, + 'snaps/tests/a.spec.js-snapshots/test/path/snapshot.txt': `Hello world`, + 'tests/a.spec.js': ` + const { test } = require('../helper'); + test('is a test', ({}) => { + expect('Hello world').toMatchSnapshot(['test', 'path', 'snapshot.txt']); + }); + ` + }); + expect(result.exitCode).toBe(0); +}); + +test('should use snapshotDir with nested test suite and path segments', async ({ runInlineTest }) => { + const result = await runInlineTest({ + ...files, + 'playwright.config.ts': ` + module.exports = { + snapshotDir: 'snaps', + }; + `, + 'snaps/path/to/tests/a.spec.js-snapshots/path/to/snapshot.txt': `Hello world`, + 'path/to/tests/a.spec.js': ` + const { test } = require('../../../helper'); + test('is a test', ({}) => { + expect('Hello world').toMatchSnapshot(['path', 'to', 'snapshot.txt']); + }); + ` + }); + expect(result.exitCode).toBe(0); +}); + +test('should use project snapshotDir over base snapshotDir', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'helper.ts': ` + export const test = pwt.test.extend({ + auto: [ async ({}, run, testInfo) => { + testInfo.snapshotSuffix = 'suffix'; + await run(); + }, { auto: true } ] + }); + `, + 'playwright.config.ts': ` + module.exports = { + projects: [ + { + name: 'foo', + snapshotDir: 'project_snaps', + }, + ], + snapshotDir: 'snaps', + }; + `, + 'project_snaps/a.spec.js-snapshots/test/path/snapshot-foo-suffix.txt': `Hello world`, + 'a.spec.js': ` + const { test } = require('./helper'); test('is a test', ({}) => { expect('Hello world').toMatchSnapshot(['test', 'path', 'snapshot.txt']); }); @@ -689,4 +778,4 @@ test('should allow comparing text with text without file extension', async ({ ru ` }); expect(result.exitCode).toBe(0); -}); \ No newline at end of file +}); diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index 9637ff6929..610a1493a1 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -51,6 +51,7 @@ interface TestProject { expect?: ExpectSettings; metadata?: any; name?: string; + snapshotDir?: string; outputDir?: string; repeatEach?: number; retries?: number; @@ -120,6 +121,7 @@ interface TestConfig { expect?: ExpectSettings; metadata?: any; name?: string; + snapshotDir?: string; outputDir?: string; repeatEach?: number; retries?: number; @@ -213,6 +215,7 @@ export interface TestInfo { stdout: (string | Buffer)[]; stderr: (string | Buffer)[]; snapshotSuffix: string; + snapshotDir: string; outputDir: string; snapshotPath: (...pathSegments: string[]) => string; outputPath: (...pathSegments: string[]) => string;