@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/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
- | {| +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. */
@@ -93,7 +102,22 @@ async function withTimeout(body: Body, timeoutMs: number): Promise<void> {
93
102
  }
94
103
  }
95
104
 
96
- function failure(thrown: mixed): Outcome {
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: firstUserSite(stack, false),
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: firstUserSite(stack, false),
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
- +path: $ReadOnlyArray<string>,
132
- +beforeEach: $ReadOnlyArray<Body>,
133
- +afterEach: $ReadOnlyArray<Body>,
134
- +skipped: boolean,
135
- +onlyPath: boolean,
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
- 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) {
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
- await withTimeout(hook, timeoutMs);
222
+ for (const hook of context.beforeEach) {
223
+ await withTimeout(hook, timeoutMs);
224
+ }
225
+ await withTimeout(test.body, timeoutMs);
196
226
  } catch (thrown) {
197
- if (outcome.status === "passed") {
198
- outcome = failure(thrown);
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
+ }