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

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
@@ -12,12 +12,19 @@
12
12
  import * as output from "./output.js";
13
13
  import * as snapshot from "./snapshot.js";
14
14
  import { AssertionError } from "./expect.js";
15
- import { firstUserSite, userFrames } from "./frames.js";
16
- import { type Body, type Case, type Suite, collected } from "./registry.js";
15
+ import { type Site, firstUserSite, siteInFile, userFrames } from "./frames.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));
@@ -102,7 +135,22 @@ async function withTimeout(body: Body, timeoutMs: number): Promise<void> {
102
135
  }
103
136
  }
104
137
 
105
- function failure(thrown: mixed): Outcome {
138
+ /**
139
+ * Where to say a failure happened.
140
+ *
141
+ * The reported position is printed under the path of the file being run, so
142
+ * when that file is known the line has to come from it: `firstUserSite` will
143
+ * hand back the first frame of whatever library raised, and a library's line
144
+ * number wearing the test file's path sends the reader to the wrong place
145
+ * (ubugeeei-prod/uf#319). The fallback is for a caller that did not say which
146
+ * file it is running — `run` is driven directly by this repository's own
147
+ * tests as well as by the worker — and is what every failure used before.
148
+ */
149
+ function siteOf(stack: string | null, file: string | null): Site | null {
150
+ return file == null || file === "" ? firstUserSite(stack, false) : siteInFile(stack, file);
151
+ }
152
+
153
+ function failure(thrown: mixed, file: string | null): Outcome {
106
154
  if (thrown instanceof AssertionError) {
107
155
  const stack = userFrames(thrown.stack);
108
156
  return {
@@ -111,7 +159,7 @@ function failure(thrown: mixed): Outcome {
111
159
  stack,
112
160
  expected: thrown.expected,
113
161
  received: thrown.received,
114
- site: firstUserSite(stack, false),
162
+ site: siteOf(stack, file),
115
163
  };
116
164
  }
117
165
  if (thrown instanceof Error) {
@@ -122,7 +170,7 @@ function failure(thrown: mixed): Outcome {
122
170
  stack,
123
171
  expected: null,
124
172
  received: null,
125
- site: firstUserSite(stack, false),
173
+ site: siteOf(stack, file),
126
174
  };
127
175
  }
128
176
  return {
@@ -173,12 +221,17 @@ async function runCase(
173
221
  });
174
222
  };
175
223
 
176
- if (test.modifier === "todo" || test.body == null) {
224
+ if (test.modifier === "todo") {
177
225
  report({ status: "todo" });
178
226
  return true;
179
227
  }
180
228
  if (context.skipped || test.modifier === "skip") {
181
- 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" });
182
235
  return true;
183
236
  }
184
237
  if (!context.onlyPath) {
@@ -190,42 +243,126 @@ async function runCase(
190
243
  report({ status: "skipped", reason: "filtered" });
191
244
  return true;
192
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
+ }
193
255
 
194
256
  const timeoutMs = test.timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
195
257
  let outcome: Outcome = { status: "passed" };
196
- // From here to the end of teardown is exactly the window in which this
197
- // case's own code runs, so it is exactly the window whose printing is this
198
- // case's. The cases above never reach it: nothing runs for a `todo`, a
199
- // `skip` or a filtered-out case, so nothing of theirs can print.
200
- output.enterTest(name);
201
- try {
202
- for (const hook of context.beforeEach) {
203
- await withTimeout(hook, timeoutMs);
204
- }
205
- await withTimeout(test.body, timeoutMs);
206
- } catch (thrown) {
207
- outcome = failure(thrown);
208
- }
209
- // Teardown runs whatever happened above, and only reports its own failure
210
- // when the body had not already failed.
211
- for (const hook of context.afterEach) {
258
+ let samples: $ReadOnlyArray<number> | null = null;
259
+ // Setup, body and teardown run *inside* the case's output context, and that
260
+ // nesting is the whole of the fix for #207. What names a printed line is no
261
+ // longer where the runner had got to when the line arrived — which named the
262
+ // next case for anything a `setTimeout` left behind — but which case's work
263
+ // the write descends from. A callback scheduled here keeps this name however
264
+ // late it fires.
265
+ //
266
+ // The cases above never reach this: nothing runs for a `todo`, a `skip` or a
267
+ // filtered-out case, so nothing of theirs can print.
268
+ await output.runInTest(name, async () => {
212
269
  try {
213
- await withTimeout(hook, timeoutMs);
270
+ for (const hook of context.beforeEach) {
271
+ await withTimeout(hook, timeoutMs);
272
+ }
273
+ if (benchmark) {
274
+ samples = await measure(body, test.bench, timeoutMs);
275
+ } else {
276
+ await withTimeout(body, timeoutMs);
277
+ }
214
278
  } catch (thrown) {
215
- if (outcome.status === "passed") {
216
- outcome = failure(thrown);
279
+ outcome = failure(thrown, options.file ?? null);
280
+ }
281
+ // Teardown runs whatever happened above, and only reports its own failure
282
+ // when the body had not already failed.
283
+ for (const hook of context.afterEach) {
284
+ try {
285
+ await withTimeout(hook, timeoutMs);
286
+ } catch (thrown) {
287
+ if (outcome.status === "passed") {
288
+ outcome = failure(thrown, options.file ?? null);
289
+ }
217
290
  }
218
291
  }
219
- }
220
- // No test is running once this one is reported, so a snapshot taken outside
221
- // one fails with something better than a key belonging to whichever test
222
- // happened to run last, and a line printed outside one is the file's.
223
- output.exitTest();
292
+ });
293
+ // The `await` above resumes outside the context it entered, so this line and
294
+ // everything after it belong to no case again — a line printed between cases
295
+ // is the file's, and a snapshot taken outside one fails with something better
296
+ // than a key belonging to whichever test happened to run last.
224
297
  snapshot.exitTest();
225
- report(outcome);
298
+ report(outcome.status === "passed" && samples != null ? { status: "passed", samples } : outcome);
226
299
  return outcome.status !== "failed";
227
300
  }
228
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
+
229
366
  /**
230
367
  * Walk one suite, running what it contains.
231
368
  *
@@ -238,6 +375,7 @@ async function runSuite(
238
375
  onlyMode: boolean,
239
376
  emit: (result: Result) => void,
240
377
  state: {| bail: boolean |},
378
+ setUpAncestors: () => Promise<void>,
241
379
  ): Promise<boolean> {
242
380
  const skipped = context.skipped || node.modifier === "skip" || node.modifier === "todo";
243
381
  const onlyPath = !onlyMode || context.onlyPath || node.modifier === "only";
@@ -252,11 +390,18 @@ async function runSuite(
252
390
 
253
391
  // `beforeAll` is deferred until a case in this suite actually runs, so a
254
392
  // fully skipped suite never sets anything up. `afterAll` mirrors it.
393
+ //
394
+ // "In this suite" means anywhere under it. A suite whose children are all
395
+ // suites has no case of its own, and setting up only for a direct child left
396
+ // every `beforeAll` in an ordinary file — one where the tests live inside a
397
+ // `describe` — never running at all, silently. The chain is walked outermost
398
+ // first, so an inner suite's setup sees what the outer one did.
255
399
  let setUp = false;
256
400
  const setUpOnce = async () => {
257
401
  if (setUp) {
258
402
  return;
259
403
  }
404
+ await setUpAncestors();
260
405
  setUp = true;
261
406
  for (const hook of node.beforeAll) {
262
407
  await withTimeout(hook, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
@@ -268,13 +413,16 @@ async function runSuite(
268
413
  if (state.bail) {
269
414
  break;
270
415
  }
271
- if (child.kind === "test") {
416
+ if (child.kind !== "suite") {
272
417
  const willRun =
273
418
  !inner.skipped &&
274
419
  child.modifier !== "skip" &&
275
420
  child.modifier !== "todo" &&
276
421
  child.body != null &&
277
- (!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);
278
426
  if (willRun) {
279
427
  try {
280
428
  await setUpOnce();
@@ -286,7 +434,7 @@ async function runSuite(
286
434
  line: child.line,
287
435
  column: child.column,
288
436
  durationMicros: 0,
289
- outcome: failure(thrown),
437
+ outcome: failure(thrown, options.file ?? null),
290
438
  });
291
439
  passed = false;
292
440
  continue;
@@ -299,7 +447,7 @@ async function runSuite(
299
447
  const ok = await runCase(child, childContext, options, emit);
300
448
  passed = passed && ok;
301
449
  } else {
302
- const ok = await runSuite(child, inner, options, onlyMode, emit, state);
450
+ const ok = await runSuite(child, inner, options, onlyMode, emit, state, setUpOnce);
303
451
  passed = passed && ok;
304
452
  }
305
453
  }
@@ -333,5 +481,5 @@ export async function run(options: RunOptions, emit: (result: Result) => void):
333
481
  skipped: false,
334
482
  onlyPath: !onlyMode,
335
483
  };
336
- await runSuite(root, context, options, onlyMode, emit, { bail: false });
484
+ await runSuite(root, context, options, onlyMode, emit, { bail: false }, async () => {});
337
485
  }
@@ -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
  }
@@ -0,0 +1,30 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/test`: the error a `uft` binding raises when this
4
+ // host cannot give it.
5
+ //
6
+ // Its own module because two of them need it — the namespace, and the module
7
+ // mocking in `./modules.js` that the namespace imports — and a class that both
8
+ // of a pair of modules reaches for is a third module, not an import cycle.
9
+ //
10
+ // The class is exported from the package, so a test can catch it by name
11
+ // rather than by matching on a message.
12
+
13
+ /**
14
+ * Raised for a `uft` binding this host cannot provide.
15
+ *
16
+ * Names what the binding needs rather than only that it is missing: a reader
17
+ * who is told that module interception needs synchronous module hooks can
18
+ * decide whether to run the suite on another host or to restructure the test,
19
+ * and neither is a decision a bare "not implemented" supports.
20
+ */
21
+ export class UnsupportedError extends Error {
22
+ /** The binding that was called. */
23
+ binding: string;
24
+
25
+ constructor(binding: string, reason: string) {
26
+ super(`uft.${binding} is not available: ${reason}`);
27
+ this.name = "UnsupportedError";
28
+ this.binding = binding;
29
+ }
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/test",
3
- "version": "0.0.0-alpha.4",
3
+ "version": "0.0.0-alpha.41",
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.4"
41
+ "@uniflowed/host": "0.0.0-alpha.41",
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
  }