@uniflowed/test 0.1.0 → 0.3.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/index.js CHANGED
@@ -26,7 +26,7 @@ export type { ModuleFactory, ModuleNamespace } from "./internal/modules.js";
26
26
  export type { Uft } from "./internal/namespace.js";
27
27
  export type { Outcome, Result, RunOptions } from "./internal/run.js";
28
28
  export type { Site } from "./internal/frames.js";
29
- export type { SpyCall, SpyResult } from "./internal/spy.js";
29
+ export type { SpyCall, SpyResult, SpySettledResult } from "./internal/spy.js";
30
30
  export type { Strictness } from "./internal/equality.js";
31
31
 
32
32
  export {
@@ -34,7 +34,8 @@ const MAX_RENDER_DEPTH = 6;
34
34
  /** Most entries of a collection the renderer shows before eliding. */
35
35
  const MAX_RENDER_ENTRIES = 32;
36
36
 
37
- function isObject(value: mixed): boolean {
37
+ /** Whether `value` is an object, as a refinement the checker can follow. */
38
+ function isObject(value: mixed): value is interface {} {
38
39
  return typeof value === "object" && value !== null;
39
40
  }
40
41
 
@@ -51,8 +52,8 @@ function tag(value: mixed): string {
51
52
  */
52
53
  function ownKeys(value: interface {}, strictness: Strictness): Array<string | symbol> {
53
54
  const strings = Object.keys(value).sort();
54
- const symbols = Object.getOwnPropertySymbols(value).filter((symbol) =>
55
- Object.prototype.propertyIsEnumerable.call(value, symbol),
55
+ const symbols = Object.getOwnPropertySymbols(value).filter(
56
+ (symbol) => Object.getOwnPropertyDescriptor(value, symbol)?.enumerable === true,
56
57
  );
57
58
  const keys: Array<string | symbol> = [...strings, ...symbols];
58
59
  if (strictness === "strict") {
@@ -199,7 +200,7 @@ export function equals(
199
200
  return false;
200
201
  }
201
202
  for (const key of leftKeys) {
202
- if (!Object.prototype.hasOwnProperty.call(right, key)) {
203
+ if (!Object.hasOwn(right, key)) {
203
204
  return false;
204
205
  }
205
206
  if (!equals((left as $FlowFixMe)[key], (right as $FlowFixMe)[key], nested, strictness)) {
@@ -233,10 +234,12 @@ export function matchesObject(received: mixed, expected: mixed, seen: Array<Pair
233
234
  if (!Array.isArray(received) || received.length !== expected.length) {
234
235
  return false;
235
236
  }
236
- return expected.every((item, index) => matchesObject(received[index], item, nested));
237
+ return expected.every((item: mixed, index: number) =>
238
+ matchesObject(received[index], item, nested),
239
+ );
237
240
  }
238
241
  for (const key of ownKeys(expected as $FlowFixMe, "loose")) {
239
- if (!Object.prototype.hasOwnProperty.call(received, key)) {
242
+ if (!Object.hasOwn(received, key)) {
240
243
  return false;
241
244
  }
242
245
  if (!matchesObject((received as $FlowFixMe)[key], (expected as $FlowFixMe)[key], nested)) {
@@ -242,14 +242,6 @@ export type Expectation = Matchers<void> & {
242
242
  */
243
243
  export type Expect = {
244
244
  (received: mixed): Expectation,
245
- // `flow/unclear-type` reads source text rather than an AST, and the shape it
246
- // recognises as a property key rather than a type is a name at the start of
247
- // a line or straight after `{`, `,` or `;`. `readonly any:` is neither, so
248
- // the rule reports Jest's, Vitest's and Sinon's name for this matcher as an
249
- // `any` type. The rule's own comment already lists `@uniflowed/test`'s
250
- // `expect.any` among the false positives it exists to avoid; this is the one
251
- // spelling it still cannot see past.
252
- // uf-lint-disable-next-line flow/unclear-type
253
245
  readonly any: (constructor: mixed) => AsymmetricMatcher,
254
246
  readonly anything: () => AsymmetricMatcher,
255
247
  readonly objectContaining: (expected: interface {}) => AsymmetricMatcher,
@@ -277,7 +269,7 @@ function propertyAt(
277
269
  if (current == null) {
278
270
  return { found: false, value: undefined };
279
271
  }
280
- if (!Object.prototype.hasOwnProperty.call(current as $FlowFixMe, key)) {
272
+ if (!Object.hasOwn(current as $FlowFixMe, key)) {
281
273
  return { found: false, value: undefined };
282
274
  }
283
275
  current = (current as $FlowFixMe)[key];
@@ -585,8 +577,8 @@ function verdicts(received: mixed): {
585
577
  requireSpy("toHaveBeenCalledWith");
586
578
  const calls = spyCalls();
587
579
  return simple(
588
- calls.some((call) => equals([...call.args], [...args])),
589
- `to have been called with ${render(args)}; the calls were ${render(calls.map((call) => call.args))}`,
580
+ calls.some((call) => equals([...call], [...args])),
581
+ `to have been called with ${render(args)}; the calls were ${render(calls)}`,
590
582
  args,
591
583
  );
592
584
  },
@@ -595,8 +587,8 @@ function verdicts(received: mixed): {
595
587
  const calls = spyCalls();
596
588
  const last = calls.length === 0 ? undefined : calls[calls.length - 1];
597
589
  return simple(
598
- last != null && equals([...last.args], [...args]),
599
- `to have last been called with ${render(args)}, not ${render(last == null ? undefined : last.args)}`,
590
+ last != null && equals([...last], [...args]),
591
+ `to have last been called with ${render(args)}, not ${render(last)}`,
600
592
  args,
601
593
  );
602
594
  },
@@ -655,6 +647,7 @@ function verdicts(received: mixed): {
655
647
  expected: render(value),
656
648
  received: render(actual),
657
649
  failure: () => `expected ${render(name)} to be ${render(value)}, not ${render(actual)}`,
650
+ negatedFailure: () => `expected ${render(name)} not to be ${render(value)}`,
658
651
  };
659
652
  },
660
653
  toHaveClass: (...names: $ReadOnlyArray<mixed>) => {
@@ -666,6 +659,8 @@ function verdicts(received: mixed): {
666
659
  expected: render(wanted),
667
660
  received: render(classes),
668
661
  failure: () => `expected the class list ${render(classes)} to include ${render(wanted)}`,
662
+ negatedFailure: () =>
663
+ `expected the class list ${render(classes)} not to include ${render(wanted)}`,
669
664
  };
670
665
  },
671
666
  toHaveTextContent: (expected: mixed) => {
@@ -678,6 +673,8 @@ function verdicts(received: mixed): {
678
673
  expected: render(expected),
679
674
  received: render(text),
680
675
  failure: () => `expected the text ${render(text)} to contain ${render(expected)}`,
676
+ negatedFailure: () =>
677
+ `expected the text ${render(text)} not to contain ${render(expected)}`,
681
678
  };
682
679
  },
683
680
  toHaveValue: (expected: mixed) => {
@@ -688,11 +685,12 @@ function verdicts(received: mixed): {
688
685
  expected: render(expected),
689
686
  received: render(actual),
690
687
  failure: () => `expected the value ${render(actual)} to be ${render(expected)}`,
688
+ negatedFailure: () => `expected the value not to be ${render(expected)}`,
691
689
  };
692
690
  },
693
691
  toHaveNoAxeViolations: async (options: mixed) => {
694
692
  const node = element("toHaveNoAxeViolations");
695
- const found = await auditElement(node, (options: $FlowFixMe));
693
+ const found = await auditElement(node, options as $FlowFixMe);
696
694
  const named = violationIds(found);
697
695
  return {
698
696
  pass: found.length === 0,
@@ -814,58 +812,114 @@ function isDisabled(node: Element): boolean {
814
812
  * `negated` decides which message a failing verdict raises, which is all of
815
813
  * what `.not` is.
816
814
  *
817
- * # Why the object is built rather than written
815
+ * # Why the matchers live on a prototype
818
816
  *
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.
817
+ * `expect` is called once per assertion, and building its answer used to be
818
+ * the largest single cost of a passing assertion: the whole verdict table and a
819
+ * wrapper for each of its forty-one entries, eighty-odd closures, to call one
820
+ * of them. On a suite of 1,000 cases and 2,000 assertions that was about a
821
+ * twentieth of a worker's CPU, spent on functions nobody called.
822
+ *
823
+ * So the object carries only what differs between two assertions — the value
824
+ * and the polarity — and every matcher is a getter on one shared prototype,
825
+ * built the first time `expect` is used. The getter hands back a function
826
+ * closed over the object it was read from, rather than being a method that
827
+ * reads `this`: `const { toBe } = expect(1)` and `[1, 2].forEach(expect(n).not.toBe)`
828
+ * keep working, because the function a destructuring or a callback receives is
829
+ * already bound, which is what they got when every matcher was an own closure.
830
+ * The table is still built per *call*, from [`verdicts`], so a matcher still
831
+ * sees the one received value it was asked about and nothing else.
832
+ *
833
+ * `.not` is a getter on the same prototype for the reason it always was
834
+ * lazy: an expectation that built its negation would build *its* negation,
835
+ * forever. `.resolves` and `.rejects` are on a second prototype that only
836
+ * [`expectValue`]'s object has, so `expect(p).not.resolves` stays what it was —
837
+ * not a thing.
827
838
  */
828
- function bind(received: mixed, negated: boolean): $FlowFixMe {
829
- const table = verdicts(received);
839
+ type Bound = { readonly received: mixed, readonly negated: boolean, ... };
840
+
841
+ /** The two prototypes, built on first use; see [`bind`]. */
842
+ type Prototypes = {| readonly bound: interface {}, readonly root: interface {} |};
843
+ let prototypes: Prototypes | null = null;
844
+
845
+ /** A getter for matcher `name`, handing back a function bound to its object. */
846
+ function matcherGetter(name: string): (this: Bound) => (...args: $ReadOnlyArray<mixed>) => mixed {
847
+ return function (this: Bound) {
848
+ const self = this;
849
+ return (...args: $ReadOnlyArray<mixed>) => apply(self, name, args);
850
+ };
851
+ }
852
+
853
+ function negation(this: Bound): mixed {
854
+ return bind(this.received, !this.negated);
855
+ }
856
+
857
+ function resolution(this: Bound): mixed {
858
+ return settled(this.received, "resolve", false);
859
+ }
860
+
861
+ function rejection(this: Bound): mixed {
862
+ return settled(this.received, "reject", false);
863
+ }
864
+
865
+ function matcherPrototypes(): Prototypes {
866
+ if (prototypes != null) {
867
+ return prototypes;
868
+ }
830
869
  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
- };
870
+ for (const name of Object.keys(verdicts(undefined))) {
871
+ Object.defineProperty(bound, name, { get: matcherGetter(name) });
867
872
  }
868
- Object.defineProperty(bound, "not", { get: () => bind(received, !negated) });
873
+ Object.defineProperty(bound, "not", { get: negation });
874
+ const root: $FlowFixMe = Object.create(bound);
875
+ Object.defineProperty(root, "resolves", { get: resolution });
876
+ Object.defineProperty(root, "rejects", { get: rejection });
877
+ prototypes = { bound, root };
878
+ return prototypes;
879
+ }
880
+
881
+ /** Decide matcher `name` on `bound`'s value, raising when it does not hold. */
882
+ function apply(bound: Bound, name: string, args: $ReadOnlyArray<mixed>): mixed {
883
+ const { received, negated } = bound;
884
+ const decide = (verdict: Verdict) => {
885
+ if (verdict.pass !== negated) {
886
+ return undefined;
887
+ }
888
+ const message = negated ? verdict.negatedFailure() : verdict.failure();
889
+ throw new AssertionError(
890
+ message,
891
+ name,
892
+ verdict.expected ?? "",
893
+ verdict.received ?? render(received),
894
+ );
895
+ };
896
+ const verdict = verdicts(received)[name](...args);
897
+ // A matcher whose engine is asynchronous answers with a promise of a
898
+ // verdict, and the promise is handed straight back rather than hidden.
899
+ //
900
+ // Hiding it was the alternative and it cannot be done: the only way to
901
+ // present an asynchronous answer synchronously is to decide before it
902
+ // arrives, which is deciding without it. What the promise costs is a
903
+ // forgotten `await`, and that case is not silent either — the rejection
904
+ // reaches the worker's unhandled-rejection handler, which fails the file
905
+ // the promise was created in and prints this same message. A missing
906
+ // `await` on a passing audit is the one case nothing reports, and it is
907
+ // the case where nothing happened.
908
+ //
909
+ // `instanceof Promise` rather than a `then` test, and it is safe for a
910
+ // reason that would not survive being generalised: every entry in the
911
+ // table is written in this file, so the only promise that can arrive
912
+ // here is one an `async` function in this module made, in this realm. A
913
+ // matcher registered from outside — which `@uniflowed/test` has no API
914
+ // for, deliberately — could hand back a foreign thenable, and this line
915
+ // would be the thing to revisit.
916
+ return verdict instanceof Promise ? verdict.then(decide) : decide(verdict);
917
+ }
918
+
919
+ function bind(received: mixed, negated: boolean): $FlowFixMe {
920
+ const bound: $FlowFixMe = Object.create(matcherPrototypes().bound);
921
+ bound.received = received;
922
+ bound.negated = negated;
869
923
  return bound;
870
924
  }
871
925
 
@@ -938,11 +992,9 @@ function settled(promise: mixed, wanted: "resolve" | "reject", negated: boolean)
938
992
  * received, for the reasons this module's header sets out.
939
993
  */
940
994
  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) });
995
+ const expectation: $FlowFixMe = Object.create(matcherPrototypes().root);
996
+ expectation.received = received;
997
+ expectation.negated = false;
946
998
  return expectation;
947
999
  }
948
1000
 
@@ -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
+ }
@@ -57,7 +57,7 @@ import { frameFile, isInternalFrame } from "./frames.js";
57
57
  import { fn } from "./spy.js";
58
58
 
59
59
  /** The shape of a module's exports, as far as a type can say it. */
60
- export type ModuleNamespace = { +[string]: mixed };
60
+ export type ModuleNamespace = { readonly [string]: mixed };
61
61
 
62
62
  /**
63
63
  * What a `uft.mock` factory hands back.
@@ -181,7 +181,7 @@ export function resolveSpecifier(specifier: string, parentURL: string): string {
181
181
  * calls and returns `undefined` — the automatic form, for a module whose shape
182
182
  * a test wants to keep and whose behaviour it wants gone.
183
183
  */
184
- export function mock<Module: ModuleNamespace>(
184
+ export function mock<Module extends ModuleNamespace>(
185
185
  specifier: string,
186
186
  factory?: ModuleFactory<Module>,
187
187
  ): Promise<void> {
@@ -189,14 +189,14 @@ export function mock<Module: ModuleNamespace>(
189
189
  const url = resolveSpecifier(specifier, callerURL("mock"));
190
190
 
191
191
  if (factory == null) {
192
- return importURL(actualUrl(url, isPathSpecifier(specifier))).then((actual) => {
192
+ return importURL<ModuleNamespace>(actualUrl(url, isPathSpecifier(specifier))).then((actual) => {
193
193
  defineModuleMock(url, automock(actual, AUTOMOCK_DEPTH, new Map()));
194
194
  });
195
195
  }
196
196
 
197
197
  const produced = factory();
198
198
  if (isThenable(produced)) {
199
- return (produced: $FlowFixMe).then((namespace) => {
199
+ return (produced as $FlowFixMe).then((namespace) => {
200
200
  defineModuleMock(url, exportsOf(specifier, namespace));
201
201
  });
202
202
  }
@@ -225,7 +225,7 @@ export function unmock(specifier: string): void {
225
225
  * also why `importActual` of a module that imports a mocked one still sees the
226
226
  * stand-in.
227
227
  */
228
- export function importActual<Module: ModuleNamespace>(specifier: string): Promise<Module> {
228
+ export function importActual<Module extends ModuleNamespace>(specifier: string): Promise<Module> {
229
229
  requireInterception("importActual");
230
230
  const url = resolveSpecifier(specifier, callerURL("importActual"));
231
231
  return importURL(actualUrl(url, isPathSpecifier(specifier)));
@@ -238,11 +238,11 @@ export function importActual<Module: ModuleNamespace>(specifier: string): Promis
238
238
  * is a stand-in the caller holds, and every other importer of that module still
239
239
  * gets whatever it got before.
240
240
  */
241
- export function importMock<Module: ModuleNamespace>(specifier: string): Promise<Module> {
241
+ export function importMock<Module extends ModuleNamespace>(specifier: string): Promise<Module> {
242
242
  requireInterception("importMock");
243
243
  const url = resolveSpecifier(specifier, callerURL("importMock"));
244
- return importURL(actualUrl(url, isPathSpecifier(specifier))).then(
245
- (actual) => (automock(actual, AUTOMOCK_DEPTH, new Map()): $FlowFixMe),
244
+ return importURL<ModuleNamespace>(actualUrl(url, isPathSpecifier(specifier))).then(
245
+ (actual) => automock(actual, AUTOMOCK_DEPTH, new Map()) as $FlowFixMe,
246
246
  );
247
247
  }
248
248
 
@@ -287,7 +287,7 @@ function isPathSpecifier(specifier: string): boolean {
287
287
 
288
288
  /** `import()`, in one place, so the marker parameter is never spelled twice. */
289
289
  function importURL<Module>(url: string): Promise<Module> {
290
- return (import(url): $FlowFixMe);
290
+ return import(url) as $FlowFixMe;
291
291
  }
292
292
 
293
293
  /** Whether `value` is a promise, or near enough for `then` to be meant. */
@@ -295,7 +295,7 @@ function isThenable(value: mixed): boolean {
295
295
  return (
296
296
  value != null &&
297
297
  (typeof value === "object" || typeof value === "function") &&
298
- typeof (value: $FlowFixMe).then === "function"
298
+ typeof (value as $FlowFixMe).then === "function"
299
299
  );
300
300
  }
301
301
 
@@ -314,7 +314,7 @@ function exportsOf(specifier: string, produced: mixed): { [string]: mixed } {
314
314
  `object, and it returned ${produced === null ? "null" : typeof produced}`,
315
315
  );
316
316
  }
317
- return { ...(produced: $FlowFixMe) };
317
+ return { ...(produced as $FlowFixMe) };
318
318
  }
319
319
 
320
320
  /**
@@ -344,10 +344,10 @@ function automock(value: mixed, depth: number, seen: Map<mixed, mixed>): mixed {
344
344
  }
345
345
 
346
346
  if (typeof value === "function") {
347
- const spy = fn().mockName((value: $FlowFixMe).name ?? "spy");
347
+ const spy = fn().mockName((value as $FlowFixMe).name ?? "spy");
348
348
  seen.set(value, spy);
349
349
  copyProperties(value, spy, depth, seen, false);
350
- const prototype = (value: $FlowFixMe).prototype;
350
+ const prototype = (value as $FlowFixMe).prototype;
351
351
  if (prototype != null && typeof prototype === "object") {
352
352
  // Every own name, not only the enumerable ones: a class's methods are
353
353
  // non-enumerable own properties of its prototype, so `Object.keys` finds
@@ -390,8 +390,8 @@ function copyProperties(
390
390
  seen: Map<mixed, mixed>,
391
391
  hidden: boolean,
392
392
  ): void {
393
- const source = (from: $FlowFixMe);
394
- const target = (onto: $FlowFixMe);
393
+ const source = from as $FlowFixMe;
394
+ const target = onto as $FlowFixMe;
395
395
  const names = hidden ? Object.getOwnPropertyNames(source) : Object.keys(source);
396
396
  for (const name of names) {
397
397
  // `constructor` on a prototype points back at the function being mocked,
@@ -426,5 +426,5 @@ function isPlainish(value: mixed): boolean {
426
426
 
427
427
  /** Whether `value` is a module namespace object. */
428
428
  function isNamespace(value: mixed): boolean {
429
- return (value: $FlowFixMe)[Symbol.toStringTag] === "Module";
429
+ return (value as $FlowFixMe)[Symbol.toStringTag] === "Module";
430
430
  }
@@ -160,17 +160,22 @@ export async function waitFor<T>(
160
160
  const deadline = Date.now() + timeout;
161
161
  let last: mixed = null;
162
162
 
163
- for (;;) {
163
+ // Ends when the deadline passes; the `throw` after it is the only way out
164
+ // that is not a result. A condition rather than `for (;;)`, because Flow does
165
+ // not treat an infinite loop as ending the function (facebook/flow#7657).
166
+ let expired = false;
167
+ while (!expired) {
164
168
  try {
165
169
  return await body();
166
170
  } catch (thrown) {
167
171
  last = thrown;
168
172
  }
169
- if (Date.now() >= deadline) {
170
- throw last ?? new Error(`uft.waitFor: gave up after ${timeout}ms`);
173
+ expired = Date.now() >= deadline;
174
+ if (!expired) {
175
+ await new Promise((resolve) => setTimeout(resolve, interval));
171
176
  }
172
- await new Promise((resolve) => setTimeout(resolve, interval));
173
177
  }
178
+ throw last ?? new Error(`uft.waitFor: gave up after ${timeout}ms`);
174
179
  }
175
180
 
176
181
  /** Run `body` until it returns something truthy, or the timeout passes. */
@@ -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
@@ -56,7 +56,7 @@ const loaded: Map<string, { entries: { [string]: string }, dirty: boolean }> = n
56
56
 
57
57
  /** Whether this run may rewrite a snapshot that did not match. */
58
58
  function updating(): boolean {
59
- const value = (globalThis: $FlowFixMe).process?.env?.UF_UPDATE_SNAPSHOTS;
59
+ const value = (globalThis as $FlowFixMe).process?.env?.UF_UPDATE_SNAPSHOTS;
60
60
  return value != null && value !== "" && value !== "0";
61
61
  }
62
62
 
@@ -101,7 +101,7 @@ function entriesFor(file: string): { entries: { [string]: string }, dirty: boole
101
101
  return already;
102
102
  }
103
103
 
104
- const state = { entries: (Object.create(null): $FlowFixMe), dirty: false };
104
+ const state = { entries: Object.create(null) as $FlowFixMe, dirty: false };
105
105
  if (existsSync(target)) {
106
106
  parseInto(readFileSync(target, "utf8"), state.entries);
107
107
  }
package/internal/spy.js CHANGED
@@ -3,9 +3,27 @@
3
3
  // Internal to `@uniflowed/test`: the spy behind `fn` and `uft.spyOn`.
4
4
  //
5
5
  // Shaped after Vitest's, because a project moving to uf should not have to
6
- // rewrite its assertions. That means `mock.calls`, `mock.results`,
7
- // `mock.lastCall`, the `Once` variants, and `mockReset` and `mockRestore`
8
- // meaning the two different things they mean there.
6
+ // rewrite its assertions. That means `mock` holds exactly what Vitest's does,
7
+ // in the same shapes:
8
+ //
9
+ // * `calls` — one array of arguments per call, so `mock.calls[0][0]` is the
10
+ // first call's first argument and `mock.calls.map((args) => args[0])` works
11
+ // as it does there. It used to hold `{ args, returned }` objects, which read
12
+ // well and broke every assertion carried over from Vitest or Jest.
13
+ // * `results` — `{ type, value }` per call: `"return"` or `"throw"`, and
14
+ // `"incomplete"` while the call has not returned yet (a spy asked about
15
+ // itself from inside its own implementation).
16
+ // * `settledResults` — `{ type, value }` per call once the value settled:
17
+ // `"fulfilled"` or `"rejected"` for a promise, `"fulfilled"` at once for
18
+ // anything else, `"incomplete"` until then.
19
+ // * `contexts` — the `this` of each call; `instances` — the same, except that
20
+ // a call made with `new` records the instance it constructed, in both.
21
+ // * `invocationCallOrder` — a number per call, counted across every spy in the
22
+ // process, so two spies can say which was called first.
23
+ // * `lastCall` — the last call's arguments, or `undefined` before any.
24
+ //
25
+ // And the `Once` variants, and `mockReset` and `mockRestore` meaning the two
26
+ // different things they mean there.
9
27
  //
10
28
  // The three reset verbs are easy to conflate and are genuinely different:
11
29
  //
@@ -18,17 +36,35 @@
18
36
  // Every spy is registered, so `uft.clearAllMocks` and its siblings can reach the
19
37
  // ones a test never held a reference to.
20
38
 
21
- /** One call: what went in, and what came out. */
22
- export type SpyCall = {
23
- readonly args: $ReadOnlyArray<mixed>,
24
- readonly returned?: mixed,
25
- readonly threw?: mixed,
26
- };
39
+ /** One call's arguments, as Vitest's `mock.calls` holds them. */
40
+ export type SpyCall = $ReadOnlyArray<mixed>;
27
41
 
28
- /** One call's outcome, in the shape Vitest reports it. */
42
+ /**
43
+ * One call's outcome, in the shape Vitest reports it.
44
+ *
45
+ * `"incomplete"` is a call that has not returned yet — only ever seen from
46
+ * inside the call itself — and its `value` is `undefined`.
47
+ */
29
48
  export type SpyResult =
30
49
  | { readonly type: "return", readonly value: mixed }
31
- | { readonly type: "throw", readonly value: mixed };
50
+ | { readonly type: "throw", readonly value: mixed }
51
+ | { readonly type: "incomplete", readonly value: void };
52
+
53
+ /**
54
+ * One call's value once it settled, in the shape Vitest reports it.
55
+ *
56
+ * A promise settles when it does; anything else settles as the call returns.
57
+ */
58
+ export type SpySettledResult =
59
+ | { readonly type: "fulfilled", readonly value: mixed }
60
+ | { readonly type: "rejected", readonly value: mixed }
61
+ | { readonly type: "incomplete", readonly value: void };
62
+
63
+ /**
64
+ * The order calls happened in, across every spy in the process: Vitest's
65
+ * `invocationCallOrder`, which starts at one.
66
+ */
67
+ let invocations = 0;
32
68
 
33
69
  /** Every spy made in this process, so the `All` verbs can reach them. */
34
70
  const registry: Array<$FlowFixMe> = [];
@@ -44,8 +80,11 @@ type Restore = null | (() => void);
44
80
  */
45
81
  function makeSpy(implementation: mixed, restore: Restore, name: string): $FlowFixMe {
46
82
  const calls: Array<SpyCall> = [];
47
- const results: Array<SpyResult> = [];
83
+ const results: Array<$FlowFixMe> = [];
84
+ const settledResults: Array<$FlowFixMe> = [];
85
+ const contexts: Array<mixed> = [];
48
86
  const instances: Array<mixed> = [];
87
+ const invocationCallOrder: Array<number> = [];
49
88
  // Implementations queued by the `Once` variants, taken from the front.
50
89
  const queued: Array<mixed> = [];
51
90
 
@@ -53,36 +92,84 @@ function makeSpy(implementation: mixed, restore: Restore, name: string): $FlowFi
53
92
  let current = implementation;
54
93
  let mockName = name;
55
94
 
56
- const spy: $FlowFixMe = function (...args: $ReadOnlyArray<mixed>) {
95
+ const spy: $FlowFixMe = function (this: mixed, ...args: $ReadOnlyArray<mixed>) {
96
+ // Recorded before the implementation runs, as Vitest records it, so a spy
97
+ // that asks about itself from inside its own call sees that call already.
98
+ calls.push(args);
99
+ invocations += 1;
100
+ invocationCallOrder.push(invocations);
101
+ const result: $FlowFixMe = { type: "incomplete", value: undefined };
102
+ const settled: $FlowFixMe = { type: "incomplete", value: undefined };
103
+ results.push(result);
104
+ settledResults.push(settled);
57
105
  // `this` is recorded because a spy on a method is often called as one, and
58
- // `mock.instances` is how a test asserts on the receiver.
59
- instances.push(this);
106
+ // `mock.contexts` is how a test asserts on the receiver. A construction has
107
+ // no receiver yet; the instance it makes is recorded once there is one.
108
+ const constructing = new.target !== undefined;
109
+ const context = constructing ? undefined : this;
110
+ const at = contexts.push(context) - 1;
111
+ instances.push(context);
60
112
  const body = queued.length > 0 ? queued.shift() : current;
113
+ let returned;
61
114
  try {
62
- const returned = typeof body === "function" ? body.apply(this, args) : undefined;
63
- calls.push({ args, returned });
64
- results.push({ type: "return", value: returned });
65
- return returned;
115
+ if (constructing) {
116
+ returned =
117
+ typeof body === "function"
118
+ ? Reflect.construct(body as $FlowFixMe, [...args], new.target)
119
+ : this;
120
+ } else {
121
+ returned = typeof body === "function" ? body.apply(this, args) : undefined;
122
+ }
66
123
  } catch (thrown) {
67
- calls.push({ args, threw: thrown });
68
- results.push({ type: "throw", value: thrown });
124
+ result.type = "throw";
125
+ result.value = thrown;
126
+ settled.type = "rejected";
127
+ settled.value = thrown;
69
128
  throw thrown;
70
129
  }
130
+ result.type = "return";
131
+ result.value = returned;
132
+ if (constructing) {
133
+ contexts[at] = returned;
134
+ instances[at] = returned;
135
+ }
136
+ if (returned instanceof Promise) {
137
+ returned.then(
138
+ (value) => {
139
+ settled.type = "fulfilled";
140
+ settled.value = value;
141
+ },
142
+ (reason) => {
143
+ settled.type = "rejected";
144
+ settled.value = reason;
145
+ },
146
+ );
147
+ } else {
148
+ settled.type = "fulfilled";
149
+ settled.value = returned;
150
+ }
151
+ return returned;
71
152
  };
72
153
 
73
154
  spy.mock = {
74
155
  calls,
75
156
  results,
157
+ settledResults,
158
+ contexts,
76
159
  instances,
77
- get lastCall(): $ReadOnlyArray<mixed> | void {
78
- return calls.length === 0 ? undefined : calls[calls.length - 1].args;
160
+ invocationCallOrder,
161
+ get lastCall(): SpyCall | void {
162
+ return calls.length === 0 ? undefined : calls[calls.length - 1];
79
163
  },
80
164
  };
81
165
 
82
166
  spy.mockClear = () => {
83
167
  calls.length = 0;
84
168
  results.length = 0;
169
+ settledResults.length = 0;
170
+ contexts.length = 0;
85
171
  instances.length = 0;
172
+ invocationCallOrder.length = 0;
86
173
  return spy;
87
174
  };
88
175
  spy.mockReset = () => {
@@ -114,8 +201,8 @@ function makeSpy(implementation: mixed, restore: Restore, name: string): $FlowFi
114
201
  const out = body();
115
202
  // An async body has to put the implementation back when it settles, not
116
203
  // when it starts, or the next test runs against this one's stand-in.
117
- if (out != null && typeof (out: $FlowFixMe).then === "function") {
118
- return (out: $FlowFixMe).finally(() => {
204
+ if (out != null && typeof (out as $FlowFixMe).then === "function") {
205
+ return (out as $FlowFixMe).finally(() => {
119
206
  current = previous;
120
207
  });
121
208
  }
@@ -136,7 +223,7 @@ function makeSpy(implementation: mixed, restore: Restore, name: string): $FlowFi
136
223
  spy.mockRejectedValueOnce = (reason: mixed) =>
137
224
  spy.mockImplementationOnce(() => Promise.reject(reason));
138
225
  spy.mockReturnThis = () =>
139
- spy.mockImplementation(function () {
226
+ spy.mockImplementation(function (this: mixed) {
140
227
  return this;
141
228
  });
142
229
 
@@ -203,7 +290,7 @@ export function spyOn(object: mixed, method: string): $FlowFixMe {
203
290
 
204
291
  /** Whether `value` is one of these spies. */
205
292
  export function isSpy(value: mixed): boolean {
206
- return typeof value === "function" && (value: $FlowFixMe).mock != null;
293
+ return typeof value === "function" && (value as $FlowFixMe).mock != null;
207
294
  }
208
295
 
209
296
  /** Forget every spy's calls, keeping their implementations. */
@@ -87,7 +87,9 @@ export function installFakeClock(): void {
87
87
  return;
88
88
  }
89
89
  const global = host();
90
- installed = {
90
+ // Kept in a local as well as the module's `installed`: the calls below would
91
+ // otherwise make Flow forget that `installed` was just set.
92
+ const saved = {
91
93
  setTimeout: global.setTimeout,
92
94
  clearTimeout: global.clearTimeout,
93
95
  setInterval: global.setInterval,
@@ -96,6 +98,7 @@ export function installFakeClock(): void {
96
98
  clearImmediate: global.clearImmediate,
97
99
  Date: global.Date,
98
100
  };
101
+ installed = saved;
99
102
 
100
103
  now = Date.now();
101
104
  tasks = [];
@@ -116,22 +119,23 @@ export function installFakeClock(): void {
116
119
  global.clearTimeout = cancel;
117
120
  global.clearInterval = cancel;
118
121
  global.clearImmediate = cancel;
119
- global.Date = fakeDate(installed.Date as $FlowFixMe);
122
+ global.Date = fakeDate(saved.Date as $FlowFixMe);
120
123
  }
121
124
 
122
125
  /** Put the real scheduling globals back. */
123
126
  export function restoreRealClock(): void {
124
- if (installed == null) {
127
+ const saved = installed;
128
+ if (saved == null) {
125
129
  return;
126
130
  }
127
131
  const global = host();
128
- global.setTimeout = installed.setTimeout;
129
- global.clearTimeout = installed.clearTimeout;
130
- global.setInterval = installed.setInterval;
131
- global.clearInterval = installed.clearInterval;
132
- global.setImmediate = installed.setImmediate;
133
- global.clearImmediate = installed.clearImmediate;
134
- global.Date = installed.Date;
132
+ global.setTimeout = saved.setTimeout;
133
+ global.clearTimeout = saved.clearTimeout;
134
+ global.setInterval = saved.setInterval;
135
+ global.clearInterval = saved.clearInterval;
136
+ global.setImmediate = saved.setImmediate;
137
+ global.clearImmediate = saved.clearImmediate;
138
+ global.Date = saved.Date;
135
139
  installed = null;
136
140
  tasks = [];
137
141
  }
@@ -167,7 +171,7 @@ function fakeDate(Real: $FlowFixMe): $FlowFixMe {
167
171
  construct(target: $FlowFixMe, args: $ReadOnlyArray<mixed>, newTarget: $FlowFixMe) {
168
172
  // Only the no-argument form reads the clock; every other form is
169
173
  // constructing a specific date and has nothing to do with "now".
170
- const actual = args.length === 0 ? [now] : args;
174
+ const actual: Array<mixed> = args.length === 0 ? [now] : [...args];
171
175
  // `newTarget` rather than `Real`, so a subclass of the faked `Date` gets
172
176
  // its own prototype instead of the real one's.
173
177
  return Reflect.construct(Real, actual, newTarget === undefined ? Real : newTarget);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/test",
3
- "version": "0.1.0",
3
+ "version": "0.3.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.3.0",
42
42
  "pixelmatch": "^7.1.0",
43
43
  "pngjs": "^7.0.0"
44
44
  },
package/worker.js CHANGED
@@ -54,6 +54,9 @@ import { writeChangedSnapshots } from "./internal/snapshot.js";
54
54
  import { createInterface } from "node:readline";
55
55
  import { fileURLToPath, pathToFileURL } from "node:url";
56
56
 
57
+ import fs from "node:fs";
58
+ import path from "node:path";
59
+
57
60
  import { installInSourceTests } from "./in-source.js";
58
61
  import { restoreSharedState } from "./internal/isolation.js";
59
62
  import { run } from "./internal/run.js";
@@ -72,9 +75,16 @@ import { run } from "./internal/run.js";
72
75
  // has the global like Node and Bun. A shim for a host uf no longer starts would
73
76
  // be a line claiming to be load-bearing while holding nothing up.
74
77
 
75
- /** What `uf` sends for one file. */
78
+ /** What `uf` sends for one file, or to say which files changed. */
76
79
  type Request = {|
77
- readonly file: string,
80
+ readonly file?: string,
81
+ /**
82
+ * Files that changed since this worker last ran one, as absolute paths.
83
+ *
84
+ * Sent on its own, between runs, to a `uf test --watch` worker that is kept
85
+ * for the next run; see [`invalidate`].
86
+ */
87
+ readonly invalidate?: $ReadOnlyArray<string>,
78
88
  readonly filter?: string | null,
79
89
  readonly timeoutMs?: number,
80
90
  /**
@@ -86,6 +96,12 @@ type Request = {|
86
96
  readonly generation?: number,
87
97
  |};
88
98
 
99
+ /** A request that names a file, which is every request but an invalidation. */
100
+ type FileRequest = {|
101
+ ...Request,
102
+ readonly file: string,
103
+ |};
104
+
89
105
  /**
90
106
  * The request whose work the code running right now descends from.
91
107
  *
@@ -159,11 +175,11 @@ function write(event: { readonly [string]: mixed }): void {
159
175
  * run, set once on the worker by `uf`, and not something each request says.
160
176
  */
161
177
  function benching(): boolean {
162
- const value = (globalThis: $FlowFixMe).process?.env?.UF_TEST_BENCH;
178
+ const value = (globalThis as $FlowFixMe).process?.env?.UF_TEST_BENCH;
163
179
  return value != null && value !== "" && value !== "0";
164
180
  }
165
181
 
166
- async function runFile(request: Request, generation: number): Promise<void> {
182
+ async function runFile(request: FileRequest, generation: number): Promise<void> {
167
183
  const started = performance.now();
168
184
  // Everything the previous file changed and this package shares with it goes
169
185
  // back: the registry, the stubbed environment and globals, the clock, the
@@ -213,7 +229,7 @@ async function runFile(request: Request, generation: number): Promise<void> {
213
229
  * either of the two `return`s below escaping it.
214
230
  */
215
231
  async function runImportedFile(
216
- request: Request,
232
+ request: FileRequest,
217
233
  generation: number,
218
234
  url: string,
219
235
  started: number,
@@ -285,6 +301,25 @@ async function runImportedFile(
285
301
  }
286
302
  }
287
303
 
304
+ /**
305
+ * Load the modules `changed` reaches afresh from the next import on.
306
+ *
307
+ * A `uf test --watch` worker is kept between runs, and an edit has to reach
308
+ * it: the changed files, and every module this process loaded that imports
309
+ * one, are given a new URL by `@uniflowed/host`'s resolve hook, so the next
310
+ * file imports them as they are on disk now and shares everything else, as a
311
+ * worker's files always have. `false` when that cannot be promised — no such
312
+ * hook in this process, or an edit that reaches a module `require()` loaded —
313
+ * and `uf` then runs the next file in a fresh worker instead.
314
+ */
315
+ function invalidate(changed: $ReadOnlyArray<string>): boolean {
316
+ const epochs = (globalThis as $FlowFixMe)[Symbol.for("@uniflowed/host/module-epochs")];
317
+ if (epochs == null || typeof epochs.invalidate !== "function") {
318
+ return false;
319
+ }
320
+ return epochs.invalidate(changed) === true;
321
+ }
322
+
288
323
  /**
289
324
  * Serve requests until stdin closes.
290
325
  *
@@ -321,13 +356,24 @@ function serve(): void {
321
356
  return;
322
357
  }
323
358
  served += 1;
359
+ if (request.file == null) {
360
+ const at = request.generation ?? served;
361
+ const changed = request.invalidate ?? [];
362
+ queue = queue.then(() =>
363
+ serving.run(at, () => {
364
+ write({ event: "invalidated", ok: invalidate(changed) });
365
+ }),
366
+ );
367
+ return;
368
+ }
369
+ const file = request.file;
324
370
  // `uf` chooses the number, because `uf` is the side that checks it. This
325
371
  // count of served requests is the same sequence and stands in for a `uf`
326
372
  // too old to send one — without something monotonic here the import below
327
373
  // would be cache-busted with `undefined` and a watch-mode rerun would see
328
374
  // the module it already had.
329
375
  const at = request.generation ?? served;
330
- queue = queue.then(() => serving.run(at, () => runFile(request, at)));
376
+ queue = queue.then(() => serving.run(at, () => runFile({ ...request, file }, at)));
331
377
  });
332
378
 
333
379
  process.stdin.on("close", () => {
@@ -355,4 +401,65 @@ process.on("unhandledRejection", (reason: mixed) => {
355
401
  process.exit(1);
356
402
  });
357
403
 
404
+ // The same for an exception nothing caught — a server's `error` event with no
405
+ // listener, a throw from a timer callback. Node's default prints the stack to
406
+ // stderr and exits, and all `uf` could then say was that the worker had died:
407
+ // the file that did it went unnamed, and the message was somewhere above the
408
+ // report, unattributed. Reported the way a rejection is, it is the failure of
409
+ // the file whose work threw, with its message and stack in the file's result.
410
+ process.on("uncaughtException", (thrown: mixed) => {
411
+ const error = thrown instanceof Error ? thrown : new Error(String(thrown));
412
+ write({
413
+ event: "file",
414
+ status: "run-failed",
415
+ message: `uncaught exception: ${error.message}`,
416
+ stack: error.stack ?? null,
417
+ });
418
+ process.exit(1);
419
+ });
420
+
421
+ /**
422
+ * Give every test file its own copy of the project's modules.
423
+ *
424
+ * The Flow loader in `@uniflowed/host` imports every project module a test
425
+ * file reaches under a URL that names the file's run, so no module-level state
426
+ * — a React context's current value, a cache, a registry a package keeps —
427
+ * outlives the file that set it (ubugeeei-prod/uf#1443). Installed packages
428
+ * and the runner itself stay one instance per process; `internal/file-scope.js`
429
+ * in `@uniflowed/host` says why each is on its side of the line.
430
+ *
431
+ * The runner is named by directory: this package's and the loader's, as the
432
+ * loader will see them — real paths, because a workspace reaches both through
433
+ * a `node_modules` symlink and the loader is handed where they really are.
434
+ */
435
+ function scopeModulesToFiles(): void {
436
+ const here = path.dirname(fileURLToPath(String(import.meta.url)));
437
+ let host: string | null = null;
438
+ try {
439
+ host = path.dirname(
440
+ fileURLToPath(String((import.meta as $FlowFixMe).resolve("@uniflowed/host/register"))),
441
+ );
442
+ } catch {
443
+ // A host without `import.meta.resolve`: the loader's own modules are
444
+ // reached from this package's, never from a test file's, so leaving them
445
+ // out of the list only matters for a test that imports the loader itself.
446
+ }
447
+ const shared = [here, host]
448
+ .filter((directory) => directory != null)
449
+ .map((directory) => {
450
+ try {
451
+ return fs.realpathSync(String(directory));
452
+ } catch {
453
+ return String(directory);
454
+ }
455
+ });
456
+ Object.defineProperty(globalThis, Symbol.for("@uniflowed/host/file-scope"), {
457
+ value: Object.freeze({ shared: Object.freeze(shared) }),
458
+ configurable: true,
459
+ enumerable: false,
460
+ writable: false,
461
+ });
462
+ }
463
+
464
+ scopeModulesToFiles();
358
465
  serve();