diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 1f9c03d77a..859634bc01 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -1751,7 +1751,7 @@ await Expect(Page.GetByTitle("Issues count")).toHaveText("25 issues"); ``` ## test-config-snapshot-path-template -- `type` ?<[string]> +- `type` ?<[string]|[SnapshotPathResolver]> * langs: js This option configures a template controlling location of snapshots generated by [`method: PageAssertions.toHaveScreenshot#1`] and [`method: SnapshotAssertions.toMatchSnapshot#1`]. @@ -1764,6 +1764,12 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests', snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}', + projects: [ + { + // Using a function for runtime control + snapshotPathTemplate: ({ testDir, testFilePath, arg, ext }) => `${testDir}/__screenshots__/${testFilePath}/${arg}${ext}` + }, + ], }); ``` diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index d7fb499645..95f0d50e31 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -24,6 +24,7 @@ import { getPackageJsonPath, mergeObjects } from '../util'; import type { Matcher } from '../util'; import type { ConfigCLIOverrides } from './ipc'; import type { FullConfig, FullProject } from '../../types/testReporter'; +import type { SnapshotPathResolver } from '../worker/testInfo'; export type ConfigLocation = { resolvedConfigFile?: string; @@ -154,7 +155,7 @@ export class FullProjectInternal { readonly fullyParallel: boolean; readonly expect: Project['expect']; readonly respectGitIgnore: boolean; - readonly snapshotPathTemplate: string; + readonly snapshotPathTemplate: string | SnapshotPathResolver; readonly ignoreSnapshots: boolean; id = ''; deps: FullProjectInternal[] = []; @@ -289,4 +290,4 @@ const configInternalSymbol = Symbol('configInternalSymbol'); export function getProjectId(project: FullProject): string { return (project as any).__projectId!; -} \ No newline at end of file +} diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 378b32524f..807f1fe9fb 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -53,6 +53,22 @@ export type TestStage = { step?: TestStepInternal; }; +export type SnapshotPathArgs = { + arg: string; + ext: string; + platform: NodeJS.Platform; + projectName?: string; + snapshotDir: string; + snapshotSuffix?: string; + testDir: string; + testFileDir: string; + testFileName: string; + testFilePath: string; + testName: string; +}; + +export type SnapshotPathResolver = (snapshotPathArgs: SnapshotPathArgs, testInfo: TestInfo) => string; + export class TestInfoImpl implements TestInfo { private _onStepBegin: (payload: StepBeginPayload) => void; private _onStepEnd: (payload: StepEndPayload) => void; @@ -441,20 +457,35 @@ export class TestInfoImpl implements TestInfo { 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 options: SnapshotPathArgs = { + arg: path.join(parsedSubPath.dir, parsedSubPath.name), + ext: parsedSubPath.ext, + platform: process.platform, + projectName: sanitizeForFilePath(this.project.name), + snapshotDir: this.project.snapshotDir, + snapshotSuffix: this.snapshotSuffix, + testDir: this.project.testDir, + testFileDir: parsedRelativeTestFilePath.dir, + testFileName: parsedRelativeTestFilePath.base, + testFilePath: relativeTestFilePath, + testName: this._fsSanitizedTestName(), + }; - const snapshotPath = (this._projectInternal.snapshotPathTemplate || '') - .replace(/\{(.)?testDir\}/g, '$1' + this.project.testDir) - .replace(/\{(.)?snapshotDir\}/g, '$1' + this.project.snapshotDir) - .replace(/\{(.)?snapshotSuffix\}/g, this.snapshotSuffix ? '$1' + this.snapshotSuffix : '') - .replace(/\{(.)?testFileDir\}/g, '$1' + parsedRelativeTestFilePath.dir) - .replace(/\{(.)?platform\}/g, '$1' + process.platform) - .replace(/\{(.)?projectName\}/g, projectNamePathSegment ? '$1' + projectNamePathSegment : '') - .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 : ''); + const snapshotPath: string = + typeof this._projectInternal.snapshotPathTemplate === 'function' ? + this._projectInternal.snapshotPathTemplate(options, this) : + (this._projectInternal.snapshotPathTemplate || '') + .replace(/\{(.)?testDir\}/g, '$1' + options.testDir) + .replace(/\{(.)?snapshotDir\}/g, '$1' + options.snapshotDir) + .replace(/\{(.)?snapshotSuffix\}/g, options.snapshotSuffix ? '$1' + options.snapshotSuffix : '') + .replace(/\{(.)?testFileDir\}/g, '$1' + options.testFileDir) + .replace(/\{(.)?platform\}/g, '$1' + options.platform) + .replace(/\{(.)?projectName\}/g, options.projectName ? '$1' + options.projectName : '') + .replace(/\{(.)?testName\}/g, '$1' + options.testName) + .replace(/\{(.)?testFileName\}/g, '$1' + options.testFileName) + .replace(/\{(.)?testFilePath\}/g, '$1' + options.testFilePath) + .replace(/\{(.)?arg\}/g, '$1' + options.arg) + .replace(/\{(.)?ext\}/g, options.ext ? '$1' + options.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 400d7cbcf3..b91b5f3525 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -417,6 +417,12 @@ interface TestProject { * export default defineConfig({ * testDir: './tests', * snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}', + * projects: [ + * { + * // Using a function for runtime control + * snapshotPathTemplate: ({ testDir, testFilePath, arg, ext }) => `${testDir}/__screenshots__/${testFilePath}/${arg}${ext}` + * }, + * ], * }); * ``` * @@ -498,7 +504,7 @@ interface TestProject { * 1. Since `snapshotPathTemplate` resolves to relative path, it will be resolved relative to `configDir`. * 1. Forward slashes `"/"` can be used as path separators on any platform. */ - snapshotPathTemplate?: string; + snapshotPathTemplate?: string|SnapshotPathResolver; /** * Name of a project that needs to run after this and all dependent projects have finished. Teardown is useful to @@ -1479,6 +1485,12 @@ interface TestConfig { * export default defineConfig({ * testDir: './tests', * snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}', + * projects: [ + * { + * // Using a function for runtime control + * snapshotPathTemplate: ({ testDir, testFilePath, arg, ext }) => `${testDir}/__screenshots__/${testFilePath}/${arg}${ext}` + * }, + * ], * }); * ``` * @@ -1560,7 +1572,7 @@ interface TestConfig { * 1. Since `snapshotPathTemplate` resolves to relative path, it will be resolved relative to `configDir`. * 1. Forward slashes `"/"` can be used as path separators on any platform. */ - snapshotPathTemplate?: string; + snapshotPathTemplate?: string|SnapshotPathResolver; /** * Directory that will be recursively scanned for test files. Defaults to the directory of the configuration file. diff --git a/tests/playwright-test/snapshot-path-template.spec.ts b/tests/playwright-test/snapshot-path-template.spec.ts index 4f260469b7..2b6ad22ca5 100644 --- a/tests/playwright-test/snapshot-path-template.spec.ts +++ b/tests/playwright-test/snapshot-path-template.spec.ts @@ -18,8 +18,8 @@ import path from 'path'; import fs from 'fs'; import { test, expect } from './playwright-test-fixtures'; +const SEPARATOR = '==== 8< ---- '; async function getSnapshotPaths(runInlineTest, testInfo, playwrightConfig, pathArgs) { - const SEPARATOR = '==== 8< ---- '; const result = await runInlineTest({ 'playwright.config.js': ` module.exports = ${JSON.stringify(playwrightConfig, null, 2)} @@ -106,6 +106,53 @@ test('tokens should expand property', async ({ runInlineTest }, testInfo) => { expect.soft(snapshotPath['testName']).toBe('suite-test-should-work'); }); +test('supports function arg', async ({ runInlineTest }, testInfo) => { + const result = await runInlineTest({ + 'playwright.config.js': ` + module.exports = { + projects: [ + { + name: 'proj', + snapshotPathTemplate: (args) => { + console.log(JSON.stringify(args)); + return 'path/to/snapshot'; + } + } + ] + } + `, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('is a test', async ({ }, testInfo) => { + console.log(${JSON.stringify(SEPARATOR)}) + const snapshotPath = testInfo.snapshotPath('foo', 'bar.png'); + console.log(${JSON.stringify(SEPARATOR)}); + console.log(JSON.stringify({ snapshotPath })); + console.log(${JSON.stringify(SEPARATOR)}); + }); + ` + }); + const output = result.output.split(SEPARATOR).slice(1, -1).map(json => JSON.parse(json)); + expect.soft(output).toEqual([ + { + arg: 'foo/bar', + ext: '.png', + platform: process.platform, + projectName: 'proj', + snapshotDir: testInfo.outputPath(test.name), + snapshotSuffix: process.platform, + testDir: testInfo.outputDir, + testFileDir: '', + testFileName: 'a.spec.ts', + testFilePath: 'a.spec.ts', + testName: 'is-a-test', + }, + { + snapshotPath: testInfo.outputPath('path/to/snapshot') + } + ]); +}); + test('args array should work', async ({ runInlineTest }, testInfo) => { const snapshotPath = await getSnapshotPaths(runInlineTest, testInfo, { projects: [{