@uniflowed/test 0.0.0-alpha.1 → 0.0.0-alpha.4
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 +19 -2
- package/internal/asymmetric.js +184 -0
- package/internal/equality.js +79 -19
- package/internal/expect.js +294 -81
- package/internal/frames.js +9 -3
- package/internal/namespace.js +251 -0
- package/internal/output.js +260 -0
- package/internal/registry.js +30 -27
- package/internal/run.js +44 -21
- package/internal/snapshot.js +306 -0
- package/internal/spy.js +236 -0
- package/internal/timers.js +362 -0
- package/package.json +5 -2
- package/worker.js +62 -15
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
15
|
import { firstUserSite, 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. */
|
|
@@ -128,11 +137,11 @@ function failure(thrown: mixed): Outcome {
|
|
|
128
137
|
|
|
129
138
|
/** Everything one case needs from the suites above it. */
|
|
130
139
|
type Context = {|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
140
|
+
readonly path: $ReadOnlyArray<string>,
|
|
141
|
+
readonly beforeEach: $ReadOnlyArray<Body>,
|
|
142
|
+
readonly afterEach: $ReadOnlyArray<Body>,
|
|
143
|
+
readonly skipped: boolean,
|
|
144
|
+
readonly onlyPath: boolean,
|
|
136
145
|
|};
|
|
137
146
|
|
|
138
147
|
/**
|
|
@@ -149,6 +158,10 @@ async function runCase(
|
|
|
149
158
|
emit: (result: Result) => void,
|
|
150
159
|
): Promise<boolean> {
|
|
151
160
|
const name = fullName([...context.path, test.name]);
|
|
161
|
+
// Snapshots are keyed by the running test, so the module has to be told which
|
|
162
|
+
// one it is — and told again that none is, so one taken outside a test fails
|
|
163
|
+
// with something better than a wrong key.
|
|
164
|
+
snapshot.enterTest(options.file ?? "", name);
|
|
152
165
|
const started = performance.now();
|
|
153
166
|
const report = (outcome: Outcome) => {
|
|
154
167
|
emit({
|
|
@@ -180,6 +193,11 @@ async function runCase(
|
|
|
180
193
|
|
|
181
194
|
const timeoutMs = test.timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
182
195
|
let outcome: Outcome = { status: "passed" };
|
|
196
|
+
// From here to the end of teardown is exactly the window in which this
|
|
197
|
+
// case's own code runs, so it is exactly the window whose printing is this
|
|
198
|
+
// case's. The cases above never reach it: nothing runs for a `todo`, a
|
|
199
|
+
// `skip` or a filtered-out case, so nothing of theirs can print.
|
|
200
|
+
output.enterTest(name);
|
|
183
201
|
try {
|
|
184
202
|
for (const hook of context.beforeEach) {
|
|
185
203
|
await withTimeout(hook, timeoutMs);
|
|
@@ -199,6 +217,11 @@ async function runCase(
|
|
|
199
217
|
}
|
|
200
218
|
}
|
|
201
219
|
}
|
|
220
|
+
// No test is running once this one is reported, so a snapshot taken outside
|
|
221
|
+
// one fails with something better than a key belonging to whichever test
|
|
222
|
+
// happened to run last, and a line printed outside one is the file's.
|
|
223
|
+
output.exitTest();
|
|
224
|
+
snapshot.exitTest();
|
|
202
225
|
report(outcome);
|
|
203
226
|
return outcome.status !== "failed";
|
|
204
227
|
}
|
|
@@ -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
|
+
}
|
package/internal/spy.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/test`: the spy behind `fn` and `uft.spyOn`.
|
|
4
|
+
//
|
|
5
|
+
// Shaped after Vitest's, because a project moving to uf should not have to
|
|
6
|
+
// rewrite its assertions. That means `mock.calls`, `mock.results`,
|
|
7
|
+
// `mock.lastCall`, the `Once` variants, and `mockReset` and `mockRestore`
|
|
8
|
+
// meaning the two different things they mean there.
|
|
9
|
+
//
|
|
10
|
+
// The three reset verbs are easy to conflate and are genuinely different:
|
|
11
|
+
//
|
|
12
|
+
// * `mockClear` forgets the calls, and keeps the implementation.
|
|
13
|
+
// * `mockReset` forgets the calls *and* the implementation, leaving the
|
|
14
|
+
// original one a `spyOn` captured — or nothing, for a bare `fn`.
|
|
15
|
+
// * `mockRestore` does what `mockReset` does and then puts the real method
|
|
16
|
+
// back on the object, which only a `spyOn` has to put back.
|
|
17
|
+
//
|
|
18
|
+
// Every spy is registered, so `uft.clearAllMocks` and its siblings can reach the
|
|
19
|
+
// ones a test never held a reference to.
|
|
20
|
+
|
|
21
|
+
/** One call: what went in, and what came out. */
|
|
22
|
+
export type SpyCall = {
|
|
23
|
+
readonly args: $ReadOnlyArray<mixed>,
|
|
24
|
+
readonly returned?: mixed,
|
|
25
|
+
readonly threw?: mixed,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** One call's outcome, in the shape Vitest reports it. */
|
|
29
|
+
export type SpyResult =
|
|
30
|
+
| { readonly type: "return", readonly value: mixed }
|
|
31
|
+
| { readonly type: "throw", readonly value: mixed };
|
|
32
|
+
|
|
33
|
+
/** Every spy made in this process, so the `All` verbs can reach them. */
|
|
34
|
+
const registry: Array<$FlowFixMe> = [];
|
|
35
|
+
|
|
36
|
+
/** How a spy puts back what it replaced, when it replaced something. */
|
|
37
|
+
type Restore = null | (() => void);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A spy, optionally standing in for something it can put back.
|
|
41
|
+
*
|
|
42
|
+
* `restore` is what separates `fn()` from `spyOn(object, "method")`: the second
|
|
43
|
+
* took something off an object and owes it back.
|
|
44
|
+
*/
|
|
45
|
+
function makeSpy(implementation: mixed, restore: Restore, name: string): $FlowFixMe {
|
|
46
|
+
const calls: Array<SpyCall> = [];
|
|
47
|
+
const results: Array<SpyResult> = [];
|
|
48
|
+
const instances: Array<mixed> = [];
|
|
49
|
+
// Implementations queued by the `Once` variants, taken from the front.
|
|
50
|
+
const queued: Array<mixed> = [];
|
|
51
|
+
|
|
52
|
+
const original = implementation;
|
|
53
|
+
let current = implementation;
|
|
54
|
+
let mockName = name;
|
|
55
|
+
|
|
56
|
+
const spy: $FlowFixMe = function (...args: $ReadOnlyArray<mixed>) {
|
|
57
|
+
// `this` is recorded because a spy on a method is often called as one, and
|
|
58
|
+
// `mock.instances` is how a test asserts on the receiver.
|
|
59
|
+
instances.push(this);
|
|
60
|
+
const body = queued.length > 0 ? queued.shift() : current;
|
|
61
|
+
try {
|
|
62
|
+
const returned = typeof body === "function" ? body.apply(this, args) : undefined;
|
|
63
|
+
calls.push({ args, returned });
|
|
64
|
+
results.push({ type: "return", value: returned });
|
|
65
|
+
return returned;
|
|
66
|
+
} catch (thrown) {
|
|
67
|
+
calls.push({ args, threw: thrown });
|
|
68
|
+
results.push({ type: "throw", value: thrown });
|
|
69
|
+
throw thrown;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
spy.mock = {
|
|
74
|
+
calls,
|
|
75
|
+
results,
|
|
76
|
+
instances,
|
|
77
|
+
get lastCall(): $ReadOnlyArray<mixed> | void {
|
|
78
|
+
return calls.length === 0 ? undefined : calls[calls.length - 1].args;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
spy.mockClear = () => {
|
|
83
|
+
calls.length = 0;
|
|
84
|
+
results.length = 0;
|
|
85
|
+
instances.length = 0;
|
|
86
|
+
return spy;
|
|
87
|
+
};
|
|
88
|
+
spy.mockReset = () => {
|
|
89
|
+
spy.mockClear();
|
|
90
|
+
queued.length = 0;
|
|
91
|
+
current = original;
|
|
92
|
+
return spy;
|
|
93
|
+
};
|
|
94
|
+
spy.mockRestore = () => {
|
|
95
|
+
spy.mockReset();
|
|
96
|
+
if (restore != null) {
|
|
97
|
+
restore();
|
|
98
|
+
}
|
|
99
|
+
return spy;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
spy.mockImplementation = (next: mixed) => {
|
|
103
|
+
current = next;
|
|
104
|
+
return spy;
|
|
105
|
+
};
|
|
106
|
+
spy.mockImplementationOnce = (next: mixed) => {
|
|
107
|
+
queued.push(next);
|
|
108
|
+
return spy;
|
|
109
|
+
};
|
|
110
|
+
spy.withImplementation = (next: mixed, body: () => mixed) => {
|
|
111
|
+
const previous = current;
|
|
112
|
+
current = next;
|
|
113
|
+
try {
|
|
114
|
+
const out = body();
|
|
115
|
+
// An async body has to put the implementation back when it settles, not
|
|
116
|
+
// when it starts, or the next test runs against this one's stand-in.
|
|
117
|
+
if (out != null && typeof (out: $FlowFixMe).then === "function") {
|
|
118
|
+
return (out: $FlowFixMe).finally(() => {
|
|
119
|
+
current = previous;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
current = previous;
|
|
123
|
+
return out;
|
|
124
|
+
} catch (thrown) {
|
|
125
|
+
current = previous;
|
|
126
|
+
throw thrown;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
spy.mockReturnValue = (value: mixed) => spy.mockImplementation(() => value);
|
|
131
|
+
spy.mockReturnValueOnce = (value: mixed) => spy.mockImplementationOnce(() => value);
|
|
132
|
+
spy.mockResolvedValue = (value: mixed) => spy.mockImplementation(() => Promise.resolve(value));
|
|
133
|
+
spy.mockResolvedValueOnce = (value: mixed) =>
|
|
134
|
+
spy.mockImplementationOnce(() => Promise.resolve(value));
|
|
135
|
+
spy.mockRejectedValue = (reason: mixed) => spy.mockImplementation(() => Promise.reject(reason));
|
|
136
|
+
spy.mockRejectedValueOnce = (reason: mixed) =>
|
|
137
|
+
spy.mockImplementationOnce(() => Promise.reject(reason));
|
|
138
|
+
spy.mockReturnThis = () =>
|
|
139
|
+
spy.mockImplementation(function () {
|
|
140
|
+
return this;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
spy.mockName = (next: string) => {
|
|
144
|
+
mockName = next;
|
|
145
|
+
return spy;
|
|
146
|
+
};
|
|
147
|
+
spy.getMockName = () => mockName;
|
|
148
|
+
|
|
149
|
+
registry.push(spy);
|
|
150
|
+
return spy;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A spy with no original behind it.
|
|
155
|
+
*
|
|
156
|
+
* `fn()` records and returns `undefined`; `fn(body)` records and runs `body`.
|
|
157
|
+
*/
|
|
158
|
+
export function fn(implementation?: mixed): $FlowFixMe {
|
|
159
|
+
return makeSpy(implementation, null, "spy");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Replace `object[method]` with a spy that calls through to it.
|
|
164
|
+
*
|
|
165
|
+
* Calls through by default, which is what makes `spyOn` an observation rather
|
|
166
|
+
* than a replacement — a test that only wants to know a method was called does
|
|
167
|
+
* not have to reimplement it. `mockImplementation` is how a test says it wants
|
|
168
|
+
* the other thing.
|
|
169
|
+
*
|
|
170
|
+
* The original is put back by `mockRestore`, and by `uft.restoreAllMocks`.
|
|
171
|
+
*/
|
|
172
|
+
export function spyOn(object: mixed, method: string): $FlowFixMe {
|
|
173
|
+
if (object == null || (typeof object !== "object" && typeof object !== "function")) {
|
|
174
|
+
throw new TypeError(`uft.spyOn: cannot spy on ${describe(object)}`);
|
|
175
|
+
}
|
|
176
|
+
const target = object as $FlowFixMe;
|
|
177
|
+
const original = target[method];
|
|
178
|
+
if (typeof original !== "function") {
|
|
179
|
+
throw new TypeError(
|
|
180
|
+
`uft.spyOn: ${method} is ${describe(original)}, and only a method can be spied on`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const owned = Object.hasOwn(target, method);
|
|
185
|
+
const spy = makeSpy(
|
|
186
|
+
original,
|
|
187
|
+
() => {
|
|
188
|
+
// Deleting rather than reassigning when the method was inherited: writing
|
|
189
|
+
// the original onto the instance would leave a copy the prototype no longer
|
|
190
|
+
// controls, and the next change to the prototype would not be seen.
|
|
191
|
+
if (owned) {
|
|
192
|
+
target[method] = original;
|
|
193
|
+
} else {
|
|
194
|
+
delete target[method];
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
method,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
target[method] = spy;
|
|
201
|
+
return spy;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Whether `value` is one of these spies. */
|
|
205
|
+
export function isSpy(value: mixed): boolean {
|
|
206
|
+
return typeof value === "function" && (value: $FlowFixMe).mock != null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Forget every spy's calls, keeping their implementations. */
|
|
210
|
+
export function clearAllMocks(): void {
|
|
211
|
+
for (const spy of registry) {
|
|
212
|
+
spy.mockClear();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Forget every spy's calls and implementations. */
|
|
217
|
+
export function resetAllMocks(): void {
|
|
218
|
+
for (const spy of registry) {
|
|
219
|
+
spy.mockReset();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Put back everything `spyOn` replaced. */
|
|
224
|
+
export function restoreAllMocks(): void {
|
|
225
|
+
for (const spy of registry) {
|
|
226
|
+
spy.mockRestore();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** A readable name for a value, for an error message. */
|
|
231
|
+
function describe(value: mixed): string {
|
|
232
|
+
if (value === null) {
|
|
233
|
+
return "null";
|
|
234
|
+
}
|
|
235
|
+
return typeof value;
|
|
236
|
+
}
|