@uniflowed/test 0.0.0-alpha.9 → 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/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,
@@ -27,7 +34,11 @@ export type Outcome =
27
34
  /** Where the failing assertion was written, when the stack says. */
28
35
  readonly site: {| readonly line: number, readonly column: number |} | null,
29
36
  |}
30
- | {| readonly status: "skipped", readonly reason: "explicit" | "not-only" | "filtered" |}
37
+ | {|
38
+ readonly status: "skipped",
39
+ readonly reason: "explicit" | "not-only" | "filtered" | "bench" | "not-bench",
40
+ readonly message?: string | null,
41
+ |}
31
42
  | {| readonly status: "todo" |};
32
43
 
33
44
  /** One finished case, as the runner reports it. */
@@ -52,11 +63,31 @@ export type RunOptions = {|
52
63
  * which file that is — a test's name alone does not locate it.
53
64
  */
54
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,
55
71
  |};
56
72
 
57
73
  /** Default budget for one case, matching what most runners use. */
58
74
  export const DEFAULT_TIMEOUT_MS: number = 5000;
59
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
+
60
91
  /** The separator between a suite's name and its child's. */
61
92
  export const NAME_SEPARATOR: string = " > ";
62
93
 
@@ -72,7 +103,9 @@ function fullName(path: $ReadOnlyArray<string>): string {
72
103
  */
73
104
  function hasOnly(node: Suite | Case, inherited: boolean): boolean {
74
105
  const marked = inherited || node.modifier === "only";
75
- 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") {
76
109
  return marked;
77
110
  }
78
111
  return node.children.some((child) => hasOnly(child, marked));
@@ -188,12 +221,17 @@ async function runCase(
188
221
  });
189
222
  };
190
223
 
191
- if (test.modifier === "todo" || test.body == null) {
224
+ if (test.modifier === "todo") {
192
225
  report({ status: "todo" });
193
226
  return true;
194
227
  }
195
228
  if (context.skipped || test.modifier === "skip") {
196
- report({ status: "skipped", reason: "explicit" });
229
+ report({ status: "skipped", reason: "explicit", message: test.skipReason });
230
+ return true;
231
+ }
232
+ const body = test.body;
233
+ if (body == null) {
234
+ report({ status: "todo" });
197
235
  return true;
198
236
  }
199
237
  if (!context.onlyPath) {
@@ -205,9 +243,19 @@ async function runCase(
205
243
  report({ status: "skipped", reason: "filtered" });
206
244
  return true;
207
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
+ }
208
255
 
209
256
  const timeoutMs = test.timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
210
257
  let outcome: Outcome = { status: "passed" };
258
+ let samples: $ReadOnlyArray<number> | null = null;
211
259
  // Setup, body and teardown run *inside* the case's output context, and that
212
260
  // nesting is the whole of the fix for #207. What names a printed line is no
213
261
  // longer where the runner had got to when the line arrived — which named the
@@ -222,7 +270,11 @@ async function runCase(
222
270
  for (const hook of context.beforeEach) {
223
271
  await withTimeout(hook, timeoutMs);
224
272
  }
225
- await withTimeout(test.body, timeoutMs);
273
+ if (benchmark) {
274
+ samples = await measure(body, test.bench, timeoutMs);
275
+ } else {
276
+ await withTimeout(body, timeoutMs);
277
+ }
226
278
  } catch (thrown) {
227
279
  outcome = failure(thrown, options.file ?? null);
228
280
  }
@@ -243,10 +295,74 @@ async function runCase(
243
295
  // is the file's, and a snapshot taken outside one fails with something better
244
296
  // than a key belonging to whichever test happened to run last.
245
297
  snapshot.exitTest();
246
- report(outcome);
298
+ report(outcome.status === "passed" && samples != null ? { status: "passed", samples } : outcome);
247
299
  return outcome.status !== "failed";
248
300
  }
249
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
+
250
366
  /**
251
367
  * Walk one suite, running what it contains.
252
368
  *
@@ -297,13 +413,16 @@ async function runSuite(
297
413
  if (state.bail) {
298
414
  break;
299
415
  }
300
- if (child.kind === "test") {
416
+ if (child.kind !== "suite") {
301
417
  const willRun =
302
418
  !inner.skipped &&
303
419
  child.modifier !== "skip" &&
304
420
  child.modifier !== "todo" &&
305
421
  child.body != null &&
306
- (!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);
307
426
  if (willRun) {
308
427
  try {
309
428
  await setUpOnce();
@@ -315,7 +434,7 @@ async function runSuite(
315
434
  line: child.line,
316
435
  column: child.column,
317
436
  durationMicros: 0,
318
- outcome: failure(thrown),
437
+ outcome: failure(thrown, options.file ?? null),
319
438
  });
320
439
  passed = false;
321
440
  continue;
@@ -82,7 +82,7 @@ function host(): $FlowFixMe {
82
82
  * formats a date sees a plausible one — and `setSystemTime` is how a test that
83
83
  * cares says which.
84
84
  */
85
- export function useFakeTimers(): void {
85
+ export function installFakeClock(): void {
86
86
  if (installed != null) {
87
87
  return;
88
88
  }
@@ -120,7 +120,7 @@ export function useFakeTimers(): void {
120
120
  }
121
121
 
122
122
  /** Put the real scheduling globals back. */
123
- export function useRealTimers(): void {
123
+ export function restoreRealClock(): void {
124
124
  if (installed == null) {
125
125
  return;
126
126
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/test",
3
- "version": "0.0.0-alpha.9",
3
+ "version": "0.1.0",
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,16 +11,52 @@
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
+ },
18
+ "./in-source": "./in-source.js",
15
19
  "./worker": "./worker.js",
16
- "./package.json": "./package.json"
20
+ "./browser-worker": "./browser-worker.js",
21
+ "./package.json": "./package.json",
22
+ "./app": {
23
+ "browser": "./app-browser.js",
24
+ "default": "./app.js"
25
+ },
26
+ "./browser": "./browser.js"
17
27
  },
18
28
  "files": [
29
+ "browser-worker.js",
30
+ "bun",
31
+ "in-source.js",
19
32
  "index.js",
33
+ "internal",
20
34
  "worker.js",
21
- "internal"
35
+ "app.js",
36
+ "browser.js",
37
+ "app-browser.js",
38
+ "!*.test.js"
22
39
  ],
23
40
  "dependencies": {
24
- "@uniflowed/host": "0.0.0-alpha.9"
41
+ "@uniflowed/host": "0.1.0",
42
+ "pixelmatch": "^7.1.0",
43
+ "pngjs": "^7.0.0"
44
+ },
45
+ "peerDependencies": {
46
+ "axe-core": ">=4"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "axe-core": {
50
+ "optional": true
51
+ }
52
+ },
53
+ "browser": {
54
+ "node:async_hooks": "./internal/browser/node.js",
55
+ "node:fs": "./internal/browser/node.js",
56
+ "node:module": "./internal/browser/node.js",
57
+ "node:path": "./internal/browser/node.js",
58
+ "node:url": "./internal/browser/node.js",
59
+ "node:util": "./internal/browser/node.js",
60
+ "./internal/browser/transport.js": "./internal/browser/page-transport.js"
25
61
  }
26
62
  }
package/worker.js CHANGED
@@ -49,14 +49,29 @@
49
49
 
50
50
  import * as output from "./internal/output.js";
51
51
  import { AsyncLocalStorage } from "node:async_hooks";
52
+ import process from "node:process";
52
53
  import { writeChangedSnapshots } from "./internal/snapshot.js";
53
54
  import { createInterface } from "node:readline";
54
55
  import { fileURLToPath, pathToFileURL } from "node:url";
55
56
 
56
- import { reset } from "./internal/registry.js";
57
- import { resetModuleState } from "./internal/modules.js";
57
+ import { installInSourceTests } from "./in-source.js";
58
+ import { restoreSharedState } from "./internal/isolation.js";
58
59
  import { run } from "./internal/run.js";
59
60
 
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.
64
+ //
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.
74
+
60
75
  /** What `uf` sends for one file. */
61
76
  type Request = {|
62
77
  readonly file: string,
@@ -137,18 +152,80 @@ function write(event: { readonly [string]: mixed }): void {
137
152
  * the `serving` store this runs inside — it is what every event written from
138
153
  * this file, or from anything this file leaves behind, is stamped with.
139
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
+
140
166
  async function runFile(request: Request, generation: number): Promise<void> {
141
167
  const started = performance.now();
142
- reset();
143
- // Every module this file stood in for goes back, before the next file can
144
- // import one of them and be handed the previous file's stand-in. A worker
145
- // serves many files out of one module registry, so this is the difference
146
- // between "one file at a time" and "one file's mocks at a time".
147
- resetModuleState();
168
+ // Everything the previous file changed and this package shares with it goes
169
+ // back: the registry, the stubbed environment and globals, the clock, the
170
+ // module stand-ins, and the document. What each of those is and why it is on
171
+ // the list is `internal/isolation.js`, which is one place rather than five
172
+ // calls here — a worker serves many files out of one process, `uf test` fans
173
+ // files across workers by size, and a leak therefore makes the *result* of a
174
+ // suite a function of the timings file rather than of the code under test.
175
+ // See ubugeeei-prod/uf#417, #581 and #607, which are that sentence three
176
+ // times over.
177
+ //
178
+ // Before the import rather than after the run, so a file that throws while
179
+ // loading still hands the next one a clean process.
180
+ restoreSharedState();
181
+ // Not a restore, and so not on that list: this is the output budget for the
182
+ // file about to run, rather than something the previous file left behind.
148
183
  output.startFile();
149
184
 
185
+ // In-source blocks in *this* file get uf's test API; the same blocks in
186
+ // every module this file imports get `undefined` and do not register. See
187
+ // `./in-source.js` for why the marker is a call rather than a constant.
188
+ const url = pathToFileURL(request.file).href;
189
+ const uninstallInSourceTests = installInSourceTests(url);
190
+ try {
191
+ await runImportedFile(request, generation, url, started);
192
+ } finally {
193
+ // For the whole file rather than only for its import. A block reads the
194
+ // marker at module scope, but a *case body* may read it too — `const
195
+ // { uft } = import.meta.uf.test` inside an `it` is an ordinary thing to
196
+ // write — and a marker that answered during the import and not during the
197
+ // run would be a value that changed under the file that read it.
198
+ //
199
+ // Taking it away earlier would not buy the isolation it looks like it
200
+ // buys: work a finished file left behind can register through a binding it
201
+ // captured just as easily as through this global, so the thing that keeps
202
+ // a straggler out of the next file is the registry reset at the top of
203
+ // this function and the generation stamped on every event, not the
204
+ // lifetime of one accessor.
205
+ uninstallInSourceTests();
206
+ }
207
+ }
208
+
209
+ /**
210
+ * The half of [`runFile`] that has a file to run.
211
+ *
212
+ * Split out so the caller's `finally` covers the import *and* the run without
213
+ * either of the two `return`s below escaping it.
214
+ */
215
+ async function runImportedFile(
216
+ request: Request,
217
+ generation: number,
218
+ url: string,
219
+ started: number,
220
+ ): Promise<void> {
221
+ let closeNative: (() => Promise<void>) | null = null;
150
222
  try {
151
- await import(`${pathToFileURL(request.file).href}?uf-run=${generation}`);
223
+ if (process.env.UF_TEST_TARGET === "react-native") {
224
+ const { loadNativeFile } = await import("./internal/native-host.js");
225
+ closeNative = await loadNativeFile(request.file);
226
+ } else {
227
+ await import(`${url}?uf-run=${generation}`);
228
+ }
152
229
  } catch (thrown) {
153
230
  const error = thrown instanceof Error ? thrown : new Error(String(thrown));
154
231
  write({
@@ -164,7 +241,12 @@ async function runFile(request: Request, generation: number): Promise<void> {
164
241
  try {
165
242
  const absolute = fileURLToPath(pathToFileURL(request.file).href);
166
243
  await run(
167
- { filter: request.filter ?? null, timeoutMs: request.timeoutMs, file: absolute },
244
+ {
245
+ filter: request.filter ?? null,
246
+ timeoutMs: request.timeoutMs,
247
+ file: absolute,
248
+ bench: benching(),
249
+ },
168
250
  (result) => {
169
251
  write({
170
252
  event: "test",
@@ -180,12 +262,18 @@ async function runFile(request: Request, generation: number): Promise<void> {
180
262
  // with forty snapshots would otherwise rewrite its snapshot file forty
181
263
  // times, and a crash halfway through would leave a partial one.
182
264
  writeChangedSnapshots();
265
+ await closeNative?.();
183
266
  write({
184
267
  event: "file",
185
268
  status: "completed",
186
269
  durationMicros: Math.round((performance.now() - started) * 1000),
187
270
  });
188
271
  } catch (thrown) {
272
+ try {
273
+ await closeNative?.();
274
+ } catch {
275
+ // Preserve the original run or teardown failure.
276
+ }
189
277
  const error = thrown instanceof Error ? thrown : new Error(String(thrown));
190
278
  write({
191
279
  event: "file",