@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 +89 -1
- package/internal/render.js +64 -3
- package/package.json +3 -3
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", "
|
|
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
|
*
|
package/internal/render.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
22
|
-
"@uniflowed/react": "0.0.0-alpha.
|
|
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": {
|