@uniflowed/test 0.0.0-alpha.4 → 0.0.0-alpha.40

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,339 @@
1
+ // @flow
2
+ //
3
+ // The process `uf test --browser` fans work out to.
4
+ //
5
+ // `./worker.js` runs a test file. This runs a *browser* that runs a test file,
6
+ // and from `uf`'s side the two are the same thing: a process started the same
7
+ // way, given a request per line on stdin, answering with an event per line on
8
+ // stdout, bounded by the same wall clock and replaced the same way when it
9
+ // stops answering. `crates/uf_test/src/host.rs` does not branch on which of
10
+ // the two it is talking to, and that is the whole design — the browser is
11
+ // another host, not a second runner.
12
+ //
13
+ // uf ──stdin──▶ this process ──HTTP──▶ a page in a real browser
14
+ // ◀─stdout── ◀─HTTP──
15
+ //
16
+ // # Why there is a process in the middle at all
17
+ //
18
+ // A page cannot read a pipe. Somebody has to hold the browser's process
19
+ // handle, serve it modules, and turn what it says into what `uf` reads, and
20
+ // that somebody has to be a JavaScript host because resolving a bare specifier
21
+ // and transforming Flow are already solved there. So the driver is Node, the
22
+ // browser is the host under test, and the split is named in `docs/hosts.md`
23
+ // rather than left for a reader to infer from a stack trace.
24
+ //
25
+ // # What this depends on, exactly
26
+ //
27
+ // One browser binary, already installed, found or named by
28
+ // `crates/uf_test/src/browser.rs` and handed over in `UF_BROWSER`. Not
29
+ // downloaded, not vendored, not version-pinned, and not a driver library:
30
+ // there is no Playwright, no Puppeteer and no DevTools protocol here. The
31
+ // browser is started with a URL and a scratch profile and is otherwise left
32
+ // alone; everything uf needs to know comes back over HTTP from the page's own
33
+ // code.
34
+ //
35
+ // That is a deliberate ceiling as much as a deliberate floor. **uf cannot
36
+ // drive this browser** — it cannot click, navigate, screenshot, throttle a
37
+ // network or read a console it was not handed. Those are the other half of
38
+ // `docs/roadmap.md`'s "Playwright-compatible browser automation" and they want
39
+ // a protocol; this half only wants a page, and buying the protocol to get the
40
+ // page would have made a test runner own a browser automation library.
41
+ //
42
+ // # How this fails
43
+ //
44
+ // Loudly, by name, in three places. The browser is not there — refused in
45
+ // Rust, before a process is started. The browser starts and never asks for
46
+ // work — refused here, with whatever it wrote to stderr, because a browser
47
+ // that will not open a page is not a suite that failed. The browser goes away
48
+ // mid-run — the stream ends, and `uf` reports a host that died, which is the
49
+ // path Node workers already take.
50
+
51
+ import { spawn } from "node:child_process";
52
+ import { createInterface } from "node:readline";
53
+ import { mkdtempSync, rmSync } from "node:fs";
54
+ import { tmpdir } from "node:os";
55
+ import path from "node:path";
56
+
57
+ import { inSourceTests, sharedService } from "@uniflowed/host/transform";
58
+
59
+ import { create } from "./internal/browser/server.js";
60
+
61
+ /** What `uf` sends for one file. */
62
+ type Request = {|
63
+ readonly file: string,
64
+ readonly filter?: string | null,
65
+ readonly timeoutMs?: number,
66
+ readonly generation?: number,
67
+ |};
68
+
69
+ /**
70
+ * How long a browser is given to open the page before the run is refused.
71
+ *
72
+ * Generous against a cold Chromium with a cold profile, which is about seven
73
+ * seconds on a laptop and much worse in a busy container, and deliberately
74
+ * *shorter than `uf_test`'s browser file budget* (`BROWSER_FILE_TIMEOUT`, two
75
+ * minutes).
76
+ * That order is the whole point: `uf` is already holding a stopwatch on the
77
+ * first file while this is happening, and whichever of the two fires first is
78
+ * what the report says. A browser that will not start should be reported as a
79
+ * browser that will not start, with its own output attached — not as a file
80
+ * that timed out, which is a sentence about tests that were never reached.
81
+ */
82
+ const START_TIMEOUT_MS = 110_000;
83
+
84
+ /**
85
+ * The switches a headless run needs, and what each is for.
86
+ *
87
+ * Kept short on purpose. Every flag here is a decision about the browser the
88
+ * tests are measured in, and a long list is a long list of ways this page
89
+ * differs from the application's — which is the difference browser mode exists
90
+ * to remove.
91
+ */
92
+ function browserArguments(profile: string, url: string): Array<string> {
93
+ return [
94
+ // The modern headless, which is the same renderer as a windowed browser.
95
+ // The old one was a separate binary with its own layout quirks, and a
96
+ // layout answer from it would not have been an answer about Chrome.
97
+ "--headless=new",
98
+ // What makes the browser die when this process does, and the only reason
99
+ // this mode names a debugging switch at all.
100
+ //
101
+ // `uf` kills a worker that misses its deadline, and a killed process runs
102
+ // no exit handler — so without this, a timed-out file would leave a browser
103
+ // running with nobody to stop it, and a suite that leaks one browser per
104
+ // timeout eventually cannot run. Chrome treats the pipe as its lifeline:
105
+ // when the file descriptors close, for any reason, it shuts itself down.
106
+ //
107
+ // Not a protocol. uf opens the two descriptors and never writes a byte to
108
+ // them; the page reports over HTTP, as everything else here does. The pipe
109
+ // is also why this is not `--remote-debugging-port`, which would open a
110
+ // port on the machine that anything could speak to — a pipe is held by this
111
+ // process alone.
112
+ //
113
+ // The tab closing is not enough on its own, which is what this replaces: a
114
+ // page that calls `window.close()` frees the renderers and leaves the
115
+ // browser process running (measured on Chrome 148).
116
+ "--remote-debugging-pipe",
117
+ // A scratch profile per run: no extensions, no saved state, no policy from
118
+ // the developer's own browser leaking into what the suite measures.
119
+ `--user-data-dir=${profile}`,
120
+ // The scratch profile stores no credentials, so keep Chrome off desktop
121
+ // keyring backends that can hang behind a broken CI DBus session before
122
+ // the first page request.
123
+ "--password-store=basic",
124
+ "--no-first-run",
125
+ "--no-default-browser-check",
126
+ // Keep startup deterministic in CI. These cut services Chrome may start
127
+ // before the first page request: extensions, sync, updater probes, and
128
+ // background network clients such as GCM. None of them changes layout or
129
+ // module execution, which is what browser mode measures.
130
+ "--disable-background-networking",
131
+ "--disable-client-side-phishing-detection",
132
+ "--disable-component-update",
133
+ "--disable-default-apps",
134
+ "--disable-domain-reliability",
135
+ "--disable-extensions",
136
+ "--disable-sync",
137
+ "--disable-features=AutofillServerCommunication,BackgroundFetch,BackgroundSync,CertificateTransparencyComponentUpdater,DialMediaRouteProvider,MediaRouter,NotificationTriggers,OptimizationHints,PushMessaging,Translate",
138
+ "--metrics-recording-only",
139
+ // Nothing here paints, and a GPU process is one more thing to fail in a
140
+ // container.
141
+ "--disable-gpu",
142
+ // `/dev/shm` is 64 MB in most containers, which is where a renderer dies
143
+ // in CI and nowhere else.
144
+ "--disable-dev-shm-usage",
145
+ "--disable-background-timer-throttling",
146
+ "--disable-renderer-backgrounding",
147
+ url,
148
+ ];
149
+ }
150
+
151
+ /**
152
+ * Write one event, exactly as `./worker.js` does.
153
+ *
154
+ * The raw stream, taken before anything in this process could have replaced
155
+ * it. Nothing here captures `console`: the driver runs no test code, and the
156
+ * printing that has to be captured happens in the page.
157
+ */
158
+ const emit = (line: string) => {
159
+ process.stdout.write(line);
160
+ };
161
+
162
+ function write(event: { readonly [string]: mixed }): void {
163
+ emit(`${JSON.stringify(event)}\n`);
164
+ }
165
+
166
+ /** Report that the run cannot happen, against the file that asked for it. */
167
+ function refuse(message: string, generation: number): void {
168
+ write({ event: "file", status: "run-failed", message, generation });
169
+ }
170
+
171
+ async function main(): Promise<void> {
172
+ const root = process.env.UF_PROJECT_ROOT ?? process.cwd();
173
+ const browser = process.env.UF_BROWSER;
174
+ const service = sharedService(root);
175
+
176
+ const server = await create({
177
+ root,
178
+ transform: async (id, code) => {
179
+ // The same three the Node loader passes (`packages/host/internal/
180
+ // node-hooks.js`), so a module means the same thing on both hosts.
181
+ // `inSourceTests` is the one that would be easy to forget and expensive
182
+ // to: without it `import.meta.uf.test` compiles to `void 0`, every
183
+ // in-source block is shaken out, and a browser run of a project that
184
+ // writes them reports fewer tests than the Node run of the same files —
185
+ // silently, and with a green summary.
186
+ const result = await service.transform(id, code, {
187
+ development: true,
188
+ sourceMap: true,
189
+ inSourceTests: inSourceTests(),
190
+ });
191
+ if (result == null) return null;
192
+ // The map is appended rather than served beside the module: a browser
193
+ // reads `sourceMappingURL` from the end of the file, and a data URL is
194
+ // one fewer route and one fewer round trip. What it buys is a stack
195
+ // frame in a failure that names the line the author wrote — the same
196
+ // thing `--enable-source-maps` buys a Node worker.
197
+ const map = result.map;
198
+ if (map == null) return result.code;
199
+ const encoded = Buffer.from(typeof map === "string" ? map : JSON.stringify(map)).toString(
200
+ "base64",
201
+ );
202
+ return `${result.code}\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${encoded}\n`;
203
+ },
204
+ warn: (message) => {
205
+ process.stderr.write(`[uf] ${message}\n`);
206
+ },
207
+ });
208
+
209
+ server.onEvent((event) => {
210
+ write(event);
211
+ });
212
+
213
+ if (browser == null || browser === "") {
214
+ // Belt and braces: `uf` refuses before starting this process, so reaching
215
+ // here means somebody ran the driver by hand.
216
+ refuse(
217
+ "`@uniflowed/test/browser-worker.js` was started with no UF_BROWSER. It is not a command to run directly; `uf test --browser` finds a browser and names it here.",
218
+ 0,
219
+ );
220
+ await server.close();
221
+ return;
222
+ }
223
+
224
+ const profile = mkdtempSync(path.join(tmpdir(), "uf-browser-"));
225
+ // Five descriptors, because `--remote-debugging-pipe` reads 3 and writes 4.
226
+ // They are opened and never used; see the flag's note for what that buys.
227
+ const child = spawn(browser, browserArguments(profile, server.url), {
228
+ stdio: ["ignore", "pipe", "pipe", "pipe", "pipe"],
229
+ });
230
+ // The DevTools pipe is a lifeline, not a protocol this driver speaks. Chrome
231
+ // can still write target bookkeeping to fd 4 before the page asks for work;
232
+ // drain it so a full pipe cannot block startup before the first HTTP request.
233
+ const devtoolsOutput = child.stdio[4];
234
+ devtoolsOutput?.on("data", () => {});
235
+ // Kept, not printed. A Chromium writes a dozen lines about GPU probing and
236
+ // Vulkan on a healthy start, and forwarding those to a passing run's report
237
+ // would be noise; they are the whole of the evidence when it does not start,
238
+ // and that is when they are shown.
239
+ let noise = "";
240
+ const keep = (chunk: mixed) => {
241
+ if (noise.length < 8 * 1024) noise += String(chunk);
242
+ };
243
+ child.stdout?.on("data", keep);
244
+ child.stderr?.on("data", keep);
245
+
246
+ let started = false;
247
+ const startFailure = await new Promise<string | null>((resolve) => {
248
+ const deadline = setTimeout(() => {
249
+ resolve(`the browser did not open the page within ${String(START_TIMEOUT_MS / 1000)}s`);
250
+ }, START_TIMEOUT_MS);
251
+ // Polled rather than pushed: the page's first request *is* the signal, and
252
+ // the server already knows when it arrives. A `Page.loadEventFired` would
253
+ // be the protocol this mode does not speak.
254
+ const poll: IntervalID = setInterval(() => {
255
+ if (!server.connected()) return;
256
+ clearInterval(poll);
257
+ clearTimeout(deadline);
258
+ started = true;
259
+ resolve(null);
260
+ }, 25);
261
+ child.on("exit", (code: mixed) => {
262
+ if (started) return;
263
+ clearInterval(poll);
264
+ clearTimeout(deadline);
265
+ resolve(`the browser exited (${String(code)}) before opening the page`);
266
+ });
267
+ child.on("error", (error: Error) => {
268
+ clearInterval(poll);
269
+ clearTimeout(deadline);
270
+ resolve(`the browser could not be started: ${error.message}`);
271
+ });
272
+ });
273
+
274
+ const stop = () => {
275
+ child.kill("SIGKILL");
276
+ try {
277
+ rmSync(profile, { recursive: true, force: true });
278
+ } catch {
279
+ // A scratch profile that outlives the run is litter in the system
280
+ // temporary directory, not a reason to fail a suite.
281
+ }
282
+ };
283
+
284
+ if (startFailure != null) {
285
+ stop();
286
+ await server.close();
287
+ // The first file's request is what `uf` is waiting on, so the refusal is
288
+ // reported against it — every subsequent file gets the same message from a
289
+ // fresh driver, which is the same shape as a worker that cannot spawn.
290
+ const trailer = noise.trim() === "" ? "" : `\n${noise.trim()}`;
291
+ refuse(`\`${browser}\`: ${startFailure}.${trailer}`, 0);
292
+ // The stream ends here, which `uf` reads as a host that died. That is
293
+ // exactly what happened, and it is what makes `uf` start a fresh driver
294
+ // for the next file rather than send it to a browser that is not there.
295
+ return;
296
+ }
297
+
298
+ createInterface({ input: process.stdin }).on("line", (line) => {
299
+ if (line.trim() === "") return;
300
+ let request: Request;
301
+ try {
302
+ request = JSON.parse(line);
303
+ } catch (error) {
304
+ write({
305
+ event: "file",
306
+ status: "run-failed",
307
+ message: `malformed request: ${String(error)}`,
308
+ generation: 0,
309
+ });
310
+ return;
311
+ }
312
+ // Handed straight over. `uf` sends one file at a time and waits for its
313
+ // `file` event, and the page runs one file per load, so there is no queue
314
+ // to keep here — the two ends already agree on the shape.
315
+ server.offer({
316
+ file: request.file,
317
+ filter: request.filter ?? null,
318
+ timeoutMs: request.timeoutMs ?? 5000,
319
+ generation: request.generation ?? 0,
320
+ });
321
+ });
322
+
323
+ process.stdin.on("close", () => {
324
+ void server.close().then(() => {
325
+ stop();
326
+ service.close();
327
+ process.exit(0);
328
+ });
329
+ });
330
+ }
331
+
332
+ // A driver that throws has no file to blame, so it says so against the request
333
+ // `uf` is waiting on and lets the stream end. Silence here would be a run that
334
+ // waits for a deadline.
335
+ main().catch((error: mixed) => {
336
+ const reason = error instanceof Error ? error.message : String(error);
337
+ refuse(`\`uf test --browser\` could not start: ${reason}`, 0);
338
+ process.exit(1);
339
+ });
package/bun/index.js ADDED
@@ -0,0 +1,305 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: `bun:test` has no Flow library definition, and the only
4
+ // thing that ever imports this is a `bun test` that `uf test` started.
5
+ //
6
+ // `@uniflowed/test`, when the suite is run by `bun test`.
7
+ //
8
+ // A project that writes `test: { runner: "bun" }` in `uf.config.js` has its
9
+ // suite run by `bun test` instead of uf's own runner, and its test files do not
10
+ // change: they still import `@uniflowed/test`. `uf test` starts Bun with the
11
+ // `uniflowed-bun-test` export condition, and this package's `exports` sends
12
+ // `@uniflowed/test` here under that condition and to `../index.js` under every
13
+ // other. uf's own runner never sets it, on Node or on Bun, so the only process
14
+ // that meets this file is one `uf test` started to hand a suite to Bun.
15
+ //
16
+ // # Why a condition rather than a plugin
17
+ //
18
+ // A `Bun.plugin` `onResolve` is not consulted for a bare specifier that
19
+ // resolves to an installed package, and `@uniflowed/test` is installed in every
20
+ // project that uses it. Tried on Bun 1.3.13, the test file imported uf's real
21
+ // API, registered its cases where Bun could not see them, and `bun test`
22
+ // printed "Ran 0 tests across 1 file" and exited 0 — a green run over a suite
23
+ // that ran nothing. Resolution is the one place the choice is made before a
24
+ // test file's imports run. See ubugeeei-prod/uf#942.
25
+ //
26
+ // # What maps, and what refuses
27
+ //
28
+ // The registration API, the hooks and `expect` are Bun's own, with the
29
+ // modifiers both runners give them: `.only`, `.skip`, `.todo` and `.each`
30
+ // (whose `%s %j %d %i` placeholders both runners format). `fn`, `spyOn`, and
31
+ // the `uft` members Bun has an equivalent for — the fake clock and the mock
32
+ // resets — are Bun's. uf's host-agnostic helpers are uf's: `uft.waitFor` and
33
+ // `uft.waitUntil` poll with `setTimeout` under either runner, `uft.mocked` is
34
+ // the identity function, and `equals` and `render` are pure.
35
+ //
36
+ // Everything else raises `UnsupportedError` at the moment it is used, naming
37
+ // what was called and why there is nothing here to give it — never an
38
+ // approximation that would report a different result:
39
+ //
40
+ // * `it.skipBecause`: Bun's skip carries no reason into its report, and a skip
41
+ // whose reason quietly disappears is what `skipBecause` exists to prevent.
42
+ // * uf's DOM matchers and `toHaveNoAxeViolations`: Bun's `expect` has none.
43
+ // * `uft.stubEnv`, `uft.stubGlobal` and their `unstubAll` pairs: uf puts a stub
44
+ // back before the next file, and `bun test` runs every file in one process
45
+ // where nothing would.
46
+ // * Module mocking: Bun's `mock.module` rewrites modules that have already
47
+ // imported the real one, which is a different promise from `uft.mock`'s.
48
+ // * `uft.advanceTimersByTimeAsync`: Bun's fake clock has no asynchronous
49
+ // advance, and flushing microtasks between timers is the whole of what it
50
+ // promises.
51
+ // * `AssertionError` and `RunawayTimersError`: Bun throws errors of its own, so
52
+ // a test comparing a failure against uf's class would be comparing it against
53
+ // a class nothing throws.
54
+
55
+ import * as bun from "bun:test";
56
+
57
+ import { equals, render } from "../internal/equality.js";
58
+ import { mocked, waitFor, waitUntil } from "../internal/namespace.js";
59
+ import { DEFAULT_TIMEOUT_MS, NAME_SEPARATOR } from "../internal/run.js";
60
+ import { UnsupportedError } from "../internal/unsupported.js";
61
+
62
+ export { UnsupportedError, equals, render, DEFAULT_TIMEOUT_MS, NAME_SEPARATOR };
63
+ export { afterAll, afterEach, beforeAll, beforeEach } from "bun:test";
64
+
65
+ /** What every refusal here says about where the suite is running. */
66
+ const UNDER_BUN = "the suite is being run by `bun test` (`test.runner` in uf.config.js)";
67
+
68
+ /**
69
+ * The error for something that is not a `uft` member.
70
+ *
71
+ * Still an `UnsupportedError`, because that is the class a test catches;
72
+ * only the message changes, since `UnsupportedError`'s is written for `uft`
73
+ * members and `it.skipBecause` or a matcher is not one.
74
+ */
75
+ class RunnerUnsupportedError extends UnsupportedError {
76
+ constructor(binding, reason) {
77
+ super(binding, reason);
78
+ this.message = `${binding} is not available: ${reason}`;
79
+ }
80
+ }
81
+
82
+ /** A function that refuses, for a binding with no equivalent under Bun. */
83
+ function refusing(binding, reason, Refusal = RunnerUnsupportedError) {
84
+ return () => {
85
+ throw new Refusal(binding, `${UNDER_BUN}, and ${reason}`);
86
+ };
87
+ }
88
+
89
+ /** A class that refuses to be constructed or compared against. */
90
+ function refusingClass(name, reason) {
91
+ const refuse = refusing(name, reason);
92
+ return class {
93
+ constructor() {
94
+ refuse();
95
+ }
96
+
97
+ static [Symbol.hasInstance]() {
98
+ return refuse();
99
+ }
100
+ };
101
+ }
102
+
103
+ /** The placeholders uf's `.each` substitutes a row into. */
104
+ const ROW_TOKEN = /%[sjdi]/g;
105
+
106
+ /**
107
+ * A row's case name, written the way uf's own runner writes it.
108
+ *
109
+ * Bun's `.each` differs from uf's twice over: it spreads an array row into the
110
+ * body's arguments where uf hands the body the row whole, and its JUnit report
111
+ * records the name with the placeholder still in it (`formats row %s`, once
112
+ * per row). A suite whose cases are named differently by the two runners is a
113
+ * suite whose results cannot be compared between them, so `.each` here is
114
+ * uf's, registering one case per row through Bun's plain registration.
115
+ * Mirrors `formatRow` in `../internal/registry.js`.
116
+ */
117
+ function formatRow(name, row) {
118
+ const values = Array.isArray(row) ? row : [row];
119
+ let index = 0;
120
+ return name.replace(ROW_TOKEN, (token) => {
121
+ const value = values[index];
122
+ index += 1;
123
+ return token === "%j" ? (JSON.stringify(value) ?? "undefined") : String(value);
124
+ });
125
+ }
126
+
127
+ /**
128
+ * One of uf's registration functions, forwarding to Bun's.
129
+ *
130
+ * Forwarded call by call rather than re-exported, so that a modifier uf has
131
+ * and Bun does not — `it.skipBecause` — can be attached without reaching into
132
+ * Bun's own function object.
133
+ */
134
+ function registration(bunApi) {
135
+ const api = (...args) => bunApi(...args);
136
+ api.only = (...args) => bunApi.only(...args);
137
+ api.skip = (...args) => bunApi.skip(...args);
138
+ api.todo = (...args) => bunApi.todo(...args);
139
+ api.each = (table) => (name, body, options) => {
140
+ for (const row of table) {
141
+ bunApi(formatRow(name, row), () => body(row), options);
142
+ }
143
+ };
144
+ return api;
145
+ }
146
+
147
+ export const describe = registration(bun.describe);
148
+ export const it = registration(bun.it);
149
+ it.skipBecause = refusing(
150
+ "it.skipBecause",
151
+ 'Bun\'s skip carries no reason into its report; write `it.skip`, or run this file with `runner: "uf"`',
152
+ );
153
+ export const test = it;
154
+
155
+ /**
156
+ * `bench`, which `bun:test` has no counterpart for.
157
+ *
158
+ * Its modifiers refuse too, by name, rather than failing on `undefined`.
159
+ */
160
+ const refuseBench = refusing(
161
+ "bench",
162
+ '`bun:test` has no benchmarks; run them with `runner: "uf"` and `uf test --bench`',
163
+ );
164
+ export const bench = (..._args) => refuseBench();
165
+ bench.only = refuseBench;
166
+ bench.skip = refuseBench;
167
+ bench.todo = refuseBench;
168
+
169
+ /** uf's matchers that Bun's `expect` has no counterpart for. */
170
+ const MATCHERS_BUN_LACKS = [
171
+ "toBeChecked",
172
+ "toBeDisabled",
173
+ "toBeEnabled",
174
+ "toBeInTheDocument",
175
+ "toBeRequired",
176
+ "toBeVisible",
177
+ "toHaveAttribute",
178
+ "toHaveClass",
179
+ "toHaveFocus",
180
+ "toHaveTextContent",
181
+ "toHaveValue",
182
+ "toHaveNoAxeViolations",
183
+ ];
184
+
185
+ bun.expect.extend(
186
+ Object.fromEntries(
187
+ MATCHERS_BUN_LACKS.map((matcher) => [
188
+ matcher,
189
+ refusing(
190
+ `expect(…).${matcher}`,
191
+ "Bun's `expect` has no such matcher; assert on the element's properties directly, or run this file with `runner: \"uf\"`",
192
+ ),
193
+ ]),
194
+ ),
195
+ );
196
+
197
+ /**
198
+ * uf's snapshot matchers.
199
+ *
200
+ * Bun has matchers by these names, and they are still not the same matchers:
201
+ * both runners write `__snapshots__/<file>.snap` in Jest's format, but each
202
+ * keys an entry by its own spelling of the test's name and serialises the value
203
+ * its own way. Passed through, a suite recorded by uf would either fail against
204
+ * its own snapshots or quietly write a second set beside them — and a snapshot
205
+ * that was rewritten rather than compared is the failure snapshots exist to
206
+ * catch.
207
+ */
208
+ const SNAPSHOT_MATCHERS = ["toMatchSnapshot", "toMatchInlineSnapshot"];
209
+
210
+ bun.expect.extend(
211
+ Object.fromEntries(
212
+ SNAPSHOT_MATCHERS.map((matcher) => [
213
+ matcher,
214
+ refusing(
215
+ `expect(…).${matcher}`,
216
+ 'uf and Bun key and serialise snapshots differently, so this would compare against — or rewrite — a snapshot uf did not write; run snapshot tests with `runner: "uf"`',
217
+ ),
218
+ ]),
219
+ ),
220
+ );
221
+
222
+ export const expect = bun.expect;
223
+
224
+ export const fn = (...args) => bun.jest.fn(...args);
225
+ export const spyOn = (...args) => bun.spyOn(...args);
226
+
227
+ export const AssertionError = refusingClass(
228
+ "AssertionError",
229
+ "Bun throws its own assertion errors, so nothing a failure throws is an instance of uf's",
230
+ );
231
+ export const RunawayTimersError = refusingClass(
232
+ "RunawayTimersError",
233
+ "Bun's fake clock throws its own error for a timer loop that never ends",
234
+ );
235
+
236
+ /** A `uft` member with no equivalent under Bun. */
237
+ const refusingMember = (member, reason) => refusing(member, reason, UnsupportedError);
238
+
239
+ const MODULE_MOCKING =
240
+ "Bun's `mock.module` rewrites modules that have already imported the real one, which is a different promise from `uft.mock`'s (see guide/testing, \"Which hosts\")";
241
+
242
+ const STUBS =
243
+ "uf puts a stub back before the next file, and `bun test` runs every file in one process where nothing would";
244
+
245
+ export const uft = Object.freeze({
246
+ fn,
247
+ spyOn,
248
+ mocked,
249
+
250
+ clearAllMocks: () => bun.jest.clearAllMocks(),
251
+ resetAllMocks: () => bun.jest.resetAllMocks(),
252
+ restoreAllMocks: () => bun.jest.restoreAllMocks(),
253
+
254
+ stubEnv: refusingMember("stubEnv", STUBS),
255
+ unstubAllEnvs: refusingMember("unstubAllEnvs", STUBS),
256
+ stubGlobal: refusingMember("stubGlobal", STUBS),
257
+ unstubAllGlobals: refusingMember("unstubAllGlobals", STUBS),
258
+
259
+ waitFor,
260
+ waitUntil,
261
+
262
+ useFakeTimers: () => {
263
+ bun.jest.useFakeTimers();
264
+ },
265
+ useRealTimers: () => {
266
+ bun.jest.useRealTimers();
267
+ },
268
+ isFakeTimers: () => bun.jest.isFakeTimers(),
269
+ advanceTimersByTime: (milliseconds) => {
270
+ bun.jest.advanceTimersByTime(milliseconds);
271
+ },
272
+ advanceTimersByTimeAsync: refusingMember(
273
+ "advanceTimersByTimeAsync",
274
+ "Bun's fake clock has no asynchronous advance; await between `uft.advanceTimersByTime` calls instead",
275
+ ),
276
+ advanceTimersToNextTimer: (steps) => {
277
+ bun.jest.advanceTimersToNextTimer(steps);
278
+ },
279
+ runAllTimers: () => {
280
+ bun.jest.runAllTimers();
281
+ },
282
+ runOnlyPendingTimers: () => {
283
+ bun.jest.runOnlyPendingTimers();
284
+ },
285
+ getTimerCount: () => bun.jest.getTimerCount(),
286
+ // uf's clock only moves while it is faked, and a time set before
287
+ // `useFakeTimers` is forgotten when the clock is installed. Bun's
288
+ // `setSystemTime` moves the real `Date` whether or not anything is faked, so
289
+ // it is only forwarded while Bun's clock is faked — outside that it does
290
+ // what uf's does, which is nothing a test can observe.
291
+ setSystemTime: (time) => {
292
+ if (bun.jest.isFakeTimers()) {
293
+ bun.setSystemTime(time);
294
+ }
295
+ },
296
+ getMockedSystemTime: () => (bun.jest.isFakeTimers() ? new Date() : null),
297
+
298
+ mock: refusingMember("mock", MODULE_MOCKING),
299
+ doMock: refusingMember("doMock", MODULE_MOCKING),
300
+ unmock: refusingMember("unmock", MODULE_MOCKING),
301
+ doUnmock: refusingMember("doUnmock", MODULE_MOCKING),
302
+ importActual: refusingMember("importActual", MODULE_MOCKING),
303
+ importMock: refusingMember("importMock", MODULE_MOCKING),
304
+ resetModules: refusingMember("resetModules", MODULE_MOCKING),
305
+ });