feat: introduce kind option in TestInfo.snapshotPath() (#35734)

This commit is contained in:
Dmitry Gozman 2025-04-24 20:21:57 +00:00 committed by GitHub
parent 2de30d694c
commit 508b1ccdcb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 635 additions and 481 deletions

View File

@ -387,17 +387,45 @@ Optional description that will be reflected in a test report.
* since: v1.10
- returns: <[string]>
Returns a path to a snapshot file with the given `pathSegments`. Learn more about [snapshots](../test-snapshots.md).
Returns a path to a snapshot file with the given `name`. Pass [`option: kind`] to obtain a specific path:
* `kind: 'screenshot'` for [`method: PageAssertions.toHaveScreenshot#1`];
* `kind: 'aria'` for [`method: LocatorAssertions.toMatchAriaSnapshot`];
* `kind: 'snapshot'` for [`method: SnapshotAssertions.toMatchSnapshot#1`].
> Note that `pathSegments` accepts path segments to the snapshot file such as `testInfo.snapshotPath('relative', 'path', 'to', 'snapshot.png')`.
> However, this path must stay within the snapshots directory for each test file (i.e. `a.spec.js-snapshots`), otherwise it will throw.
**Usage**
### param: TestInfo.snapshotPath.pathSegments
```js
await expect(page).toHaveScreenshot('header.png');
// Screenshot assertion above expects screenshot at this path:
const screenshotPath = test.info().snapshotPath('header.png', { kind: 'screenshot' });
await expect(page.getByRole('main')).toMatchAriaSnapshot({ name: 'main.aria.yml' });
// Aria snapshot assertion above expects snapshot at this path:
const ariaSnapshotPath = test.info().snapshotPath('main.aria.yml', { kind: 'aria' });
expect('some text').toMatchSnapshot('snapshot.txt');
// Snapshot assertion above expects snapshot at this path:
const snapshotPath = test.info().snapshotPath('snapshot.txt');
expect('some text').toMatchSnapshot(['dir', 'subdir', 'snapshot.txt']);
// Snapshot assertion above expects snapshot at this path:
const nestedPath = test.info().snapshotPath('dir', 'subdir', 'snapshot.txt');
```
### param: TestInfo.snapshotPath.name
* since: v1.10
- `...pathSegments` <[Array]<[string]>>
- `...name` <[Array]<[string]>>
The name of the snapshot or the path segments to define the snapshot file path. Snapshots with the same name in the same test file are expected to be the same.
When passing [`option: kind`], multiple name segments are not supported.
### option: TestInfo.snapshotPath.kind
* since: v1.53
- `kind` <[SnapshotKind]<"snapshot"|"screenshot"|"aria">>
The snapshot kind controls which snapshot path template is used. See [`property: TestConfig.snapshotPathTemplate`] for more details. Defaults to `'snapshot'`.
## property: TestInfo.snapshotSuffix
* since: v1.10
- type: <[string]>

View File

@ -54,8 +54,6 @@ 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,
@ -70,8 +68,7 @@ export async function toMatchAriaSnapshot(
timeout = options.timeout ?? this.timeout;
} else {
if (expectedParam?.name) {
const ext = expectedParam.name.endsWith('.aria.yml') ? '.aria.yml' : undefined;
expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [expectedParam.name], true, ext);
expectedPath = testInfo._resolveSnapshotPath('aria', [expectedParam.name], true);
} else {
let snapshotNames = (testInfo as any)[snapshotNamesSymbol] as SnapshotNames;
if (!snapshotNames) {
@ -79,11 +76,11 @@ export async function toMatchAriaSnapshot(
(testInfo as any)[snapshotNamesSymbol] = snapshotNames;
}
const fullTitleWithoutSpec = [...testInfo.titlePath.slice(1), ++snapshotNames.anonymousSnapshotIndex].join(' ');
expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [trimLongString(fullTitleWithoutSpec) + '.aria.yml'], true, '.aria.yml');
expectedPath = testInfo._resolveSnapshotPath('aria', [trimLongString(fullTitleWithoutSpec) + '.aria.yml'], true);
// in 1.51, we changed the default template to use .aria.yml extension
// for backwards compatibility, we check for the legacy .yml extension
if (!(await fileExistsAsync(expectedPath))) {
const legacyPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [trimLongString(fullTitleWithoutSpec) + '.yml'], true, '.yml');
const legacyPath = testInfo._resolveSnapshotPath('aria', [trimLongString(fullTitleWithoutSpec) + '.yml'], true);
if (await fileExistsAsync(legacyPath))
expectedPath = legacyPath;
}

View File

@ -95,7 +95,7 @@ class SnapshotHelper {
constructor(
testInfo: TestInfoImpl,
matcherName: string,
matcherName: 'toMatchSnapshot' | 'toHaveScreenshot',
locator: Locator | undefined,
anonymousSnapshotExtension: string,
configOptions: ToHaveScreenshotConfigOptions,
@ -160,8 +160,7 @@ class SnapshotHelper {
outputBasePath = testInfo._getOutputPath(sanitizedName);
this.attachmentBaseName = sanitizedName;
}
const defaultTemplate = '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}';
this.expectedPath = testInfo._resolveSnapshotPath(configOptions.pathTemplate, defaultTemplate, expectedPathSegments, sanitizeFilePath);
this.expectedPath = testInfo._resolveSnapshotPath(matcherName === 'toHaveScreenshot' ? 'screenshot' : 'snapshot', expectedPathSegments, sanitizeFilePath);
this.legacyExpectedPath = addSuffixToFilePath(outputBasePath, '-expected');
this.previousPath = addSuffixToFilePath(outputBasePath, '-previous');
this.actualPath = addSuffixToFilePath(outputBasePath, '-actual');

View File

@ -448,20 +448,33 @@ export class TestInfoImpl implements TestInfo {
return sanitizeForFilePath(trimLongString(fullTitleWithoutSpec));
}
_resolveSnapshotPath(template: string | undefined, defaultTemplate: string, pathSegments: string[], sanitizeFilePath: boolean, extension?: string) {
_resolveSnapshotPath(kind: 'snapshot' | 'screenshot' | 'aria', pathSegments: string[], sanitizeFilePath: boolean) {
let subPath = path.join(...pathSegments);
let ext = path.extname(subPath);
const legacyTemplate = '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}';
let template: string;
if (kind === 'screenshot') {
template = this._projectInternal.expect?.toHaveScreenshot?.pathTemplate || this._projectInternal.snapshotPathTemplate || legacyTemplate;
} else if (kind === 'aria') {
const ariaDefaultTemplate = '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}';
template = this._projectInternal.expect?.toMatchAriaSnapshot?.pathTemplate || this._projectInternal.snapshotPathTemplate || ariaDefaultTemplate;
if (subPath.endsWith('.aria.yml'))
ext = '.aria.yml';
} else {
template = this._projectInternal.snapshotPathTemplate || legacyTemplate;
}
if (sanitizeFilePath)
subPath = sanitizeFilePathBeforeExtension(subPath, extension);
subPath = sanitizeFilePathBeforeExtension(subPath, ext);
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);
const actualTemplate = (template || this._projectInternal.snapshotPathTemplate || defaultTemplate);
const snapshotPath = actualTemplate
const snapshotPath = template
.replace(/\{(.)?testDir\}/g, '$1' + this.project.testDir)
.replace(/\{(.)?snapshotDir\}/g, '$1' + this.project.snapshotDir)
.replace(/\{(.)?snapshotSuffix\}/g, this.snapshotSuffix ? '$1' + this.snapshotSuffix : '')
@ -477,12 +490,25 @@ export class TestInfoImpl implements TestInfo {
return path.normalize(path.resolve(this._configInternal.configDir, snapshotPath));
}
snapshotPath(...pathSegments: string[]) {
snapshotPath(...name: string[]): string;
snapshotPath(name: string, options: { kind: 'snapshot' | 'screenshot' | 'aria' }): string;
snapshotPath(...args: any[]) {
let pathSegments: string[] = args;
let kind: 'snapshot' | 'screenshot' | 'aria' = 'snapshot';
const options = args[args.length - 1];
if (options && typeof options === 'object') {
kind = options.kind ?? kind;
pathSegments = args.slice(0, -1);
}
if (!['snapshot', 'screenshot', 'aria'].includes(kind))
throw new Error(`testInfo.snapshotPath: unknown kind "${kind}", must be one of "snapshot", "screenshot" or "aria"`);
// Assume a single path segment corresponds to `toHaveScreenshot(name)` and sanitize it,
// like we do in SnapshotHelper. See https://github.com/microsoft/playwright/pull/9156 for history.
const sanitizeFilePath = pathSegments.length === 1;
const legacyTemplate = '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}';
return this._resolveSnapshotPath(undefined, legacyTemplate, pathSegments, sanitizeFilePath);
return this._resolveSnapshotPath(kind, pathSegments, sanitizeFilePath);
}
skip(...args: [arg?: any, description?: string]) {

File diff suppressed because it is too large Load Diff

View File

@ -201,14 +201,19 @@ test('should respect config.snapshotPathTemplate', async ({ runInlineTest }, tes
snapshotPathTemplate: 'my-snapshots/{testFilePath}/{arg}{ext}',
};
`,
'my-snapshots/dir/a.spec.ts/test.aria.yml': `
'my-snapshots/dir/a.spec.ts/my-test.aria.yml': `
- heading "hello world"
`,
'dir/a.spec.ts': `
import path from 'path';
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
const testDir = test.info().project.testDir;
const screenshotPath = path.join(testDir, 'my-snapshots/dir/a.spec.ts/my-test.aria.yml');
expect(test.info().snapshotPath('my_test.aria.yml', { kind: 'aria' })).toBe(screenshotPath);
await page.setContent(\`<h1>hello world</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.aria.yml' });
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'my_test.aria.yml' });
});
`
});

View File

@ -137,3 +137,18 @@ test('arg should receive default arg', async ({ runInlineTest }, testInfo) => {
expect(result.output).toContain(`A snapshot doesn't exist at ${snapshotOutputPath}, writing actual`);
expect(fs.existsSync(snapshotOutputPath)).toBe(true);
});
test('should throw for unknown snapshot kind', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('is a test', async ({}) => {
test.info().snapshotPath('foo', { kind: 'bar' });
});
`
});
expect(result.exitCode).toBe(1);
expect(result.passed).toBe(0);
expect(result.output).toContain(`testInfo.snapshotPath: unknown kind "bar"`);
});

View File

@ -782,8 +782,17 @@ test('should respect config.expect.toHaveScreenshot.pathTemplate', async ({ runI
'__screenshots__/a.spec.js/snapshot.png': blueImage,
'actual-screenshots/a.spec.js/snapshot.png': whiteImage,
'a.spec.js': `
const path = require('path');
const { test, expect } = require('@playwright/test');
test('is a test', async ({ page }) => {
const testDir = test.info().project.testDir;
const screenshotPath = path.join(testDir, 'actual-screenshots/a.spec.js/snapshot.png');
expect(test.info().snapshotPath('snapshot.png', { kind: 'screenshot' })).toBe(screenshotPath);
const snapshotPath = path.join(testDir, '__screenshots__/a.spec.js/snapshot.png');
expect(test.info().snapshotPath('snapshot.png', { kind: 'snapshot' })).toBe(snapshotPath);
await expect(page).toHaveScreenshot('snapshot.png');
});
`

View File

@ -29,6 +29,11 @@ test('basics should work', async ({ runTSC }) => {
expect(testInfo.title).toBe('my test');
testInfo.annotations[0].type;
test.setTimeout(123);
testInfo.snapshotPath('a', 'b');
testInfo.snapshotPath();
testInfo.snapshotPath('foo.png', { kind: 'screenshot' });
// @ts-expect-error
testInfo.snapshotPath('a', 'b', { kind: 'aria' });
});
test.skip('my test', async () => {});
test.fixme('my test', async () => {});

View File

@ -63,6 +63,11 @@ export interface FullConfig<TestArgs = {}, WorkerArgs = {}> {
webServer: TestConfigWebServer | null;
}
export interface TestInfo {
snapshotPath(...name: ReadonlyArray<string>): string;
snapshotPath(name: string, options: { kind: 'snapshot' | 'screenshot' | 'aria' }): string;
}
export type TestStatus = 'passed' | 'failed' | 'timedOut' | 'skipped' | 'interrupted';
export type TestDetailsAnnotation = {