Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4044f4a
feat: add path option to toMatchSnapshot
nickofthyme Sep 26, 2021
0d0510a
refactor: validate contained path using full path
nickofthyme Sep 27, 2021
9b9b677
refactor: revert path sanitization on path and add contained check
nickofthyme Sep 27, 2021
aae55cb
Merge branch 'master' into feat/snapshot-path
nickofthyme Sep 27, 2021
e899857
docs: update docs with path segment logic
nickofthyme Sep 29, 2021
df1e578
chore: add error message for contained path method
nickofthyme Sep 29, 2021
93d9632
refactor: use name and pathSegments union type, allow toMatchSnapshot…
nickofthyme Sep 29, 2021
c4fd5a0
Merge branch 'master' into feat/snapshot-path
nickofthyme Sep 29, 2021
45f5329
chore: apply suggestions from code review
nickofthyme Sep 30, 2021
311fa94
refactor: types, renaming and test cleanup per pr comments
nickofthyme Sep 30, 2021
07567bc
chore: add better error messages for escaping parent path
nickofthyme Sep 30, 2021
76efbc2
fix: path segments args and test cleanup
nickofthyme Sep 30, 2021
b3f7b9a
Merge branch 'master' into feat/snapshot-path
nickofthyme Sep 30, 2021
df06c4b
fix: bad null error type
nickofthyme Oct 1, 2021
ac578b1
fix: fix pathSegements type in overrides-test
nickofthyme Oct 1, 2021
b705b26
fix: remove path sep from parent directory check
nickofthyme Oct 1, 2021
5785cdb
fix: missing quote in test
nickofthyme Oct 1, 2021
a6931ca
fix: remote test.only
nickofthyme Oct 1, 2021
9bc8041
fix: add exact check for parent path
nickofthyme Oct 1, 2021
c17469e
test: fix failing
nickofthyme Oct 1, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/src/test-advanced-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ In addition to everything from the [`workerInfo`](#workerinfo), the following in
- `timeout: number` - Test timeout.
- `annotations` - [Annotations](./test-annotations.md) that were added to the test.
- `snapshotSuffix: string` - Suffix used to locate snapshots for the test.
- `snapshotPath(snapshotName: string)` - Function that returns the full path to a particular snapshot for the test.
- `snapshotPath(...pathSegments: string[])` - Function that returns the full path to a particular snapshot for the test.
Comment thread
nickofthyme marked this conversation as resolved.
- `outputDir: string` - Path to the output directory for this test run.
- `outputPath(...pathSegments: string[])` - Function that returns the full path to a particular output artifact for the test.

Expand Down
15 changes: 11 additions & 4 deletions docs/src/test-api/class-testinfo.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,14 @@ test('example test', async ({}, testInfo) => {
});
```

> Note that `pathSegments` accepts path segments to the test output directory such as `testInfo.outputPath('relative', 'path', 'to', 'output')`.
> However, this path must stay within the [`property: TestInfo.outputDir`] directory for each test (i.e. `test-results/a-test-title`), otherwise it will throw.

### param: TestInfo.outputPath.pathSegments
- `pathSegments` <[string...]>

Path segments to append at the end of the resulting path.


## property: TestInfo.project
- type: <[TestProject]>

Expand Down Expand Up @@ -275,10 +277,15 @@ Optional description that will be reflected in a test report.
## method: TestInfo.snapshotPath
- returns: <[string]>

Returns a path to a snapshot file with the given `snapshotName`. Learn more about [snapshots](./test-snapshots.md).
Returns a path to a snapshot file with the given `pathSegments`. Learn more about [snapshots](./test-snapshots.md).

> 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.

### param: TestInfo.snapshotPath.pathSegments
- `pathSegments` <[string...]>

### param: TestInfo.snapshotPath.snapshotName
- `snapshotName` <[string]> The name of the snapshot. Note that snapshots with the same name in the same test file are expected to be the same.
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.

## property: TestInfo.snapshotSuffix
- type: <[string]>
Expand Down
3 changes: 2 additions & 1 deletion docs/src/test-snapshots-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ Sometimes you need to update the reference screenshot, for example when the page
npx playwright test --update-snapshots
```

Note that `snapshotName` is *not a path* relative to the test file, so don't try to use it like `expect(value).toMatchSnapshot('../../test-snapshots/snapshot.png')`.
> Note that `snapshotName` also accepts an array of path segments to the snapshot file such as `expect(value).toMatchSnapshot(['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.

Playwright Test uses the [pixelmatch](https://github.com/mapbox/pixelmatch) library. You can pass comparison `threshold` as an option.

Expand Down
29 changes: 13 additions & 16 deletions src/test/matchers/golden.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import path from 'path';
import jpeg from 'jpeg-js';
import pixelmatch from 'pixelmatch';
import { diff_match_patch, DIFF_INSERT, DIFF_DELETE, DIFF_EQUAL } from '../../third_party/diff_match_patch';
import { UpdateSnapshots } from '../types';
import { TestInfoImpl, UpdateSnapshots } from '../types';
import { addSuffixToFilePath } from '../util';

// Note: we require the pngjs version of pixelmatch to avoid version mismatches.
const { PNG } = require(require.resolve('pngjs', { paths: [require.resolve('pixelmatch')] }));
Expand Down Expand Up @@ -82,18 +83,18 @@ function compareText(actual: Buffer | string, expectedBuffer: Buffer): { diff?:

export function compare(
actual: Buffer | string,
name: string,
snapshotPath: (name: string) => string,
outputPath: (name: string) => string,
pathSegments: string[],
snapshotPath: TestInfoImpl['snapshotPath'],
outputPath: TestInfoImpl['outputPath'],
updateSnapshots: UpdateSnapshots,
withNegateComparison: boolean,
options?: { threshold?: number }
): { pass: boolean; message?: string; expectedPath?: string, actualPath?: string, diffPath?: string, mimeType?: string } {
const snapshotFile = snapshotPath(name);
const outputFile = outputPath(name);
const expectedPath = addSuffix(outputFile, '-expected');
const actualPath = addSuffix(outputFile, '-actual');
const diffPath = addSuffix(outputFile, '-diff');
const snapshotFile = snapshotPath(...pathSegments);
Comment thread
dgozman marked this conversation as resolved.
const outputFile = outputPath(...pathSegments);
const expectedPath = addSuffixToFilePath(outputFile, '-expected');
const actualPath = addSuffixToFilePath(outputFile, '-actual');
const diffPath = addSuffixToFilePath(outputFile, '-diff');

if (!fs.existsSync(snapshotFile)) {
const isWriteMissingMode = updateSnapshots === 'all' || updateSnapshots === 'missing';
Expand All @@ -104,6 +105,7 @@ export function compare(
}
if (isWriteMissingMode) {
fs.mkdirSync(path.dirname(snapshotFile), { recursive: true });
fs.mkdirSync(path.dirname(actualPath), { recursive: true });
fs.writeFileSync(snapshotFile, actual);
fs.writeFileSync(actualPath, actual);
}
Expand Down Expand Up @@ -159,6 +161,8 @@ export function compare(
};
}

fs.mkdirSync(path.dirname(expectedPath), { recursive: true });
fs.mkdirSync(path.dirname(actualPath), { recursive: true });
fs.writeFileSync(expectedPath, expected);
fs.writeFileSync(actualPath, actual);
if (result.diff)
Expand Down Expand Up @@ -191,13 +195,6 @@ function indent(lines: string, tab: string) {
return lines.replace(/^(?=.+$)/gm, tab);
}

function addSuffix(filePath: string, suffix: string, customExtension?: string): string {
const dirname = path.dirname(filePath);
const ext = path.extname(filePath);
const name = path.basename(filePath, ext);
return path.join(dirname, name + suffix + (customExtension || ext));
}

function diff_prettyTerminal(diffs: diff_match_patch.Diff[]) {
const html = [];
for (let x = 0; x < diffs.length; x++) {
Expand Down
12 changes: 8 additions & 4 deletions src/test/matchers/toMatchSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,21 @@
import type { Expect } from '../types';
import { currentTestInfo } from '../globals';
import { compare } from './golden';
import { addSuffixToFilePath } from '../util';

// from expect/build/types
type SyncExpectationResult = {
pass: boolean;
message: () => string;
};

export function toMatchSnapshot(this: ReturnType<Expect['getState']>, received: Buffer | string, nameOrOptions: string | { name: string, threshold?: number }, optOptions: { threshold?: number } = {}): SyncExpectationResult {
let options: { name: string, threshold?: number };
type NameOrSegments = string | string[];
export function toMatchSnapshot(this: ReturnType<Expect['getState']>, received: Buffer | string, nameOrOptions: NameOrSegments | { name: NameOrSegments, threshold?: number }, optOptions: { threshold?: number } = {}): SyncExpectationResult {
let options: { name: NameOrSegments, threshold?: number };
const testInfo = currentTestInfo();
if (!testInfo)
throw new Error(`toMatchSnapshot() must be called during the test`);
if (typeof nameOrOptions === 'string')
if (Array.isArray(nameOrOptions) || typeof nameOrOptions === 'string')
options = { name: nameOrOptions, ...optOptions };
else
options = { ...nameOrOptions };
Expand All @@ -40,10 +42,12 @@ export function toMatchSnapshot(this: ReturnType<Expect['getState']>, received:
if (options.threshold === undefined && projectThreshold !== undefined)
options.threshold = projectThreshold;

// sanitizes path if string
const pathSegments = Array.isArray(options.name) ? options.name : [addSuffixToFilePath(options.name, '', undefined, true)];
const withNegateComparison = this.isNot;
const { pass, message, expectedPath, actualPath, diffPath, mimeType } = compare(
received,
options.name,
pathSegments,
testInfo.snapshotPath,
testInfo.outputPath,
testInfo.config.updateSnapshots,
Expand Down
17 changes: 17 additions & 0 deletions src/test/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,21 @@ export function sanitizeForFilePath(s: string) {
return s.replace(/[\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
}

export function addSuffixToFilePath(filePath: string, suffix: string, customExtension?: string, sanitize = false): string {
const dirname = path.dirname(filePath);
const ext = path.extname(filePath);
const name = path.basename(filePath, ext);
const base = path.join(dirname, name);
return (sanitize ? sanitizeForFilePath(base) : base) + suffix + (customExtension || ext);
}

/**
* Returns absolute path contained within parent directory.
*/
export function getContainedPath(parentPath: string, subPath: string = ''): string | null {
const resolvedPath = path.resolve(parentPath, subPath);
if (resolvedPath === parentPath || resolvedPath.startsWith(parentPath + path.sep)) return resolvedPath;
return null;
}

export const debugTest = debug('pw:test');
23 changes: 14 additions & 9 deletions src/test/workerRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import rimraf from 'rimraf';
import util from 'util';
import colors from 'colors/safe';
import { EventEmitter } from 'events';
import { monotonicTime, serializeError, sanitizeForFilePath } from './util';
import { monotonicTime, serializeError, sanitizeForFilePath, getContainedPath, addSuffixToFilePath } from './util';
import { TestBeginPayload, TestEndPayload, RunPayload, TestEntry, DonePayload, WorkerInitParams, StepBeginPayload, StepEndPayload } from './ipc';
import { setCurrentTestInfo } from './globals';
import { Loader } from './loader';
Expand Down Expand Up @@ -255,20 +255,25 @@ export class WorkerRunner extends EventEmitter {
outputDir: baseOutputDir,
outputPath: (...pathSegments: string[]): string => {
fs.mkdirSync(baseOutputDir, { recursive: true });
return path.join(baseOutputDir, ...pathSegments);
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: (snapshotName: string): string => {
snapshotPath: (...pathSegments: string[]): string => {
let suffix = '';
if (this._projectNamePathSegment)
suffix += '-' + this._projectNamePathSegment;
if (testInfo.snapshotSuffix)
suffix += '-' + testInfo.snapshotSuffix;
const ext = path.extname(snapshotName);
if (ext)
snapshotName = sanitizeForFilePath(snapshotName.substring(0, snapshotName.length - ext.length)) + suffix + ext;
else
snapshotName = sanitizeForFilePath(snapshotName) + suffix;
return path.join(test._requireFile + '-snapshots', snapshotName);

const baseSnapshotPath = test._requireFile + '-snapshots';
const subPath = addSuffixToFilePath(path.join(...pathSegments), suffix);
const snapshotPath = getContainedPath(baseSnapshotPath, 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}`);
},
skip: (...args: [arg?: any, description?: string]) => modifier(testInfo, 'skip', args),
fixme: (...args: [arg?: any, description?: string]) => modifier(testInfo, 'fixme', args),
Expand Down
2 changes: 1 addition & 1 deletion src/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ export async function removeFolders(dirs: string[]): Promise<Array<Error|undefin
return await Promise.all(dirs.map((dir: string) => {
return new Promise<Error|undefined>(fulfill => {
removeFolder(dir, { maxBusyTries: 10 }, error => {
fulfill(error);
fulfill(error ?? undefined);
});
});
}));
Expand Down
77 changes: 74 additions & 3 deletions tests/playwright-test/golden.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,12 +456,12 @@ test('should respect project threshold', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(0);
});

test('should sanitize snapshot name', async ({ runInlineTest }) => {
test('should sanitize snapshot name when passed as string', async ({ runInlineTest }) => {
const result = await runInlineTest({
...files,
'a.spec.js-snapshots/-snapshot-.txt': `Hello world`,
'a.spec.js': `
const { test } = require('./helper');
const { test } = require('./helper');;
test('is a test', ({}) => {
expect('Hello world').toMatchSnapshot('../../snapshot!.txt');
});
Expand All @@ -474,7 +474,7 @@ test('should write missing expectations with sanitized snapshot name', async ({
const result = await runInlineTest({
...files,
'a.spec.js': `
const { test } = require('./helper');
const { test } = require('./helper');;
test('is a test', ({}) => {
expect('Hello world').toMatchSnapshot('../../snapshot!.txt');
});
Expand All @@ -488,6 +488,77 @@ 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 }) => {
const result = await runInlineTest({
...files,
'a.spec.js-snapshots/test/path/snapshot.txt': `Hello world`,
'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 update snapshot with array of path segments', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...files,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', ({}) => {
expect('Hello world').toMatchSnapshot(['test', 'path', 'snapshot.txt']);
});
`
}, { 'update-snapshots': true });

expect(result.exitCode).toBe(0);
const snapshotOutputPath = testInfo.outputPath('a.spec.js-snapshots/test/path/snapshot.txt');
expect(result.output).toContain(`${snapshotOutputPath} is missing in snapshots, writing actual`);
const data = fs.readFileSync(snapshotOutputPath);
expect(data.toString()).toBe('Hello world');
});

test('should attach expected/actual/diff with snapshot path', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...files,
'a.spec.js-snapshots/test/path/snapshot.png':
Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==', 'base64'),
'a.spec.js': `
const { test } = require('./helper');
test.afterEach(async ({}, testInfo) => {
console.log('## ' + JSON.stringify(testInfo.attachments));
});
test('is a test', ({}) => {
expect(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII==', 'base64')).toMatchSnapshot(['test', 'path', 'snapshot.png']);
});
`
});

const outputText = stripAscii(result.output);
const attachments = outputText.split('\n').filter(l => l.startsWith('## ')).map(l => l.substring(3)).map(l => JSON.parse(l))[0];
for (const attachment of attachments)
attachment.path = attachment.path.replace(/\\/g, '/').replace(/.*test-results\//, '');
expect(attachments).toEqual([
{
name: 'expected',
contentType: 'image/png',
path: 'a-is-a-test/test/path/snapshot-expected.png'
},
{
name: 'actual',
contentType: 'image/png',
path: 'a-is-a-test/test/path/snapshot-actual.png'
},
{
name: 'diff',
contentType: 'image/png',
path: 'a-is-a-test/test/path/snapshot-diff.png'
}
]);
});

test('should attach expected/actual/diff', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...files,
Expand Down
Loading