@uniflowed/test 0.0.0-alpha.1 → 0.0.0-alpha.5

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/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
- | {| +status: "passed" |}
20
+ | {| readonly status: "passed" |}
19
21
  | {|
20
- +status: "failed",
21
- +message: string,
22
- +stack: string | null,
23
- +expected: string | null,
24
- +received: string | null,
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
- +site: {| +line: number, +column: number |} | null,
28
+ readonly site: {| readonly line: number, readonly column: number |} | null,
27
29
  |}
28
- | {| +status: "skipped", +reason: "explicit" | "not-only" | "filtered" |}
29
- | {| +status: "todo" |};
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
- +name: string,
34
- +line: number,
35
- +column: number,
36
- +durationMicros: number,
37
- +outcome: Outcome,
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
- +filter?: string | null,
45
+ readonly filter?: string | null,
44
46
  /** Wall-clock budget for one case, in milliseconds. */
45
- +timeoutMs?: number,
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
- +path: $ReadOnlyArray<string>,
132
- +beforeEach: $ReadOnlyArray<Body>,
133
- +afterEach: $ReadOnlyArray<Body>,
134
- +skipped: boolean,
135
- +onlyPath: boolean,
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,25 +193,41 @@ async function runCase(
180
193
 
181
194
  const timeoutMs = test.timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
182
195
  let outcome: Outcome = { status: "passed" };
183
- try {
184
- for (const hook of context.beforeEach) {
185
- await withTimeout(hook, timeoutMs);
186
- }
187
- await withTimeout(test.body, timeoutMs);
188
- } catch (thrown) {
189
- outcome = failure(thrown);
190
- }
191
- // Teardown runs whatever happened above, and only reports its own failure
192
- // when the body had not already failed.
193
- for (const hook of context.afterEach) {
196
+ // Setup, body and teardown run *inside* the case's output context, and that
197
+ // nesting is the whole of the fix for #207. What names a printed line is no
198
+ // longer where the runner had got to when the line arrived — which named the
199
+ // next case for anything a `setTimeout` left behind — but which case's work
200
+ // the write descends from. A callback scheduled here keeps this name however
201
+ // late it fires.
202
+ //
203
+ // The cases above never reach this: nothing runs for a `todo`, a `skip` or a
204
+ // filtered-out case, so nothing of theirs can print.
205
+ await output.runInTest(name, async () => {
194
206
  try {
195
- await withTimeout(hook, timeoutMs);
207
+ for (const hook of context.beforeEach) {
208
+ await withTimeout(hook, timeoutMs);
209
+ }
210
+ await withTimeout(test.body, timeoutMs);
196
211
  } catch (thrown) {
197
- if (outcome.status === "passed") {
198
- outcome = failure(thrown);
212
+ outcome = failure(thrown);
213
+ }
214
+ // Teardown runs whatever happened above, and only reports its own failure
215
+ // when the body had not already failed.
216
+ for (const hook of context.afterEach) {
217
+ try {
218
+ await withTimeout(hook, timeoutMs);
219
+ } catch (thrown) {
220
+ if (outcome.status === "passed") {
221
+ outcome = failure(thrown);
222
+ }
199
223
  }
200
224
  }
201
- }
225
+ });
226
+ // The `await` above resumes outside the context it entered, so this line and
227
+ // everything after it belong to no case again — a line printed between cases
228
+ // is the file's, and a snapshot taken outside one fails with something better
229
+ // than a key belonging to whichever test happened to run last.
230
+ snapshot.exitTest();
202
231
  report(outcome);
203
232
  return outcome.status !== "failed";
204
233
  }
@@ -215,6 +244,7 @@ async function runSuite(
215
244
  onlyMode: boolean,
216
245
  emit: (result: Result) => void,
217
246
  state: {| bail: boolean |},
247
+ setUpAncestors: () => Promise<void>,
218
248
  ): Promise<boolean> {
219
249
  const skipped = context.skipped || node.modifier === "skip" || node.modifier === "todo";
220
250
  const onlyPath = !onlyMode || context.onlyPath || node.modifier === "only";
@@ -229,11 +259,18 @@ async function runSuite(
229
259
 
230
260
  // `beforeAll` is deferred until a case in this suite actually runs, so a
231
261
  // fully skipped suite never sets anything up. `afterAll` mirrors it.
262
+ //
263
+ // "In this suite" means anywhere under it. A suite whose children are all
264
+ // suites has no case of its own, and setting up only for a direct child left
265
+ // every `beforeAll` in an ordinary file — one where the tests live inside a
266
+ // `describe` — never running at all, silently. The chain is walked outermost
267
+ // first, so an inner suite's setup sees what the outer one did.
232
268
  let setUp = false;
233
269
  const setUpOnce = async () => {
234
270
  if (setUp) {
235
271
  return;
236
272
  }
273
+ await setUpAncestors();
237
274
  setUp = true;
238
275
  for (const hook of node.beforeAll) {
239
276
  await withTimeout(hook, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
@@ -276,7 +313,7 @@ async function runSuite(
276
313
  const ok = await runCase(child, childContext, options, emit);
277
314
  passed = passed && ok;
278
315
  } else {
279
- const ok = await runSuite(child, inner, options, onlyMode, emit, state);
316
+ const ok = await runSuite(child, inner, options, onlyMode, emit, state, setUpOnce);
280
317
  passed = passed && ok;
281
318
  }
282
319
  }
@@ -310,5 +347,5 @@ export async function run(options: RunOptions, emit: (result: Result) => void):
310
347
  skipped: false,
311
348
  onlyPath: !onlyMode,
312
349
  };
313
- await runSuite(root, context, options, onlyMode, emit, { bail: false });
350
+ await runSuite(root, context, options, onlyMode, emit, { bail: false }, async () => {});
314
351
  }
@@ -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
+ }