@uniflowed/test 0.0.0-alpha.12 → 0.0.0-alpha.14

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
@@ -12,6 +12,7 @@
12
12
  //
13
13
  // The whole surface is importable from here, so a test file has one import.
14
14
 
15
+ export type { Expect, Expectation, Matchers } from "./internal/expect.js";
15
16
  export type { Body as TestBody, Case, Modifier, Suite, TestOptions } from "./internal/registry.js";
16
17
  export type { ModuleFactory, ModuleNamespace } from "./internal/modules.js";
17
18
  export type { Uft } from "./internal/namespace.js";
@@ -11,7 +11,61 @@
11
11
  // `.resolves` and `.rejects` settle the promise first and then apply the same
12
12
  // matcher table to what came out, so `await expect(p).resolves.toBe(1)` reads
13
13
  // the way the synchronous form does.
14
+ //
15
+ // # The names are written down, and the behaviour is not
16
+ //
17
+ // `expect` used to be `$FlowFixMe`, and so was everything it handed back. That
18
+ // is a hole in the published type of the package a project writes every one of
19
+ // its assertions against: `expect(user).toBaa(1)` was not a misspelling
20
+ // anybody's checker would find, `expect(list).toHaveLength("3")` was not a type
21
+ // error, and `expect(p).resolves` on a value that is not a promise was fine
22
+ // until it ran. Every test in this repository is written against `expect`,
23
+ // which is the largest surface in the packages and was the least checked.
24
+ //
25
+ // So [`Matchers`] below writes the names out, one signature per matcher, the
26
+ // way `@uniflowed/react-testing`'s `Queries` writes out its thirty-six. Flow
27
+ // has no template literal types and no way to read a name out of a value, so
28
+ // the listing has to exist for the type to exist at all. What is *not*
29
+ // repeated is any behaviour: [`verdicts`] is still the one place a matcher is
30
+ // decided, and the listing is a naming that a reader can check against it by
31
+ // eye.
32
+ //
33
+ // [`Matchers`] is generic in what a matcher *returns*, which is what lets
34
+ // `.resolves` reuse the one listing: the same forty-one names, each handing
35
+ // back a promise.
36
+ //
37
+ // # The received value's type is not carried, and that was tried
38
+ //
39
+ // ubugeeei-prod/uf#402 asked for a second parameter as well — the type handed
40
+ // to `expect`, carried through the matchers so that `expect(count).toBe("two")`
41
+ // is an error at the call. It is written here rather than left for somebody to
42
+ // discover, because it looks obviously right and is not.
43
+ //
44
+ // `toBe` is `Object.is`, and identity is not assignability. Typing it
45
+ // `(expected: T)` demands that the expected value be a subtype of the received
46
+ // one, which is a direction the runtime has no opinion about and which ordinary
47
+ // assertions fail in both:
48
+ // `expect(document.activeElement).toBe(screen.getByRole("button"))` compares an
49
+ // `HTMLElement | null` with an `Element`, neither is the other's subtype, and
50
+ // that is the most common assertion in a DOM test. Jest and Vitest both type
51
+ // this argument as `unknown` for the same reason.
52
+ //
53
+ // Worse, the parameter has to be *inferred*, and `expect(x)` is where a lot of
54
+ // otherwise unconstrained expressions sit. `await
55
+ // expect(client.request("/users/1")).resolves.toEqual({ id: 1 })` stops
56
+ // checking and starts reporting that `request`'s own type parameter is
57
+ // underconstrained, because a `mixed` parameter asked nothing of the argument
58
+ // and a generic one asks it to be solved. `expect([])` becomes "cannot
59
+ // determine type of empty array literal". Both are correct tests, and the type
60
+ // that rejects them is worse than the type that missed a mistyped comparison.
61
+ //
62
+ // So the received value arrives as `mixed`, and what makes a matcher checked is
63
+ // its own signature: `toHaveLength` wants a number, `toMatch` a pattern,
64
+ // `toBeTypeOf` one of the eight words `typeof` answers with, and every name is
65
+ // a name. The comparison between two unrelated types stays unchecked, and it is
66
+ // the only part of the issue that does.
14
67
 
68
+ import type { AsymmetricMatcher } from "./asymmetric.js";
15
69
  import type { SpyCall } from "./spy.js";
16
70
  import * as asymmetric from "./asymmetric.js";
17
71
  import * as snapshot from "./snapshot.js";
@@ -45,6 +99,152 @@ type Verdict = {|
45
99
  readonly received?: string,
46
100
  |};
47
101
 
102
+ /** What `typeof` can answer, for the matcher that compares against it. */
103
+ type TypeName =
104
+ | "bigint"
105
+ | "boolean"
106
+ | "function"
107
+ | "number"
108
+ | "object"
109
+ | "string"
110
+ | "symbol"
111
+ | "undefined";
112
+
113
+ /**
114
+ * Every matcher, each returning `R`.
115
+ *
116
+ * Generic in the return type because the surface exists twice and that is the
117
+ * only thing that differs: `expect(x)` raises where a matcher fails and hands
118
+ * back nothing, while `expect(p).resolves` settles first and so hands back a
119
+ * promise. Writing the forty-one names once and saying what changes is the
120
+ * whole reason for the parameter — the alternative was the same list twice,
121
+ * with `=> void` on one copy and `=> Promise<void>` on the other, and a reader
122
+ * left to diff them.
123
+ *
124
+ * Where an argument is `mixed` it is because the runtime genuinely takes
125
+ * anything there and the checker would be lying to say otherwise: `toEqual`
126
+ * accepts an asymmetric matcher standing in for a value at any depth, and
127
+ * `toHaveValue` compares whatever a control is holding. Where it is not —
128
+ * `toHaveLength` wants a number, `toMatch` a string or a pattern, `toBeTypeOf`
129
+ * one of the eight words `typeof` produces — the narrower type is what the
130
+ * implementation already assumes, and saying it out loud is the point of the
131
+ * exercise.
132
+ *
133
+ * `not` is the same list again because negation is the only thing it changes.
134
+ * `resolves` and `rejects` are deliberately not here: they belong to
135
+ * [`Expectation`], because `expect(p).resolves.not` exists and
136
+ * `expect(x).not.resolves` does not.
137
+ */
138
+ export type Matchers<R> = {
139
+ readonly toBe: (expected: mixed) => R,
140
+ readonly toEqual: (expected: mixed) => R,
141
+ readonly toStrictEqual: (expected: mixed) => R,
142
+ readonly toBeTruthy: () => R,
143
+ readonly toBeFalsy: () => R,
144
+ readonly toBeNull: () => R,
145
+ readonly toBeUndefined: () => R,
146
+ readonly toBeDefined: () => R,
147
+ readonly toBeNaN: () => R,
148
+ readonly toBeGreaterThan: (expected: number) => R,
149
+ readonly toBeGreaterThanOrEqual: (expected: number) => R,
150
+ readonly toBeLessThan: (expected: number) => R,
151
+ readonly toBeLessThanOrEqual: (expected: number) => R,
152
+ readonly toBeCloseTo: (expected: number, digits?: number) => R,
153
+ readonly toContain: (expected: mixed) => R,
154
+ readonly toContainEqual: (expected: mixed) => R,
155
+ readonly toHaveLength: (expected: number) => R,
156
+ readonly toHaveProperty: (path: string, ...rest: $ReadOnlyArray<mixed>) => R,
157
+ readonly toMatch: (expected: string | RegExp) => R,
158
+ readonly toMatchObject: (expected: mixed) => R,
159
+ readonly toBeInstanceOf: (expected: mixed) => R,
160
+ readonly toBeTypeOf: (expected: TypeName) => R,
161
+ readonly toSatisfy: (predicate: (value: mixed) => boolean) => R,
162
+ readonly toMatchSnapshot: (hint?: string) => R,
163
+ readonly toMatchInlineSnapshot: (expected?: string) => R,
164
+ readonly toThrow: (...rest: $ReadOnlyArray<mixed>) => R,
165
+ readonly toHaveBeenCalled: () => R,
166
+ readonly toHaveBeenCalledTimes: (count: number) => R,
167
+ readonly toHaveBeenCalledWith: (...args: $ReadOnlyArray<mixed>) => R,
168
+ readonly toHaveBeenLastCalledWith: (...args: $ReadOnlyArray<mixed>) => R,
169
+ readonly toBeInTheDocument: () => R,
170
+ readonly toBeVisible: () => R,
171
+ readonly toBeDisabled: () => R,
172
+ readonly toBeEnabled: () => R,
173
+ readonly toBeChecked: () => R,
174
+ readonly toBeRequired: () => R,
175
+ readonly toHaveFocus: () => R,
176
+ readonly toHaveAttribute: (name: string, value?: mixed) => R,
177
+ readonly toHaveClass: (...names: $ReadOnlyArray<string>) => R,
178
+ readonly toHaveTextContent: (expected: string | RegExp) => R,
179
+ readonly toHaveValue: (expected: mixed) => R,
180
+ readonly not: Matchers<R>,
181
+ ...
182
+ };
183
+
184
+ /**
185
+ * What `expect(received)` hands back.
186
+ *
187
+ * The matchers, plus the two that settle a promise before applying them. An
188
+ * intersection rather than a copy of the list with two lines added, and
189
+ * rather than an object spread, because a spread of an object type drops the
190
+ * `readonly` off every property it carries over — Flow computes a fresh object
191
+ * from the spread and the fresh one is writable, which would publish forty-one
192
+ * assignable matchers.
193
+ *
194
+ * # What is still not checked
195
+ *
196
+ * `expect(5).resolves` types, and fails when it runs. Saying otherwise needs
197
+ * `expect` to have two call signatures — one for a promise handing back a
198
+ * shape with `resolves`, one for everything else handing back a shape without
199
+ * — and Flow then requires the single function behind them to satisfy both,
200
+ * which no single function does. The overload is written down here rather than
201
+ * attempted because "it did not type" is the kind of thing that gets tried
202
+ * twice.
203
+ */
204
+ export type Expectation = Matchers<void> & {
205
+ readonly resolves: Matchers<Promise<void>>,
206
+ readonly rejects: Matchers<Promise<void>>,
207
+ ...
208
+ };
209
+
210
+ /**
211
+ * `expect` itself: callable, and carrying the matchers that stand in for a
212
+ * value instead of being one.
213
+ *
214
+ * Inexact, and it has to be. The value is a function, every function has
215
+ * `name`, `length`, `call`, `apply` and `bind`, and an exact object type
216
+ * refuses one for exactly that reason. Inexactness costs nothing that matters
217
+ * here: Flow still reports a read of a property this type does not list, which
218
+ * is what makes `expect.anythign()` an error.
219
+ */
220
+ export type Expect = {
221
+ (received: mixed): Expectation,
222
+ // `flow/unclear-type` reads source text rather than an AST, and the shape it
223
+ // recognises as a property key rather than a type is a name at the start of
224
+ // a line or straight after `{`, `,` or `;`. `readonly any:` is neither, so
225
+ // the rule reports Jest's, Vitest's and Sinon's name for this matcher as an
226
+ // `any` type. The rule's own comment already lists `@uniflowed/test`'s
227
+ // `expect.any` among the false positives it exists to avoid; this is the one
228
+ // spelling it still cannot see past.
229
+ // uf-lint-disable-next-line flow/unclear-type
230
+ readonly any: (constructor: mixed) => AsymmetricMatcher,
231
+ readonly anything: () => AsymmetricMatcher,
232
+ readonly objectContaining: (expected: interface {}) => AsymmetricMatcher,
233
+ readonly arrayContaining: (expected: $ReadOnlyArray<mixed>) => AsymmetricMatcher,
234
+ readonly stringContaining: (substring: string) => AsymmetricMatcher,
235
+ readonly stringMatching: (pattern: string | RegExp) => AsymmetricMatcher,
236
+ readonly closeTo: (value: number, digits?: number) => AsymmetricMatcher,
237
+ readonly not: {
238
+ readonly objectContaining: (expected: interface {}) => AsymmetricMatcher,
239
+ readonly arrayContaining: (expected: $ReadOnlyArray<mixed>) => AsymmetricMatcher,
240
+ readonly stringContaining: (substring: string) => AsymmetricMatcher,
241
+ readonly stringMatching: (pattern: string | RegExp) => AsymmetricMatcher,
242
+ readonly closeTo: (value: number, digits?: number) => AsymmetricMatcher,
243
+ ...
244
+ },
245
+ ...
246
+ };
247
+
48
248
  function propertyAt(
49
249
  value: mixed,
50
250
  path: string,
@@ -89,28 +289,36 @@ function matchesThrown(thrown: mixed, expected: mixed): boolean {
89
289
  * Every entry returns a [`Verdict`] rather than throwing, which is what lets
90
290
  * `.not` reuse all of them.
91
291
  *
92
- * # The `any` in the indexer
292
+ * # Every entry takes `mixed`, and that is what makes the indexer sayable
293
+ *
294
+ * [`bind`] reaches an entry by a computed key and applies it to the
295
+ * `$ReadOnlyArray<mixed>` it collected from the caller, so the indexer has to
296
+ * describe a function that will accept those arguments. Parameters are
297
+ * contravariant, so an entry that demanded a `number` could not be described
298
+ * by one — `(...args: $ReadOnlyArray<mixed>)` rejects it, and
299
+ * `(...args: $ReadOnlyArray<empty>)` accepts it and rejects the call. That
300
+ * disagreement is why this indexer used to be written `$ReadOnlyArray<any>`,
301
+ * with a `flow/unclear-type` suppression on it.
93
302
  *
94
- * The entries do not agree about their arguments — `toBe` takes a `mixed`,
95
- * `toHaveLength` takes a `number`, `toBeCloseTo` takes two — and [`bind`]
96
- * applies whichever one it was asked for to a `$ReadOnlyArray<mixed>` it
97
- * collected from a caller. Parameters are contravariant, so one indexer cannot
98
- * describe both ends: `(...args: $ReadOnlyArray<mixed>)` rejects every entry
99
- * that wants a `number`, and `(...args: $ReadOnlyArray<empty>)` accepts every
100
- * entry and rejects the call.
303
+ * So the disagreement is gone instead of papered over: every entry here takes
304
+ * `mixed` and coerces what it needs, the way most of them — `toBe`,
305
+ * `toBeGreaterThan`, `toHaveAttribute` — already did. Nothing a caller can see
306
+ * got wider: `toHaveLength` still refuses a string and `toBeTypeOf` still
307
+ * refuses a word `typeof` never says, because those are [`Matchers`]'s
308
+ * signatures and [`Matchers`] is the published type. What changed is that the
309
+ * table behind them stopped claiming a narrower argument than the one `bind`
310
+ * can hand it, which is a claim that was never true. The `String(…)` and
311
+ * `Number(…)` calls that appeared with it are the coercion the runtime was
312
+ * already doing, said out loud, and each produces the same message the implicit
313
+ * one did for the same input.
101
314
  *
102
- * `mixed` with a cast at the call would move the same unsoundness one line
103
- * without checking anything, because the caller is `bind`, whose result is
104
- * `$FlowFixMe` and whose result's result is `expect`, also `$FlowFixMe`. The
105
- * type that makes any of this checked is a written-out matcher interface —
106
- * one signature per matcher, plus `.not`, `.resolves` and `.rejects` — which
107
- * is what `expect`'s own annotation is waiting for, and is
108
- * ubugeeei-prod/uf#402. Until that exists, a narrower type here would be
109
- * precision nobody can reach.
315
+ * The alternative — narrowing the indexer by writing `bind`'s forty-one
316
+ * wrappers out to avoid the computed lookup — is a second copy of the listing
317
+ * to keep in step with [`Matchers`], and is a worse trade than either. See
318
+ * ubugeeei-prod/uf#402.
110
319
  */
111
320
  function verdicts(received: mixed): {
112
- // uf-lint-disable-next-line flow/unclear-type
113
- readonly [string]: (...args: $ReadOnlyArray<any>) => Verdict,
321
+ readonly [string]: (...args: $ReadOnlyArray<mixed>) => Verdict,
114
322
  } {
115
323
  const shown = () => render(received);
116
324
  const simple = (pass: boolean, what: string, expected?: mixed): Verdict => ({
@@ -185,14 +393,15 @@ function verdicts(received: mixed): {
185
393
  `to be at most ${render(expected)}`,
186
394
  expected,
187
395
  ),
188
- toBeCloseTo: (expected: number, digits?: number) => {
189
- const places = digits ?? 2;
396
+ toBeCloseTo: (expected: mixed, digits?: mixed) => {
397
+ const target = Number(expected);
398
+ const places = digits === undefined ? 2 : Number(digits);
190
399
  const tolerance = 10 ** -places / 2;
191
- const difference = Math.abs((received as $FlowFixMe) - expected);
400
+ const difference = Math.abs((received as $FlowFixMe) - target);
192
401
  return simple(
193
402
  difference < tolerance,
194
- `to be within ${tolerance} of ${expected}, but it is off by ${difference}`,
195
- expected,
403
+ `to be within ${tolerance} of ${target}, but it is off by ${difference}`,
404
+ target,
196
405
  );
197
406
  },
198
407
  toContain: (expected: mixed) => {
@@ -218,22 +427,23 @@ function verdicts(received: mixed): {
218
427
  expected,
219
428
  );
220
429
  },
221
- toHaveLength: (expected: number) => {
430
+ toHaveLength: (expected: mixed) => {
222
431
  const length = received == null ? undefined : (received as $FlowFixMe).length;
223
432
  return simple(
224
433
  length === expected,
225
- `to have length ${expected}, not ${render(length)}`,
434
+ `to have length ${String(expected)}, not ${render(length)}`,
226
435
  expected,
227
436
  );
228
437
  },
229
- toHaveProperty: (path: string, ...rest: $ReadOnlyArray<mixed>) => {
230
- const found = propertyAt(received, path);
438
+ toHaveProperty: (path: mixed, ...rest: $ReadOnlyArray<mixed>) => {
439
+ const at = String(path);
440
+ const found = propertyAt(received, at);
231
441
  if (rest.length === 0) {
232
- return simple(found.found, `to have a property at \`${path}\``);
442
+ return simple(found.found, `to have a property at \`${at}\``);
233
443
  }
234
444
  return simple(
235
445
  found.found && equals(found.value, rest[0]),
236
- `to have \`${path}\` equal to ${render(rest[0])}, not ${render(found.value)}`,
446
+ `to have \`${at}\` equal to ${render(rest[0])}, not ${render(found.value)}`,
237
447
  rest[0],
238
448
  );
239
449
  },
@@ -253,16 +463,24 @@ function verdicts(received: mixed): {
253
463
  `to be an instance of ${render(expected)}`,
254
464
  expected,
255
465
  ),
256
- toBeTypeOf: (expected: string) =>
466
+ toBeTypeOf: (expected: mixed) => {
467
+ const name = String(expected);
468
+ return simple(
469
+ typeof received === name,
470
+ `to be of type ${name}, not ${typeof received}`,
471
+ name,
472
+ );
473
+ },
474
+ toSatisfy: (predicate: mixed) =>
257
475
  simple(
258
- typeof received === expected,
259
- `to be of type ${expected}, not ${typeof received}`,
260
- expected,
476
+ typeof predicate === "function" && predicate(received) === true,
477
+ "to satisfy the predicate",
261
478
  ),
262
- toSatisfy: (predicate: (value: mixed) => boolean) =>
263
- simple(predicate(received) === true, "to satisfy the predicate"),
264
- toMatchSnapshot: (hint?: string): Verdict => {
265
- const verdict = snapshot.matchSnapshot(received, hint);
479
+ toMatchSnapshot: (hint?: mixed): Verdict => {
480
+ const verdict = snapshot.matchSnapshot(
481
+ received,
482
+ hint === undefined ? undefined : String(hint),
483
+ );
266
484
  return {
267
485
  pass: verdict.pass,
268
486
  expected: verdict.expected ?? "(no snapshot yet)",
@@ -276,8 +494,11 @@ function verdicts(received: mixed): {
276
494
  negatedFailure: () => "expected the value not to match its snapshot",
277
495
  };
278
496
  },
279
- toMatchInlineSnapshot: (expected?: string): Verdict => {
280
- const verdict = snapshot.matchInlineSnapshot(received, expected);
497
+ toMatchInlineSnapshot: (expected?: mixed): Verdict => {
498
+ const verdict = snapshot.matchInlineSnapshot(
499
+ received,
500
+ expected === undefined ? undefined : String(expected),
501
+ );
281
502
  return {
282
503
  pass: verdict.pass,
283
504
  expected: verdict.expected ?? "(no inline snapshot yet)",
@@ -328,10 +549,14 @@ function verdicts(received: mixed): {
328
549
  requireSpy("toHaveBeenCalled");
329
550
  return simple(spyCalls().length > 0, "to have been called");
330
551
  },
331
- toHaveBeenCalledTimes: (count: number) => {
552
+ toHaveBeenCalledTimes: (count: mixed) => {
332
553
  requireSpy("toHaveBeenCalledTimes");
333
554
  const actual = spyCalls().length;
334
- return simple(actual === count, `to have been called ${count} times, not ${actual}`, count);
555
+ return simple(
556
+ actual === count,
557
+ `to have been called ${String(count)} times, not ${actual}`,
558
+ count,
559
+ );
335
560
  },
336
561
  toHaveBeenCalledWith: (...args: $ReadOnlyArray<mixed>) => {
337
562
  requireSpy("toHaveBeenCalledWith");
@@ -466,14 +691,42 @@ function verdicts(received: mixed): {
466
691
  * Walks the ancestors, because `display: none` on a parent hides a child whose
467
692
  * own style says nothing. `hidden`, `aria-hidden` and a `details` that is not
468
693
  * open each hide their subtree too.
694
+ *
695
+ * # The one thing a closed `<details>` still shows
696
+ *
697
+ * Its `<summary>`. A closed disclosure renders exactly one child and hides the
698
+ * rest, so the rule is "everything under a closed `<details>` except its
699
+ * summary" — and saying that needs the child the walk arrived from, not only
700
+ * the ancestor it is standing on. Without it the rule was written as "unless
701
+ * the `<details>` is the element being asked about", which exempted the
702
+ * disclosure from its own rule and left the summary inside it invisible:
703
+ * `expect(screen.getByText("More")).toBeVisible()` failed for the one thing on
704
+ * the screen, while the reader was looking at it.
705
+ *
706
+ * # Why this is not the walk in `react-testing`
707
+ *
708
+ * `packages/react-testing/internal/queries.js` has one that looks like this
709
+ * and answers a different question. `exposed` asks whether the accessibility
710
+ * tree announces the element, so it ignores `opacity: 0` — a screen reader
711
+ * reads text at zero opacity, which is exactly why hiding text that way is a
712
+ * bug rather than a technique — and it takes `aria-hidden` as decisive. This
713
+ * one asks whether a reader would *see* it, so the two answers part company
714
+ * there on purpose. The `<details>` half is the half they agree on, and it is
715
+ * written the same way in both.
469
716
  */
470
717
  function isVisible(node: Element): boolean {
718
+ let child: $FlowFixMe = null;
471
719
  let current: $FlowFixMe = node;
472
720
  while (current != null && current.nodeType === 1) {
473
721
  if (current.hasAttribute("hidden") || current.getAttribute("aria-hidden") === "true") {
474
722
  return false;
475
723
  }
476
- if (current.tagName === "DETAILS" && !current.hasAttribute("open") && current !== node) {
724
+ if (
725
+ child != null &&
726
+ current.tagName === "DETAILS" &&
727
+ !current.hasAttribute("open") &&
728
+ child.tagName !== "SUMMARY"
729
+ ) {
477
730
  return false;
478
731
  }
479
732
  const style = current.ownerDocument?.defaultView?.getComputedStyle?.(current);
@@ -485,6 +738,7 @@ function isVisible(node: Element): boolean {
485
738
  return false;
486
739
  }
487
740
  }
741
+ child = current;
488
742
  current = current.parentElement;
489
743
  }
490
744
  return true;
@@ -510,6 +764,17 @@ function isDisabled(node: Element): boolean {
510
764
  *
511
765
  * `negated` decides which message a failing verdict raises, which is all of
512
766
  * what `.not` is.
767
+ *
768
+ * # Why the object is built rather than written
769
+ *
770
+ * `.not` has to be reached lazily or building an expectation would build its
771
+ * negation, which would build *its* negation, forever. A lazily installed
772
+ * property is not something an object literal carries, so the value is
773
+ * completed with `Object.defineProperty` after it exists — and an object
774
+ * completed after the fact is not one Flow can check a literal against. That
775
+ * is what this `$FlowFixMe` is, and it now covers a construction rather than a
776
+ * published type: [`expectValue`] states the real one, and the checker holds
777
+ * every caller to it.
513
778
  */
514
779
  function bind(received: mixed, negated: boolean): $FlowFixMe {
515
780
  const table = verdicts(received);
@@ -591,8 +856,12 @@ function settled(promise: mixed, wanted: "resolve" | "reject", negated: boolean)
591
856
  * expect(() => parse("")).toThrow(/empty/);
592
857
  * await expect(load()).resolves.toHaveLength(3);
593
858
  * ```
859
+ *
860
+ * Takes a `mixed` and hands back a written-out [`Expectation`]. What each
861
+ * matcher will accept is decided by its own signature rather than by what was
862
+ * received, for the reasons this module's header sets out.
594
863
  */
595
- function expectValue(received: mixed): $FlowFixMe {
864
+ function expectValue(received: mixed): Expectation {
596
865
  const expectation: $FlowFixMe = bind(received, false);
597
866
  Object.defineProperty(expectation, "resolves", {
598
867
  get: () => settled(received, "resolve", false),
@@ -602,11 +871,45 @@ function expectValue(received: mixed): $FlowFixMe {
602
871
  }
603
872
 
604
873
  /**
605
- * Assert about a value.
874
+ * Build the callable that carries the asymmetric matchers.
606
875
  *
607
- * Declared with its matchers attached rather than assigned afterwards: a
608
- * shipped module may only declare, import and export at its top level, and
609
- * `expect.any = …` is a statement that runs when the module is imported.
876
+ * The statics are attached inside a builder rather than at the module's top
877
+ * level, the way `internal/registry.js` builds `describe`: a shipped module
878
+ * may only declare, import and export at its top level, and `expect.any = …`
879
+ * out here is a statement that runs when the module is imported.
880
+ *
881
+ * It was `Object.assign(expectValue, { … })`, which is what a reader expects
882
+ * and what does not type. Flow models `Object.assign` as returning the
883
+ * *target*, so the result of assigning matchers onto a function is still a
884
+ * function with no matchers on it — eight `prop-missing` errors saying so, and
885
+ * a `flow/unsafe-object-assign` suppression on top of them. Attaching to a
886
+ * local before it is returned is the same runtime value with none of that: the
887
+ * checker sees the statics arrive and holds the result to [`Expect`].
888
+ */
889
+ function expecting(): Expect {
890
+ const api = (received: mixed): Expectation => expectValue(received);
891
+ api.any = asymmetric.any;
892
+ api.anything = asymmetric.anything;
893
+ api.objectContaining = asymmetric.objectContaining;
894
+ api.arrayContaining = asymmetric.arrayContaining;
895
+ api.stringContaining = asymmetric.stringContaining;
896
+ api.stringMatching = asymmetric.stringMatching;
897
+ api.closeTo = asymmetric.closeTo;
898
+ api.not = {
899
+ objectContaining: (expected: interface {}) =>
900
+ asymmetric.not(asymmetric.objectContaining(expected)),
901
+ arrayContaining: (expected: $ReadOnlyArray<mixed>) =>
902
+ asymmetric.not(asymmetric.arrayContaining(expected)),
903
+ stringContaining: (substring: string) => asymmetric.not(asymmetric.stringContaining(substring)),
904
+ stringMatching: (pattern: string | RegExp) =>
905
+ asymmetric.not(asymmetric.stringMatching(pattern)),
906
+ closeTo: (value: number, digits?: number) => asymmetric.not(asymmetric.closeTo(value, digits)),
907
+ };
908
+ return api;
909
+ }
910
+
911
+ /**
912
+ * Assert about a value.
610
913
  *
611
914
  * The `expect.*` half are the matchers that stand in for a value instead of
612
915
  * being one. `expect(user).toEqual({ id: expect.any(String), name: "uf" })`
@@ -619,31 +922,4 @@ function expectValue(received: mixed): $FlowFixMe {
619
922
  * than a negated assertion around the whole object, and is the form a suite
620
923
  * being ported will already have.
621
924
  */
622
- // `flow/unsafe-object-assign` asks for an object spread, and a spread cannot
623
- // produce this value: `expect` is a *function* with matchers hanging off it,
624
- // and `{ ...expectValue, ...matchers }` is a plain object that a test cannot
625
- // call. `Object.assign` onto a callable is the only expression that makes one,
626
- // and the alternative the rule is really warning about — `expect.any = …`
627
- // afterwards — is the top-level statement the comment above rules out. What it
628
- // mutates is a function this module declared six lines up and exports here; no
629
- // object belonging to anybody else is touched.
630
- // uf-lint-disable-next-line flow/unsafe-object-assign
631
- export const expect: $FlowFixMe = Object.assign(expectValue, {
632
- any: asymmetric.any,
633
- anything: asymmetric.anything,
634
- objectContaining: asymmetric.objectContaining,
635
- arrayContaining: asymmetric.arrayContaining,
636
- stringContaining: asymmetric.stringContaining,
637
- stringMatching: asymmetric.stringMatching,
638
- closeTo: asymmetric.closeTo,
639
- not: {
640
- objectContaining: (expected: interface {}) =>
641
- asymmetric.not(asymmetric.objectContaining(expected)),
642
- arrayContaining: (expected: $ReadOnlyArray<mixed>) =>
643
- asymmetric.not(asymmetric.arrayContaining(expected)),
644
- stringContaining: (substring: string) => asymmetric.not(asymmetric.stringContaining(substring)),
645
- stringMatching: (pattern: string | RegExp) =>
646
- asymmetric.not(asymmetric.stringMatching(pattern)),
647
- closeTo: (value: number, digits?: number) => asymmetric.not(asymmetric.closeTo(value, digits)),
648
- },
649
- });
925
+ export const expect: Expect = expecting();
@@ -59,10 +59,15 @@ function environment(): { [string]: string } | null {
59
59
  }
60
60
 
61
61
  /**
62
- * Replace an environment variable for the rest of the test.
62
+ * Replace an environment variable for the rest of the file.
63
63
  *
64
- * Undone by `unstubAllEnvs`, which the runner calls between files — a stub that
65
- * outlived its test would be a test that passes alone and fails in a suite.
64
+ * The file, and not the test: `process.env` belongs to the process, so a stub
65
+ * stands until something puts it back. `./worker.js` does that between files,
66
+ * beside the spy registry it clears for the same reason — a worker serves many
67
+ * files, and a stub that outlived its file would be a test that passes because
68
+ * of another one, in a suite where which files share a worker is decided by a
69
+ * timings file. A case that wants a narrower scope calls `unstubAllEnvs` in an
70
+ * `afterEach`, which is also what makes the scope visible to a reader.
66
71
  */
67
72
  export function stubEnv(name: string, value: string | void): void {
68
73
  const env = environment();
@@ -97,7 +102,11 @@ export function unstubAllEnvs(): void {
97
102
  }
98
103
 
99
104
  /**
100
- * Replace a global for the rest of the test.
105
+ * Replace a global for the rest of the file.
106
+ *
107
+ * Undone by `unstubAllGlobals`, which `./worker.js` calls between files, for
108
+ * the reason [`stubEnv`] above gives: `globalThis` outlives every file that
109
+ * writes to it.
101
110
  *
102
111
  * Whether the global was the object's own property is recorded, because putting
103
112
  * back an inherited one by assignment would leave a copy that shadows whatever
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/test",
3
- "version": "0.0.0-alpha.12",
3
+ "version": "0.0.0-alpha.14",
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",
@@ -21,6 +21,6 @@
21
21
  "internal"
22
22
  ],
23
23
  "dependencies": {
24
- "@uniflowed/host": "0.0.0-alpha.12"
24
+ "@uniflowed/host": "0.0.0-alpha.14"
25
25
  }
26
26
  }
package/worker.js CHANGED
@@ -56,6 +56,15 @@ import { fileURLToPath, pathToFileURL } from "node:url";
56
56
  import { reset } from "./internal/registry.js";
57
57
  import { resetModuleState } from "./internal/modules.js";
58
58
  import { run } from "./internal/run.js";
59
+ import { unstubAllEnvs, unstubAllGlobals } from "./internal/namespace.js";
60
+ // Renamed at the door, for two reasons that agree. It reads as the resets
61
+ // beside it do — `reset`, `unstubAllEnvs`, `resetModuleState` are all
62
+ // verb-first, and so is what this does to the clock. And `useRealTimers` is
63
+ // not a React hook: it is uf's own timer control, which happens to be named
64
+ // the way every runner names it, and calling it bare in a plain function is
65
+ // a `react/hooks-rules` error on the name alone. A suppression would assert
66
+ // something about this call; the name is simply accurate.
67
+ import { useRealTimers as restoreRealClock } from "./internal/timers.js";
59
68
 
60
69
  /** What `uf` sends for one file. */
61
70
  type Request = {|
@@ -140,6 +149,32 @@ function write(event: { readonly [string]: mixed }): void {
140
149
  async function runFile(request: Request, generation: number): Promise<void> {
141
150
  const started = performance.now();
142
151
  reset();
152
+ // Every environment variable and global the previous file replaced goes back
153
+ // too. A spy lives in the registry `reset` clears, but a stub is a write to
154
+ // something the whole process shares: `uft.stubEnv("NODE_ENV", "production")`
155
+ // stays set for every later file this worker serves, and `uf test` fans files
156
+ // across workers by size, so which files those are changes with the timings
157
+ // file. That is a suite whose result depends on its schedule — and worse, one
158
+ // whose failure names the file that read the value rather than the file that
159
+ // wrote it. See ubugeeei-prod/uf#417.
160
+ unstubAllEnvs();
161
+ unstubAllGlobals();
162
+ // And the clock goes back, whatever the previous file did with it. A spy
163
+ // lives in the registry `reset` clears; a fake clock is a write to the
164
+ // scheduling globals the whole process shares, so `uft.useFakeTimers()` in
165
+ // one file is still installed when the next one imports.
166
+ //
167
+ // Worse than a leaked value, and worse in a way that hides it. A leaked stub
168
+ // makes the next file read something wrong, which arrives as an assertion
169
+ // naming the value. A leaked clock makes the next file's `setTimeout` never
170
+ // fire — including the one `withTimeout` races each case against — so the
171
+ // file hangs with nothing on screen until `uf`'s own deadline kills the
172
+ // worker, and the report names the file that waited rather than the file
173
+ // that stopped time. See ubugeeei-prod/uf#581.
174
+ //
175
+ // Before the import rather than after the run, so a file that throws while
176
+ // loading still hands the next one a real clock.
177
+ restoreRealClock();
143
178
  // Every module this file stood in for goes back, before the next file can
144
179
  // import one of them and be handed the previous file's stand-in. A worker
145
180
  // serves many files out of one module registry, so this is the difference