@uniflowed/test 0.0.0-alpha.14 → 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.
@@ -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.14",
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.14"
24
+ "@uniflowed/host": "0.0.0-alpha.15"
25
25
  }
26
26
  }
package/worker.js CHANGED
@@ -53,18 +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
- // 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";
68
58
 
69
59
  /** What `uf` sends for one file. */
70
60
  type Request = {|
@@ -148,38 +138,21 @@ function write(event: { readonly [string]: mixed }): void {
148
138
  */
149
139
  async function runFile(request: Request, generation: number): Promise<void> {
150
140
  const started = performance.now();
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.
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.
174
150
  //
175
151
  // 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();
178
- // Every module this file stood in for goes back, before the next file can
179
- // import one of them and be handed the previous file's stand-in. A worker
180
- // serves many files out of one module registry, so this is the difference
181
- // between "one file at a time" and "one file's mocks at a time".
182
- resetModuleState();
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.
183
156
  output.startFile();
184
157
 
185
158
  try {