@uniflowed/test 0.0.0-alpha.8 → 0.1.0

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/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
+ });
package/in-source.js ADDED
@@ -0,0 +1,169 @@
1
+ // @flow
2
+ //
3
+ // In-source tests: `if (import.meta.uf.test) { … }` in the file being tested.
4
+ //
5
+ // A three-line test for a three-line pure function does not want a file of its
6
+ // own, an import of the thing it tests, and a path that has to be kept in step
7
+ // with it. `import.meta.uf.test` is the block that holds it, and it is the
8
+ // same idea as Vitest's `import.meta.vitest` for the same reason.
9
+ //
10
+ // # What a block looks like
11
+ //
12
+ // import { expect, it } from "@uniflowed/test";
13
+ //
14
+ // export function add(a: number, b: number): number {
15
+ // return a + b;
16
+ // }
17
+ //
18
+ // if (import.meta.uf.test) {
19
+ // it("adds", () => {
20
+ // expect(add(1, 2)).toBe(3);
21
+ // });
22
+ // }
23
+ //
24
+ // An ordinary import for the bindings, because that is what makes them typed:
25
+ // Flow has no `typeof import("…")` for a library definition to use, so a
26
+ // marker that carried the API could not be described to the checker. The
27
+ // marker's *value* is the API anyway — `const { it } = import.meta.uf.test` is
28
+ // what somebody arriving from Vitest writes and it works — but it is untyped,
29
+ // and the import is the form uf recommends.
30
+ //
31
+ // The import costs nothing in a build. The branch folds to `if (void 0)` and
32
+ // goes, `@uniflowed/test` declares `sideEffects: false` so the now-unused
33
+ // import goes with it, and the package carries a `browser` field mapping the
34
+ // six `node:` builtins its edges import at `./internal/browser/node.js` — so
35
+ // the bundler does not even print a warning about modules it is in the middle
36
+ // of removing.
37
+ //
38
+ // # What makes the block disappear
39
+ //
40
+ // `uf` compiles the marker away rather than leaving it to be falsy at runtime:
41
+ // `crates/uf_transform`'s printer substitutes `void 0` for
42
+ // `import.meta.uf.test` in every transform except the ones `uf test` asks for,
43
+ // so `uf build` sees `if (void 0)` and the bundler removes the block. There is
44
+ // no `import.meta.uf` at runtime, in any host, which is also why the block
45
+ // cannot throw in a browser that never heard of uf.
46
+ //
47
+ // `tests/library/in-source.test.js` and `tests/library/in-source-subject.js`
48
+ // are the block running. The other half is in `crates/uf_cli/tests/vite.rs`,
49
+ // which builds a fixture whose block holds a string that appears nowhere else
50
+ // and reads the emitted client bundle back looking for it — in the same file
51
+ // the module's other marker has to be present in, because "a string is
52
+ // missing" is only evidence when something establishes the module is not.
53
+ //
54
+ // # Why the marker is a call, and what it is called with
55
+ //
56
+ // Under `uf test` the printer substitutes
57
+ // `globalThis.__ufInSourceTests?.(import.meta.url)`, and this module is what
58
+ // answers it. The argument is the module asking, because a source file with an
59
+ // in-source block is an ordinary module and is imported more than once in a
60
+ // normal run: `uf test` imports `add.js` as the file it is running, and
61
+ // `add.test.js` imports it again for the function. Both evaluations reach the
62
+ // marker. Only the first is the file the run is reporting on, so only the
63
+ // first is given the API — otherwise every in-source case would register twice
64
+ // and the second copy would be filed under whichever test file imported it.
65
+
66
+ import {
67
+ afterAll,
68
+ afterEach,
69
+ beforeAll,
70
+ beforeEach,
71
+ describe,
72
+ it,
73
+ test,
74
+ } from "./internal/registry.js";
75
+ import { fn, spyOn } from "./internal/spy.js";
76
+
77
+ import type { Expect } from "./internal/expect.js";
78
+ import type { Uft } from "./internal/namespace.js";
79
+ import { expect } from "./internal/expect.js";
80
+ import { uft } from "./internal/namespace.js";
81
+
82
+ /**
83
+ * The name `uf` compiles `import.meta.uf.test` into a call on.
84
+ *
85
+ * Exported so the worker and the tests name it once; it is spelled out in `crates/uf_transform/src/print.rs` as well, and
86
+ * `tests/library/in-source.test.js` is what keeps the two spellings equal.
87
+ */
88
+ export const IN_SOURCE_GLOBAL: "__ufInSourceTests" = "__ufInSourceTests";
89
+
90
+ /**
91
+ * What an in-source block is handed.
92
+ *
93
+ * The subset of `@uniflowed/test` a test body needs, and deliberately not all
94
+ * of it: a block that wants module mocking or a snapshot is a block that has
95
+ * outgrown living inside the file it tests, and `import { uft } from
96
+ * "@uniflowed/test"` in a file of its own is the answer to that. `uft` is here
97
+ * anyway because spies and the fake clock are ordinary for a unit test.
98
+ */
99
+ export type InSourceTests = {
100
+ readonly describe: typeof describe,
101
+ readonly it: typeof it,
102
+ readonly test: typeof test,
103
+ readonly expect: Expect,
104
+ readonly beforeAll: typeof beforeAll,
105
+ readonly beforeEach: typeof beforeEach,
106
+ readonly afterAll: typeof afterAll,
107
+ readonly afterEach: typeof afterEach,
108
+ readonly fn: typeof fn,
109
+ readonly spyOn: typeof spyOn,
110
+ readonly uft: Uft,
111
+ };
112
+
113
+ /**
114
+ * The API every in-source block in a run shares.
115
+ *
116
+ * One frozen object rather than one per file: the bindings are the module's
117
+ * own singletons — `it` registers into the one registry the worker resets
118
+ * between files — so a copy per file would only be a copy of the same
119
+ * references.
120
+ */
121
+ const API: InSourceTests = Object.freeze({
122
+ describe,
123
+ it,
124
+ test,
125
+ expect,
126
+ beforeAll,
127
+ beforeEach,
128
+ afterAll,
129
+ afterEach,
130
+ fn,
131
+ spyOn,
132
+ uft,
133
+ });
134
+
135
+ /**
136
+ * The module URL, without whatever a host appended to it.
137
+ *
138
+ * The worker busts its import cache with `?uf-run=<generation>` so a watch
139
+ * rerun sees the edited file, which means the file being run reports an
140
+ * `import.meta.url` that its own path does not match. Comparing the part
141
+ * before the query is comparing the module.
142
+ *
143
+ * A hash is stripped for the same reason and never appears in practice; it
144
+ * costs one `indexOf` to not have to think about it again.
145
+ */
146
+ function moduleUrl(url: string): string {
147
+ const end = url.search(/[?#]/);
148
+ return end === -1 ? url : url.slice(0, end);
149
+ }
150
+
151
+ /**
152
+ * Hand in-source blocks their API for the file `url`, and nothing to any other
153
+ * module.
154
+ *
155
+ * Returns the function that puts the global back as it was, which the worker
156
+ * calls when the file is over. Restoring rather than leaving it set is the
157
+ * same rule the worker applies to environment stubs and module mocks: a file
158
+ * may not change what the next file on this worker sees.
159
+ */
160
+ export function installInSourceTests(url: string): () => void {
161
+ const wanted = moduleUrl(url);
162
+ const globals: { [string]: mixed } = globalThis as $FlowFixMe;
163
+ const previous = globals[IN_SOURCE_GLOBAL];
164
+ globals[IN_SOURCE_GLOBAL] = (asking: string): InSourceTests | void =>
165
+ moduleUrl(asking) === wanted ? API : undefined;
166
+ return () => {
167
+ globals[IN_SOURCE_GLOBAL] = previous;
168
+ };
169
+ }
package/index.js CHANGED
@@ -12,7 +12,16 @@
12
12
  //
13
13
  // The whole surface is importable from here, so a test file has one import.
14
14
 
15
- export type { Body as TestBody, Case, Modifier, Suite, TestOptions } from "./internal/registry.js";
15
+ export type { Expect, Expectation, Matchers } from "./internal/expect.js";
16
+ export type { InSourceTests } from "./in-source.js";
17
+ export type {
18
+ BenchOptions,
19
+ Body as TestBody,
20
+ Case,
21
+ Modifier,
22
+ Suite,
23
+ TestOptions,
24
+ } from "./internal/registry.js";
16
25
  export type { ModuleFactory, ModuleNamespace } from "./internal/modules.js";
17
26
  export type { Uft } from "./internal/namespace.js";
18
27
  export type { Outcome, Result, RunOptions } from "./internal/run.js";
@@ -25,6 +34,7 @@ export {
25
34
  afterEach,
26
35
  beforeAll,
27
36
  beforeEach,
37
+ bench,
28
38
  describe,
29
39
  it,
30
40
  test,