@uniflowed/test 0.0.0-alpha.13 → 0.0.0-alpha.15

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.
@@ -289,29 +289,36 @@ function matchesThrown(thrown: mixed, expected: mixed): boolean {
289
289
  * Every entry returns a [`Verdict`] rather than throwing, which is what lets
290
290
  * `.not` reuse all of them.
291
291
  *
292
- * # The `any` in the indexer
292
+ * # Every entry takes `mixed`, and that is what makes the indexer sayable
293
293
  *
294
- * The entries do not agree about their arguments — `toBe` takes a `mixed`,
295
- * `toHaveLength` takes a `number`, `toBeCloseTo` takes two — and [`bind`]
296
- * applies whichever one it was asked for to a `$ReadOnlyArray<mixed>` it
297
- * collected from a caller. Parameters are contravariant, so one indexer cannot
298
- * describe both ends: `(...args: $ReadOnlyArray<mixed>)` rejects every entry
299
- * that wants a `number`, and `(...args: $ReadOnlyArray<empty>)` accepts every
300
- * entry and rejects the call.
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.
301
302
  *
302
- * That is unchanged, and it is now the last of it. Where a caller used to meet
303
- * this indexer through an unbroken chain of `$FlowFixMe`, the published
304
- * surface is [`Expectation`] and the disagreement the indexer papers over is
305
- * written down there, one signature per name. What survives is a table reached
306
- * by a computed key inside this module, between `verdicts` and `bind` — two
307
- * functions in one file, neither of which a consumer can see — and narrowing
308
- * it means writing `bind`'s forty-one wrappers out by hand to avoid the
309
- * lookup. That is a second copy of the listing to keep in step with this one,
310
- * which is a worse trade than the suppression it removes.
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.
314
+ *
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.
311
319
  */
312
320
  function verdicts(received: mixed): {
313
- // uf-lint-disable-next-line flow/unclear-type
314
- readonly [string]: (...args: $ReadOnlyArray<any>) => Verdict,
321
+ readonly [string]: (...args: $ReadOnlyArray<mixed>) => Verdict,
315
322
  } {
316
323
  const shown = () => render(received);
317
324
  const simple = (pass: boolean, what: string, expected?: mixed): Verdict => ({
@@ -386,14 +393,15 @@ function verdicts(received: mixed): {
386
393
  `to be at most ${render(expected)}`,
387
394
  expected,
388
395
  ),
389
- toBeCloseTo: (expected: number, digits?: number) => {
390
- const places = digits ?? 2;
396
+ toBeCloseTo: (expected: mixed, digits?: mixed) => {
397
+ const target = Number(expected);
398
+ const places = digits === undefined ? 2 : Number(digits);
391
399
  const tolerance = 10 ** -places / 2;
392
- const difference = Math.abs((received as $FlowFixMe) - expected);
400
+ const difference = Math.abs((received as $FlowFixMe) - target);
393
401
  return simple(
394
402
  difference < tolerance,
395
- `to be within ${tolerance} of ${expected}, but it is off by ${difference}`,
396
- expected,
403
+ `to be within ${tolerance} of ${target}, but it is off by ${difference}`,
404
+ target,
397
405
  );
398
406
  },
399
407
  toContain: (expected: mixed) => {
@@ -419,22 +427,23 @@ function verdicts(received: mixed): {
419
427
  expected,
420
428
  );
421
429
  },
422
- toHaveLength: (expected: number) => {
430
+ toHaveLength: (expected: mixed) => {
423
431
  const length = received == null ? undefined : (received as $FlowFixMe).length;
424
432
  return simple(
425
433
  length === expected,
426
- `to have length ${expected}, not ${render(length)}`,
434
+ `to have length ${String(expected)}, not ${render(length)}`,
427
435
  expected,
428
436
  );
429
437
  },
430
- toHaveProperty: (path: string, ...rest: $ReadOnlyArray<mixed>) => {
431
- const found = propertyAt(received, path);
438
+ toHaveProperty: (path: mixed, ...rest: $ReadOnlyArray<mixed>) => {
439
+ const at = String(path);
440
+ const found = propertyAt(received, at);
432
441
  if (rest.length === 0) {
433
- return simple(found.found, `to have a property at \`${path}\``);
442
+ return simple(found.found, `to have a property at \`${at}\``);
434
443
  }
435
444
  return simple(
436
445
  found.found && equals(found.value, rest[0]),
437
- `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)}`,
438
447
  rest[0],
439
448
  );
440
449
  },
@@ -454,16 +463,24 @@ function verdicts(received: mixed): {
454
463
  `to be an instance of ${render(expected)}`,
455
464
  expected,
456
465
  ),
457
- 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) =>
458
475
  simple(
459
- typeof received === expected,
460
- `to be of type ${expected}, not ${typeof received}`,
461
- expected,
476
+ typeof predicate === "function" && predicate(received) === true,
477
+ "to satisfy the predicate",
462
478
  ),
463
- toSatisfy: (predicate: (value: mixed) => boolean) =>
464
- simple(predicate(received) === true, "to satisfy the predicate"),
465
- toMatchSnapshot: (hint?: string): Verdict => {
466
- 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
+ );
467
484
  return {
468
485
  pass: verdict.pass,
469
486
  expected: verdict.expected ?? "(no snapshot yet)",
@@ -477,8 +494,11 @@ function verdicts(received: mixed): {
477
494
  negatedFailure: () => "expected the value not to match its snapshot",
478
495
  };
479
496
  },
480
- toMatchInlineSnapshot: (expected?: string): Verdict => {
481
- 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
+ );
482
502
  return {
483
503
  pass: verdict.pass,
484
504
  expected: verdict.expected ?? "(no inline snapshot yet)",
@@ -529,10 +549,14 @@ function verdicts(received: mixed): {
529
549
  requireSpy("toHaveBeenCalled");
530
550
  return simple(spyCalls().length > 0, "to have been called");
531
551
  },
532
- toHaveBeenCalledTimes: (count: number) => {
552
+ toHaveBeenCalledTimes: (count: mixed) => {
533
553
  requireSpy("toHaveBeenCalledTimes");
534
554
  const actual = spyCalls().length;
535
- 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
+ );
536
560
  },
537
561
  toHaveBeenCalledWith: (...args: $ReadOnlyArray<mixed>) => {
538
562
  requireSpy("toHaveBeenCalledWith");
@@ -0,0 +1,200 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/test`: what a file is allowed to leave behind.
4
+ //
5
+ // A worker serves many files out of one process, one at a time (`../worker.js`
6
+ // says why). Everything a file registers lives in this package and is cleared
7
+ // with it; everything a file *reaches around* the package to change belongs to
8
+ // the process, outlives the file, and is handed to whichever file the schedule
9
+ // puts next in that worker.
10
+ //
11
+ // That second list is what this module is. It has been discovered three times,
12
+ // once per entry, and each time the same way — a suite that passed alone and
13
+ // failed beside another, naming the file that read the value rather than the
14
+ // file that wrote it:
15
+ //
16
+ // * ubugeeei-prod/uf#417, `uft.stubEnv("NODE_ENV", …)` still set for the
17
+ // next file;
18
+ // * ubugeeei-prod/uf#581, a fake clock still installed, so the next file's
19
+ // `setTimeout` — including the one each case is raced against — never
20
+ // fired and the file hung with nothing on screen;
21
+ // * ubugeeei-prod/uf#607, `document.body` still holding the markup a
22
+ // hydration test wrote into it, so the next file's "there is one image on
23
+ // the page" found six.
24
+ //
25
+ // Which files share a worker is decided by `.uf/test-timings.json`, so a leak
26
+ // makes the *result* of a suite depend on how the machine was loaded the last
27
+ // time it ran. That is the failure this module exists to end, and the reason
28
+ // it is a list in one place rather than three calls in `../worker.js`: the
29
+ // question "what else does a file share with the next one" now has somewhere
30
+ // to be answered, and a fourth answer is one entry rather than one more thing
31
+ // to remember.
32
+ //
33
+ // # What belongs here
34
+ //
35
+ // State that (a) the process shares, (b) a test can change from inside a file,
36
+ // and (c) nothing else puts back. Anything a file merely *reads* does not
37
+ // belong here, and neither does anything the registry already clears — a spy
38
+ // is not on this list because `reset()` is what a spy lives in.
39
+ //
40
+ // # When it runs
41
+ //
42
+ // Before the next file is imported, not after the previous one is run. A file
43
+ // that throws while loading is still a file that has run code, and it still
44
+ // hands the next one whatever that code changed; putting things back on the
45
+ // way *in* covers the load failure and the crash as well as the ordinary end.
46
+ // It also means the first file in a worker starts from the same state as the
47
+ // tenth.
48
+
49
+ import { reset } from "./registry.js";
50
+ import { resetModuleState } from "./modules.js";
51
+ import { unstubAllEnvs, unstubAllGlobals } from "./namespace.js";
52
+ // Renamed at the door, for two reasons that agree. It reads as the resets
53
+ // beside it do — `reset`, `unstubAllEnvs`, `resetModuleState` are all
54
+ // verb-first, and so is what this does to the clock. And `useRealTimers` is
55
+ // not a React hook: it is uf's own timer control, which happens to be named
56
+ // the way every runner names it, and calling it bare in a plain function is a
57
+ // `react/hooks-rules` error on the name alone. A suppression would assert
58
+ // something about this call; the name is simply accurate.
59
+ import { useRealTimers as restoreRealClock } from "./timers.js";
60
+
61
+ /**
62
+ * One piece of process-wide state a file can change, and how to put it back.
63
+ *
64
+ * `what` is written for a person reading this list rather than for any code:
65
+ * nothing branches on it, and it is here because a list of five bare function
66
+ * references is a list nobody can check against the paragraph above it.
67
+ */
68
+ type Shared = {|
69
+ readonly what: string,
70
+ readonly restore: () => void,
71
+ |};
72
+
73
+ /**
74
+ * Everything a file shares with the file after it, in the order it goes back.
75
+ *
76
+ * The order matters in one place and is harmless everywhere else: the clock
77
+ * goes back before the module stand-ins do, because a stand-in's factory runs
78
+ * on the next import and a factory that schedules anything under a leaked fake
79
+ * clock would schedule it into a clock nobody is going to advance.
80
+ */
81
+ const SHARED: $ReadOnlyArray<Shared> = [
82
+ {
83
+ what: "the tests, hooks and spies this package registered",
84
+ restore: reset,
85
+ },
86
+ {
87
+ // `process.env` belongs to the process. `uft.stubEnv("NODE_ENV",
88
+ // "production")` in one file is still set when the next one imports, and
89
+ // the file that fails is the one that read it.
90
+ what: "environment variables `uft.stubEnv` replaced",
91
+ restore: unstubAllEnvs,
92
+ },
93
+ {
94
+ // `globalThis` likewise, and worse: a stubbed `fetch` makes the next file
95
+ // talk to a stand-in that does not know about it.
96
+ what: "globals `uft.stubGlobal` replaced",
97
+ restore: unstubAllGlobals,
98
+ },
99
+ {
100
+ // Worse than a leaked value, and worse in a way that hides it. A leaked
101
+ // stub makes the next file read something wrong, which arrives as an
102
+ // assertion naming the value. A leaked clock makes the next file's
103
+ // `setTimeout` never fire — including the one `withTimeout` races each
104
+ // case against — so the file hangs with nothing on screen until `uf`'s own
105
+ // deadline kills the worker, and the report names the file that waited
106
+ // rather than the file that stopped time.
107
+ what: "the clock, whatever `uft.useFakeTimers` did to it",
108
+ restore: restoreRealClock,
109
+ },
110
+ {
111
+ // A worker serves many files out of one module registry, so this is the
112
+ // difference between "one file at a time" and "one file's mocks at a
113
+ // time".
114
+ what: "modules `uft.mock` stood in for",
115
+ restore: resetModuleState,
116
+ },
117
+ {
118
+ what: "the document, if this process has one",
119
+ restore: restoreDocument,
120
+ },
121
+ ];
122
+
123
+ /**
124
+ * Hand the next file a document nobody else has written to.
125
+ *
126
+ * The document is process-wide in a way that is easy to miss, because nothing
127
+ * in this package installs it: `@uniflowed/react-testing` puts one on the
128
+ * global object the first time a test renders and deliberately keeps it for
129
+ * the life of the process — replacing it would strand every React root already
130
+ * mounted in the old one. So one document serves every file a worker runs, and
131
+ * what a file leaves in it is what the next file queries.
132
+ *
133
+ * Its *contents* are put back rather than the document itself, and "back"
134
+ * means empty: a file is handed the body it would have had if it had installed
135
+ * the document itself. `cleanup()` already unmounts what `render` mounted, and
136
+ * that is not the leak — the leak is markup a test wrote into the body by
137
+ * hand, which a hydration test must do because hydration is React attaching to
138
+ * markup that is already there. `rsc-split.test.js` and `streaming.test.js`
139
+ * both `replaceChildren` into the body, and the file after them in that worker
140
+ * started with somebody else's page.
141
+ *
142
+ * Read through `globalThis` and guarded by `typeof`, because a worker running
143
+ * a suite that never renders has no `document` at all and must not pay for
144
+ * one — and because on a host that *is* a browser this is the page, whose body
145
+ * a run of `uf test` has no business emptying. It only ever clears a body that
146
+ * a test process is using as scratch space, which is every case this can
147
+ * reach: the shim's document, or a page the project chose to run its own tests
148
+ * in.
149
+ */
150
+ function restoreDocument(): void {
151
+ if (typeof globalThis.document === "undefined") {
152
+ return;
153
+ }
154
+ const body = globalThis.document.body;
155
+ if (body == null) {
156
+ // A document a parser produced need not have one, and a document with no
157
+ // body is a document with nothing to put back.
158
+ return;
159
+ }
160
+ body.replaceChildren();
161
+ // The attributes too, and not for tidiness: `<body class="dark">` is how a
162
+ // theme test says what it is testing, and a file that leaves one behind
163
+ // makes the next file's "the page is in light mode" false for a reason it
164
+ // cannot see. Read into an array first — removing an attribute while
165
+ // iterating a live list is the loop that skips every other entry.
166
+ for (const name of [...body.getAttributeNames()]) {
167
+ body.removeAttribute(name);
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Put back everything the file that just ran may have changed.
173
+ *
174
+ * Called by `../worker.js` before it imports the next file. Nothing here
175
+ * reports a file for having changed any of this: a file is *allowed* to — that
176
+ * is what the `uft` namespace is for — and the contract is that the change
177
+ * does not outlive the file, not that it never happened.
178
+ *
179
+ * Every entry is attempted even after one has thrown, and the first failure is
180
+ * raised afterwards under the name of what it could not put back. Stopping at
181
+ * the first would leave the four entries behind it un-restored, which is the
182
+ * defect this module exists to prevent arriving by a new route — and a bare
183
+ * throw from one of these used to say only that something in the runner failed
184
+ * between two files.
185
+ */
186
+ export function restoreSharedState(): void {
187
+ let failure: { readonly what: string, readonly thrown: mixed } | null = null;
188
+ for (const shared of SHARED) {
189
+ try {
190
+ shared.restore();
191
+ } catch (thrown) {
192
+ failure ??= { what: shared.what, thrown };
193
+ }
194
+ }
195
+ if (failure != null) {
196
+ const cause = failure.thrown;
197
+ const message = cause instanceof Error ? cause.message : String(cause);
198
+ throw new Error(`@uniflowed/test could not put back ${failure.what}: ${message}`);
199
+ }
200
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/test",
3
- "version": "0.0.0-alpha.13",
3
+ "version": "0.0.0-alpha.15",
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.13"
24
+ "@uniflowed/host": "0.0.0-alpha.15"
25
25
  }
26
26
  }
package/worker.js CHANGED
@@ -53,10 +53,8 @@ import { writeChangedSnapshots } from "./internal/snapshot.js";
53
53
  import { createInterface } from "node:readline";
54
54
  import { fileURLToPath, pathToFileURL } from "node:url";
55
55
 
56
- import { reset } from "./internal/registry.js";
57
- import { resetModuleState } from "./internal/modules.js";
56
+ import { restoreSharedState } from "./internal/isolation.js";
58
57
  import { run } from "./internal/run.js";
59
- import { unstubAllEnvs, unstubAllGlobals } from "./internal/namespace.js";
60
58
 
61
59
  /** What `uf` sends for one file. */
62
60
  type Request = {|
@@ -140,22 +138,21 @@ function write(event: { readonly [string]: mixed }): void {
140
138
  */
141
139
  async function runFile(request: Request, generation: number): Promise<void> {
142
140
  const started = performance.now();
143
- reset();
144
- // Every environment variable and global the previous file replaced goes back
145
- // too. A spy lives in the registry `reset` clears, but a stub is a write to
146
- // something the whole process shares: `uft.stubEnv("NODE_ENV", "production")`
147
- // stays set for every later file this worker serves, and `uf test` fans files
148
- // across workers by size, so which files those are changes with the timings
149
- // file. That is a suite whose result depends on its schedule — and worse, one
150
- // whose failure names the file that read the value rather than the file that
151
- // wrote it. See ubugeeei-prod/uf#417.
152
- unstubAllEnvs();
153
- unstubAllGlobals();
154
- // Every module this file stood in for goes back, before the next file can
155
- // import one of them and be handed the previous file's stand-in. A worker
156
- // serves many files out of one module registry, so this is the difference
157
- // between "one file at a time" and "one file's mocks at a time".
158
- resetModuleState();
141
+ // Everything the previous file changed and this package shares with it goes
142
+ // back: the registry, the stubbed environment and globals, the clock, the
143
+ // module stand-ins, and the document. What each of those is and why it is on
144
+ // the list is `internal/isolation.js`, which is one place rather than five
145
+ // calls here — a worker serves many files out of one process, `uf test` fans
146
+ // files across workers by size, and a leak therefore makes the *result* of a
147
+ // suite a function of the timings file rather than of the code under test.
148
+ // See ubugeeei-prod/uf#417, #581 and #607, which are that sentence three
149
+ // times over.
150
+ //
151
+ // Before the import rather than after the run, so a file that throws while
152
+ // loading still hands the next one a clean process.
153
+ restoreSharedState();
154
+ // Not a restore, and so not on that list: this is the output budget for the
155
+ // file about to run, rather than something the previous file left behind.
159
156
  output.startFile();
160
157
 
161
158
  try {