Skip to content

Commit 352cddf

Browse files
committed
eng: add support for snapshot tests
This adds Jest-like support for snapshot testing. Developers can do something like: ```js await assertSnapshot(myComplexObject) ``` The first time this is run, the snapshot expectation file is written to a `__snapshots__` directory beside the test file. Subsequent runs will compare the object to the snapshot, and fail if it doesn't match. You can see an example of this in the test for snapshots themselves! After a successful run, any unused snapshots are cleaned up. On a failed run, a gitignored `.actual` snapshot file is created beside the snapshot for easy processing and inspection. Shortly I will do some integration with the selfhost test extension to allow developers to easily update snapshots from the vscode UI. For #189680 cc @ulugbekna @hediet
1 parent 8ca2aef commit 352cddf

File tree

15 files changed

+464
-29
lines changed

15 files changed

+464
-29
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ vscode.db
1818
/cli/target
1919
/cli/openssl
2020
product.overrides.json
21+
*.snap.actual

build/hygiene.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,12 @@ function hygiene(some, linting = true) {
150150
}
151151

152152
const productJsonFilter = filter('product.json', { restore: true });
153+
const snapshotFilter = filter(['**', '!**/*.snap', '!**/*.snap.actual']);
153154
const unicodeFilterStream = filter(unicodeFilter, { restore: true });
154155

155156
const result = input
156157
.pipe(filter((f) => !f.stat.isDirectory()))
158+
.pipe(snapshotFilter)
157159
.pipe(productJsonFilter)
158160
.pipe(process.env['BUILD_SOURCEVERSION'] ? es.through() : productJson)
159161
.pipe(productJsonFilter.restore)

src/vs/base/common/iterator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export namespace Iterable {
4747
return false;
4848
}
4949

50-
export function find<T, R extends T>(iterable: Iterable<T>, predicate: (t: T) => t is R): T | undefined;
50+
export function find<T, R extends T>(iterable: Iterable<T>, predicate: (t: T) => t is R): R | undefined;
5151
export function find<T>(iterable: Iterable<T>, predicate: (t: T) => boolean): T | undefined;
5252
export function find<T>(iterable: Iterable<T>, predicate: (t: T) => boolean): T | undefined {
5353
for (const element of iterable) {
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { Lazy } from 'vs/base/common/lazy';
7+
import { FileAccess } from 'vs/base/common/network';
8+
import { URI } from 'vs/base/common/uri';
9+
10+
declare const __readFileInTests: (path: string) => Promise<string>;
11+
declare const __writeFileInTests: (path: string, contents: string) => Promise<void>;
12+
declare const __readDirInTests: (path: string) => Promise<string[]>;
13+
declare const __unlinkInTests: (path: string) => Promise<void>;
14+
declare const __mkdirPInTests: (path: string) => Promise<void>;
15+
16+
// setup on import so assertSnapshot has the current context without explicit passing
17+
let context: Lazy<SnapshotContext> | undefined;
18+
const snapshotFileSuffix = '.snap';
19+
const sanitizeName = (name: string) => name.replace(/[^a-z0-9_-]/gi, '_');
20+
const normalizeCrlf = (str: string) => str.replace(/\r\n/g, '\n');
21+
22+
export interface ISnapshotOptions {
23+
/** Name for snapshot file, rather than an incremented number */
24+
name?: string;
25+
}
26+
27+
/**
28+
* This is exported only for tests against the snapshotting itself! Use
29+
* {@link assertSnapshot} as a consumer!
30+
*/
31+
export class SnapshotContext {
32+
private nextIndex = 0;
33+
private readonly namePrefix: string;
34+
private readonly snapshotsDir: URI;
35+
private readonly usedNames = new Set();
36+
37+
constructor(private readonly test: Mocha.Test | undefined) {
38+
if (!test) {
39+
throw new Error('assertSnapshot can only be used in a test');
40+
}
41+
42+
if (!test.file) {
43+
throw new Error('currentTest.file is not set, please open an issue with the test you\'re trying to run');
44+
}
45+
46+
const src = FileAccess.asFileUri('');
47+
const parts = test.file.split(/[/\\]/g);
48+
49+
this.namePrefix = sanitizeName(test.fullTitle()) + '_';
50+
this.snapshotsDir = URI.joinPath(src, ...[...parts.slice(0, -1), '__snapshots__']);
51+
}
52+
53+
public async assert(value: any, options?: ISnapshotOptions) {
54+
const originalStack = new Error().stack!; // save to make the stack nicer on failure
55+
const nameOrIndex = (options?.name ? sanitizeName(options.name) : this.nextIndex++);
56+
const fileName = this.namePrefix + nameOrIndex + snapshotFileSuffix;
57+
this.usedNames.add(fileName);
58+
59+
const fpath = URI.joinPath(this.snapshotsDir, fileName).fsPath;
60+
const actual = formatValue(value);
61+
let expected: string;
62+
try {
63+
expected = await __readFileInTests(fpath);
64+
} catch {
65+
console.info(`Creating new snapshot in: ${fpath}`);
66+
await __mkdirPInTests(this.snapshotsDir.fsPath);
67+
await __writeFileInTests(fpath, actual);
68+
return;
69+
}
70+
71+
if (normalizeCrlf(expected) !== normalizeCrlf(actual)) {
72+
await __writeFileInTests(fpath + '.actual', actual);
73+
const err: any = new Error(`Snapshot #${nameOrIndex} does not match expected output`);
74+
err.expected = expected;
75+
err.actual = actual;
76+
err.snapshotPath = fpath;
77+
err.stack = (err.stack as string)
78+
.split('\n')
79+
// remove all frames from the async stack and keep the original caller's frame
80+
.slice(0, 1)
81+
.concat(originalStack.split('\n').slice(3))
82+
.join('\n');
83+
throw err;
84+
}
85+
}
86+
87+
public async removeOldSnapshots() {
88+
const contents = await __readDirInTests(this.snapshotsDir.fsPath);
89+
const toDelete = contents.filter(f => f.startsWith(this.namePrefix) && !this.usedNames.has(f));
90+
if (toDelete.length) {
91+
console.info(`Deleting ${toDelete.length} old snapshots for ${this.test?.fullTitle()}`);
92+
}
93+
94+
await Promise.all(toDelete.map(f => __unlinkInTests(URI.joinPath(this.snapshotsDir, f).fsPath)));
95+
}
96+
}
97+
98+
const debugDescriptionSymbol = Symbol.for('debug.description');
99+
100+
function formatValue(value: unknown, level = 0, seen: unknown[] = []): string {
101+
switch (typeof value) {
102+
case 'bigint':
103+
case 'boolean':
104+
case 'number':
105+
case 'symbol':
106+
case 'undefined':
107+
return String(value);
108+
case 'string':
109+
return level === 0 ? value : JSON.stringify(value);
110+
case 'function':
111+
return `[Function ${value.name}]`;
112+
case 'object': {
113+
if (value === null) {
114+
return 'null';
115+
}
116+
if (value instanceof RegExp) {
117+
return String(value);
118+
}
119+
if (seen.includes(value)) {
120+
return '[Circular]';
121+
}
122+
if (debugDescriptionSymbol in value && typeof (value as any)[debugDescriptionSymbol] === 'function') {
123+
return (value as any)[debugDescriptionSymbol]();
124+
}
125+
const oi = ' '.repeat(level);
126+
const ci = ' '.repeat(level + 1);
127+
if (Array.isArray(value)) {
128+
const children = value.map(v => formatValue(v, level + 1, [...seen, value]));
129+
const multiline = children.some(c => c.includes('\n')) || children.join(', ').length > 80;
130+
return multiline ? `[\n${ci}${children.join(`,\n${ci}`)}\n${oi}]` : `[ ${children.join(', ')} ]`;
131+
}
132+
133+
let entries;
134+
let prefix = '';
135+
if (value instanceof Map) {
136+
prefix = 'Map ';
137+
entries = [...value.entries()];
138+
} else if (value instanceof Set) {
139+
prefix = 'Set ';
140+
entries = [...value.entries()];
141+
} else {
142+
entries = Object.entries(value);
143+
}
144+
145+
const lines = entries.map(([k, v]) => `${k}: ${formatValue(v, level + 1, [...seen, value])}`);
146+
return prefix + (lines.length > 1
147+
? `{\n${ci}${lines.join(`,\n${ci}`)}\n${oi}}`
148+
: `{ ${lines.join(',\n')} }`);
149+
}
150+
default:
151+
throw new Error(`Unknown type ${value}`);
152+
}
153+
}
154+
155+
setup(function () {
156+
const currentTest = this.currentTest;
157+
context = new Lazy(() => new SnapshotContext(currentTest));
158+
});
159+
teardown(async function () {
160+
if (this.currentTest?.state === 'passed') {
161+
await context?.rawValue?.removeOldSnapshots();
162+
}
163+
context = undefined;
164+
});
165+
166+
/**
167+
* Implements a snapshot testing utility. ⚠️ This is async! ⚠️
168+
*
169+
* The first time a snapshot test is run, it'll record the value it's called
170+
* with as the expected value. Subsequent runs will fail if the value differs,
171+
* but the snapshot can be regenerated by hand or using the Selfhost Test
172+
* Provider Extension which'll offer to update it.
173+
*
174+
* The snapshot will be associated with the currently running test and stored
175+
* in a `__snapshots__` directory next to the test file, which is expected to
176+
* be the first `.test.js` file in the callstack.
177+
*/
178+
export function assertSnapshot(value: any, options?: ISnapshotOptions): Promise<void> {
179+
if (!context) {
180+
throw new Error('assertSnapshot can only be used in a test');
181+
}
182+
183+
return context.value.assert(value, options);
184+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
hello_world__0.snap:
2+
{ cool: true }
3+
hello_world__1.snap:
4+
{ nifty: true }
5+
hello_world__fourthTest.snap:
6+
{ customName: 2 }
7+
hello_world__thirdTest.snap:
8+
{ customName: 1 }
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
hello_world__0.snap:
2+
{ cool: true }
3+
hello_world__thirdTest.snap:
4+
{ customName: 1 }
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
hello_world__0.snap:
2+
{ cool: true }
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[
2+
1,
3+
true,
4+
undefined,
5+
null,
6+
123,
7+
Symbol(heyo),
8+
"hello",
9+
{ hello: "world" },
10+
{ a: [Circular] },
11+
Map {
12+
hello: 1,
13+
goodbye: 2
14+
},
15+
Set {
16+
1: 1,
17+
2: 2,
18+
3: 3
19+
},
20+
[Function helloWorld],
21+
/hello/g,
22+
[
23+
"long stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong string",
24+
"long stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong string",
25+
"long stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong string",
26+
"long stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong string",
27+
"long stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong string",
28+
"long stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong string",
29+
"long stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong string",
30+
"long stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong string",
31+
"long stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong string",
32+
"long stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong stringlong string"
33+
],
34+
Range [1 -> 5]
35+
]

0 commit comments

Comments
 (0)