@uniflowed/test 0.2.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,
@@ -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. */
@@ -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.2.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.2.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", () => {
@@ -372,4 +418,48 @@ process.on("uncaughtException", (thrown: mixed) => {
372
418
  process.exit(1);
373
419
  });
374
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();
375
465
  serve();