@uniflowed/react-testing 0.0.0-alpha.10
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 +32 -0
- package/internal/dom.js +312 -0
- package/internal/events.js +503 -0
- package/internal/queries.js +635 -0
- package/internal/render.js +298 -0
- package/internal/screen.js +294 -0
- package/package.json +29 -0
package/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/react-testing`: React Testing Library's surface, over a real DOM.
|
|
4
|
+
//
|
|
5
|
+
// The library's premise is that a test should reach for an element the way a
|
|
6
|
+
// person does — by the text on it, by the role it plays, by the label next to
|
|
7
|
+
// it — so a test keeps passing when the markup is refactored and stops passing
|
|
8
|
+
// when the thing a user relies on breaks. That premise is worth keeping, so
|
|
9
|
+
// this is the same shape: `render`, `screen`, `fireEvent`, `userEvent`,
|
|
10
|
+
// `waitFor`.
|
|
11
|
+
//
|
|
12
|
+
// It used to be a declaration whose every function threw. `uf test` runs on
|
|
13
|
+
// Node.js, Bun or Deno and none of them has a DOM, so one is installed on
|
|
14
|
+
// first render — which is why a component test needs nothing configured.
|
|
15
|
+
//
|
|
16
|
+
// # Queries
|
|
17
|
+
//
|
|
18
|
+
// Every query is `getBy`, `queryBy`, `findBy`, `getAllBy`, `queryAllBy` or
|
|
19
|
+
// `findAllBy` over the same matcher, and the choice says what the test means:
|
|
20
|
+
// `getBy` asserts presence now, `queryBy` is for asking about absence, and
|
|
21
|
+
// `findBy` waits. They are on `screen`, which searches the whole document so a
|
|
22
|
+
// portal is found, and on the result of `render`, which searches only what it
|
|
23
|
+
// mounted.
|
|
24
|
+
|
|
25
|
+
export type { Matcher, MatcherOptions } from "./internal/queries.js";
|
|
26
|
+
export type { RenderResult } from "./internal/render.js";
|
|
27
|
+
export type { Queries } from "./internal/screen.js";
|
|
28
|
+
|
|
29
|
+
export { normalize, accessibleName, roleOf } from "./internal/queries.js";
|
|
30
|
+
export { actively as act, cleanup, render, waitFor } from "./internal/render.js";
|
|
31
|
+
export { dispatch, fireEvent, userEvent } from "./internal/events.js";
|
|
32
|
+
export { screen, within } from "./internal/screen.js";
|
package/internal/dom.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A document for a test process that has none.
|
|
4
|
+
//
|
|
5
|
+
// `uf test` runs on Node.js, Bun or Deno, and none of them has a DOM. React
|
|
6
|
+
// needs one before `react-dom` is imported, not after: `react-dom/client`
|
|
7
|
+
// reads `document` while it is being evaluated, so installing the globals has
|
|
8
|
+
// to happen first and exactly once per process.
|
|
9
|
+
//
|
|
10
|
+
// The window is created lazily rather than at import time, because a test file
|
|
11
|
+
// that imports this module and never renders should not pay for a DOM, and
|
|
12
|
+
// because `@uniflowed/lib`'s invariants forbid a package from doing work while
|
|
13
|
+
// it is being imported.
|
|
14
|
+
|
|
15
|
+
import { Window } from "happy-dom";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A function this module found on a window and knows nothing else about.
|
|
19
|
+
*
|
|
20
|
+
* `mixed` in and `mixed` out is the whole contract: these are copied across
|
|
21
|
+
* for React and for components to call, and nothing here ever calls one.
|
|
22
|
+
*/
|
|
23
|
+
type HostFunction = (...args: $ReadOnlyArray<mixed>) => mixed;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Values kept by name, which is all this module knows about a window, the
|
|
27
|
+
* global object, or a Storage.
|
|
28
|
+
*
|
|
29
|
+
* The indexer is the honest shape rather than a placeholder for a type nobody
|
|
30
|
+
* wrote: the work this module does is to read names out of a list and hand the
|
|
31
|
+
* values to `Object.defineProperty`. It does not call them, construct them, or
|
|
32
|
+
* look inside them, so `mixed` is the amount it knows.
|
|
33
|
+
*/
|
|
34
|
+
type Named = { readonly [string]: mixed };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A window, as this module uses one.
|
|
38
|
+
*
|
|
39
|
+
* A table of globals to copy, plus the three functions that are *bound* rather
|
|
40
|
+
* than copied — and those are named because binding is the one thing an
|
|
41
|
+
* indexer cannot describe. `typeof win[name] === "function"` refines a `mixed`
|
|
42
|
+
* to a function whose parameters Flow does not know, and `.bind` is not
|
|
43
|
+
* something that can be done to one of those:
|
|
44
|
+
*
|
|
45
|
+
* error[incompatible-use]: Cannot call `value.bind` because
|
|
46
|
+
* `unknown function` is not a function type.
|
|
47
|
+
*
|
|
48
|
+
* So the list of functions to bind, which used to be a `FUNCTIONS` array
|
|
49
|
+
* beside the other three arrays, is this part of the type instead. One list
|
|
50
|
+
* rather than two, and it is the one the checker reads.
|
|
51
|
+
*
|
|
52
|
+
* `happy-dom` ships TypeScript rather than Flow, so `new Window(…)` is `any`.
|
|
53
|
+
* This annotation is the first statement anywhere about what comes back, not a
|
|
54
|
+
* cast that discards one.
|
|
55
|
+
*/
|
|
56
|
+
type HostWindow = {
|
|
57
|
+
readonly getComputedStyle?: HostFunction,
|
|
58
|
+
readonly requestAnimationFrame?: HostFunction,
|
|
59
|
+
readonly cancelAnimationFrame?: HostFunction,
|
|
60
|
+
readonly [string]: mixed,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The global object, under the one description this module has of it.
|
|
65
|
+
*
|
|
66
|
+
* `globalThis` is a namespace to the checker rather than an object, so
|
|
67
|
+
* `globalThis[name]` is not an expression that can be written —
|
|
68
|
+
* `Cannot access namespace globalThis with computed property using string` —
|
|
69
|
+
* and reading an installed global by a name from a list is what deciding
|
|
70
|
+
* whether to install one requires. Naming the same object as a table says what
|
|
71
|
+
* those reads are: a name in, and no claim at all about what comes out.
|
|
72
|
+
*
|
|
73
|
+
* Writes still go through `define`, because they have to be
|
|
74
|
+
* `Object.defineProperty`; see the reason there.
|
|
75
|
+
*/
|
|
76
|
+
const globals: Named = globalThis;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The document's own classes, which always replace whatever the host had.
|
|
80
|
+
*
|
|
81
|
+
* A document rejects an event built by a different implementation, and Node
|
|
82
|
+
* defines `Event` and `CustomEvent` itself — `dispatchEvent` refused them with
|
|
83
|
+
* "parameter 1 is not of type 'Event'" for every event this module had no more
|
|
84
|
+
* specific constructor for. Whatever the host already had, the document's own
|
|
85
|
+
* classes are the ones that work with the document.
|
|
86
|
+
*
|
|
87
|
+
* The list is also the list of questions the rest of the package can ask.
|
|
88
|
+
* `internal/queries.js` and `internal/events.js` narrow an `Element` with
|
|
89
|
+
* `instanceof HTMLInputElement` rather than reading `.value` off a cast, and
|
|
90
|
+
* an `instanceof` against a name that was never installed is not a `false` —
|
|
91
|
+
* it is `ReferenceError: HTMLFieldSetElement is not defined`, from a line
|
|
92
|
+
* about clicking a tab. So a class this package needs to *recognise* belongs
|
|
93
|
+
* here as much as one the document needs to accept.
|
|
94
|
+
*/
|
|
95
|
+
const CLASSES = [
|
|
96
|
+
"Node",
|
|
97
|
+
"Element",
|
|
98
|
+
"HTMLElement",
|
|
99
|
+
"HTMLInputElement",
|
|
100
|
+
"HTMLTextAreaElement",
|
|
101
|
+
"HTMLSelectElement",
|
|
102
|
+
"HTMLButtonElement",
|
|
103
|
+
"HTMLFieldSetElement",
|
|
104
|
+
"HTMLAnchorElement",
|
|
105
|
+
"SVGElement",
|
|
106
|
+
"Event",
|
|
107
|
+
"CustomEvent",
|
|
108
|
+
"MouseEvent",
|
|
109
|
+
"KeyboardEvent",
|
|
110
|
+
"InputEvent",
|
|
111
|
+
"FocusEvent",
|
|
112
|
+
"PointerEvent",
|
|
113
|
+
"SubmitEvent",
|
|
114
|
+
"DOMParser",
|
|
115
|
+
"MutationObserver",
|
|
116
|
+
"ResizeObserver",
|
|
117
|
+
"IntersectionObserver",
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Objects a page has, installed only where the host has none.
|
|
122
|
+
*
|
|
123
|
+
* `navigator` is the reason for the distinction: on Node it is an accessor
|
|
124
|
+
* with no setter, and assigning to it throws. A test does not need it
|
|
125
|
+
* replaced — it needs it to exist.
|
|
126
|
+
*/
|
|
127
|
+
const OBJECTS = ["location", "history", "navigator"];
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Storage, which is installed where the host has none *or has one that does
|
|
131
|
+
* not work*.
|
|
132
|
+
*
|
|
133
|
+
* Node defines `globalThis.localStorage` and leaves it empty unless the
|
|
134
|
+
* process was started with `--localstorage-file`:
|
|
135
|
+
*
|
|
136
|
+
* ```text
|
|
137
|
+
* typeof globalThis.localStorage // "object"
|
|
138
|
+
* globalThis.localStorage.setItem // undefined
|
|
139
|
+
* ```
|
|
140
|
+
*
|
|
141
|
+
* So "the host already has one" is the wrong question, and asking it left
|
|
142
|
+
* every `useStorage` test writing into an object with no `setItem` —
|
|
143
|
+
* `globalThis.localStorage.setItem is not a function`, from a line that had
|
|
144
|
+
* nothing to do with the hook under test. The question is whether it works.
|
|
145
|
+
*/
|
|
146
|
+
const STORAGE = ["localStorage", "sessionStorage"];
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Whether a value is a Storage a test can actually use.
|
|
150
|
+
*
|
|
151
|
+
* The four methods, not one: a half-implemented shim that has `getItem` and
|
|
152
|
+
* no `removeItem` fails later and further away than one that is absent.
|
|
153
|
+
*/
|
|
154
|
+
function isUsableStorage(value: mixed): boolean {
|
|
155
|
+
if (value == null || typeof value !== "object") {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
const storage: Named = value;
|
|
159
|
+
return (
|
|
160
|
+
typeof storage.getItem === "function" &&
|
|
161
|
+
typeof storage.setItem === "function" &&
|
|
162
|
+
typeof storage.removeItem === "function" &&
|
|
163
|
+
typeof storage.clear === "function"
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let installed: HostWindow | null = null;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Install a DOM on the global object, once.
|
|
171
|
+
*
|
|
172
|
+
* Returns the window, so a caller that wants the document can have it without
|
|
173
|
+
* reaching through `globalThis`. Calling this a second time is free and does
|
|
174
|
+
* not replace the document — replacing it mid-process would strand every React
|
|
175
|
+
* root already mounted in the old one.
|
|
176
|
+
*/
|
|
177
|
+
export function installDom(): HostWindow {
|
|
178
|
+
installActEnvironment();
|
|
179
|
+
if (installed != null) {
|
|
180
|
+
return installed;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A real browser is not required to be absent — a project may already be
|
|
184
|
+
// running these tests in one, and then the page's own DOM is the right one.
|
|
185
|
+
if (typeof globalThis.document !== "undefined") {
|
|
186
|
+
installed = globalThis.window ?? globalThis;
|
|
187
|
+
return installed;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const win: HostWindow = new Window({ url: "http://localhost/" });
|
|
191
|
+
|
|
192
|
+
for (const name of CLASSES) {
|
|
193
|
+
const value = win[name];
|
|
194
|
+
if (value !== undefined) {
|
|
195
|
+
define(name, value);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// The three by name rather than from a list: see `HostWindow`.
|
|
199
|
+
defineBound("getComputedStyle", win.getComputedStyle, win);
|
|
200
|
+
defineBound("requestAnimationFrame", win.requestAnimationFrame, win);
|
|
201
|
+
defineBound("cancelAnimationFrame", win.cancelAnimationFrame, win);
|
|
202
|
+
for (const name of OBJECTS) {
|
|
203
|
+
const value = win[name];
|
|
204
|
+
if (value !== undefined && globals[name] === undefined) {
|
|
205
|
+
define(name, value);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
for (const name of STORAGE) {
|
|
209
|
+
if (isUsableStorage(globals[name])) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const value = win[name];
|
|
213
|
+
if (isUsableStorage(value)) {
|
|
214
|
+
define(name, value);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// React reads these to decide it is in a browser and to pick its event
|
|
219
|
+
// system, and they must be the objects the elements belong to.
|
|
220
|
+
define("window", win);
|
|
221
|
+
define("document", win.document);
|
|
222
|
+
|
|
223
|
+
installed = win;
|
|
224
|
+
return installed;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Tell React that this process is running tests.
|
|
229
|
+
*
|
|
230
|
+
* React cannot tell a test from a production render, so `act` warns "The
|
|
231
|
+
* current testing environment is not configured to support act(...)" unless
|
|
232
|
+
* the harness says so. Every render in this package goes through `act`, so
|
|
233
|
+
* without this every component test printed the warning — 73 times in one
|
|
234
|
+
* file of this repository — and a warning worth reading was lost among them.
|
|
235
|
+
*
|
|
236
|
+
* Separate from the document because the two are independent: a project
|
|
237
|
+
* already running in a browser has a DOM and still has to say it is testing.
|
|
238
|
+
*/
|
|
239
|
+
export function installActEnvironment(): void {
|
|
240
|
+
if (declared) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
declared = true;
|
|
244
|
+
define("IS_REACT_ACT_ENVIRONMENT", true);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Turn the act environment on or off.
|
|
249
|
+
*
|
|
250
|
+
* `waitFor` stands it down for the length of a wait; see the reason there.
|
|
251
|
+
*/
|
|
252
|
+
export function setActEnvironment(active: boolean): void {
|
|
253
|
+
declared = true;
|
|
254
|
+
define("IS_REACT_ACT_ENVIRONMENT", active);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Whether the flag has been installed, tracked separately from its value.
|
|
259
|
+
*
|
|
260
|
+
* Every query calls `installDom`, which installs the act environment, and
|
|
261
|
+
* every query inside a `waitFor` therefore ran while `waitFor` had stood the
|
|
262
|
+
* environment down. Reading the flag to decide whether to set it turned it
|
|
263
|
+
* back on at the first assertion, so only the first poll of a wait was quiet.
|
|
264
|
+
*/
|
|
265
|
+
let declared = false;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Assign a global, even where the host declared it as a getter.
|
|
269
|
+
*
|
|
270
|
+
* `navigator` on Node is an accessor with no setter, so a plain assignment
|
|
271
|
+
* throws; anything installed here has to be defined rather than assigned.
|
|
272
|
+
*/
|
|
273
|
+
function define(name: string, value: mixed): void {
|
|
274
|
+
Object.defineProperty(globalThis, name, {
|
|
275
|
+
value,
|
|
276
|
+
writable: true,
|
|
277
|
+
configurable: true,
|
|
278
|
+
enumerable: true,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Install one of the window's own functions, still reading that window. */
|
|
283
|
+
function defineBound(name: string, fn: HostFunction | void, win: HostWindow): void {
|
|
284
|
+
if (typeof fn === "function") {
|
|
285
|
+
define(name, fn.bind(win));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The document tests query, installing one if the process has none. */
|
|
290
|
+
export function documentOf(): Document {
|
|
291
|
+
installDom();
|
|
292
|
+
return globalThis.document;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The body a test renders into and queries, installing a document first.
|
|
297
|
+
*
|
|
298
|
+
* Separate from `documentOf` because `Document.body` is `HTMLBodyElement |
|
|
299
|
+
* null` — a document with no `<body>` is a document a parser can produce — and
|
|
300
|
+
* both callers want the element rather than the question. A document this
|
|
301
|
+
* module installed has a body, and a document the host already had is a page,
|
|
302
|
+
* which also has one; the throw is for the third case, and it says what is
|
|
303
|
+
* missing rather than leaving `Cannot read properties of null (reading
|
|
304
|
+
* 'appendChild')` to be read at a line about rendering.
|
|
305
|
+
*/
|
|
306
|
+
export function bodyOf(): HTMLElement {
|
|
307
|
+
const body = documentOf().body;
|
|
308
|
+
if (body == null) {
|
|
309
|
+
throw new Error("@uniflowed/react-testing: the document has no <body> to render into");
|
|
310
|
+
}
|
|
311
|
+
return body;
|
|
312
|
+
}
|