@uniflowed/test 0.0.0-alpha.35 → 0.0.0-alpha.37

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/index.js CHANGED
@@ -14,7 +14,14 @@
14
14
 
15
15
  export type { Expect, Expectation, Matchers } from "./internal/expect.js";
16
16
  export type { InSourceTests } from "./in-source.js";
17
- export type { Body as TestBody, Case, Modifier, Suite, TestOptions } from "./internal/registry.js";
17
+ export type {
18
+ BenchOptions,
19
+ Body as TestBody,
20
+ Case,
21
+ Modifier,
22
+ Suite,
23
+ TestOptions,
24
+ } from "./internal/registry.js";
18
25
  export type { ModuleFactory, ModuleNamespace } from "./internal/modules.js";
19
26
  export type { Uft } from "./internal/namespace.js";
20
27
  export type { Outcome, Result, RunOptions } from "./internal/run.js";
@@ -27,6 +34,7 @@ export {
27
34
  afterEach,
28
35
  beforeAll,
29
36
  beforeEach,
37
+ bench,
30
38
  describe,
31
39
  it,
32
40
  test,
@@ -30,14 +30,29 @@ export type Body = () => mixed | Promise<mixed>;
30
30
  /** The suffix written on a registration call. */
31
31
  export type Modifier = "none" | "only" | "skip" | "todo";
32
32
 
33
- /** One registered test case. */
33
+ /**
34
+ * How a benchmark runs under `uf test --bench`.
35
+ *
36
+ * `warmup` calls are made and their times thrown away, then `iterations` calls
37
+ * are each timed. `timeout` is the budget for one call, in milliseconds, and
38
+ * the benchmark as a whole is held to that budget once per call.
39
+ */
40
+ export type BenchOptions = {|
41
+ readonly warmup?: number,
42
+ readonly iterations?: number,
43
+ readonly timeout?: number,
44
+ |};
45
+
46
+ /** One registered test case, or one benchmark. */
34
47
  export type Case = {|
35
- readonly kind: "test",
48
+ readonly kind: "test" | "bench",
36
49
  readonly name: string,
37
50
  readonly body: Body | null,
38
51
  readonly modifier: Modifier,
39
52
  readonly skipReason: string | null,
40
53
  readonly timeoutMs: number | null,
54
+ /** How to run it, for a benchmark; `null` for a test. */
55
+ readonly bench: BenchOptions | null,
41
56
  readonly line: number,
42
57
  readonly column: number,
43
58
  |};
@@ -125,15 +140,17 @@ function addCase(
125
140
  modifier: Modifier,
126
141
  timeoutMs: number | null,
127
142
  skipReason: string | null = null,
143
+ bench: BenchOptions | null = null,
128
144
  ): void {
129
145
  const position = callSite();
130
146
  current.children.push({
131
- kind: "test",
147
+ kind: bench == null ? "test" : "bench",
132
148
  name,
133
149
  body,
134
150
  modifier,
135
151
  skipReason,
136
152
  timeoutMs,
153
+ bench,
137
154
  line: position.line,
138
155
  column: position.column,
139
156
  });
@@ -217,6 +234,34 @@ export const it: $FlowFixMe = caseApi();
217
234
  /** `test` is `it`, for people who write it that way. */
218
235
  export const test: $FlowFixMe = it;
219
236
 
237
+ /** The `bench` API, and its modifiers. See [`suiteApi`] for the shape. */
238
+ function benchApi(): $FlowFixMe {
239
+ const api: $FlowFixMe = (name: string, body: Body, options?: BenchOptions) => {
240
+ addCase(name, body, "none", options?.timeout ?? null, null, options ?? {});
241
+ };
242
+ api.only = (name: string, body: Body, options?: BenchOptions) => {
243
+ addCase(name, body, "only", options?.timeout ?? null, null, options ?? {});
244
+ };
245
+ api.skip = (name: string, body?: Body) => {
246
+ addCase(name, body ?? null, "skip", null, null, {});
247
+ };
248
+ api.todo = (name: string) => {
249
+ addCase(name, null, "todo", null, null, {});
250
+ };
251
+ return api;
252
+ }
253
+
254
+ /**
255
+ * Register one benchmark.
256
+ *
257
+ * `uf test` reports a benchmark as skipped, so a suite does not pay for timing
258
+ * one, and `uf test --bench` runs the benchmarks in place of the tests and
259
+ * reports how long each call took. `bench.only`, `bench.skip` and `bench.todo`
260
+ * do what `it`'s do. See [`BenchOptions`] for `warmup`, `iterations` and
261
+ * `timeout`.
262
+ */
263
+ export const bench: $FlowFixMe = benchApi();
264
+
220
265
  /**
221
266
  * Substitute a row into a name, the way every runner spells it: `%s` for the
222
267
  * value, `%j` for its JSON.
package/internal/run.js CHANGED
@@ -13,11 +13,18 @@ import * as output from "./output.js";
13
13
  import * as snapshot from "./snapshot.js";
14
14
  import { AssertionError } from "./expect.js";
15
15
  import { type Site, firstUserSite, siteInFile, userFrames } from "./frames.js";
16
- import { type Body, type Case, type Suite, collected } from "./registry.js";
16
+ import { type BenchOptions, type Body, type Case, type Suite, collected } from "./registry.js";
17
17
 
18
18
  /** How one case ended. */
19
19
  export type Outcome =
20
- | {| readonly status: "passed" |}
20
+ | {|
21
+ readonly status: "passed",
22
+ /**
23
+ * One timing per measured call, in whole microseconds, for a benchmark
24
+ * run under `uf test --bench`.
25
+ */
26
+ readonly samples?: $ReadOnlyArray<number>,
27
+ |}
21
28
  | {|
22
29
  readonly status: "failed",
23
30
  readonly message: string,
@@ -29,7 +36,7 @@ export type Outcome =
29
36
  |}
30
37
  | {|
31
38
  readonly status: "skipped",
32
- readonly reason: "explicit" | "not-only" | "filtered",
39
+ readonly reason: "explicit" | "not-only" | "filtered" | "bench" | "not-bench",
33
40
  readonly message?: string | null,
34
41
  |}
35
42
  | {| readonly status: "todo" |};
@@ -56,11 +63,31 @@ export type RunOptions = {|
56
63
  * which file that is — a test's name alone does not locate it.
57
64
  */
58
65
  readonly file?: string,
66
+ /**
67
+ * Run the benchmarks and report the tests skipped, rather than the other way
68
+ * round. `uf test --bench` sets it.
69
+ */
70
+ readonly bench?: boolean,
59
71
  |};
60
72
 
61
73
  /** Default budget for one case, matching what most runners use. */
62
74
  export const DEFAULT_TIMEOUT_MS: number = 5000;
63
75
 
76
+ /** Calls a benchmark makes and throws away before it times any, unless it says. */
77
+ export const DEFAULT_BENCH_WARMUP: number = 5;
78
+
79
+ /** Timed calls a benchmark makes, unless it says. */
80
+ export const DEFAULT_BENCH_ITERATIONS: number = 50;
81
+
82
+ /** Most timed calls one benchmark may ask for, and most samples `uf` keeps. */
83
+ export const MAX_BENCH_ITERATIONS: number = 100000;
84
+
85
+ /** Most untimed calls one benchmark may ask for first. */
86
+ export const MAX_BENCH_WARMUP: number = 10000;
87
+
88
+ /** The longest delay `setTimeout` honours; past it, the timer fires at once. */
89
+ const MAX_TIMER_MS = 2147483647;
90
+
64
91
  /** The separator between a suite's name and its child's. */
65
92
  export const NAME_SEPARATOR: string = " > ";
66
93
 
@@ -76,7 +103,9 @@ function fullName(path: $ReadOnlyArray<string>): string {
76
103
  */
77
104
  function hasOnly(node: Suite | Case, inherited: boolean): boolean {
78
105
  const marked = inherited || node.modifier === "only";
79
- if (node.kind === "test") {
106
+ // A benchmark is a case like a test: `bench.only` restricts a file as
107
+ // `it.only` does.
108
+ if (node.kind !== "suite") {
80
109
  return marked;
81
110
  }
82
111
  return node.children.some((child) => hasOnly(child, marked));
@@ -214,9 +243,19 @@ async function runCase(
214
243
  report({ status: "skipped", reason: "filtered" });
215
244
  return true;
216
245
  }
246
+ // A run is the tests or the benchmarks, never both. A benchmark timed inside
247
+ // an ordinary run would slow every suite that has one, beside workers busy
248
+ // with other files; a test inside a run of benchmarks would be timed with
249
+ // them.
250
+ const benchmark = test.kind === "bench";
251
+ if (benchmark !== (options.bench === true)) {
252
+ report({ status: "skipped", reason: benchmark ? "bench" : "not-bench" });
253
+ return true;
254
+ }
217
255
 
218
256
  const timeoutMs = test.timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
219
257
  let outcome: Outcome = { status: "passed" };
258
+ let samples: $ReadOnlyArray<number> | null = null;
220
259
  // Setup, body and teardown run *inside* the case's output context, and that
221
260
  // nesting is the whole of the fix for #207. What names a printed line is no
222
261
  // longer where the runner had got to when the line arrived — which named the
@@ -231,7 +270,11 @@ async function runCase(
231
270
  for (const hook of context.beforeEach) {
232
271
  await withTimeout(hook, timeoutMs);
233
272
  }
234
- await withTimeout(body, timeoutMs);
273
+ if (benchmark) {
274
+ samples = await measure(body, test.bench, timeoutMs);
275
+ } else {
276
+ await withTimeout(body, timeoutMs);
277
+ }
235
278
  } catch (thrown) {
236
279
  outcome = failure(thrown, options.file ?? null);
237
280
  }
@@ -252,10 +295,74 @@ async function runCase(
252
295
  // is the file's, and a snapshot taken outside one fails with something better
253
296
  // than a key belonging to whichever test happened to run last.
254
297
  snapshot.exitTest();
255
- report(outcome);
298
+ report(outcome.status === "passed" && samples != null ? { status: "passed", samples } : outcome);
256
299
  return outcome.status !== "failed";
257
300
  }
258
301
 
302
+ /**
303
+ * Time a benchmark's body: `warmup` calls thrown away, then `iterations` timed.
304
+ *
305
+ * The body is called directly and awaited, with nothing else between the two
306
+ * readings of the clock: a timer set around every call would be timed with
307
+ * it, and on a body that takes microseconds that is most of the number. The
308
+ * budget is held around the whole loop instead, at one call's budget per
309
+ * call, so a benchmark that hangs still fails rather than holding the worker.
310
+ */
311
+ async function measure(
312
+ body: Body,
313
+ options: BenchOptions | null,
314
+ timeoutMs: number,
315
+ ): Promise<$ReadOnlyArray<number>> {
316
+ const warmup = count("warmup", options?.warmup, DEFAULT_BENCH_WARMUP, 0, MAX_BENCH_WARMUP);
317
+ const iterations = count(
318
+ "iterations",
319
+ options?.iterations,
320
+ DEFAULT_BENCH_ITERATIONS,
321
+ 1,
322
+ MAX_BENCH_ITERATIONS,
323
+ );
324
+ const rounds = warmup + iterations;
325
+ const samples: Array<number> = [];
326
+ await withTimeout(
327
+ async () => {
328
+ for (let round = 0; round < rounds; round += 1) {
329
+ const started = performance.now();
330
+ await body();
331
+ if (round >= warmup) {
332
+ samples.push(Math.round((performance.now() - started) * 1000));
333
+ }
334
+ }
335
+ },
336
+ Math.min(timeoutMs * rounds, MAX_TIMER_MS),
337
+ );
338
+ return samples;
339
+ }
340
+
341
+ /**
342
+ * A benchmark option that has to be a whole number from `least` to `most`.
343
+ *
344
+ * Refused rather than clamped: `iterations: 0` or `warmup: 1e9` is a mistake,
345
+ * and a benchmark quietly run some other number of times reports numbers
346
+ * about a run nobody asked for.
347
+ */
348
+ function count(
349
+ option: string,
350
+ value: ?number,
351
+ fallback: number,
352
+ least: number,
353
+ most: number,
354
+ ): number {
355
+ if (value == null) {
356
+ return fallback;
357
+ }
358
+ if (!Number.isInteger(value) || value < least || value > most) {
359
+ throw new Error(
360
+ `bench \`${option}\` has to be a whole number from ${least} to ${most}, and was ${String(value)}`,
361
+ );
362
+ }
363
+ return value;
364
+ }
365
+
259
366
  /**
260
367
  * Walk one suite, running what it contains.
261
368
  *
@@ -306,13 +413,16 @@ async function runSuite(
306
413
  if (state.bail) {
307
414
  break;
308
415
  }
309
- if (child.kind === "test") {
416
+ if (child.kind !== "suite") {
310
417
  const willRun =
311
418
  !inner.skipped &&
312
419
  child.modifier !== "skip" &&
313
420
  child.modifier !== "todo" &&
314
421
  child.body != null &&
315
- (!onlyMode || inner.onlyPath || child.modifier === "only");
422
+ (!onlyMode || inner.onlyPath || child.modifier === "only") &&
423
+ // A benchmark in a run of the tests, or a test in a run of the
424
+ // benchmarks, is reported skipped and sets nothing up.
425
+ (child.kind === "bench") === (options.bench === true);
316
426
  if (willRun) {
317
427
  try {
318
428
  await setUpOnce();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/test",
3
- "version": "0.0.0-alpha.35",
3
+ "version": "0.0.0-alpha.37",
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",
@@ -11,7 +11,10 @@
11
11
  "directory": "packages/test"
12
12
  },
13
13
  "exports": {
14
- ".": "./index.js",
14
+ ".": {
15
+ "uniflowed-bun-test": "./bun/index.js",
16
+ "default": "./index.js"
17
+ },
15
18
  "./in-source": "./in-source.js",
16
19
  "./worker": "./worker.js",
17
20
  "./browser-worker": "./browser-worker.js",
@@ -19,6 +22,7 @@
19
22
  },
20
23
  "files": [
21
24
  "browser-worker.js",
25
+ "bun",
22
26
  "in-source.js",
23
27
  "index.js",
24
28
  "internal",
@@ -26,7 +30,7 @@
26
30
  "!*.test.js"
27
31
  ],
28
32
  "dependencies": {
29
- "@uniflowed/host": "0.0.0-alpha.35"
33
+ "@uniflowed/host": "0.0.0-alpha.37"
30
34
  },
31
35
  "peerDependencies": {
32
36
  "axe-core": ">=4"
package/worker.js CHANGED
@@ -58,26 +58,19 @@ import { installInSourceTests } from "./in-source.js";
58
58
  import { restoreSharedState } from "./internal/isolation.js";
59
59
  import { run } from "./internal/run.js";
60
60
 
61
- // `process` is imported rather than read off the global, and then put *on*
62
- // the global, because one host in three does not have it there.
61
+ // `process` is imported rather than read off the global because this file is
62
+ // the process entry point, and an entry point that names what it depends on is
63
+ // one a reader does not have to trust.
63
64
  //
64
- // Deno exposes the whole object as `node:process` and nothing as
65
- // `globalThis.process` — `crates/uf_cli/tests/deno_host.rs` starts a real one
66
- // and asserts both halves. Every module this worker reaches that wants the
67
- // process reaches for the global: `internal/output.js` replaces
68
- // `process.stdout.write` so a test's printing cannot land in the middle of the
69
- // protocol, `internal/namespace.js` reads `process.env` for `stubEnv`, and
70
- // `internal/axe.js` reads `UF_AXE` from it. Threading an import through all
71
- // three would put a `node:` specifier into modules a browser build resolves,
72
- // for a difference no other host has.
73
- //
74
- // So the entry point installs it, once, before anything below runs. `??=`
75
- // rather than `=`: on Node and Bun the global is already the real object and
76
- // this must not replace it with a second view of the same thing.
77
- //
78
- // It is a shim of the host, in the one file that is a process entry point, and
79
- // it stops here — nothing else in `@uniflowed/test` may do this.
80
- globalThis.process ??= process;
65
+ // It used to be put *on* the global as well. Deno 1.x exposed the object as
66
+ // `node:process` and nothing as `globalThis.process`, and every module this
67
+ // worker reaches that wants the process reads the global: `internal/output.js`
68
+ // replaces `process.stdout.write`, `internal/namespace.js` reads
69
+ // `process.env` for `stubEnv`, and `internal/axe.js` reads `UF_AXE`. The Deno
70
+ // uf starts is 2.8 or newer — `@uniflowed/host/deno-preload` is built on that
71
+ // release's `registerHooks`, and `uf test` refuses anything older — and Deno 2
72
+ // has the global like Node and Bun. A shim for a host uf no longer starts would
73
+ // be a line claiming to be load-bearing while holding nothing up.
81
74
 
82
75
  /** What `uf` sends for one file. */
83
76
  type Request = {|
@@ -159,6 +152,17 @@ function write(event: { readonly [string]: mixed }): void {
159
152
  * the `serving` store this runs inside — it is what every event written from
160
153
  * this file, or from anything this file leaves behind, is stamped with.
161
154
  */
155
+ /**
156
+ * Whether this is a run of the benchmarks, `uf test --bench`.
157
+ *
158
+ * Read from the environment, as snapshot updates are: it is a property of the
159
+ * run, set once on the worker by `uf`, and not something each request says.
160
+ */
161
+ function benching(): boolean {
162
+ const value = (globalThis: $FlowFixMe).process?.env?.UF_TEST_BENCH;
163
+ return value != null && value !== "" && value !== "0";
164
+ }
165
+
162
166
  async function runFile(request: Request, generation: number): Promise<void> {
163
167
  const started = performance.now();
164
168
  // Everything the previous file changed and this package shares with it goes
@@ -231,7 +235,12 @@ async function runImportedFile(
231
235
  try {
232
236
  const absolute = fileURLToPath(pathToFileURL(request.file).href);
233
237
  await run(
234
- { filter: request.filter ?? null, timeoutMs: request.timeoutMs, file: absolute },
238
+ {
239
+ filter: request.filter ?? null,
240
+ timeoutMs: request.timeoutMs,
241
+ file: absolute,
242
+ bench: benching(),
243
+ },
235
244
  (result) => {
236
245
  write({
237
246
  event: "test",