@uniflowed/test 0.0.0-alpha.7 → 0.0.0-alpha.9

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 CHANGED
@@ -13,6 +13,8 @@
13
13
  // The whole surface is importable from here, so a test file has one import.
14
14
 
15
15
  export type { Body as TestBody, Case, Modifier, Suite, TestOptions } from "./internal/registry.js";
16
+ export type { ModuleFactory, ModuleNamespace } from "./internal/modules.js";
17
+ export type { Uft } from "./internal/namespace.js";
16
18
  export type { Outcome, Result, RunOptions } from "./internal/run.js";
17
19
  export type { Site } from "./internal/frames.js";
18
20
  export type { SpyCall, SpyResult } from "./internal/spy.js";
@@ -104,10 +104,12 @@ function matchesThrown(thrown: mixed, expected: mixed): boolean {
104
104
  * `$FlowFixMe` and whose result's result is `expect`, also `$FlowFixMe`. The
105
105
  * type that makes any of this checked is a written-out matcher interface —
106
106
  * one signature per matcher, plus `.not`, `.resolves` and `.rejects` — which
107
- * is what `expect`'s own annotation is waiting for. Until that exists, a
108
- * narrower type here would be precision nobody can reach.
107
+ * is what `expect`'s own annotation is waiting for, and is
108
+ * ubugeeei-prod/uf#402. Until that exists, a narrower type here would be
109
+ * precision nobody can reach.
109
110
  */
110
111
  function verdicts(received: mixed): {
112
+ // uf-lint-disable-next-line flow/unclear-type
111
113
  readonly [string]: (...args: $ReadOnlyArray<any>) => Verdict,
112
114
  } {
113
115
  const shown = () => render(received);
@@ -617,6 +619,15 @@ function expectValue(received: mixed): $FlowFixMe {
617
619
  * than a negated assertion around the whole object, and is the form a suite
618
620
  * being ported will already have.
619
621
  */
622
+ // `flow/unsafe-object-assign` asks for an object spread, and a spread cannot
623
+ // produce this value: `expect` is a *function* with matchers hanging off it,
624
+ // and `{ ...expectValue, ...matchers }` is a plain object that a test cannot
625
+ // call. `Object.assign` onto a callable is the only expression that makes one,
626
+ // and the alternative the rule is really warning about — `expect.any = …`
627
+ // afterwards — is the top-level statement the comment above rules out. What it
628
+ // mutates is a function this module declared six lines up and exports here; no
629
+ // object belonging to anybody else is touched.
630
+ // uf-lint-disable-next-line flow/unsafe-object-assign
620
631
  export const expect: $FlowFixMe = Object.assign(expectValue, {
621
632
  any: asymmetric.any,
622
633
  anything: asymmetric.anything,
@@ -66,6 +66,49 @@ export function frameSite(frame: string): Site | null {
66
66
  return { line: line.value, column: column.value };
67
67
  }
68
68
 
69
+ /**
70
+ * The file a stack frame names, or `null`.
71
+ *
72
+ * The same scan as [`frameSite`], stopping one step earlier: everything before
73
+ * the `:line:column` is where the code is, and what that is depends on how V8
74
+ * wrote the frame — `at name (/path:1:2)` when it has a function name, and
75
+ * `at /path:1:2` or `at async file:///path:1:2` when it does not.
76
+ *
77
+ * What comes back is whatever the frame said, a path or a URL, because those
78
+ * are the two things it can be and a caller resolving a module specifier
79
+ * against it has to tell them apart anyway.
80
+ */
81
+ export function frameFile(frame: string): string | null {
82
+ let end = frame.length;
83
+ while (end > 0 && (frame[end - 1] === " " || frame[end - 1] === ")")) {
84
+ end -= 1;
85
+ }
86
+
87
+ const column = digitsBefore(frame, end);
88
+ if (column == null || column.start === 0 || frame[column.start - 1] !== ":") {
89
+ return null;
90
+ }
91
+ const line = digitsBefore(frame, column.start - 1);
92
+ if (line == null || line.start === 0 || frame[line.start - 1] !== ":") {
93
+ return null;
94
+ }
95
+
96
+ let text = frame.slice(0, line.start - 1);
97
+ const open = text.lastIndexOf("(");
98
+ if (open !== -1) {
99
+ text = text.slice(open + 1);
100
+ } else {
101
+ const at = text.lastIndexOf(" at ");
102
+ text = at === -1 ? text : text.slice(at + 4);
103
+ }
104
+ text = text.trim();
105
+ // `at async /path:1:2` — the marker belongs to the frame, not to the file.
106
+ if (text.startsWith("async ")) {
107
+ text = text.slice("async ".length).trim();
108
+ }
109
+ return text === "" ? null : text;
110
+ }
111
+
69
112
  /** The run of digits ending at `end`, with where it starts. */
70
113
  function digitsBefore(
71
114
  text: string,
@@ -0,0 +1,430 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/test`: standing a module in for another one.
4
+ //
5
+ // `uft.spyOn` replaces a method on an object a test can reach. This replaces a
6
+ // *module*, which is the only seam a great deal of a React application has: a
7
+ // component that imports a client and calls it at module scope, a page that
8
+ // imports `useRouter`, a `"use server"` module a client component calls. The
9
+ // mechanism belongs to the loader and lives in `@uniflowed/host`
10
+ // (`module-mocks.js`); what lives here is the API, the resolution of a
11
+ // specifier to the module it names, and the automatic stand-in.
12
+ //
13
+ // # When a mock takes effect
14
+ //
15
+ // **A mock is not hoisted.** Vitest lifts `vi.mock` above the importing file's
16
+ // `import` declarations with a Babel pass; uf has no Babel in its pipeline and
17
+ // `docs/architecture.md` says so on purpose, so inventing one for this would be
18
+ // a compiler pass whose only customer is a test helper. Bun answers the same
19
+ // question the same way, and it is the answer this package gives:
20
+ //
21
+ // * every `import` declaration in a file runs before the file's first
22
+ // statement, so a static import is *never* affected by a `uft.mock` written
23
+ // below it — it already holds the real module;
24
+ // * `uft.mock` takes effect for every import that begins after the promise it
25
+ // returns settles;
26
+ // * so `await import("./client.js")` — after the mock — is how a test reaches
27
+ // the stand-in.
28
+ //
29
+ // A synchronous factory is installed before `uft.mock` returns, which means the
30
+ // rule holds whether or not the promise is awaited. Awaiting is still the habit
31
+ // worth having, because an automatic stand-in has to read the real module first
32
+ // and cannot be installed synchronously at all.
33
+ //
34
+ // # What a mock does not do
35
+ //
36
+ // A module that has already been evaluated is not re-written. Node's registry
37
+ // hands out a namespace that is linked into every importer, and no loader hook
38
+ // can reach back into it — Bun's engine can, and having one API mean two things
39
+ // on two hosts is worse than having it mean the narrower one on both. So a mock
40
+ // affects the next import, not the last one, and `uft.resetModules` is how a
41
+ // test gets a module evaluated again.
42
+
43
+ import {
44
+ actualUrl,
45
+ defineModuleMock,
46
+ installInterception,
47
+ interceptionSupported,
48
+ removeModuleMock,
49
+ resetModuleMocks,
50
+ startModuleEpoch,
51
+ } from "@uniflowed/host/module-mocks";
52
+ import { createRequire } from "node:module";
53
+ import { pathToFileURL } from "node:url";
54
+
55
+ import { UnsupportedError } from "./unsupported.js";
56
+ import { frameFile, isInternalFrame } from "./frames.js";
57
+ import { fn } from "./spy.js";
58
+
59
+ /** The shape of a module's exports, as far as a type can say it. */
60
+ export type ModuleNamespace = { +[string]: mixed };
61
+
62
+ /**
63
+ * What a `uft.mock` factory hands back.
64
+ *
65
+ * `Partial<Module>` rather than `Module` because a partial mock is the common
66
+ * case — replace `send`, keep everything else — and rather than an unconstrained
67
+ * object because the whole point of a Flow-first toolchain doing this is that
68
+ * `{ send: 42 }` for a module whose `send` is a function is a type error at the
69
+ * call rather than a `TypeError` three tests later.
70
+ */
71
+ export type ModuleFactory<Module> = () => Partial<Module> | Promise<Partial<Module>>;
72
+
73
+ /** How deep an automatic stand-in follows nested objects. */
74
+ const AUTOMOCK_DEPTH = 5;
75
+
76
+ /**
77
+ * Why module interception is unavailable here, or `null` when it is available.
78
+ *
79
+ * Named as a reason rather than a boolean because the reason is what a reader
80
+ * needs: "this host has no synchronous module hooks" tells them the suite will
81
+ * work on Node, and "not implemented" tells them nothing.
82
+ */
83
+ export function moduleMockingUnavailable(): string | null {
84
+ return interceptionSupported() ? null : unsupportedReason(hostName());
85
+ }
86
+
87
+ /** The name of the host this process is. */
88
+ export function hostName(): string {
89
+ const host = globalThis as $FlowFixMe;
90
+ if (host.Deno != null) {
91
+ return "Deno";
92
+ }
93
+ if (host.process?.versions?.bun != null) {
94
+ return "Bun";
95
+ }
96
+ return "this host";
97
+ }
98
+
99
+ /** What to tell someone whose host cannot intercept a module. */
100
+ export function unsupportedReason(host: string): string {
101
+ return (
102
+ `replacing a module before it is imported needs synchronous module ` +
103
+ `customization hooks (\`node:module\`'s \`registerHooks\`), and ${host} does ` +
104
+ `not provide them; run the suite on Node, or replace what the module ` +
105
+ `hands out with \`uft.spyOn\` or \`@uniflowed/mock\` instead`
106
+ );
107
+ }
108
+
109
+ /** Raise unless this host can intercept a module. */
110
+ function requireInterception(binding: string): void {
111
+ const reason = moduleMockingUnavailable();
112
+ if (reason != null) {
113
+ throw new UnsupportedError(binding, reason);
114
+ }
115
+ installInterception();
116
+ }
117
+
118
+ /**
119
+ * The module that called into this one.
120
+ *
121
+ * A specifier is relative to the file it was written in, so that file has to be
122
+ * found, and a stack trace is the only place it is recorded. The frames are
123
+ * `--enable-source-maps`'d back to the author's own file, so what comes back is
124
+ * the path of the source rather than of the transform — which is exactly what a
125
+ * relative specifier in that source should resolve against.
126
+ */
127
+ function callerURL(binding: string): string {
128
+ const stack = new Error("uft").stack;
129
+ for (const frame of (stack ?? "").split("\n").slice(1)) {
130
+ if (isInternalFrame(frame)) {
131
+ continue;
132
+ }
133
+ const file = frameFile(frame);
134
+ if (file == null) {
135
+ continue;
136
+ }
137
+ if (file.startsWith("file:")) {
138
+ const url = new URL(file);
139
+ url.search = "";
140
+ url.hash = "";
141
+ return url.href;
142
+ }
143
+ if (file.startsWith("/")) {
144
+ return pathToFileURL(file).href;
145
+ }
146
+ }
147
+ throw new UnsupportedError(
148
+ binding,
149
+ "the calling file could not be read off the stack, so a relative " +
150
+ "specifier has nothing to resolve against; pass an absolute path or a " +
151
+ "`file:` URL",
152
+ );
153
+ }
154
+
155
+ /**
156
+ * The URL a specifier names, resolved from the file that wrote it.
157
+ *
158
+ * A path is resolved as a URL, which is what an ES module specifier is; a bare
159
+ * specifier goes through the package resolver, so `@uniflowed/router` reaches
160
+ * the same file an `import` of it would.
161
+ */
162
+ export function resolveSpecifier(specifier: string, parentURL: string): string {
163
+ if (specifier.startsWith("file:")) {
164
+ return specifier;
165
+ }
166
+ if (isPathSpecifier(specifier)) {
167
+ return new URL(specifier, parentURL).href;
168
+ }
169
+ try {
170
+ return pathToFileURL(createRequire(parentURL).resolve(specifier)).href;
171
+ } catch (error) {
172
+ throw new Error(`uft could not resolve "${specifier}" from ${parentURL}: ${String(error)}`);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Register a stand-in for the module `specifier` names.
178
+ *
179
+ * With a factory, the factory is what the module exports. Without one, the real
180
+ * module is read and every function it exports becomes a spy that records its
181
+ * calls and returns `undefined` — the automatic form, for a module whose shape
182
+ * a test wants to keep and whose behaviour it wants gone.
183
+ */
184
+ export function mock<Module: ModuleNamespace>(
185
+ specifier: string,
186
+ factory?: ModuleFactory<Module>,
187
+ ): Promise<void> {
188
+ requireInterception("mock");
189
+ const url = resolveSpecifier(specifier, callerURL("mock"));
190
+
191
+ if (factory == null) {
192
+ return importURL(actualUrl(url, isPathSpecifier(specifier))).then((actual) => {
193
+ defineModuleMock(url, automock(actual, AUTOMOCK_DEPTH, new Map()));
194
+ });
195
+ }
196
+
197
+ const produced = factory();
198
+ if (isThenable(produced)) {
199
+ return (produced: $FlowFixMe).then((namespace) => {
200
+ defineModuleMock(url, exportsOf(specifier, namespace));
201
+ });
202
+ }
203
+ defineModuleMock(url, exportsOf(specifier, produced));
204
+ return Promise.resolve();
205
+ }
206
+
207
+ /** Stop standing in for the module `specifier` names. */
208
+ export function unmock(specifier: string): void {
209
+ requireInterception("unmock");
210
+ removeModuleMock(resolveSpecifier(specifier, callerURL("unmock")));
211
+ }
212
+
213
+ /**
214
+ * The real module, whatever is registered for it.
215
+ *
216
+ * A separate instance from the one an ordinary import gets while the module is
217
+ * mocked — there is only one URL per module instance, and the mocked one is
218
+ * occupying the other. One instance per module epoch, so two calls in a row
219
+ * hand back the same module.
220
+ *
221
+ * It reaches past the mock for the module it *names* and for no other: the
222
+ * modules that one imports are resolved the way they would be anywhere else,
223
+ * stand-ins included. That is what makes a partial mock possible — the factory
224
+ * asks for the real module while its own stand-in is being built — and it is
225
+ * also why `importActual` of a module that imports a mocked one still sees the
226
+ * stand-in.
227
+ */
228
+ export function importActual<Module: ModuleNamespace>(specifier: string): Promise<Module> {
229
+ requireInterception("importActual");
230
+ const url = resolveSpecifier(specifier, callerURL("importActual"));
231
+ return importURL(actualUrl(url, isPathSpecifier(specifier)));
232
+ }
233
+
234
+ /**
235
+ * The module with every function it exports replaced by a spy.
236
+ *
237
+ * The automatic form of `mock`, without registering anything: what comes back
238
+ * is a stand-in the caller holds, and every other importer of that module still
239
+ * gets whatever it got before.
240
+ */
241
+ export function importMock<Module: ModuleNamespace>(specifier: string): Promise<Module> {
242
+ requireInterception("importMock");
243
+ const url = resolveSpecifier(specifier, callerURL("importMock"));
244
+ return importURL(actualUrl(url, isPathSpecifier(specifier))).then(
245
+ (actual) => (automock(actual, AUTOMOCK_DEPTH, new Map()): $FlowFixMe),
246
+ );
247
+ }
248
+
249
+ /**
250
+ * Evaluate modules again on the next import of them.
251
+ *
252
+ * "Modules" means the ones reached by a path — this project's own. A package is
253
+ * left alone: handing a second copy of `@uniflowed/test` to a file would give
254
+ * it a second registry and a second set of spies, and a reset that did that
255
+ * would break far more than it fixed.
256
+ *
257
+ * It does not run a mock's factory again. The factory ran when `uft.mock` was
258
+ * called; register the mock again to get new stand-ins out of it.
259
+ */
260
+ export function resetModules(): void {
261
+ requireInterception("resetModules");
262
+ startModuleEpoch();
263
+ }
264
+
265
+ /**
266
+ * Forget every mock and every reset.
267
+ *
268
+ * Called by the worker before each file, because a worker serves many files and
269
+ * a mock that outlived its file would be a suite that passes alone and fails
270
+ * beside another.
271
+ */
272
+ export function resetModuleState(): void {
273
+ resetModuleMocks();
274
+ }
275
+
276
+ /**
277
+ * Whether `specifier` names a file rather than a package.
278
+ *
279
+ * The same three prefixes `@uniflowed/host`'s resolve hook tests for, and
280
+ * deliberately not `file:`: a `file:` specifier names the URL it wants, and the
281
+ * one module that writes one — the generated stand-in, reaching back for its
282
+ * values — must land on the copy of the registry that put it there.
283
+ */
284
+ function isPathSpecifier(specifier: string): boolean {
285
+ return specifier.startsWith("./") || specifier.startsWith("../") || specifier.startsWith("/");
286
+ }
287
+
288
+ /** `import()`, in one place, so the marker parameter is never spelled twice. */
289
+ function importURL<Module>(url: string): Promise<Module> {
290
+ return (import(url): $FlowFixMe);
291
+ }
292
+
293
+ /** Whether `value` is a promise, or near enough for `then` to be meant. */
294
+ function isThenable(value: mixed): boolean {
295
+ return (
296
+ value != null &&
297
+ (typeof value === "object" || typeof value === "function") &&
298
+ typeof (value: $FlowFixMe).then === "function"
299
+ );
300
+ }
301
+
302
+ /**
303
+ * The exports a factory produced, as a plain object.
304
+ *
305
+ * Copied rather than kept, so that the export list the loader writes out is
306
+ * fixed at the moment the mock is registered: a factory that hands back an
307
+ * object it goes on adding keys to would otherwise produce a module whose
308
+ * exports depend on when it was first imported.
309
+ */
310
+ function exportsOf(specifier: string, produced: mixed): { [string]: mixed } {
311
+ if (produced == null || (typeof produced !== "object" && typeof produced !== "function")) {
312
+ throw new TypeError(
313
+ `uft.mock("${specifier}"): the factory must return the module's exports as an ` +
314
+ `object, and it returned ${produced === null ? "null" : typeof produced}`,
315
+ );
316
+ }
317
+ return { ...(produced: $FlowFixMe) };
318
+ }
319
+
320
+ /**
321
+ * `value` with its behaviour taken out and its shape left in.
322
+ *
323
+ * The rules, which are the ones a reader has to be able to predict:
324
+ *
325
+ * * a function becomes a spy that records its calls and returns `undefined`,
326
+ * keeping its name and its prototype's methods — so a class stays `new`-able
327
+ * and its methods are spies too;
328
+ * * an array becomes an empty array, because a fixture list a test did not
329
+ * write is a fixture list a test should not depend on;
330
+ * * a plain object is followed, key by key, to [`AUTOMOCK_DEPTH`];
331
+ * * a getter is kept as a getter rather than followed: reading one here would
332
+ * run code the module did not expect to run yet, and would fix an answer
333
+ * the getter exists to recompute;
334
+ * * everything else — a string, a number, a `Map`, a `Date`, a `RegExp` — is
335
+ * kept, because there is nothing in it to call.
336
+ */
337
+ function automock(value: mixed, depth: number, seen: Map<mixed, mixed>): mixed {
338
+ if (value == null || depth <= 0) {
339
+ return value;
340
+ }
341
+ const already = seen.get(value);
342
+ if (already !== undefined) {
343
+ return already;
344
+ }
345
+
346
+ if (typeof value === "function") {
347
+ const spy = fn().mockName((value: $FlowFixMe).name ?? "spy");
348
+ seen.set(value, spy);
349
+ copyProperties(value, spy, depth, seen, false);
350
+ const prototype = (value: $FlowFixMe).prototype;
351
+ if (prototype != null && typeof prototype === "object") {
352
+ // Every own name, not only the enumerable ones: a class's methods are
353
+ // non-enumerable own properties of its prototype, so `Object.keys` finds
354
+ // none of them and the stand-in of a class would have no methods at all.
355
+ copyProperties(prototype, spy.prototype, depth, seen, true);
356
+ }
357
+ return spy;
358
+ }
359
+
360
+ if (typeof value !== "object") {
361
+ return value;
362
+ }
363
+ if (Array.isArray(value)) {
364
+ const empty: Array<mixed> = [];
365
+ seen.set(value, empty);
366
+ return empty;
367
+ }
368
+ if (!isPlainish(value)) {
369
+ return value;
370
+ }
371
+
372
+ const copy: { [string]: mixed } = {};
373
+ seen.set(value, copy);
374
+ copyProperties(value, copy, depth, seen, false);
375
+ return copy;
376
+ }
377
+
378
+ /**
379
+ * Automock the properties of `from` onto `onto`.
380
+ *
381
+ * `hidden` is what separates a prototype from everything else: a class's
382
+ * methods are own but not enumerable, so a prototype needs every own name,
383
+ * while an object or a function's statics want the enumerable ones — the same
384
+ * set a spread or a module namespace would show.
385
+ */
386
+ function copyProperties(
387
+ from: mixed,
388
+ onto: mixed,
389
+ depth: number,
390
+ seen: Map<mixed, mixed>,
391
+ hidden: boolean,
392
+ ): void {
393
+ const source = (from: $FlowFixMe);
394
+ const target = (onto: $FlowFixMe);
395
+ const names = hidden ? Object.getOwnPropertyNames(source) : Object.keys(source);
396
+ for (const name of names) {
397
+ // `constructor` on a prototype points back at the function being mocked,
398
+ // and rewriting it would replace the spy with a spy of the spy.
399
+ if (name === "constructor") {
400
+ continue;
401
+ }
402
+ const descriptor = Object.getOwnPropertyDescriptor(source, name);
403
+ if (descriptor == null) {
404
+ continue;
405
+ }
406
+ if (!Object.hasOwn(descriptor, "value")) {
407
+ Object.defineProperty(target, name, descriptor);
408
+ continue;
409
+ }
410
+ target[name] = automock(descriptor.value, depth - 1, seen);
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Whether an object is one to look inside.
416
+ *
417
+ * A module namespace, an object literal and a `null`-prototype object are; a
418
+ * `Map`, a `Date`, a `RegExp` and anything else with behaviour of its own are
419
+ * not — replacing their methods with spies would produce something that claims
420
+ * to be a `Date` and cannot tell the time.
421
+ */
422
+ function isPlainish(value: mixed): boolean {
423
+ const prototype = Object.getPrototypeOf(value);
424
+ return prototype === null || prototype === Object.prototype || isNamespace(value);
425
+ }
426
+
427
+ /** Whether `value` is a module namespace object. */
428
+ function isNamespace(value: mixed): boolean {
429
+ return (value: $FlowFixMe)[Symbol.toStringTag] === "Module";
430
+ }
@@ -18,14 +18,28 @@
18
18
  // name — two very different things one dot apart. `uft` is three characters,
19
19
  // belongs to nothing else, and is what a reader types a hundred times a file.
20
20
  //
21
- // What is *not* here is as deliberate. `uft.mock` intercepts a module before it
22
- // is imported, which needs the loader rather than the runner, and uf's loader is
23
- // `@uniflowed/host` so it is a real piece of work rather than a wrapper, and
24
- // it is not pretended at here. A missing binding throws with what it would take;
25
- // a binding that silently did nothing would be worse than not having it.
21
+ // `uft.mock` and the six names beside it intercept a module before it is
22
+ // imported, which is the loader's job rather than the runner's: by the time the
23
+ // runner sees an `import`, the module has been fetched, linked and evaluated.
24
+ // So the mechanism is `@uniflowed/host`'s (`module-mocks.js`) and the API is
25
+ // `./modules.js`'s, and this file only names them. A host that cannot provide
26
+ // synchronous module hooks gets an `UnsupportedError` that says which host it
27
+ // is and what to do instead — never a binding that silently does nothing.
26
28
 
29
+ import {
30
+ importActual as importActualModule,
31
+ importMock as importMockModule,
32
+ mock as mockModule,
33
+ resetModules as resetModulesNow,
34
+ unmock as unmockModule,
35
+ } from "./modules.js";
27
36
  import { clearAllMocks, fn, resetAllMocks, restoreAllMocks, spyOn } from "./spy.js";
28
37
  import * as timers from "./timers.js";
38
+ import { UnsupportedError } from "./unsupported.js";
39
+
40
+ // Declared in `./unsupported.js` rather than here, because `./modules.js` needs
41
+ // it too and a class both halves of a pair reach for is a third module.
42
+ export { UnsupportedError } from "./unsupported.js";
29
43
 
30
44
  /** Environment variables `stubEnv` replaced, and what they were. */
31
45
  const stubbedEnv: Map<string, string | void> = new Map();
@@ -33,24 +47,6 @@ const stubbedEnv: Map<string, string | void> = new Map();
33
47
  /** Globals `stubGlobal` replaced, and what they were. */
34
48
  const stubbedGlobals: Map<string, { readonly owned: boolean, readonly value: mixed }> = new Map();
35
49
 
36
- /**
37
- * Raised for a `uft` namespace binding that is not implemented.
38
- *
39
- * Names what the binding needs rather than only that it is missing: `uft.mock`
40
- * is absent because module interception belongs to the loader, and a reader who
41
- * knows that can decide whether to wait or to restructure the test.
42
- */
43
- export class UnsupportedError extends Error {
44
- /** The binding that was called. */
45
- binding: string;
46
-
47
- constructor(binding: string, reason: string) {
48
- super(`uft.${binding} is not implemented yet: ${reason}`);
49
- this.name = "UnsupportedError";
50
- this.binding = binding;
51
- }
52
- }
53
-
54
50
  /**
55
51
  * Read the process environment, whichever host this is.
56
52
  *
@@ -190,17 +186,54 @@ export function mocked<T>(value: T): $FlowFixMe {
190
186
  return value;
191
187
  }
192
188
 
193
- /** Not implemented, and specific about what it would take. */
194
- function unsupported(binding: string, reason: string): () => empty {
195
- return () => {
196
- throw new UnsupportedError(binding, reason);
197
- };
198
- }
189
+ /**
190
+ * The `uft` namespace's type.
191
+ *
192
+ * Written out member by member rather than left as one `$FlowFixMe`, because
193
+ * the module-mocking half of it is the half a type can genuinely check: a
194
+ * factory that hands back the wrong shape for the module it is standing in for
195
+ * is an error at the call site, and that only works if `uft` has a type at all.
196
+ * The members that were already typed loosely keep the types they have —
197
+ * `typeof` reads them from their definitions, so this list cannot drift from
198
+ * them.
199
+ */
200
+ export type Uft = {
201
+ readonly fn: typeof fn,
202
+ readonly spyOn: typeof spyOn,
203
+ readonly mocked: typeof mocked,
204
+
205
+ readonly clearAllMocks: typeof clearAllMocks,
206
+ readonly resetAllMocks: typeof resetAllMocks,
207
+ readonly restoreAllMocks: typeof restoreAllMocks,
208
+
209
+ readonly stubEnv: typeof stubEnv,
210
+ readonly unstubAllEnvs: typeof unstubAllEnvs,
211
+ readonly stubGlobal: typeof stubGlobal,
212
+ readonly unstubAllGlobals: typeof unstubAllGlobals,
213
+
214
+ readonly waitFor: typeof waitFor,
215
+ readonly waitUntil: typeof waitUntil,
216
+
217
+ readonly useFakeTimers: typeof timers.useFakeTimers,
218
+ readonly useRealTimers: typeof timers.useRealTimers,
219
+ readonly isFakeTimers: typeof timers.isFaked,
220
+ readonly advanceTimersByTime: typeof timers.advanceTimersByTime,
221
+ readonly advanceTimersByTimeAsync: typeof timers.advanceTimersByTimeAsync,
222
+ readonly advanceTimersToNextTimer: typeof timers.advanceTimersToNextTimer,
223
+ readonly runAllTimers: typeof timers.runAllTimers,
224
+ readonly runOnlyPendingTimers: typeof timers.runOnlyPendingTimers,
225
+ readonly getTimerCount: typeof timers.getTimerCount,
226
+ readonly setSystemTime: typeof timers.setSystemTime,
227
+ readonly getMockedSystemTime: typeof timers.getMockedSystemTime,
199
228
 
200
- /** The reason every module-interception binding is absent. */
201
- const NEEDS_LOADER =
202
- "intercepting a module before it is imported belongs to the loader " +
203
- "(`@uniflowed/host`), not to the runner, and uf has not wired it yet";
229
+ readonly mock: typeof mockModule,
230
+ readonly doMock: typeof mockModule,
231
+ readonly unmock: typeof unmockModule,
232
+ readonly doUnmock: typeof unmockModule,
233
+ readonly importActual: typeof importActualModule,
234
+ readonly importMock: typeof importMockModule,
235
+ readonly resetModules: typeof resetModulesNow,
236
+ };
204
237
 
205
238
  /**
206
239
  * The `uft` namespace.
@@ -209,7 +242,7 @@ const NEEDS_LOADER =
209
242
  * per-instance, and freezing it means a test cannot leave a monkey-patch behind
210
243
  * for the next one.
211
244
  */
212
- export const uft: $FlowFixMe = Object.freeze({
245
+ export const uft: Uft = Object.freeze({
213
246
  fn,
214
247
  spyOn,
215
248
  mocked,
@@ -240,12 +273,15 @@ export const uft: $FlowFixMe = Object.freeze({
240
273
  setSystemTime: timers.setSystemTime,
241
274
  getMockedSystemTime: timers.getMockedSystemTime,
242
275
 
243
- // Module interception. Absent rather than faked; see `NEEDS_LOADER`.
244
- mock: unsupported("mock", NEEDS_LOADER),
245
- doMock: unsupported("doMock", NEEDS_LOADER),
246
- unmock: unsupported("unmock", NEEDS_LOADER),
247
- doUnmock: unsupported("doUnmock", NEEDS_LOADER),
248
- importActual: unsupported("importActual", NEEDS_LOADER),
249
- importMock: unsupported("importMock", NEEDS_LOADER),
250
- resetModules: unsupported("resetModules", NEEDS_LOADER),
276
+ // Module interception. `doMock` is `mock` and `doUnmock` is `unmock`, under
277
+ // the names Vitest gives the un-hoisted forms: there is one form here,
278
+ // because uf hoists neither, and a `doMock` that was a different function
279
+ // would be claiming a difference that does not exist.
280
+ mock: mockModule,
281
+ doMock: mockModule,
282
+ unmock: unmockModule,
283
+ doUnmock: unmockModule,
284
+ importActual: importActualModule,
285
+ importMock: importMockModule,
286
+ resetModules: resetModulesNow,
251
287
  });
@@ -0,0 +1,30 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/test`: the error a `uft` binding raises when this
4
+ // host cannot give it.
5
+ //
6
+ // Its own module because two of them need it — the namespace, and the module
7
+ // mocking in `./modules.js` that the namespace imports — and a class that both
8
+ // of a pair of modules reaches for is a third module, not an import cycle.
9
+ //
10
+ // The class is exported from the package, so a test can catch it by name
11
+ // rather than by matching on a message.
12
+
13
+ /**
14
+ * Raised for a `uft` binding this host cannot provide.
15
+ *
16
+ * Names what the binding needs rather than only that it is missing: a reader
17
+ * who is told that module interception needs synchronous module hooks can
18
+ * decide whether to run the suite on another host or to restructure the test,
19
+ * and neither is a decision a bare "not implemented" supports.
20
+ */
21
+ export class UnsupportedError extends Error {
22
+ /** The binding that was called. */
23
+ binding: string;
24
+
25
+ constructor(binding: string, reason: string) {
26
+ super(`uft.${binding} is not available: ${reason}`);
27
+ this.name = "UnsupportedError";
28
+ this.binding = binding;
29
+ }
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/test",
3
- "version": "0.0.0-alpha.7",
3
+ "version": "0.0.0-alpha.9",
4
4
  "description": "The test API and worker for `uf test`: describe/it, a full matcher set, and the process uf fans test files out to.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,6 +21,6 @@
21
21
  "internal"
22
22
  ],
23
23
  "dependencies": {
24
- "@uniflowed/host": "0.0.0-alpha.7"
24
+ "@uniflowed/host": "0.0.0-alpha.9"
25
25
  }
26
26
  }
package/worker.js CHANGED
@@ -54,6 +54,7 @@ import { createInterface } from "node:readline";
54
54
  import { fileURLToPath, pathToFileURL } from "node:url";
55
55
 
56
56
  import { reset } from "./internal/registry.js";
57
+ import { resetModuleState } from "./internal/modules.js";
57
58
  import { run } from "./internal/run.js";
58
59
 
59
60
  /** What `uf` sends for one file. */
@@ -139,6 +140,11 @@ function write(event: { readonly [string]: mixed }): void {
139
140
  async function runFile(request: Request, generation: number): Promise<void> {
140
141
  const started = performance.now();
141
142
  reset();
143
+ // Every module this file stood in for goes back, before the next file can
144
+ // import one of them and be handed the previous file's stand-in. A worker
145
+ // serves many files out of one module registry, so this is the difference
146
+ // between "one file at a time" and "one file's mocks at a time".
147
+ resetModuleState();
142
148
  output.startFile();
143
149
 
144
150
  try {