@uniflowed/test 0.0.0-alpha.1 → 0.0.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +21 -2
- package/internal/asymmetric.js +184 -0
- package/internal/equality.js +79 -19
- package/internal/expect.js +323 -81
- package/internal/frames.js +96 -4
- package/internal/modules.js +430 -0
- package/internal/namespace.js +287 -0
- package/internal/output.js +309 -0
- package/internal/registry.js +30 -27
- package/internal/run.js +94 -42
- package/internal/snapshot.js +306 -0
- package/internal/spy.js +236 -0
- package/internal/timers.js +362 -0
- package/internal/unsupported.js +30 -0
- package/package.json +5 -2
- package/worker.js +163 -25
package/internal/run.js
CHANGED
|
@@ -9,40 +9,49 @@
|
|
|
9
9
|
// driven by the worker, by a unit test, or by a future host that is not
|
|
10
10
|
// Node.js, without any of them re-deciding what `.only` means.
|
|
11
11
|
|
|
12
|
+
import * as output from "./output.js";
|
|
13
|
+
import * as snapshot from "./snapshot.js";
|
|
12
14
|
import { AssertionError } from "./expect.js";
|
|
13
|
-
import { firstUserSite, userFrames } from "./frames.js";
|
|
15
|
+
import { type Site, firstUserSite, siteInFile, userFrames } from "./frames.js";
|
|
14
16
|
import { type Body, type Case, type Suite, collected } from "./registry.js";
|
|
15
17
|
|
|
16
18
|
/** How one case ended. */
|
|
17
19
|
export type Outcome =
|
|
18
|
-
| {|
|
|
20
|
+
| {| readonly status: "passed" |}
|
|
19
21
|
| {|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
readonly status: "failed",
|
|
23
|
+
readonly message: string,
|
|
24
|
+
readonly stack: string | null,
|
|
25
|
+
readonly expected: string | null,
|
|
26
|
+
readonly received: string | null,
|
|
25
27
|
/** Where the failing assertion was written, when the stack says. */
|
|
26
|
-
|
|
28
|
+
readonly site: {| readonly line: number, readonly column: number |} | null,
|
|
27
29
|
|}
|
|
28
|
-
| {|
|
|
29
|
-
| {|
|
|
30
|
+
| {| readonly status: "skipped", readonly reason: "explicit" | "not-only" | "filtered" |}
|
|
31
|
+
| {| readonly status: "todo" |};
|
|
30
32
|
|
|
31
33
|
/** One finished case, as the runner reports it. */
|
|
32
34
|
export type Result = {|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
readonly name: string,
|
|
36
|
+
readonly line: number,
|
|
37
|
+
readonly column: number,
|
|
38
|
+
readonly durationMicros: number,
|
|
39
|
+
readonly outcome: Outcome,
|
|
38
40
|
|};
|
|
39
41
|
|
|
40
42
|
/** How a run is configured. */
|
|
41
43
|
export type RunOptions = {|
|
|
42
44
|
/** Keep only cases whose full name contains this, reporting the rest skipped. */
|
|
43
|
-
|
|
45
|
+
readonly filter?: string | null,
|
|
44
46
|
/** Wall-clock budget for one case, in milliseconds. */
|
|
45
|
-
|
|
47
|
+
readonly timeoutMs?: number,
|
|
48
|
+
/**
|
|
49
|
+
* Absolute path of the file being run.
|
|
50
|
+
*
|
|
51
|
+
* Snapshots live beside the file that took them, so the runner has to say
|
|
52
|
+
* which file that is — a test's name alone does not locate it.
|
|
53
|
+
*/
|
|
54
|
+
readonly file?: string,
|
|
46
55
|
|};
|
|
47
56
|
|
|
48
57
|
/** Default budget for one case, matching what most runners use. */
|
|
@@ -93,7 +102,22 @@ async function withTimeout(body: Body, timeoutMs: number): Promise<void> {
|
|
|
93
102
|
}
|
|
94
103
|
}
|
|
95
104
|
|
|
96
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Where to say a failure happened.
|
|
107
|
+
*
|
|
108
|
+
* The reported position is printed under the path of the file being run, so
|
|
109
|
+
* when that file is known the line has to come from it: `firstUserSite` will
|
|
110
|
+
* hand back the first frame of whatever library raised, and a library's line
|
|
111
|
+
* number wearing the test file's path sends the reader to the wrong place
|
|
112
|
+
* (ubugeeei-prod/uf#319). The fallback is for a caller that did not say which
|
|
113
|
+
* file it is running — `run` is driven directly by this repository's own
|
|
114
|
+
* tests as well as by the worker — and is what every failure used before.
|
|
115
|
+
*/
|
|
116
|
+
function siteOf(stack: string | null, file: string | null): Site | null {
|
|
117
|
+
return file == null || file === "" ? firstUserSite(stack, false) : siteInFile(stack, file);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function failure(thrown: mixed, file: string | null): Outcome {
|
|
97
121
|
if (thrown instanceof AssertionError) {
|
|
98
122
|
const stack = userFrames(thrown.stack);
|
|
99
123
|
return {
|
|
@@ -102,7 +126,7 @@ function failure(thrown: mixed): Outcome {
|
|
|
102
126
|
stack,
|
|
103
127
|
expected: thrown.expected,
|
|
104
128
|
received: thrown.received,
|
|
105
|
-
site:
|
|
129
|
+
site: siteOf(stack, file),
|
|
106
130
|
};
|
|
107
131
|
}
|
|
108
132
|
if (thrown instanceof Error) {
|
|
@@ -113,7 +137,7 @@ function failure(thrown: mixed): Outcome {
|
|
|
113
137
|
stack,
|
|
114
138
|
expected: null,
|
|
115
139
|
received: null,
|
|
116
|
-
site:
|
|
140
|
+
site: siteOf(stack, file),
|
|
117
141
|
};
|
|
118
142
|
}
|
|
119
143
|
return {
|
|
@@ -128,11 +152,11 @@ function failure(thrown: mixed): Outcome {
|
|
|
128
152
|
|
|
129
153
|
/** Everything one case needs from the suites above it. */
|
|
130
154
|
type Context = {|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
155
|
+
readonly path: $ReadOnlyArray<string>,
|
|
156
|
+
readonly beforeEach: $ReadOnlyArray<Body>,
|
|
157
|
+
readonly afterEach: $ReadOnlyArray<Body>,
|
|
158
|
+
readonly skipped: boolean,
|
|
159
|
+
readonly onlyPath: boolean,
|
|
136
160
|
|};
|
|
137
161
|
|
|
138
162
|
/**
|
|
@@ -149,6 +173,10 @@ async function runCase(
|
|
|
149
173
|
emit: (result: Result) => void,
|
|
150
174
|
): Promise<boolean> {
|
|
151
175
|
const name = fullName([...context.path, test.name]);
|
|
176
|
+
// Snapshots are keyed by the running test, so the module has to be told which
|
|
177
|
+
// one it is — and told again that none is, so one taken outside a test fails
|
|
178
|
+
// with something better than a wrong key.
|
|
179
|
+
snapshot.enterTest(options.file ?? "", name);
|
|
152
180
|
const started = performance.now();
|
|
153
181
|
const report = (outcome: Outcome) => {
|
|
154
182
|
emit({
|
|
@@ -180,25 +208,41 @@ async function runCase(
|
|
|
180
208
|
|
|
181
209
|
const timeoutMs = test.timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
182
210
|
let outcome: Outcome = { status: "passed" };
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
for (const hook of context.afterEach) {
|
|
211
|
+
// Setup, body and teardown run *inside* the case's output context, and that
|
|
212
|
+
// nesting is the whole of the fix for #207. What names a printed line is no
|
|
213
|
+
// longer where the runner had got to when the line arrived — which named the
|
|
214
|
+
// next case for anything a `setTimeout` left behind — but which case's work
|
|
215
|
+
// the write descends from. A callback scheduled here keeps this name however
|
|
216
|
+
// late it fires.
|
|
217
|
+
//
|
|
218
|
+
// The cases above never reach this: nothing runs for a `todo`, a `skip` or a
|
|
219
|
+
// filtered-out case, so nothing of theirs can print.
|
|
220
|
+
await output.runInTest(name, async () => {
|
|
194
221
|
try {
|
|
195
|
-
|
|
222
|
+
for (const hook of context.beforeEach) {
|
|
223
|
+
await withTimeout(hook, timeoutMs);
|
|
224
|
+
}
|
|
225
|
+
await withTimeout(test.body, timeoutMs);
|
|
196
226
|
} catch (thrown) {
|
|
197
|
-
|
|
198
|
-
|
|
227
|
+
outcome = failure(thrown, options.file ?? null);
|
|
228
|
+
}
|
|
229
|
+
// Teardown runs whatever happened above, and only reports its own failure
|
|
230
|
+
// when the body had not already failed.
|
|
231
|
+
for (const hook of context.afterEach) {
|
|
232
|
+
try {
|
|
233
|
+
await withTimeout(hook, timeoutMs);
|
|
234
|
+
} catch (thrown) {
|
|
235
|
+
if (outcome.status === "passed") {
|
|
236
|
+
outcome = failure(thrown, options.file ?? null);
|
|
237
|
+
}
|
|
199
238
|
}
|
|
200
239
|
}
|
|
201
|
-
}
|
|
240
|
+
});
|
|
241
|
+
// The `await` above resumes outside the context it entered, so this line and
|
|
242
|
+
// everything after it belong to no case again — a line printed between cases
|
|
243
|
+
// is the file's, and a snapshot taken outside one fails with something better
|
|
244
|
+
// than a key belonging to whichever test happened to run last.
|
|
245
|
+
snapshot.exitTest();
|
|
202
246
|
report(outcome);
|
|
203
247
|
return outcome.status !== "failed";
|
|
204
248
|
}
|
|
@@ -215,6 +259,7 @@ async function runSuite(
|
|
|
215
259
|
onlyMode: boolean,
|
|
216
260
|
emit: (result: Result) => void,
|
|
217
261
|
state: {| bail: boolean |},
|
|
262
|
+
setUpAncestors: () => Promise<void>,
|
|
218
263
|
): Promise<boolean> {
|
|
219
264
|
const skipped = context.skipped || node.modifier === "skip" || node.modifier === "todo";
|
|
220
265
|
const onlyPath = !onlyMode || context.onlyPath || node.modifier === "only";
|
|
@@ -229,11 +274,18 @@ async function runSuite(
|
|
|
229
274
|
|
|
230
275
|
// `beforeAll` is deferred until a case in this suite actually runs, so a
|
|
231
276
|
// fully skipped suite never sets anything up. `afterAll` mirrors it.
|
|
277
|
+
//
|
|
278
|
+
// "In this suite" means anywhere under it. A suite whose children are all
|
|
279
|
+
// suites has no case of its own, and setting up only for a direct child left
|
|
280
|
+
// every `beforeAll` in an ordinary file — one where the tests live inside a
|
|
281
|
+
// `describe` — never running at all, silently. The chain is walked outermost
|
|
282
|
+
// first, so an inner suite's setup sees what the outer one did.
|
|
232
283
|
let setUp = false;
|
|
233
284
|
const setUpOnce = async () => {
|
|
234
285
|
if (setUp) {
|
|
235
286
|
return;
|
|
236
287
|
}
|
|
288
|
+
await setUpAncestors();
|
|
237
289
|
setUp = true;
|
|
238
290
|
for (const hook of node.beforeAll) {
|
|
239
291
|
await withTimeout(hook, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
@@ -276,7 +328,7 @@ async function runSuite(
|
|
|
276
328
|
const ok = await runCase(child, childContext, options, emit);
|
|
277
329
|
passed = passed && ok;
|
|
278
330
|
} else {
|
|
279
|
-
const ok = await runSuite(child, inner, options, onlyMode, emit, state);
|
|
331
|
+
const ok = await runSuite(child, inner, options, onlyMode, emit, state, setUpOnce);
|
|
280
332
|
passed = passed && ok;
|
|
281
333
|
}
|
|
282
334
|
}
|
|
@@ -310,5 +362,5 @@ export async function run(options: RunOptions, emit: (result: Result) => void):
|
|
|
310
362
|
skipped: false,
|
|
311
363
|
onlyPath: !onlyMode,
|
|
312
364
|
};
|
|
313
|
-
await runSuite(root, context, options, onlyMode, emit, { bail: false });
|
|
365
|
+
await runSuite(root, context, options, onlyMode, emit, { bail: false }, async () => {});
|
|
314
366
|
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/test`: `toMatchSnapshot` and its inline sibling.
|
|
4
|
+
//
|
|
5
|
+
// A snapshot is an assertion whose expected value was written by the last run
|
|
6
|
+
// rather than by a person. That is the whole idea and also the whole danger:
|
|
7
|
+
// a snapshot nobody reads is a test that asserts whatever the code did, which
|
|
8
|
+
// is not a test. Two things here take that seriously.
|
|
9
|
+
//
|
|
10
|
+
// **A missing snapshot is written; a different one is a failure.** Never
|
|
11
|
+
// "updated because it changed" — that is how a snapshot suite becomes a diff
|
|
12
|
+
// nobody looks at. Rewriting on mismatch happens only when a run was explicitly
|
|
13
|
+
// asked to, through `UF_UPDATE_SNAPSHOTS`, which `uf test -u` sets.
|
|
14
|
+
//
|
|
15
|
+
// **The diff is in the failure.** A snapshot mismatch that says only "snapshot
|
|
16
|
+
// did not match" makes a reader open two files; the whole expected and received
|
|
17
|
+
// text is in the message, because that is what they were going to look at.
|
|
18
|
+
//
|
|
19
|
+
// # Where they live
|
|
20
|
+
//
|
|
21
|
+
// `__snapshots__/<file>.snap` beside the test file, one file per test file, in
|
|
22
|
+
// the format Jest and Vitest both write: a module of `exports[key] = ...`. Not
|
|
23
|
+
// because uf runs either, but because the format is diffable, the tooling that
|
|
24
|
+
// reads it already exists, and inventing a different one would buy nothing.
|
|
25
|
+
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
import { render } from "./equality.js";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A backtick, by code point.
|
|
33
|
+
*
|
|
34
|
+
* Written this way rather than as a literal because this module is scanned by
|
|
35
|
+
* `uf_lib`'s surface tests, whose tokenizer does not model regex literals and
|
|
36
|
+
* reads a stray backtick as the start of a template. One constant is cheaper
|
|
37
|
+
* than teaching that scanner about regular expressions, and it is used often
|
|
38
|
+
* enough here to earn a name.
|
|
39
|
+
*/
|
|
40
|
+
const BACKTICK = String.fromCharCode(96);
|
|
41
|
+
|
|
42
|
+
/** Which test is running, so a snapshot can be keyed by it. */
|
|
43
|
+
type Current = {
|
|
44
|
+
/** Absolute path of the test file. */
|
|
45
|
+
readonly file: string,
|
|
46
|
+
/** The test's full name, suites included. */
|
|
47
|
+
readonly name: string,
|
|
48
|
+
/** How many snapshots this test has taken, so a second one gets `2`. */
|
|
49
|
+
taken: number,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
let current: Current | null = null;
|
|
53
|
+
|
|
54
|
+
/** Snapshots read from disk, by snapshot file, and whether they changed. */
|
|
55
|
+
const loaded: Map<string, { entries: { [string]: string }, dirty: boolean }> = new Map();
|
|
56
|
+
|
|
57
|
+
/** Whether this run may rewrite a snapshot that did not match. */
|
|
58
|
+
function updating(): boolean {
|
|
59
|
+
const value = (globalThis: $FlowFixMe).process?.env?.UF_UPDATE_SNAPSHOTS;
|
|
60
|
+
return value != null && value !== "" && value !== "0";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Say which test is running.
|
|
65
|
+
*
|
|
66
|
+
* Called by the runner around each case. `null` between them, so a snapshot
|
|
67
|
+
* taken outside a test fails with something better than a wrong key.
|
|
68
|
+
*/
|
|
69
|
+
export function enterTest(file: string, name: string): void {
|
|
70
|
+
current = { file, name, taken: 0 };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Say that no test is running. */
|
|
74
|
+
export function exitTest(): void {
|
|
75
|
+
current = null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Raised when a snapshot is taken where it cannot be keyed or stored. */
|
|
79
|
+
export class SnapshotError extends Error {
|
|
80
|
+
constructor(message: string) {
|
|
81
|
+
super(message);
|
|
82
|
+
this.name = "SnapshotError";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The snapshot file for a test file. */
|
|
87
|
+
function snapshotPath(file: string): string {
|
|
88
|
+
return path.join(path.dirname(file), "__snapshots__", `${path.basename(file)}.snap`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Read a snapshot file, or start an empty one.
|
|
93
|
+
*
|
|
94
|
+
* Parsed rather than imported: the file is data, and importing it would run it
|
|
95
|
+
* — which is a fine way to execute whatever a snapshot happens to contain.
|
|
96
|
+
*/
|
|
97
|
+
function entriesFor(file: string): { entries: { [string]: string }, dirty: boolean } {
|
|
98
|
+
const target = snapshotPath(file);
|
|
99
|
+
const already = loaded.get(target);
|
|
100
|
+
if (already != null) {
|
|
101
|
+
return already;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const state = { entries: (Object.create(null): $FlowFixMe), dirty: false };
|
|
105
|
+
if (existsSync(target)) {
|
|
106
|
+
parseInto(readFileSync(target, "utf8"), state.entries);
|
|
107
|
+
}
|
|
108
|
+
loaded.set(target, state);
|
|
109
|
+
return state;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The literal text an entry opens with, before its key. */
|
|
113
|
+
const ENTRY_OPEN = "exports[" + BACKTICK;
|
|
114
|
+
|
|
115
|
+
/** The literal text between an entry's key and its value. */
|
|
116
|
+
const ENTRY_MIDDLE = BACKTICK + "] = " + BACKTICK;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Read `exports[<key>] = <value>;` entries out of a snapshot file.
|
|
120
|
+
*
|
|
121
|
+
* A scanner rather than a regular expression, for two reasons. A snapshot's
|
|
122
|
+
* value holds newlines and backticks of its own, so the terminator has to be
|
|
123
|
+
* found by walking the escapes rather than by matching a pattern. And a regular
|
|
124
|
+
* expression for this would have to contain a backtick, which is the one
|
|
125
|
+
* character `code_only` in `uf_lib`'s surface tests cannot see past — the
|
|
126
|
+
* scanner there does not model regex literals, and a backtick inside one reads
|
|
127
|
+
* as the start of a template.
|
|
128
|
+
*/
|
|
129
|
+
function parseInto(source: string, into: { [string]: string }): void {
|
|
130
|
+
let at = 0;
|
|
131
|
+
for (;;) {
|
|
132
|
+
const open = source.indexOf(ENTRY_OPEN, at);
|
|
133
|
+
if (open < 0) {
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
const keyFrom = open + ENTRY_OPEN.length;
|
|
137
|
+
const keyEnd = findClose(source, keyFrom);
|
|
138
|
+
if (keyEnd < 0) {
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
if (!source.startsWith(ENTRY_MIDDLE, keyEnd)) {
|
|
142
|
+
at = keyFrom;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const valueFrom = keyEnd + ENTRY_MIDDLE.length;
|
|
146
|
+
const valueEnd = findClose(source, valueFrom);
|
|
147
|
+
if (valueEnd < 0) {
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
into[unescape(source.slice(keyFrom, keyEnd))] = unescape(source.slice(valueFrom, valueEnd));
|
|
151
|
+
at = valueEnd + 1;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The index of the unescaped backtick closing a value that starts at `from`. */
|
|
156
|
+
function findClose(source: string, from: number): number {
|
|
157
|
+
for (let at = from; at < source.length; at += 1) {
|
|
158
|
+
if (source[at] === "\\") {
|
|
159
|
+
at += 1;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (source[at] === "`") {
|
|
163
|
+
return at;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return -1;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Undo what `escapeValue` did. */
|
|
170
|
+
function unescape(value: string): string {
|
|
171
|
+
let out = "";
|
|
172
|
+
for (let at = 0; at < value.length; at += 1) {
|
|
173
|
+
if (value[at] === "\\" && at + 1 < value.length) {
|
|
174
|
+
at += 1;
|
|
175
|
+
}
|
|
176
|
+
out += value[at];
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Make a value safe inside a template literal.
|
|
183
|
+
*
|
|
184
|
+
* A backtick would end it, a backslash would eat the next character, and `${`
|
|
185
|
+
* would start a substitution that runs code — which matters because a snapshot
|
|
186
|
+
* file is written by a test and read by whatever opens it next.
|
|
187
|
+
*/
|
|
188
|
+
function escapeValue(value: string): string {
|
|
189
|
+
let out = "";
|
|
190
|
+
for (let at = 0; at < value.length; at += 1) {
|
|
191
|
+
const char = value[at];
|
|
192
|
+
if (char === "\\" || char === BACKTICK) {
|
|
193
|
+
out += "\\";
|
|
194
|
+
} else if (char === "$" && value[at + 1] === "{") {
|
|
195
|
+
out += "\\";
|
|
196
|
+
}
|
|
197
|
+
out += char;
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Write a snapshot file back, in key order so a diff is readable. */
|
|
203
|
+
function flush(target: string, entries: { [string]: string }): void {
|
|
204
|
+
const keys = Object.keys(entries).sort();
|
|
205
|
+
const body = keys
|
|
206
|
+
.map((key) => `exports[\`${escapeValue(key)}\`] = \`${escapeValue(entries[key])}\`;\n`)
|
|
207
|
+
.join("\n");
|
|
208
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
209
|
+
writeFileSync(
|
|
210
|
+
target,
|
|
211
|
+
`// uf snapshot file. Read the diff — a snapshot nobody reads is not a test.\n\n${body}`,
|
|
212
|
+
"utf8",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Write every snapshot file this run changed. */
|
|
217
|
+
export function writeChangedSnapshots(): void {
|
|
218
|
+
for (const [target, state] of loaded) {
|
|
219
|
+
if (state.dirty) {
|
|
220
|
+
flush(target, state.entries);
|
|
221
|
+
state.dirty = false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** What a snapshot comparison decided. */
|
|
227
|
+
export type SnapshotVerdict = {
|
|
228
|
+
readonly pass: boolean,
|
|
229
|
+
/** The stored snapshot, or `null` when there was none. */
|
|
230
|
+
readonly expected: string | null,
|
|
231
|
+
/** What this run produced. */
|
|
232
|
+
readonly received: string,
|
|
233
|
+
/** Whether the file was written, and why. */
|
|
234
|
+
readonly wrote: "created" | "updated" | null,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Compare `value` against the stored snapshot for the running test.
|
|
239
|
+
*
|
|
240
|
+
* A missing snapshot is written and passes — the first run of a new assertion
|
|
241
|
+
* has nothing to compare against, and failing it would mean every new snapshot
|
|
242
|
+
* test fails once by design. A *different* one fails unless the run was asked
|
|
243
|
+
* to update.
|
|
244
|
+
*/
|
|
245
|
+
export function matchSnapshot(value: mixed, hint?: string): SnapshotVerdict {
|
|
246
|
+
if (current == null) {
|
|
247
|
+
throw new SnapshotError(
|
|
248
|
+
"toMatchSnapshot was called outside a test, so there is nothing to key it by",
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
current.taken += 1;
|
|
252
|
+
const suffix = hint != null && hint !== "" ? `: ${hint}` : "";
|
|
253
|
+
const key = `${current.name}${suffix} ${current.taken}`;
|
|
254
|
+
const state = entriesFor(current.file);
|
|
255
|
+
const received = render(value);
|
|
256
|
+
|
|
257
|
+
if (!Object.hasOwn(state.entries, key)) {
|
|
258
|
+
state.entries[key] = received;
|
|
259
|
+
state.dirty = true;
|
|
260
|
+
return { pass: true, expected: null, received, wrote: "created" };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const expected = state.entries[key];
|
|
264
|
+
if (expected === received) {
|
|
265
|
+
return { pass: true, expected, received, wrote: null };
|
|
266
|
+
}
|
|
267
|
+
if (updating()) {
|
|
268
|
+
state.entries[key] = received;
|
|
269
|
+
state.dirty = true;
|
|
270
|
+
return { pass: true, expected, received, wrote: "updated" };
|
|
271
|
+
}
|
|
272
|
+
return { pass: false, expected, received, wrote: null };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Compare `value` against a snapshot written in the test file itself.
|
|
277
|
+
*
|
|
278
|
+
* Nothing is written back: uf does not rewrite a test file, because a tool that
|
|
279
|
+
* edits the file you are editing is a tool that loses work. A missing inline
|
|
280
|
+
* snapshot reports what to paste in, which is the same information with the
|
|
281
|
+
* decision left to a person.
|
|
282
|
+
*/
|
|
283
|
+
export function matchInlineSnapshot(value: mixed, expected?: string): SnapshotVerdict {
|
|
284
|
+
const received = render(value);
|
|
285
|
+
if (expected == null) {
|
|
286
|
+
return { pass: false, expected: null, received, wrote: null };
|
|
287
|
+
}
|
|
288
|
+
// The stored form is indented to sit inside the call, so both sides are
|
|
289
|
+
// compared with that indentation removed.
|
|
290
|
+
return {
|
|
291
|
+
pass: dedent(expected) === dedent(received),
|
|
292
|
+
expected,
|
|
293
|
+
received,
|
|
294
|
+
wrote: null,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Strip the common leading whitespace, so indentation is not the assertion. */
|
|
299
|
+
export function dedent(value: string): string {
|
|
300
|
+
const lines = value.replace(/^\n/, "").replace(/\s+$/, "").split("\n");
|
|
301
|
+
const indents = lines
|
|
302
|
+
.filter((line) => line.trim() !== "")
|
|
303
|
+
.map((line) => line.length - line.trimStart().length);
|
|
304
|
+
const common = indents.length === 0 ? 0 : Math.min(...indents);
|
|
305
|
+
return lines.map((line) => line.slice(common)).join("\n");
|
|
306
|
+
}
|