@uniflowed/react-testing 0.0.0-alpha.2 → 0.0.0-alpha.4

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/internal/dom.js CHANGED
@@ -59,7 +59,45 @@ const FUNCTIONS = ["getComputedStyle", "requestAnimationFrame", "cancelAnimation
59
59
  * with no setter, and assigning to it throws. A test does not need it
60
60
  * replaced — it needs it to exist.
61
61
  */
62
- const OBJECTS = ["location", "history", "localStorage", "sessionStorage", "navigator"];
62
+ const OBJECTS = ["location", "history", "navigator"];
63
+
64
+ /**
65
+ * Storage, which is installed where the host has none *or has one that does
66
+ * not work*.
67
+ *
68
+ * Node defines `globalThis.localStorage` and leaves it empty unless the
69
+ * process was started with `--localstorage-file`:
70
+ *
71
+ * ```text
72
+ * typeof globalThis.localStorage // "object"
73
+ * globalThis.localStorage.setItem // undefined
74
+ * ```
75
+ *
76
+ * So "the host already has one" is the wrong question, and asking it left
77
+ * every `useStorage` test writing into an object with no `setItem` —
78
+ * `globalThis.localStorage.setItem is not a function`, from a line that had
79
+ * nothing to do with the hook under test. The question is whether it works.
80
+ */
81
+ const STORAGE = ["localStorage", "sessionStorage"];
82
+
83
+ /**
84
+ * Whether a value is a Storage a test can actually use.
85
+ *
86
+ * The four methods, not one: a half-implemented shim that has `getItem` and
87
+ * no `removeItem` fails later and further away than one that is absent.
88
+ */
89
+ function isUsableStorage(value: mixed): boolean {
90
+ if (value == null || typeof value !== "object") {
91
+ return false;
92
+ }
93
+ const storage: { [string]: mixed } = value as any;
94
+ return (
95
+ typeof storage.getItem === "function" &&
96
+ typeof storage.setItem === "function" &&
97
+ typeof storage.removeItem === "function" &&
98
+ typeof storage.clear === "function"
99
+ );
100
+ }
63
101
 
64
102
  let installed: mixed = null;
65
103
 
@@ -72,6 +110,7 @@ let installed: mixed = null;
72
110
  * root already mounted in the old one.
73
111
  */
74
112
  export function installDom(): mixed {
113
+ installActEnvironment();
75
114
  if (installed != null) {
76
115
  return installed;
77
116
  }
@@ -103,6 +142,15 @@ export function installDom(): mixed {
103
142
  define(name, value);
104
143
  }
105
144
  }
145
+ for (const name of STORAGE) {
146
+ if (isUsableStorage(globalThis[name])) {
147
+ continue;
148
+ }
149
+ const value = (win as any)[name];
150
+ if (isUsableStorage(value)) {
151
+ define(name, value);
152
+ }
153
+ }
106
154
 
107
155
  // React reads these to decide it is in a browser and to pick its event
108
156
  // system, and they must be the objects the elements belong to.
@@ -113,6 +161,46 @@ export function installDom(): mixed {
113
161
  return installed;
114
162
  }
115
163
 
164
+ /**
165
+ * Tell React that this process is running tests.
166
+ *
167
+ * React cannot tell a test from a production render, so `act` warns "The
168
+ * current testing environment is not configured to support act(...)" unless
169
+ * the harness says so. Every render in this package goes through `act`, so
170
+ * without this every component test printed the warning — 73 times in one
171
+ * file of this repository — and a warning worth reading was lost among them.
172
+ *
173
+ * Separate from the document because the two are independent: a project
174
+ * already running in a browser has a DOM and still has to say it is testing.
175
+ */
176
+ export function installActEnvironment(): void {
177
+ if (declared) {
178
+ return;
179
+ }
180
+ declared = true;
181
+ define("IS_REACT_ACT_ENVIRONMENT", true);
182
+ }
183
+
184
+ /**
185
+ * Turn the act environment on or off.
186
+ *
187
+ * `waitFor` stands it down for the length of a wait; see the reason there.
188
+ */
189
+ export function setActEnvironment(active: boolean): void {
190
+ declared = true;
191
+ define("IS_REACT_ACT_ENVIRONMENT", active);
192
+ }
193
+
194
+ /**
195
+ * Whether the flag has been installed, tracked separately from its value.
196
+ *
197
+ * Every query calls `installDom`, which installs the act environment, and
198
+ * every query inside a `waitFor` therefore ran while `waitFor` had stood the
199
+ * environment down. Reading the flag to decide whether to set it turned it
200
+ * back on at the first assertion, so only the first poll of a wait was quiet.
201
+ */
202
+ let declared = false;
203
+
116
204
  /**
117
205
  * Assign a global, even where the host declared it as a getter.
118
206
  *
@@ -16,7 +16,7 @@ import { createRequire } from "node:module";
16
16
  import type * as React from "@uniflowed/react";
17
17
  import { act } from "@uniflowed/react";
18
18
 
19
- import { installDom } from "./dom.js";
19
+ import { installActEnvironment, installDom, setActEnvironment } from "./dom.js";
20
20
 
21
21
  /** What `render` hands back. */
22
22
  export type RenderResult = {|
@@ -131,13 +131,38 @@ function requireClient(): { createRoot: (Element) => any } {
131
131
  * and this is how.
132
132
  */
133
133
  export function actively<T>(body: () => T): T {
134
- let result: T;
135
- act(() => {
134
+ // A test that acts without having rendered — a timer firing in a hook test
135
+ // — reaches `act` without going through `render`, and `act` still has to
136
+ // know it is being called by a test.
137
+ installActEnvironment();
138
+ let result: mixed;
139
+ const scope: mixed = act(() => {
136
140
  result = body();
141
+ // Handed back so React keeps the scope open until an async body settles.
142
+ // Without this the scope closed on the first tick and every update the
143
+ // body was still waiting for landed outside it, which React reports as
144
+ // "an update was not wrapped in act(...)".
145
+ return result;
137
146
  });
147
+ if (isThenable(result) && isThenable(scope)) {
148
+ // `Promise.resolve`, not `scope.then(…)`: `act` hands back a bare thenable
149
+ // — an object with a `then` and nothing else — whose `then` returns
150
+ // `undefined` rather than a promise. Chaining off it directly produced an
151
+ // `undefined` that `await` resolved immediately, so the caller carried on
152
+ // while the scope was still open: the body's timers had not fired, and
153
+ // every later `act` nested inside the scope that was never closed and
154
+ // flushed nothing. `render` after one of those returned an empty
155
+ // container.
156
+ return Promise.resolve(scope).then(() => result) as any;
157
+ }
138
158
  return result as any;
139
159
  }
140
160
 
161
+ /** Whether `value` is something to await. */
162
+ function isThenable(value: mixed): boolean {
163
+ return value != null && typeof value === "object" && typeof (value as any).then === "function";
164
+ }
165
+
141
166
  /**
142
167
  * Wait until `body` stops throwing, or give up.
143
168
  *
@@ -149,8 +174,44 @@ export async function waitFor<T>(
149
174
  body: () => T | Promise<T>,
150
175
  options?: {| readonly timeout?: number, readonly interval?: number |},
151
176
  ): Promise<T> {
177
+ installActEnvironment();
152
178
  const timeout = options?.timeout ?? 1000;
153
179
  const interval = options?.interval ?? 20;
180
+
181
+ // React is told this is not an act environment for as long as the wait
182
+ // lasts, and told again afterwards.
183
+ //
184
+ // The update a test waits for arrives between two polls, and React reports
185
+ // it as "an update to X inside a test was not wrapped in act(...)" —
186
+ // correctly, since nothing was there to flush it. The fix cannot be to put
187
+ // the polling loop inside an `act` scope: `act` holds updates back until
188
+ // the scope closes, so the loop would poll a tree that cannot change and
189
+ // every `waitFor` would run to its timeout.
190
+ //
191
+ // So the scope is stood down instead. The warning exists to catch an update
192
+ // a test did not know it was causing; a test that wrote `waitFor` knows.
193
+ // Counted rather than saved and restored, because waits nest: every
194
+ // `findBy…` is a `waitFor`, and a test may put one inside another. The
195
+ // outermost wait stands the environment down and the outermost restores it.
196
+ waits += 1;
197
+ if (waits === 1) {
198
+ setActEnvironment(false);
199
+ }
200
+ try {
201
+ return await poll(body, timeout, interval);
202
+ } finally {
203
+ waits -= 1;
204
+ if (waits === 0) {
205
+ setActEnvironment(true);
206
+ }
207
+ }
208
+ }
209
+
210
+ /** How many waits are in progress. */
211
+ let waits = 0;
212
+
213
+ /** Call `body` until it stops throwing, or give up after `timeout`. */
214
+ async function poll<T>(body: () => T | Promise<T>, timeout: number, interval: number): Promise<T> {
154
215
  const deadline = Date.now() + timeout;
155
216
  let lastError: mixed = null;
156
217
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/react-testing",
3
- "version": "0.0.0-alpha.2",
3
+ "version": "0.0.0-alpha.4",
4
4
  "description": "React Testing Library over a real DOM, part of the Unified Toolchain for Flow.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,8 +18,8 @@
18
18
  "internal"
19
19
  ],
20
20
  "dependencies": {
21
- "@uniflowed/core": "0.0.0-alpha.2",
22
- "@uniflowed/react": "0.0.0-alpha.2",
21
+ "@uniflowed/core": "0.0.0-alpha.4",
22
+ "@uniflowed/react": "0.0.0-alpha.4",
23
23
  "happy-dom": "^20.13.2"
24
24
  },
25
25
  "peerDependencies": {