@uniflowed/test 0.1.0 → 0.2.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.
@@ -814,58 +814,114 @@ function isDisabled(node: Element): boolean {
814
814
  * `negated` decides which message a failing verdict raises, which is all of
815
815
  * what `.not` is.
816
816
  *
817
- * # Why the object is built rather than written
817
+ * # Why the matchers live on a prototype
818
818
  *
819
- * `.not` has to be reached lazily or building an expectation would build its
820
- * negation, which would build *its* negation, forever. A lazily installed
821
- * property is not something an object literal carries, so the value is
822
- * completed with `Object.defineProperty` after it exists — and an object
823
- * completed after the fact is not one Flow can check a literal against. That
824
- * is what this `$FlowFixMe` is, and it now covers a construction rather than a
825
- * published type: [`expectValue`] states the real one, and the checker holds
826
- * every caller to it.
819
+ * `expect` is called once per assertion, and building its answer used to be
820
+ * the largest single cost of a passing assertion: the whole verdict table and a
821
+ * wrapper for each of its forty-one entries, eighty-odd closures, to call one
822
+ * of them. On a suite of 1,000 cases and 2,000 assertions that was about a
823
+ * twentieth of a worker's CPU, spent on functions nobody called.
824
+ *
825
+ * So the object carries only what differs between two assertions — the value
826
+ * and the polarity — and every matcher is a getter on one shared prototype,
827
+ * built the first time `expect` is used. The getter hands back a function
828
+ * closed over the object it was read from, rather than being a method that
829
+ * reads `this`: `const { toBe } = expect(1)` and `[1, 2].forEach(expect(n).not.toBe)`
830
+ * keep working, because the function a destructuring or a callback receives is
831
+ * already bound, which is what they got when every matcher was an own closure.
832
+ * The table is still built per *call*, from [`verdicts`], so a matcher still
833
+ * sees the one received value it was asked about and nothing else.
834
+ *
835
+ * `.not` is a getter on the same prototype for the reason it always was
836
+ * lazy: an expectation that built its negation would build *its* negation,
837
+ * forever. `.resolves` and `.rejects` are on a second prototype that only
838
+ * [`expectValue`]'s object has, so `expect(p).not.resolves` stays what it was —
839
+ * not a thing.
827
840
  */
828
- function bind(received: mixed, negated: boolean): $FlowFixMe {
829
- const table = verdicts(received);
841
+ type Bound = { readonly received: mixed, readonly negated: boolean, ... };
842
+
843
+ /** The two prototypes, built on first use; see [`bind`]. */
844
+ type Prototypes = {| readonly bound: interface {}, readonly root: interface {} |};
845
+ let prototypes: Prototypes | null = null;
846
+
847
+ /** A getter for matcher `name`, handing back a function bound to its object. */
848
+ function matcherGetter(name: string): (this: Bound) => (...args: $ReadOnlyArray<mixed>) => mixed {
849
+ return function (this: Bound) {
850
+ const self = this;
851
+ return (...args: $ReadOnlyArray<mixed>) => apply(self, name, args);
852
+ };
853
+ }
854
+
855
+ function negation(this: Bound): mixed {
856
+ return bind(this.received, !this.negated);
857
+ }
858
+
859
+ function resolution(this: Bound): mixed {
860
+ return settled(this.received, "resolve", false);
861
+ }
862
+
863
+ function rejection(this: Bound): mixed {
864
+ return settled(this.received, "reject", false);
865
+ }
866
+
867
+ function matcherPrototypes(): Prototypes {
868
+ if (prototypes != null) {
869
+ return prototypes;
870
+ }
830
871
  const bound: $FlowFixMe = {};
831
- for (const name of Object.keys(table)) {
832
- const decide = (verdict: Verdict) => {
833
- if (verdict.pass !== negated) {
834
- return undefined;
835
- }
836
- const message = negated ? verdict.negatedFailure() : verdict.failure();
837
- throw new AssertionError(
838
- message,
839
- name,
840
- verdict.expected ?? "",
841
- verdict.received ?? render(received),
842
- );
843
- };
844
- bound[name] = (...args: $ReadOnlyArray<mixed>) => {
845
- const verdict = table[name](...args);
846
- // A matcher whose engine is asynchronous answers with a promise of a
847
- // verdict, and the promise is handed straight back rather than hidden.
848
- //
849
- // Hiding it was the alternative and it cannot be done: the only way to
850
- // present an asynchronous answer synchronously is to decide before it
851
- // arrives, which is deciding without it. What the promise costs is a
852
- // forgotten `await`, and that case is not silent either — the rejection
853
- // reaches the worker's unhandled-rejection handler, which fails the file
854
- // the promise was created in and prints this same message. A missing
855
- // `await` on a passing audit is the one case nothing reports, and it is
856
- // the case where nothing happened.
857
- //
858
- // `instanceof Promise` rather than a `then` test, and it is safe for a
859
- // reason that would not survive being generalised: every entry in the
860
- // table is written in this file, so the only promise that can arrive
861
- // here is one an `async` function in this module made, in this realm. A
862
- // matcher registered from outside — which `@uniflowed/test` has no API
863
- // for, deliberately — could hand back a foreign thenable, and this line
864
- // would be the thing to revisit.
865
- return verdict instanceof Promise ? verdict.then(decide) : decide(verdict);
866
- };
872
+ for (const name of Object.keys(verdicts(undefined))) {
873
+ Object.defineProperty(bound, name, { get: matcherGetter(name) });
867
874
  }
868
- Object.defineProperty(bound, "not", { get: () => bind(received, !negated) });
875
+ Object.defineProperty(bound, "not", { get: negation });
876
+ const root: $FlowFixMe = Object.create(bound);
877
+ Object.defineProperty(root, "resolves", { get: resolution });
878
+ Object.defineProperty(root, "rejects", { get: rejection });
879
+ prototypes = { bound, root };
880
+ return prototypes;
881
+ }
882
+
883
+ /** Decide matcher `name` on `bound`'s value, raising when it does not hold. */
884
+ function apply(bound: Bound, name: string, args: $ReadOnlyArray<mixed>): mixed {
885
+ const { received, negated } = bound;
886
+ const decide = (verdict: Verdict) => {
887
+ if (verdict.pass !== negated) {
888
+ return undefined;
889
+ }
890
+ const message = negated ? verdict.negatedFailure() : verdict.failure();
891
+ throw new AssertionError(
892
+ message,
893
+ name,
894
+ verdict.expected ?? "",
895
+ verdict.received ?? render(received),
896
+ );
897
+ };
898
+ const verdict = verdicts(received)[name](...args);
899
+ // A matcher whose engine is asynchronous answers with a promise of a
900
+ // verdict, and the promise is handed straight back rather than hidden.
901
+ //
902
+ // Hiding it was the alternative and it cannot be done: the only way to
903
+ // present an asynchronous answer synchronously is to decide before it
904
+ // arrives, which is deciding without it. What the promise costs is a
905
+ // forgotten `await`, and that case is not silent either — the rejection
906
+ // reaches the worker's unhandled-rejection handler, which fails the file
907
+ // the promise was created in and prints this same message. A missing
908
+ // `await` on a passing audit is the one case nothing reports, and it is
909
+ // the case where nothing happened.
910
+ //
911
+ // `instanceof Promise` rather than a `then` test, and it is safe for a
912
+ // reason that would not survive being generalised: every entry in the
913
+ // table is written in this file, so the only promise that can arrive
914
+ // here is one an `async` function in this module made, in this realm. A
915
+ // matcher registered from outside — which `@uniflowed/test` has no API
916
+ // for, deliberately — could hand back a foreign thenable, and this line
917
+ // would be the thing to revisit.
918
+ return verdict instanceof Promise ? verdict.then(decide) : decide(verdict);
919
+ }
920
+
921
+ function bind(received: mixed, negated: boolean): $FlowFixMe {
922
+ const bound: $FlowFixMe = Object.create(matcherPrototypes().bound);
923
+ bound.received = received;
924
+ bound.negated = negated;
869
925
  return bound;
870
926
  }
871
927
 
@@ -938,11 +994,9 @@ function settled(promise: mixed, wanted: "resolve" | "reject", negated: boolean)
938
994
  * received, for the reasons this module's header sets out.
939
995
  */
940
996
  function expectValue(received: mixed): Expectation {
941
- const expectation: $FlowFixMe = bind(received, false);
942
- Object.defineProperty(expectation, "resolves", {
943
- get: () => settled(received, "resolve", false),
944
- });
945
- Object.defineProperty(expectation, "rejects", { get: () => settled(received, "reject", false) });
997
+ const expectation: $FlowFixMe = Object.create(matcherPrototypes().root);
998
+ expectation.received = received;
999
+ expectation.negated = false;
946
1000
  return expectation;
947
1001
  }
948
1002
 
@@ -195,3 +195,181 @@ export function userFrames(stack: string | null | void): string | null {
195
195
  const frames = lines.slice(1).filter((frame) => !isInternalFrame(frame));
196
196
  return frames.length === 0 ? head : [head, ...frames].join("\n");
197
197
  }
198
+
199
+ /**
200
+ * A V8 call site, as much of it as [`callerSite`] reads.
201
+ *
202
+ * Written out rather than imported: there is no library definition for V8's
203
+ * structured stack API, and these four methods are all it asks of one.
204
+ */
205
+ type CallSite = interface {
206
+ getFileName(): ?string,
207
+ getLineNumber(): ?number,
208
+ getColumnNumber(): ?number,
209
+ };
210
+
211
+ /** The piece of `node:module`'s source-map API [`callerSite`] needs. */
212
+ type SourceMapEntry = {|
213
+ readonly originalLine?: number,
214
+ readonly originalColumn?: number,
215
+ readonly originalSource?: string,
216
+ |};
217
+ type FindSourceMap = (
218
+ file: string,
219
+ ) => ?interface { findEntry(line: number, column: number): SourceMapEntry };
220
+
221
+ /**
222
+ * `node:module`'s `findSourceMap` when the host is Node with source maps on;
223
+ * `false` when the structured path must not be taken; `undefined` until asked.
224
+ *
225
+ * Asked once per process: the host does not change under a running worker.
226
+ */
227
+ let nodeSourceMaps: FindSourceMap | false | void;
228
+
229
+ /**
230
+ * How to map a generated position the way Node's own stack traces do, or
231
+ * `false` when this host's stacks cannot be reproduced from call sites.
232
+ *
233
+ * Node only. Bun and Deno implement the call-site API too, but whether their
234
+ * call sites carry the generated position or the mapped one is theirs to
235
+ * decide and has changed between releases, so they keep the string path,
236
+ * whose answer is by construction the one their `.stack` prints. A browser
237
+ * has no `process` at all.
238
+ */
239
+ function sourceMapsForCallSites(): FindSourceMap | false {
240
+ if (nodeSourceMaps !== undefined) {
241
+ return nodeSourceMaps;
242
+ }
243
+ const host: $FlowFixMe = globalThis;
244
+ const process = host.process;
245
+ const isNode =
246
+ typeof process?.versions?.node === "string" &&
247
+ process.versions.bun == null &&
248
+ host.Deno == null &&
249
+ typeof process.getBuiltinModule === "function" &&
250
+ typeof Error.captureStackTrace === "function";
251
+ if (!isNode) {
252
+ nodeSourceMaps = false;
253
+ return false;
254
+ }
255
+ const findSourceMap = process.getBuiltinModule("node:module")?.findSourceMap;
256
+ nodeSourceMaps =
257
+ typeof findSourceMap === "function"
258
+ ? // Only while Node applies source maps to its own stacks: with them off
259
+ // a `.stack` prints the generated position, and so must this.
260
+ (file) => (process.sourceMapsEnabled === true ? findSourceMap(file) : null)
261
+ : false;
262
+ return nodeSourceMaps;
263
+ }
264
+
265
+ /**
266
+ * The first position outside the runner on the stack of the call to `skip`,
267
+ * as `firstUserSite(new Error().stack)` would read it — or `undefined` when
268
+ * this host cannot answer that way, and the caller should build the string.
269
+ *
270
+ * # Why not simply read `.stack`
271
+ *
272
+ * Because it is the most expensive line in registering a test. `describe` and
273
+ * `it` ask where they were called from, once per case, and on Node with
274
+ * `--enable-source-maps` — which every `uf test` worker runs with — the string
275
+ * `.stack` is built by mapping *every* frame through its module's source map
276
+ * and printing each one, to read back one line and column from the first that
277
+ * is not the runner's. On a suite of 50 files and 1,000 cases that was about a
278
+ * sixth of a worker's CPU.
279
+ *
280
+ * V8 hands the same frames over unprinted to a `prepareStackTrace` installed
281
+ * for the one capture, and only the frame the answer comes from is mapped,
282
+ * with the lookup Node's printer itself uses — `findSourceMap(file)` then
283
+ * `findEntry(line - 1, column - 1)`, falling back to the generated position
284
+ * when there is no map or no entry — so the number is the one the string would
285
+ * have carried. `packages/test/registration-site.test.js` holds the two paths
286
+ * to that.
287
+ */
288
+ export function callerSite(skip: (...args: $ReadOnlyArray<empty>) => mixed): Site | null | void {
289
+ const findSourceMap = sourceMapsForCallSites();
290
+ if (findSourceMap === false) {
291
+ return undefined;
292
+ }
293
+ const errors: $FlowFixMe = Error;
294
+ const limit: mixed = errors.stackTraceLimit;
295
+ const full = typeof limit === "number" ? limit : 0;
296
+ // A few frames first. The caller of a registration is two or three frames
297
+ // above it — `it`, the modifier or `each` wrapper, `addCase` — and V8's cost
298
+ // is per frame it materialises, so the whole default ten is walked only for
299
+ // the rare caller that is deeper than that.
300
+ const shallow = Math.min(full, SHALLOW_FRAMES);
301
+ const first = readCallSites(skip, shallow);
302
+ if (first === undefined) {
303
+ return undefined;
304
+ }
305
+ const found = firstUserCallSite(first, findSourceMap);
306
+ if (found != null || first.length < shallow || shallow === full) {
307
+ return found;
308
+ }
309
+ const again = readCallSites(skip, full);
310
+ return again === undefined ? undefined : firstUserCallSite(again, findSourceMap);
311
+ }
312
+
313
+ /** How many frames [`callerSite`] asks for before it asks for all of them. */
314
+ const SHALLOW_FRAMES = 4;
315
+
316
+ /** Hands V8's call sites back unprinted; one function, so none is made per capture. */
317
+ function unprinted(_error: mixed, sites: $ReadOnlyArray<CallSite>): $ReadOnlyArray<CallSite> {
318
+ return sites;
319
+ }
320
+
321
+ /**
322
+ * Up to `limit` call sites above `skip`, or `undefined` when the host does not
323
+ * hand them over.
324
+ */
325
+ function readCallSites(
326
+ skip: (...args: $ReadOnlyArray<empty>) => mixed,
327
+ limit: number,
328
+ ): $ReadOnlyArray<CallSite> | void {
329
+ const errors: $FlowFixMe = Error;
330
+ const prepare = errors.prepareStackTrace;
331
+ const before = errors.stackTraceLimit;
332
+ const holder: $FlowFixMe = {};
333
+ try {
334
+ errors.prepareStackTrace = unprinted;
335
+ errors.stackTraceLimit = limit;
336
+ errors.captureStackTrace(holder, skip);
337
+ // Read inside the `try`: V8 formats `stack` lazily, on first access, and
338
+ // with whatever `prepareStackTrace` is installed *then*. What comes back is
339
+ // what `unprinted` returned, which V8 does not type.
340
+ const sites: $FlowFixMe = holder.stack;
341
+ return Array.isArray(sites) ? sites : undefined;
342
+ } finally {
343
+ errors.prepareStackTrace = prepare;
344
+ errors.stackTraceLimit = before;
345
+ }
346
+ }
347
+
348
+ /** The first of `sites` outside the runner, mapped as Node maps a printed frame. */
349
+ function firstUserCallSite(
350
+ sites: $ReadOnlyArray<CallSite>,
351
+ findSourceMap: FindSourceMap,
352
+ ): Site | null {
353
+ for (const site of sites) {
354
+ const file = site.getFileName();
355
+ if (file == null || isInternalFrame(file)) {
356
+ continue;
357
+ }
358
+ const line = site.getLineNumber();
359
+ const column = site.getColumnNumber();
360
+ if (line == null || column == null) {
361
+ continue;
362
+ }
363
+ const entry = findSourceMap(file)?.findEntry(line - 1, column - 1);
364
+ if (
365
+ entry?.originalSource != null &&
366
+ entry.originalSource !== "" &&
367
+ entry.originalLine != null &&
368
+ entry.originalColumn != null
369
+ ) {
370
+ return { line: entry.originalLine + 1, column: entry.originalColumn + 1 };
371
+ }
372
+ return { line, column };
373
+ }
374
+ return null;
375
+ }
@@ -19,7 +19,7 @@
19
19
  // * `.only` anywhere in the file restricts the file to marked cases and their
20
20
  // ancestors; everything else is reported skipped, never silently dropped.
21
21
 
22
- import { firstUserSite } from "./frames.js";
22
+ import { callerSite, firstUserSite } from "./frames.js";
23
23
 
24
24
  /** The placeholders `it.each` substitutes a row into. */
25
25
  const ROW_TOKEN = /%[sjdi]/g;
@@ -27,6 +27,16 @@ const ROW_TOKEN = /%[sjdi]/g;
27
27
  /** What a test or hook body may return. */
28
28
  export type Body = () => mixed | Promise<mixed>;
29
29
 
30
+ /**
31
+ * One `beforeAll`, `afterAll`, `beforeEach` or `afterEach`.
32
+ *
33
+ * `timeoutMs` is the hook's own budget when its registration named one, and
34
+ * `null` when the hook runs under the budget of the case it belongs to. A hook
35
+ * that starts a process or a server needs longer than the cases it sets up for,
36
+ * and without a budget of its own the only lever was the file-wide timeout.
37
+ */
38
+ export type Hook = {| readonly body: Body, readonly timeoutMs: number | null |};
39
+
30
40
  /** The suffix written on a registration call. */
31
41
  export type Modifier = "none" | "only" | "skip" | "todo";
32
42
 
@@ -63,10 +73,10 @@ export type Suite = {|
63
73
  readonly name: string,
64
74
  readonly modifier: Modifier,
65
75
  readonly children: Array<Suite | Case>,
66
- readonly beforeAll: Array<Body>,
67
- readonly afterAll: Array<Body>,
68
- readonly beforeEach: Array<Body>,
69
- readonly afterEach: Array<Body>,
76
+ readonly beforeAll: Array<Hook>,
77
+ readonly afterAll: Array<Hook>,
78
+ readonly beforeEach: Array<Hook>,
79
+ readonly afterEach: Array<Hook>,
70
80
  readonly line: number,
71
81
  readonly column: number,
72
82
  |};
@@ -118,6 +128,10 @@ export function collected(): Suite {
118
128
  * treats as "unknown" rather than as line one.
119
129
  */
120
130
  function callSite(): {| readonly line: number, readonly column: number |} {
131
+ const site = callerSite(callSite);
132
+ if (site !== undefined) {
133
+ return site ?? { line: 0, column: 0 };
134
+ }
121
135
  return firstUserSite(new Error("position").stack) ?? { line: 0, column: 0 };
122
136
  }
123
137
 
@@ -276,22 +290,42 @@ function formatRow(name: string, row: mixed): string {
276
290
  });
277
291
  }
278
292
 
279
- /** Run once before the first test in this suite that runs. */
280
- export function beforeAll(body: Body): void {
281
- current.beforeAll.push(body);
293
+ /**
294
+ * A hook's own budget, from the second argument of its registration.
295
+ *
296
+ * `{ timeout }` is what `it` takes, and a bare number is what Jest and Vitest
297
+ * take — a suite moved from either writes `beforeAll(start, 30_000)`, and a
298
+ * budget that was silently dropped would fail as a timeout the file never
299
+ * asked for.
300
+ */
301
+ function hookTimeout(options: ?(TestOptions | number)): number | null {
302
+ if (typeof options === "number") {
303
+ return options;
304
+ }
305
+ return options?.timeout ?? null;
306
+ }
307
+
308
+ /**
309
+ * Run once before the first test in this suite that runs.
310
+ *
311
+ * When it fails, every case it was setting up for fails with its error: none
312
+ * of them runs against a setup that did not happen.
313
+ */
314
+ export function beforeAll(body: Body, options?: TestOptions | number): void {
315
+ current.beforeAll.push({ body, timeoutMs: hookTimeout(options) });
282
316
  }
283
317
 
284
318
  /** Run once after the last test in this suite that ran. */
285
- export function afterAll(body: Body): void {
286
- current.afterAll.push(body);
319
+ export function afterAll(body: Body, options?: TestOptions | number): void {
320
+ current.afterAll.push({ body, timeoutMs: hookTimeout(options) });
287
321
  }
288
322
 
289
323
  /** Run before every test in this suite and its children. */
290
- export function beforeEach(body: Body): void {
291
- current.beforeEach.push(body);
324
+ export function beforeEach(body: Body, options?: TestOptions | number): void {
325
+ current.beforeEach.push({ body, timeoutMs: hookTimeout(options) });
292
326
  }
293
327
 
294
328
  /** Run after every test in this suite and its children, including failures. */
295
- export function afterEach(body: Body): void {
296
- current.afterEach.push(body);
329
+ export function afterEach(body: Body, options?: TestOptions | number): void {
330
+ current.afterEach.push({ body, timeoutMs: hookTimeout(options) });
297
331
  }
package/internal/run.js CHANGED
@@ -13,7 +13,14 @@ 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 BenchOptions, type Body, type Case, type Suite, collected } from "./registry.js";
16
+ import {
17
+ type BenchOptions,
18
+ type Body,
19
+ type Case,
20
+ type Hook,
21
+ type Suite,
22
+ collected,
23
+ } from "./registry.js";
17
24
 
18
25
  /** How one case ended. */
19
26
  export type Outcome =
@@ -186,8 +193,8 @@ function failure(thrown: mixed, file: string | null): Outcome {
186
193
  /** Everything one case needs from the suites above it. */
187
194
  type Context = {|
188
195
  readonly path: $ReadOnlyArray<string>,
189
- readonly beforeEach: $ReadOnlyArray<Body>,
190
- readonly afterEach: $ReadOnlyArray<Body>,
196
+ readonly beforeEach: $ReadOnlyArray<Hook>,
197
+ readonly afterEach: $ReadOnlyArray<Hook>,
191
198
  readonly skipped: boolean,
192
199
  readonly onlyPath: boolean,
193
200
  |};
@@ -268,7 +275,7 @@ async function runCase(
268
275
  await output.runInTest(name, async () => {
269
276
  try {
270
277
  for (const hook of context.beforeEach) {
271
- await withTimeout(hook, timeoutMs);
278
+ await withTimeout(hook.body, hook.timeoutMs ?? timeoutMs);
272
279
  }
273
280
  if (benchmark) {
274
281
  samples = await measure(body, test.bench, timeoutMs);
@@ -282,7 +289,7 @@ async function runCase(
282
289
  // when the body had not already failed.
283
290
  for (const hook of context.afterEach) {
284
291
  try {
285
- await withTimeout(hook, timeoutMs);
292
+ await withTimeout(hook.body, hook.timeoutMs ?? timeoutMs);
286
293
  } catch (thrown) {
287
294
  if (outcome.status === "passed") {
288
295
  outcome = failure(thrown, options.file ?? null);
@@ -396,16 +403,25 @@ async function runSuite(
396
403
  // every `beforeAll` in an ordinary file — one where the tests live inside a
397
404
  // `describe` — never running at all, silently. The chain is walked outermost
398
405
  // first, so an inner suite's setup sees what the outer one did.
406
+ //
407
+ // Once, and remembered either way. The outcome is kept rather than a "done"
408
+ // flag, because a flag raised before the hooks ran let every case after the
409
+ // first go ahead when setup had failed: the first case reported the hook's
410
+ // error and the rest ran against a fixture that was never made, failing — or
411
+ // worse, passing — for reasons that had nothing to do with them.
399
412
  let setUp = false;
400
- const setUpOnce = async () => {
401
- if (setUp) {
402
- return;
403
- }
404
- await setUpAncestors();
405
- setUp = true;
406
- for (const hook of node.beforeAll) {
407
- await withTimeout(hook, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
413
+ let setup: Promise<void> | null = null;
414
+ const setUpOnce = (): Promise<void> => {
415
+ if (setup == null) {
416
+ setUp = true;
417
+ setup = (async () => {
418
+ await setUpAncestors();
419
+ for (const hook of node.beforeAll) {
420
+ await withTimeout(hook.body, hook.timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
421
+ }
422
+ })();
408
423
  }
424
+ return setup;
409
425
  };
410
426
 
411
427
  let passed = true;
@@ -455,7 +471,7 @@ async function runSuite(
455
471
  if (setUp) {
456
472
  for (const hook of node.afterAll) {
457
473
  try {
458
- await withTimeout(hook, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
474
+ await withTimeout(hook.body, hook.timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
459
475
  } catch {
460
476
  // A teardown failure cannot fail a test that already reported, and
461
477
  // there is nothing left to attach it to; the file's own status carries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/test",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
@@ -38,7 +38,7 @@
38
38
  "!*.test.js"
39
39
  ],
40
40
  "dependencies": {
41
- "@uniflowed/host": "0.1.0",
41
+ "@uniflowed/host": "0.2.0",
42
42
  "pixelmatch": "^7.1.0",
43
43
  "pngjs": "^7.0.0"
44
44
  },
package/worker.js CHANGED
@@ -355,4 +355,21 @@ process.on("unhandledRejection", (reason: mixed) => {
355
355
  process.exit(1);
356
356
  });
357
357
 
358
+ // The same for an exception nothing caught — a server's `error` event with no
359
+ // listener, a throw from a timer callback. Node's default prints the stack to
360
+ // stderr and exits, and all `uf` could then say was that the worker had died:
361
+ // the file that did it went unnamed, and the message was somewhere above the
362
+ // report, unattributed. Reported the way a rejection is, it is the failure of
363
+ // the file whose work threw, with its message and stack in the file's result.
364
+ process.on("uncaughtException", (thrown: mixed) => {
365
+ const error = thrown instanceof Error ? thrown : new Error(String(thrown));
366
+ write({
367
+ event: "file",
368
+ status: "run-failed",
369
+ message: `uncaught exception: ${error.message}`,
370
+ stack: error.stack ?? null,
371
+ });
372
+ process.exit(1);
373
+ });
374
+
358
375
  serve();