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

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.
@@ -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
+ }