@uniflowed/react-testing 0.0.0-alpha.4 → 0.0.0-alpha.6
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 +112 -23
- package/internal/events.js +203 -46
- package/internal/queries.js +86 -27
- package/internal/render.js +78 -13
- package/internal/screen.js +187 -47
- package/package.json +3 -3
package/internal/dom.js
CHANGED
|
@@ -14,6 +14,67 @@
|
|
|
14
14
|
|
|
15
15
|
import { Window } from "happy-dom";
|
|
16
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
|
+
|
|
17
78
|
/**
|
|
18
79
|
* The document's own classes, which always replace whatever the host had.
|
|
19
80
|
*
|
|
@@ -22,6 +83,14 @@ import { Window } from "happy-dom";
|
|
|
22
83
|
* "parameter 1 is not of type 'Event'" for every event this module had no more
|
|
23
84
|
* specific constructor for. Whatever the host already had, the document's own
|
|
24
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.
|
|
25
94
|
*/
|
|
26
95
|
const CLASSES = [
|
|
27
96
|
"Node",
|
|
@@ -31,6 +100,7 @@ const CLASSES = [
|
|
|
31
100
|
"HTMLTextAreaElement",
|
|
32
101
|
"HTMLSelectElement",
|
|
33
102
|
"HTMLButtonElement",
|
|
103
|
+
"HTMLFieldSetElement",
|
|
34
104
|
"HTMLAnchorElement",
|
|
35
105
|
"SVGElement",
|
|
36
106
|
"Event",
|
|
@@ -47,11 +117,6 @@ const CLASSES = [
|
|
|
47
117
|
"IntersectionObserver",
|
|
48
118
|
];
|
|
49
119
|
|
|
50
|
-
/**
|
|
51
|
-
* Functions that read the window they came from, so they are bound to it.
|
|
52
|
-
*/
|
|
53
|
-
const FUNCTIONS = ["getComputedStyle", "requestAnimationFrame", "cancelAnimationFrame"];
|
|
54
|
-
|
|
55
120
|
/**
|
|
56
121
|
* Objects a page has, installed only where the host has none.
|
|
57
122
|
*
|
|
@@ -90,7 +155,7 @@ function isUsableStorage(value: mixed): boolean {
|
|
|
90
155
|
if (value == null || typeof value !== "object") {
|
|
91
156
|
return false;
|
|
92
157
|
}
|
|
93
|
-
const storage:
|
|
158
|
+
const storage: Named = value;
|
|
94
159
|
return (
|
|
95
160
|
typeof storage.getItem === "function" &&
|
|
96
161
|
typeof storage.setItem === "function" &&
|
|
@@ -99,7 +164,7 @@ function isUsableStorage(value: mixed): boolean {
|
|
|
99
164
|
);
|
|
100
165
|
}
|
|
101
166
|
|
|
102
|
-
let installed:
|
|
167
|
+
let installed: HostWindow | null = null;
|
|
103
168
|
|
|
104
169
|
/**
|
|
105
170
|
* Install a DOM on the global object, once.
|
|
@@ -109,7 +174,7 @@ let installed: mixed = null;
|
|
|
109
174
|
* not replace the document — replacing it mid-process would strand every React
|
|
110
175
|
* root already mounted in the old one.
|
|
111
176
|
*/
|
|
112
|
-
export function installDom():
|
|
177
|
+
export function installDom(): HostWindow {
|
|
113
178
|
installActEnvironment();
|
|
114
179
|
if (installed != null) {
|
|
115
180
|
return installed;
|
|
@@ -122,31 +187,29 @@ export function installDom(): mixed {
|
|
|
122
187
|
return installed;
|
|
123
188
|
}
|
|
124
189
|
|
|
125
|
-
const win = new Window({ url: "http://localhost/" });
|
|
190
|
+
const win: HostWindow = new Window({ url: "http://localhost/" });
|
|
126
191
|
|
|
127
192
|
for (const name of CLASSES) {
|
|
128
|
-
const value =
|
|
193
|
+
const value = win[name];
|
|
129
194
|
if (value !== undefined) {
|
|
130
195
|
define(name, value);
|
|
131
196
|
}
|
|
132
197
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
}
|
|
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);
|
|
139
202
|
for (const name of OBJECTS) {
|
|
140
|
-
const value =
|
|
141
|
-
if (value !== undefined &&
|
|
203
|
+
const value = win[name];
|
|
204
|
+
if (value !== undefined && globals[name] === undefined) {
|
|
142
205
|
define(name, value);
|
|
143
206
|
}
|
|
144
207
|
}
|
|
145
208
|
for (const name of STORAGE) {
|
|
146
|
-
if (isUsableStorage(
|
|
209
|
+
if (isUsableStorage(globals[name])) {
|
|
147
210
|
continue;
|
|
148
211
|
}
|
|
149
|
-
const value =
|
|
212
|
+
const value = win[name];
|
|
150
213
|
if (isUsableStorage(value)) {
|
|
151
214
|
define(name, value);
|
|
152
215
|
}
|
|
@@ -154,8 +217,8 @@ export function installDom(): mixed {
|
|
|
154
217
|
|
|
155
218
|
// React reads these to decide it is in a browser and to pick its event
|
|
156
219
|
// system, and they must be the objects the elements belong to.
|
|
157
|
-
define("window", win
|
|
158
|
-
define("document",
|
|
220
|
+
define("window", win);
|
|
221
|
+
define("document", win.document);
|
|
159
222
|
|
|
160
223
|
installed = win;
|
|
161
224
|
return installed;
|
|
@@ -216,8 +279,34 @@ function define(name: string, value: mixed): void {
|
|
|
216
279
|
});
|
|
217
280
|
}
|
|
218
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
|
+
|
|
219
289
|
/** The document tests query, installing one if the process has none. */
|
|
220
290
|
export function documentOf(): Document {
|
|
221
291
|
installDom();
|
|
222
|
-
return globalThis.document
|
|
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;
|
|
223
312
|
}
|
package/internal/events.js
CHANGED
|
@@ -15,8 +15,22 @@
|
|
|
15
15
|
// not at all under a bare `click` event, and a test that only fires `click`
|
|
16
16
|
// would pass while the feature was broken.
|
|
17
17
|
|
|
18
|
+
import { bodyOf, documentOf } from "./dom.js";
|
|
19
|
+
import { displayValue } from "./queries.js";
|
|
18
20
|
import { actively } from "./render.js";
|
|
19
21
|
|
|
22
|
+
/**
|
|
23
|
+
* What a caller wants the event to carry.
|
|
24
|
+
*
|
|
25
|
+
* An indexer, because which properties are meaningful is decided by the event's
|
|
26
|
+
* *interface* and the interface is decided by the name — `{ key: "Escape" }`
|
|
27
|
+
* for a `keydown`, `{ clientX: 40 }` for a `pointermove` — and the name is a
|
|
28
|
+
* string a caller computes. There is no type that says "the initialisers of
|
|
29
|
+
* whichever interface `name` maps to", so this says what is true: names in,
|
|
30
|
+
* and what each one means is the DOM's business.
|
|
31
|
+
*/
|
|
32
|
+
export type EventInit = { readonly [string]: mixed };
|
|
33
|
+
|
|
20
34
|
/** Event constructors by DOM event name, with the right interface for each. */
|
|
21
35
|
const EVENT_TYPES: { readonly [string]: string } = {
|
|
22
36
|
click: "MouseEvent",
|
|
@@ -66,22 +80,71 @@ const ALSO_BUBBLES: { readonly [string]: string } = {
|
|
|
66
80
|
blur: "focusout",
|
|
67
81
|
};
|
|
68
82
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
83
|
+
/**
|
|
84
|
+
* The event a name asks for, built by the document's own class for it.
|
|
85
|
+
*
|
|
86
|
+
* # Why `Constructor` is `any`
|
|
87
|
+
*
|
|
88
|
+
* Neither half of this can be typed.
|
|
89
|
+
*
|
|
90
|
+
* Reading it: `globalThis` is a namespace to the checker rather than an
|
|
91
|
+
* object, and the value under a computed name is `mixed`, which cannot be
|
|
92
|
+
* `new`ed — refining a `mixed` with `typeof x === "function"` gives a function
|
|
93
|
+
* whose signature Flow says it does not know.
|
|
94
|
+
*
|
|
95
|
+
* Calling it: this is the half that is not uf's to fix. Flow's library
|
|
96
|
+
* definitions declare every event initialiser dictionary with *writable*
|
|
97
|
+
* properties — `MouseEvent$MouseEventInit` has `clientX?: number`, not
|
|
98
|
+
* `readonly clientX?: number` — so those properties are invariant, and no
|
|
99
|
+
* value whose type was computed rather than written inline can be passed to
|
|
100
|
+
* one. `{ readonly [string]: mixed }` fails on variance before it fails on
|
|
101
|
+
* anything else:
|
|
102
|
+
*
|
|
103
|
+
* error[incompatible-variance]: property `bubbles` is read-only in
|
|
104
|
+
* `EventInit` but writable in `Event$Init`
|
|
105
|
+
* error[incompatible-type]: in property `clientX`: `unknown` is not
|
|
106
|
+
* exactly the same as `number`
|
|
107
|
+
*
|
|
108
|
+
* — twelve of those for `MouseEvent` alone, and Flow's own advice in the
|
|
109
|
+
* message is to make the library definition's property readonly. So an
|
|
110
|
+
* `EventInit` cannot reach an event constructor under any spelling, and this
|
|
111
|
+
* stays one cast at one line rather than a dozen errors at every call.
|
|
112
|
+
*/
|
|
113
|
+
function construct(name: string, init: EventInit): Event {
|
|
114
|
+
// One cast, covering the lookup and both constructions; see above.
|
|
115
|
+
const classes: any = globalThis;
|
|
116
|
+
const Constructor = classes[EVENT_TYPES[name] ?? "Event"] ?? classes.Event;
|
|
117
|
+
const options = optionsFor(name, init);
|
|
77
118
|
try {
|
|
78
119
|
return new Constructor(name, options);
|
|
79
120
|
} catch {
|
|
80
121
|
// A host whose constructor is stricter than the init we were handed.
|
|
81
|
-
return new
|
|
122
|
+
return new classes.Event(name, options);
|
|
82
123
|
}
|
|
83
124
|
}
|
|
84
125
|
|
|
126
|
+
/**
|
|
127
|
+
* The defaults an event is built with, under whatever the caller asked for.
|
|
128
|
+
*
|
|
129
|
+
* Written as a loop rather than `{ bubbles, cancelable, ...init }` because
|
|
130
|
+
* spreading an indexer is something Flow declines to compute a type for —
|
|
131
|
+
* "the indexer `string` may overwrite properties with explicit keys in a way
|
|
132
|
+
* that Flow cannot track", which is precisely what this is for. Its
|
|
133
|
+
* suggestion, spreading `init` first, would reverse the precedence and stop a
|
|
134
|
+
* caller from passing `bubbles: false`; the loop keeps the caller on top,
|
|
135
|
+
* which is what the spread said.
|
|
136
|
+
*/
|
|
137
|
+
function optionsFor(name: string, init: EventInit): EventInit {
|
|
138
|
+
const options: { [string]: mixed } = {
|
|
139
|
+
bubbles: !NON_BUBBLING.has(name),
|
|
140
|
+
cancelable: true,
|
|
141
|
+
};
|
|
142
|
+
for (const key of Object.keys(init)) {
|
|
143
|
+
options[key] = init[key];
|
|
144
|
+
}
|
|
145
|
+
return options;
|
|
146
|
+
}
|
|
147
|
+
|
|
85
148
|
/**
|
|
86
149
|
* Dispatch one event, inside `act`.
|
|
87
150
|
*
|
|
@@ -89,11 +152,7 @@ function construct(name: string, init: { readonly [string]: mixed }): Event {
|
|
|
89
152
|
* `preventDefault`, which is what `dispatchEvent` reports and what a test
|
|
90
153
|
* asserting "the form did not submit" needs.
|
|
91
154
|
*/
|
|
92
|
-
export function dispatch(
|
|
93
|
-
target: EventTarget,
|
|
94
|
-
name: string,
|
|
95
|
-
init?: { readonly [string]: mixed },
|
|
96
|
-
): boolean {
|
|
155
|
+
export function dispatch(target: EventTarget, name: string, init?: EventInit): boolean {
|
|
97
156
|
const event = construct(name, init ?? {});
|
|
98
157
|
const paired = ALSO_BUBBLES[name];
|
|
99
158
|
let ran = true;
|
|
@@ -114,19 +173,57 @@ export function dispatch(
|
|
|
114
173
|
* A proxy rather than a written-out table: the set of DOM events is long,
|
|
115
174
|
* grows, and every entry would be the same line. `fireEvent(target, name)`
|
|
116
175
|
* also works, for an event whose name is computed.
|
|
176
|
+
*
|
|
177
|
+
* # Why the type is still `any`, and what was tried
|
|
178
|
+
*
|
|
179
|
+
* The type this wants is a function that also answers to every event name.
|
|
180
|
+
* Written with an indexer:
|
|
181
|
+
*
|
|
182
|
+
* type FireEvent = {
|
|
183
|
+
* (target: EventTarget, name: string, init?: EventInit): boolean,
|
|
184
|
+
* readonly [string]: (target: EventTarget, init?: EventInit) => boolean,
|
|
185
|
+
* };
|
|
186
|
+
*
|
|
187
|
+
* Flow declines the indexer, and is right to. As an `interface`, so the
|
|
188
|
+
* assignment gets far enough to say why, it reads "an unknown property that
|
|
189
|
+
* may exist on the inexact function is incompatible with `Firer`" — the value
|
|
190
|
+
* is a function, a function has `name`, `length`, `call`, `apply` and `bind`,
|
|
191
|
+
* and the trap below hands those back as themselves because `property in base`
|
|
192
|
+
* is true for them. None of the five is a DOM event, so the lie is unreachable
|
|
193
|
+
* from any real call, but a type is not something to be right about on
|
|
194
|
+
* average.
|
|
195
|
+
*
|
|
196
|
+
* The written-out table the runtime deliberately is not — a call signature
|
|
197
|
+
* plus a named property per event — fails earlier and for a reason no list of
|
|
198
|
+
* names would fix:
|
|
199
|
+
*
|
|
200
|
+
* error[incompatible-type]: Cannot assign `new Proxy(...)` to `fireEvent`
|
|
201
|
+
* because `(target: EventTarget, name: string, init?: EventInit) =>
|
|
202
|
+
* boolean` is incompatible with `FireEvent`.
|
|
203
|
+
* Functions without statics are not compatible with objects.
|
|
204
|
+
*
|
|
205
|
+
* A `Proxy` over a function *is* a function, and Flow will not treat a
|
|
206
|
+
* function with no statics as an object with properties whatever those
|
|
207
|
+
* properties are. So no type at all can be assigned to this value: typing
|
|
208
|
+
* `fireEvent` means changing what it is — a function carrying real static
|
|
209
|
+
* properties, one per event name, which is a table of a hundred-odd entries
|
|
210
|
+
* that stops answering to the hundred-and-first. That is a design decision
|
|
211
|
+
* about a published API and not a cast to remove in passing.
|
|
212
|
+
*
|
|
213
|
+
* Left as `any` rather than renamed to `$FlowFixMe`, which would move it out
|
|
214
|
+
* of `flow/unclear-type`'s sight without moving it out of the package.
|
|
117
215
|
*/
|
|
118
216
|
export const fireEvent: any = new Proxy(
|
|
119
|
-
(target: EventTarget, name: string, init?:
|
|
120
|
-
dispatch(target, name, init),
|
|
217
|
+
(target: EventTarget, name: string, init?: EventInit) => dispatch(target, name, init),
|
|
121
218
|
{
|
|
122
219
|
get(base, property) {
|
|
123
220
|
if (typeof property !== "string") {
|
|
124
|
-
return (base
|
|
221
|
+
return Reflect.get(base, property);
|
|
125
222
|
}
|
|
126
223
|
if (property in base) {
|
|
127
|
-
return (base
|
|
224
|
+
return Reflect.get(base, property);
|
|
128
225
|
}
|
|
129
|
-
return (target: EventTarget, init?:
|
|
226
|
+
return (target: EventTarget, init?: EventInit) =>
|
|
130
227
|
dispatch(target, property.toLowerCase(), init);
|
|
131
228
|
},
|
|
132
229
|
},
|
|
@@ -134,16 +231,23 @@ export const fireEvent: any = new Proxy(
|
|
|
134
231
|
|
|
135
232
|
/** Set a control's value the way a browser does, so React sees the change. */
|
|
136
233
|
function setValue(element: HTMLElement, value: string): void {
|
|
137
|
-
const target: any = element;
|
|
138
234
|
// React tracks the last value it wrote on the node and skips an `input`
|
|
139
235
|
// event whose value it believes it already knows. Writing through the
|
|
140
236
|
// prototype's setter is what a browser does and what clears that.
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
} else
|
|
146
|
-
|
|
237
|
+
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), "value");
|
|
238
|
+
const set = descriptor?.set;
|
|
239
|
+
if (set != null) {
|
|
240
|
+
set.call(element, value);
|
|
241
|
+
} else if (
|
|
242
|
+
element instanceof HTMLInputElement ||
|
|
243
|
+
element instanceof HTMLTextAreaElement ||
|
|
244
|
+
element instanceof HTMLSelectElement
|
|
245
|
+
) {
|
|
246
|
+
// The fallback, for a host whose control classes keep `value` as an own
|
|
247
|
+
// property rather than an accessor on the prototype. The three classes are
|
|
248
|
+
// the ones `displayValue` reads, so what a test writes is what a
|
|
249
|
+
// `ByDisplayValue` query can find.
|
|
250
|
+
element.value = value;
|
|
147
251
|
}
|
|
148
252
|
}
|
|
149
253
|
|
|
@@ -170,12 +274,46 @@ function describeKey(key: string): {| key: string, code: string, text: string |
|
|
|
170
274
|
return { key, code: `Key${key.toUpperCase()}`, text: key };
|
|
171
275
|
}
|
|
172
276
|
|
|
173
|
-
/**
|
|
277
|
+
/**
|
|
278
|
+
* Elements the tab order includes, in document order.
|
|
279
|
+
*
|
|
280
|
+
* `instanceof HTMLElement` rather than a cast, and it is not only the
|
|
281
|
+
* checker's question: the next thing done to one of these is `focus`, and
|
|
282
|
+
* `focus` is a method of `HTMLElement`. The declared return type has always
|
|
283
|
+
* said `HTMLElement` while the selector could match an `Element` — Flow said
|
|
284
|
+
* so, "in array element: `Element` is incompatible with `HTMLElement`" — and
|
|
285
|
+
* anything that reached here without being one would have been handed to a
|
|
286
|
+
* `(element as any).focus?.()` that silently did nothing, swallowing the Tab.
|
|
287
|
+
*/
|
|
174
288
|
function tabbable(): Array<HTMLElement> {
|
|
175
289
|
const selector =
|
|
176
290
|
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
177
|
-
|
|
178
|
-
|
|
291
|
+
const order = [];
|
|
292
|
+
for (const element of documentOf().querySelectorAll(selector)) {
|
|
293
|
+
if (element instanceof HTMLElement && element.getAttribute("aria-hidden") !== "true") {
|
|
294
|
+
order.push(element);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return order;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Whether a control is disabled, for the elements that can be.
|
|
302
|
+
*
|
|
303
|
+
* The classes rather than `(element as any).disabled === true`, which was the
|
|
304
|
+
* same question asked in a way that answered `any`. `disabled` belongs to the
|
|
305
|
+
* form controls and to `fieldset`; on a `div` it is an attribute React put in
|
|
306
|
+
* the markup and not a property, which is why the old test was `=== true`
|
|
307
|
+
* rather than truthiness, and why the answer for one is still `false`.
|
|
308
|
+
*/
|
|
309
|
+
function isDisabled(element: HTMLElement): boolean {
|
|
310
|
+
return (
|
|
311
|
+
(element instanceof HTMLButtonElement ||
|
|
312
|
+
element instanceof HTMLInputElement ||
|
|
313
|
+
element instanceof HTMLSelectElement ||
|
|
314
|
+
element instanceof HTMLTextAreaElement ||
|
|
315
|
+
element instanceof HTMLFieldSetElement) &&
|
|
316
|
+
element.disabled
|
|
179
317
|
);
|
|
180
318
|
}
|
|
181
319
|
|
|
@@ -190,8 +328,8 @@ function tabbable(): Array<HTMLElement> {
|
|
|
190
328
|
*/
|
|
191
329
|
export const userEvent = {
|
|
192
330
|
/** Press and release, with the events a real click produces, in order. */
|
|
193
|
-
async click(element: HTMLElement, init?:
|
|
194
|
-
if ((element
|
|
331
|
+
async click(element: HTMLElement, init?: EventInit): Promise<void> {
|
|
332
|
+
if (isDisabled(element)) {
|
|
195
333
|
return;
|
|
196
334
|
}
|
|
197
335
|
dispatch(element, "pointerdown", init);
|
|
@@ -224,8 +362,11 @@ export const userEvent = {
|
|
|
224
362
|
for (const character of text) {
|
|
225
363
|
const { key, code, text: printable } = describeKey(character);
|
|
226
364
|
dispatch(element, "keydown", { key, code });
|
|
227
|
-
|
|
228
|
-
|
|
365
|
+
// Nothing is typed into an element that shows no value; see `keyboard`
|
|
366
|
+
// for what that used to do instead.
|
|
367
|
+
const current = displayValue(element);
|
|
368
|
+
if (printable != null && printable !== "\n" && current != null) {
|
|
369
|
+
setValue(element, `${current}${printable}`);
|
|
229
370
|
dispatch(element, "input", { data: printable });
|
|
230
371
|
}
|
|
231
372
|
dispatch(element, "keyup", { key, code });
|
|
@@ -243,12 +384,22 @@ export const userEvent = {
|
|
|
243
384
|
|
|
244
385
|
/** Press keys at whatever has focus. Named keys go in braces: `{Enter}`. */
|
|
245
386
|
async keyboard(sequence: string): Promise<void> {
|
|
246
|
-
const target
|
|
387
|
+
const target = documentOf().activeElement ?? bodyOf();
|
|
247
388
|
for (const token of parseKeys(sequence)) {
|
|
248
389
|
const { key, code, text } = describeKey(token);
|
|
249
390
|
dispatch(target, "keydown", { key, code });
|
|
250
|
-
|
|
251
|
-
|
|
391
|
+
// `displayValue` rather than `target.value !== undefined`.
|
|
392
|
+
//
|
|
393
|
+
// The two agree about every control a person can type into, and differ
|
|
394
|
+
// about `<button>`, `<option>`, `<progress>` and the rest of the
|
|
395
|
+
// elements that have a `value` property without showing one: pressing
|
|
396
|
+
// Space at a focused button used to write `button.value = " "` and
|
|
397
|
+
// dispatch an `input` event at it, which no browser does — Space on a
|
|
398
|
+
// button is a click. Nothing in this repository's suite depended on it,
|
|
399
|
+
// and `ui.test.js` presses Space at a switch on the way past.
|
|
400
|
+
const current = displayValue(target);
|
|
401
|
+
if (text != null && text !== "\n" && current != null) {
|
|
402
|
+
setValue(target, `${current}${text}`);
|
|
252
403
|
dispatch(target, "input", { data: text });
|
|
253
404
|
}
|
|
254
405
|
dispatch(target, "keyup", { key, code });
|
|
@@ -262,8 +413,10 @@ export const userEvent = {
|
|
|
262
413
|
if (order.length === 0) {
|
|
263
414
|
return;
|
|
264
415
|
}
|
|
265
|
-
const active
|
|
266
|
-
|
|
416
|
+
const active = documentOf().activeElement;
|
|
417
|
+
// `indexOf` needs an element; a document with nothing focused is the same
|
|
418
|
+
// "not in the order" that `indexOf` answers `-1` to, said in front.
|
|
419
|
+
const at = active == null ? -1 : order.indexOf(active);
|
|
267
420
|
const shift = options?.shift ?? false;
|
|
268
421
|
const next =
|
|
269
422
|
at < 0
|
|
@@ -281,9 +434,14 @@ export const userEvent = {
|
|
|
281
434
|
values: string | $ReadOnlyArray<string>,
|
|
282
435
|
): Promise<void> {
|
|
283
436
|
const wanted = typeof values === "string" ? [values] : values;
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
437
|
+
// Only a `select` has options; anything else has none, which is what
|
|
438
|
+
// `select.options ?? []` used to say. The events are dispatched either
|
|
439
|
+
// way, because a component listening for `change` on something that is not
|
|
440
|
+
// a select is a component under test and not this function's business.
|
|
441
|
+
if (element instanceof HTMLSelectElement) {
|
|
442
|
+
for (const option of Array.from(element.options)) {
|
|
443
|
+
option.selected = wanted.includes(option.value);
|
|
444
|
+
}
|
|
287
445
|
}
|
|
288
446
|
dispatch(element, "input");
|
|
289
447
|
dispatch(element, "change");
|
|
@@ -293,20 +451,19 @@ export const userEvent = {
|
|
|
293
451
|
/** Move focus away, which is what makes a blur-validated field validate. */
|
|
294
452
|
async tabAway(element: HTMLElement): Promise<void> {
|
|
295
453
|
dispatch(element, "blur");
|
|
296
|
-
|
|
454
|
+
element.blur();
|
|
297
455
|
await settle();
|
|
298
456
|
},
|
|
299
457
|
};
|
|
300
458
|
|
|
301
459
|
function focus(element: HTMLElement): void {
|
|
302
|
-
|
|
303
|
-
if (previous === element) {
|
|
460
|
+
if (documentOf().activeElement === element) {
|
|
304
461
|
return;
|
|
305
462
|
}
|
|
306
463
|
actively(() => {
|
|
307
|
-
|
|
464
|
+
element.focus();
|
|
308
465
|
});
|
|
309
|
-
if (
|
|
466
|
+
if (documentOf().activeElement !== element) {
|
|
310
467
|
// A host whose `focus` does not move `activeElement`; the events are what
|
|
311
468
|
// components listen for, so dispatch them regardless.
|
|
312
469
|
dispatch(element, "focus");
|
package/internal/queries.js
CHANGED
|
@@ -14,6 +14,22 @@
|
|
|
14
14
|
// The distinction matters because `getBy` failing with "found none" is a much
|
|
15
15
|
// better test failure than `queryBy` returning null and the assertion failing
|
|
16
16
|
// three lines later on `null.textContent`.
|
|
17
|
+
//
|
|
18
|
+
// # Where a query looks
|
|
19
|
+
//
|
|
20
|
+
// `Element`. Every function here used to say `ParentNode`, which reads as the
|
|
21
|
+
// right name — "something with children to search" is exactly what a root is —
|
|
22
|
+
// and is not a type: the DOM specification has a `ParentNode` mixin, and Flow
|
|
23
|
+
// folds it into `Document`, `DocumentFragment` and `Element` as comments
|
|
24
|
+
// rather than declaring anything by that name. So it was twelve
|
|
25
|
+
// `cannot-resolve-name` errors between here and `internal/screen.js`, and an
|
|
26
|
+
// unresolvable name is `any`: `root` answered every question, which is why the
|
|
27
|
+
// casts below it existed at all.
|
|
28
|
+
//
|
|
29
|
+
// `Element` is what the two callers actually pass — the document's body, and
|
|
30
|
+
// an element a test already found — and it carries `querySelector`,
|
|
31
|
+
// `querySelectorAll` and `innerHTML`, which is everything this module asks of
|
|
32
|
+
// a root.
|
|
17
33
|
|
|
18
34
|
/** What a query will accept as a description of the thing to find. */
|
|
19
35
|
export type Matcher = string | RegExp | ((content: string, element: Element) => boolean);
|
|
@@ -24,6 +40,20 @@ export type MatcherOptions = {|
|
|
|
24
40
|
readonly exact?: boolean,
|
|
25
41
|
|};
|
|
26
42
|
|
|
43
|
+
/**
|
|
44
|
+
* How to narrow a role query.
|
|
45
|
+
*
|
|
46
|
+
* A role is shared by every button on the page, so `name` is the option that
|
|
47
|
+
* makes the query mean something: "the button called Save", which is how a
|
|
48
|
+
* person would say it and what a screen reader announces.
|
|
49
|
+
*/
|
|
50
|
+
export type RoleOptions = {|
|
|
51
|
+
/** The accessible name the element must have. */
|
|
52
|
+
readonly name?: Matcher,
|
|
53
|
+
/** `false` matches a substring of the name. Defaults to `true`. */
|
|
54
|
+
readonly exact?: boolean,
|
|
55
|
+
|};
|
|
56
|
+
|
|
27
57
|
/**
|
|
28
58
|
* Collapse whitespace the way a browser does when it lays text out.
|
|
29
59
|
*
|
|
@@ -58,13 +88,13 @@ export function textOf(element: Element): string {
|
|
|
58
88
|
return normalize(element.textContent ?? "");
|
|
59
89
|
}
|
|
60
90
|
|
|
61
|
-
function candidates(root:
|
|
91
|
+
function candidates(root: Element, selector: string): Array<Element> {
|
|
62
92
|
return Array.from(root.querySelectorAll(selector));
|
|
63
93
|
}
|
|
64
94
|
|
|
65
95
|
/** Elements whose own visible text matches. */
|
|
66
96
|
export function allByText(
|
|
67
|
-
root:
|
|
97
|
+
root: Element,
|
|
68
98
|
matcher: Matcher,
|
|
69
99
|
options?: MatcherOptions,
|
|
70
100
|
): Array<Element> {
|
|
@@ -82,11 +112,7 @@ export function allByText(
|
|
|
82
112
|
}
|
|
83
113
|
|
|
84
114
|
/** Elements with this ARIA role, whether written down or implied by the tag. */
|
|
85
|
-
export function allByRole(
|
|
86
|
-
root: ParentNode,
|
|
87
|
-
role: string,
|
|
88
|
-
options?: {| readonly name?: Matcher, readonly exact?: boolean |},
|
|
89
|
-
): Array<Element> {
|
|
115
|
+
export function allByRole(root: Element, role: string, options?: RoleOptions): Array<Element> {
|
|
90
116
|
const found = candidates(root, "*").filter((element) => roleOf(element) === role);
|
|
91
117
|
const name = options?.name;
|
|
92
118
|
if (name == null) {
|
|
@@ -99,7 +125,7 @@ export function allByRole(
|
|
|
99
125
|
|
|
100
126
|
/** Form controls labelled by this text. */
|
|
101
127
|
export function allByLabelText(
|
|
102
|
-
root:
|
|
128
|
+
root: Element,
|
|
103
129
|
matcher: Matcher,
|
|
104
130
|
options?: MatcherOptions,
|
|
105
131
|
): Array<Element> {
|
|
@@ -125,7 +151,7 @@ export function allByLabelText(
|
|
|
125
151
|
|
|
126
152
|
/** Elements with this placeholder. */
|
|
127
153
|
export function allByPlaceholderText(
|
|
128
|
-
root:
|
|
154
|
+
root: Element,
|
|
129
155
|
matcher: Matcher,
|
|
130
156
|
options?: MatcherOptions,
|
|
131
157
|
): Array<Element> {
|
|
@@ -136,7 +162,7 @@ export function allByPlaceholderText(
|
|
|
136
162
|
|
|
137
163
|
/** Elements marked for tests, which is the query of last resort. */
|
|
138
164
|
export function allByTestId(
|
|
139
|
-
root:
|
|
165
|
+
root: Element,
|
|
140
166
|
matcher: Matcher,
|
|
141
167
|
options?: MatcherOptions,
|
|
142
168
|
): Array<Element> {
|
|
@@ -147,25 +173,57 @@ export function allByTestId(
|
|
|
147
173
|
|
|
148
174
|
/** Elements whose value matches, for inputs and selects. */
|
|
149
175
|
export function allByDisplayValue(
|
|
150
|
-
root:
|
|
176
|
+
root: Element,
|
|
151
177
|
matcher: Matcher,
|
|
152
178
|
options?: MatcherOptions,
|
|
153
179
|
): Array<Element> {
|
|
154
180
|
return candidates(root, "input, textarea, select").filter((element) =>
|
|
155
|
-
matches(normalize((element
|
|
181
|
+
matches(normalize(displayValue(element) ?? ""), element, matcher, options),
|
|
156
182
|
);
|
|
157
183
|
}
|
|
158
184
|
|
|
185
|
+
/**
|
|
186
|
+
* The value a control is showing, or `null` for an element that has none.
|
|
187
|
+
*
|
|
188
|
+
* The three classes rather than `element.value`, because `value` is not a
|
|
189
|
+
* property of `Element` — it belongs to each control class — and the selector
|
|
190
|
+
* that produced this element is a string the checker cannot read. An
|
|
191
|
+
* `instanceof` is the same fact stated where the checker can see it, and it is
|
|
192
|
+
* true of the elements this is called with for the reason `internal/dom.js`
|
|
193
|
+
* installs the document's own classes as the global ones: every element in the
|
|
194
|
+
* document under test is an instance of them.
|
|
195
|
+
*
|
|
196
|
+
* A cast was the other answer, and it is what was here. `(element as any).value`
|
|
197
|
+
* types this function's whole result as `any`, which then flows into
|
|
198
|
+
* `normalize` and out through `accessibleName` — a published function whose
|
|
199
|
+
* return type stopped being checked because of an expression three calls away.
|
|
200
|
+
*
|
|
201
|
+
* Exported so that `internal/events.js` asks the same question the same way:
|
|
202
|
+
* typing into a control and finding a control by its value have to agree about
|
|
203
|
+
* which elements have one, or `userEvent.type` would write a value that
|
|
204
|
+
* `getByDisplayValue` could not then find.
|
|
205
|
+
*/
|
|
206
|
+
export function displayValue(element: Element): string | null {
|
|
207
|
+
if (
|
|
208
|
+
element instanceof HTMLInputElement ||
|
|
209
|
+
element instanceof HTMLTextAreaElement ||
|
|
210
|
+
element instanceof HTMLSelectElement
|
|
211
|
+
) {
|
|
212
|
+
return element.value;
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
159
217
|
/**
|
|
160
218
|
* The control a label labels.
|
|
161
219
|
*
|
|
162
220
|
* `for` first, because it is explicit; then a control nested inside the label,
|
|
163
221
|
* which is the other way HTML allows it.
|
|
164
222
|
*/
|
|
165
|
-
function controlFor(root:
|
|
223
|
+
function controlFor(root: Element, label: Element): Element | null {
|
|
166
224
|
const id = label.getAttribute("for");
|
|
167
225
|
if (id != null && id !== "") {
|
|
168
|
-
const byId =
|
|
226
|
+
const byId = root.querySelector(`#${cssEscape(id)}`);
|
|
169
227
|
if (byId != null) {
|
|
170
228
|
return byId;
|
|
171
229
|
}
|
|
@@ -263,11 +321,17 @@ export function accessibleName(element: Element): string {
|
|
|
263
321
|
|
|
264
322
|
const labelledBy = element.getAttribute("aria-labelledby");
|
|
265
323
|
if (labelledBy != null && labelledBy !== "") {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
324
|
+
// A loop rather than `.map().filter(Boolean).map()`: `filter(Boolean)`
|
|
325
|
+
// removes the nulls at runtime and not from the type, so the second `map`
|
|
326
|
+
// saw `HTMLElement | null` and the cast that hid it also hid whether
|
|
327
|
+
// `textOf` was being handed an element at all.
|
|
328
|
+
const parts = [];
|
|
329
|
+
for (const id of labelledBy.split(/\s+/)) {
|
|
330
|
+
const target = element.ownerDocument.getElementById(id);
|
|
331
|
+
if (target != null) {
|
|
332
|
+
parts.push(textOf(target));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
271
335
|
if (parts.length > 0) {
|
|
272
336
|
return normalize(parts.join(" "));
|
|
273
337
|
}
|
|
@@ -284,7 +348,7 @@ export function accessibleName(element: Element): string {
|
|
|
284
348
|
if (element.tagName.toLowerCase() === "input") {
|
|
285
349
|
const type = (element.getAttribute("type") ?? "").toLowerCase();
|
|
286
350
|
if (type === "submit" || type === "button" || type === "reset") {
|
|
287
|
-
return normalize((element
|
|
351
|
+
return normalize(displayValue(element) ?? "");
|
|
288
352
|
}
|
|
289
353
|
}
|
|
290
354
|
|
|
@@ -292,19 +356,14 @@ export function accessibleName(element: Element): string {
|
|
|
292
356
|
}
|
|
293
357
|
|
|
294
358
|
/** Why a query failed, with enough of the DOM to see why. */
|
|
295
|
-
export function queryFailure(
|
|
296
|
-
kind: string,
|
|
297
|
-
matcher: Matcher,
|
|
298
|
-
root: ParentNode,
|
|
299
|
-
found: number,
|
|
300
|
-
): Error {
|
|
359
|
+
export function queryFailure(kind: string, matcher: Matcher, root: Element, found: number): Error {
|
|
301
360
|
const description =
|
|
302
361
|
typeof matcher === "function"
|
|
303
362
|
? "the given predicate"
|
|
304
363
|
: matcher instanceof RegExp
|
|
305
364
|
? String(matcher)
|
|
306
365
|
: JSON.stringify(matcher);
|
|
307
|
-
const html =
|
|
366
|
+
const html = root.innerHTML;
|
|
308
367
|
const shown = html.length > 2000 ? `${html.slice(0, 2000)}\n…` : html;
|
|
309
368
|
const count = found === 0 ? "found nothing" : `found ${found} elements and needed exactly one`;
|
|
310
369
|
return new Error(`${kind} ${description}: ${count}\n\n${shown}`);
|
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 { installActEnvironment, installDom, setActEnvironment } from "./dom.js";
|
|
19
|
+
import { bodyOf, installActEnvironment, installDom, setActEnvironment } from "./dom.js";
|
|
20
20
|
|
|
21
21
|
/** What `render` hands back. */
|
|
22
22
|
export type RenderResult = {|
|
|
@@ -34,7 +34,7 @@ export type RenderResult = {|
|
|
|
34
34
|
|
|
35
35
|
type Mounted = {|
|
|
36
36
|
container: Element,
|
|
37
|
-
root:
|
|
37
|
+
root: ReactRoot,
|
|
38
38
|
|};
|
|
39
39
|
|
|
40
40
|
const mounted: Array<Mounted> = [];
|
|
@@ -62,7 +62,7 @@ export function render(ui: React.Node, options?: {| readonly container?: Element
|
|
|
62
62
|
|
|
63
63
|
return {
|
|
64
64
|
container,
|
|
65
|
-
baseElement:
|
|
65
|
+
baseElement: bodyOf(),
|
|
66
66
|
rerender: (next: React.Node) => {
|
|
67
67
|
act(() => {
|
|
68
68
|
root.render(next);
|
|
@@ -98,7 +98,7 @@ export function cleanup(): void {
|
|
|
98
98
|
|
|
99
99
|
function createContainer(): Element {
|
|
100
100
|
const container = globalThis.document.createElement("div");
|
|
101
|
-
|
|
101
|
+
bodyOf().appendChild(container);
|
|
102
102
|
return container;
|
|
103
103
|
}
|
|
104
104
|
|
|
@@ -114,15 +114,33 @@ function createContainer(): Element {
|
|
|
114
114
|
* uf supports provide it, and React ships a CommonJS build for exactly this
|
|
115
115
|
* kind of caller.
|
|
116
116
|
*/
|
|
117
|
-
let client:
|
|
118
|
-
function requireClient():
|
|
117
|
+
let client: ReactDomClient | null = null;
|
|
118
|
+
function requireClient(): ReactDomClient {
|
|
119
119
|
if (client == null) {
|
|
120
120
|
const load = createRequire(import.meta.url);
|
|
121
|
-
client = load("react-dom/client")
|
|
121
|
+
client = load("react-dom/client");
|
|
122
122
|
}
|
|
123
|
-
return client
|
|
123
|
+
return client;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* As much of `react-dom/client` as this module uses.
|
|
128
|
+
*
|
|
129
|
+
* The annotation is the trust boundary and it is deliberately one line wide: a
|
|
130
|
+
* synchronous `require` of a CommonJS build returns `any` whatever anyone
|
|
131
|
+
* writes, so the choice is not between `any` and certainty, it is between
|
|
132
|
+
* saying what is expected of the module and saying nothing. `createRoot` and
|
|
133
|
+
* the two methods below are the whole of what is expected, and a React that
|
|
134
|
+
* stopped providing them would fail here rather than at
|
|
135
|
+
* `root.render is not a function` inside an unrelated test.
|
|
136
|
+
*/
|
|
137
|
+
type ReactDomClient = {|
|
|
138
|
+
readonly createRoot: (container: Element) => ReactRoot,
|
|
139
|
+
|};
|
|
140
|
+
|
|
141
|
+
/** A React root, as much of one as this module touches. */
|
|
142
|
+
type ReactRoot = {| render(node: React.Node): void, unmount(): void |};
|
|
143
|
+
|
|
126
144
|
/**
|
|
127
145
|
* Run `body`, letting React flush everything it queues.
|
|
128
146
|
*
|
|
@@ -135,15 +153,32 @@ export function actively<T>(body: () => T): T {
|
|
|
135
153
|
// — reaches `act` without going through `render`, and `act` still has to
|
|
136
154
|
// know it is being called by a test.
|
|
137
155
|
installActEnvironment();
|
|
138
|
-
|
|
156
|
+
|
|
157
|
+
// A box holding the body's result, rather than a `let result: T`.
|
|
158
|
+
//
|
|
159
|
+
// The result is produced inside a callback, and Flow cannot see that `act`
|
|
160
|
+
// called it: an annotated `let` written only there is
|
|
161
|
+
// `possibly uninitialized variable` on the way out, and a `T | void` would
|
|
162
|
+
// be wrong for the caller who wrote `act(() => {})` and whose `T` *is*
|
|
163
|
+
// `void`. A box distinguishes "not produced" from "produced `undefined`",
|
|
164
|
+
// and the check below states, at runtime, the invariant the checker cannot
|
|
165
|
+
// prove: `act` calls its scope, synchronously, always.
|
|
166
|
+
const produced: { current: {| value: T |} | null } = { current: null };
|
|
139
167
|
const scope: mixed = act(() => {
|
|
140
|
-
|
|
168
|
+
produced.current = { value: body() };
|
|
141
169
|
// Handed back so React keeps the scope open until an async body settles.
|
|
142
170
|
// Without this the scope closed on the first tick and every update the
|
|
143
171
|
// body was still waiting for landed outside it, which React reports as
|
|
144
172
|
// "an update was not wrapped in act(...)".
|
|
145
|
-
return
|
|
173
|
+
return produced.current.value;
|
|
146
174
|
});
|
|
175
|
+
|
|
176
|
+
const held = produced.current;
|
|
177
|
+
if (held == null) {
|
|
178
|
+
throw new Error("act(...) did not run its scope, so there is no result to return");
|
|
179
|
+
}
|
|
180
|
+
const result = held.value;
|
|
181
|
+
|
|
147
182
|
if (isThenable(result) && isThenable(scope)) {
|
|
148
183
|
// `Promise.resolve`, not `scope.then(…)`: `act` hands back a bare thenable
|
|
149
184
|
// — an object with a `then` and nothing else — whose `then` returns
|
|
@@ -153,14 +188,44 @@ export function actively<T>(body: () => T): T {
|
|
|
153
188
|
// every later `act` nested inside the scope that was never closed and
|
|
154
189
|
// flushed nothing. `render` after one of those returned an empty
|
|
155
190
|
// container.
|
|
191
|
+
//
|
|
192
|
+
// # The one cast in this file, and why it is still here
|
|
193
|
+
//
|
|
194
|
+
// On this branch `T` *is* a promise — `isThenable(result)` is the runtime
|
|
195
|
+
// proof — so a promise that resolves to what `result` resolves to, once
|
|
196
|
+
// the scope has closed, is a `T`, and the signature above is true. Flow
|
|
197
|
+
// cannot follow the last step. Refining `result` says something about the
|
|
198
|
+
// value; the return type is about `T`, and there is no way to write "T is
|
|
199
|
+
// a promise here" in Flow:
|
|
200
|
+
//
|
|
201
|
+
// * a type guard (`value is Promise<mixed>`) refines the value and
|
|
202
|
+
// leaves `T` alone, so the helper it enables returns `Promise<mixed>`
|
|
203
|
+
// and `Promise<unknown> is incompatible with T` in its place;
|
|
204
|
+
// * a conditional return type (`T extends Promise<infer U> ? Promise<U>
|
|
205
|
+
// : T`) — which this checker does support — is unevaluated while `T`
|
|
206
|
+
// is generic, so the body cannot be checked against it either;
|
|
207
|
+
// * overloading, which is how Flow's own `react` library definition
|
|
208
|
+
// describes `act`, is available to a library definition and not to an
|
|
209
|
+
// implementation.
|
|
210
|
+
//
|
|
211
|
+
// The remaining honest answers all change behaviour: returning `Promise<T>`
|
|
212
|
+
// for every call would hand `act(() => {})` a floating promise, and
|
|
213
|
+
// returning the scope itself would depend on React's thenable passing the
|
|
214
|
+
// callback's value through, which is the assumption the comment above
|
|
215
|
+
// records going wrong. So the cast stays, visible to `flow/unclear-type`
|
|
216
|
+
// rather than renamed to `$FlowFixMe` to quiet it.
|
|
156
217
|
return Promise.resolve(scope).then(() => result) as any;
|
|
157
218
|
}
|
|
158
|
-
return result
|
|
219
|
+
return result;
|
|
159
220
|
}
|
|
160
221
|
|
|
161
222
|
/** Whether `value` is something to await. */
|
|
162
223
|
function isThenable(value: mixed): boolean {
|
|
163
|
-
|
|
224
|
+
if (value == null || typeof value !== "object") {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
const object: { readonly [string]: mixed } = value;
|
|
228
|
+
return typeof object.then === "function";
|
|
164
229
|
}
|
|
165
230
|
|
|
166
231
|
/**
|
package/internal/screen.js
CHANGED
|
@@ -3,11 +3,29 @@
|
|
|
3
3
|
// The six forms of every query, generated once.
|
|
4
4
|
//
|
|
5
5
|
// Writing `getByText`, `queryByText`, `findByText`, `getAllByText`,
|
|
6
|
-
// `queryAllByText` and `findAllByText` by hand, for
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
6
|
+
// `queryAllByText` and `findAllByText` by hand, for six queries, is thirty-six
|
|
7
|
+
// functions that differ in two decisions: whether finding nothing is an error,
|
|
8
|
+
// and whether to wait. So the two decisions are written once and the thirty-six
|
|
9
|
+
// are derived — which also means a new query is one entry rather than six
|
|
10
|
+
// functions.
|
|
11
|
+
//
|
|
12
|
+
// # The names are written down, and the behaviour is not
|
|
13
|
+
//
|
|
14
|
+
// `Queries` used to be `{ readonly [string]: (matcher: mixed, options?: mixed)
|
|
15
|
+
// => any }`, which is a way of writing "this object has whatever you ask it
|
|
16
|
+
// for, and it is whatever you like". That is a hole in the published type of a
|
|
17
|
+
// package whose entire purpose is testing *typed* components:
|
|
18
|
+
// `screen.getByRole("button").valeu` was not a mistake anybody's checker would
|
|
19
|
+
// find, `screen.getByTest("save")` was not a misspelling, and
|
|
20
|
+
// `await screen.getByText("Save")` — the missing `find`, which is the single
|
|
21
|
+
// most common mistake this library invites — was fine.
|
|
22
|
+
//
|
|
23
|
+
// So the thirty-six names are written out below. Flow has no template literal
|
|
24
|
+
// types, so `getBy${Name}` is not something a type can compute; the names have
|
|
25
|
+
// to be listed for the type to exist at all. What is *not* repeated is any
|
|
26
|
+
// behaviour: `forms` is still the one place the six decisions are made, and the
|
|
27
|
+
// listing below is a naming, six lines per query, which is the part a reader
|
|
28
|
+
// wants to be able to check against the runtime by eye.
|
|
11
29
|
|
|
12
30
|
import {
|
|
13
31
|
allByDisplayValue,
|
|
@@ -18,24 +36,92 @@ import {
|
|
|
18
36
|
allByText,
|
|
19
37
|
queryFailure,
|
|
20
38
|
} from "./queries.js";
|
|
21
|
-
import type { Matcher, MatcherOptions } from "./queries.js";
|
|
22
|
-
import {
|
|
39
|
+
import type { Matcher, MatcherOptions, RoleOptions } from "./queries.js";
|
|
40
|
+
import { bodyOf } from "./dom.js";
|
|
23
41
|
import { waitFor } from "./render.js";
|
|
24
42
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
/**
|
|
44
|
+
* One query's six forms, over whatever that query matches on.
|
|
45
|
+
*
|
|
46
|
+
* Generic in the target because `ByRole` does not take a `Matcher` — it takes
|
|
47
|
+
* a role, which is a string and only a string, and a regular expression over
|
|
48
|
+
* role names is a query that would silently match nothing. Generic in the
|
|
49
|
+
* options because `ByRole` is also the only query with more than `exact` to
|
|
50
|
+
* say.
|
|
51
|
+
*/
|
|
52
|
+
type Forms<TTarget, TOptions> = {|
|
|
53
|
+
readonly get: (target: TTarget, options?: TOptions) => Element,
|
|
54
|
+
readonly getAll: (target: TTarget, options?: TOptions) => Array<Element>,
|
|
55
|
+
readonly query: (target: TTarget, options?: TOptions) => Element | null,
|
|
56
|
+
readonly queryAll: (target: TTarget, options?: TOptions) => Array<Element>,
|
|
57
|
+
readonly find: (target: TTarget, options?: TOptions) => Promise<Element>,
|
|
58
|
+
readonly findAll: (target: TTarget, options?: TOptions) => Promise<Array<Element>>,
|
|
59
|
+
|};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The queries available on `screen` and on `within(element)`.
|
|
63
|
+
*
|
|
64
|
+
* Read down one column and the four questions of the module comment are the
|
|
65
|
+
* four return types: `getBy…` is an `Element` because it throws rather than
|
|
66
|
+
* hand back nothing, `queryBy…` is `Element | null` because its whole purpose
|
|
67
|
+
* is asking about absence, and the `findBy…` pair are promises because they
|
|
68
|
+
* wait.
|
|
69
|
+
*/
|
|
70
|
+
export type Queries = {|
|
|
71
|
+
readonly getByText: (matcher: Matcher, options?: MatcherOptions) => Element,
|
|
72
|
+
readonly getAllByText: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
|
|
73
|
+
readonly queryByText: (matcher: Matcher, options?: MatcherOptions) => Element | null,
|
|
74
|
+
readonly queryAllByText: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
|
|
75
|
+
readonly findByText: (matcher: Matcher, options?: MatcherOptions) => Promise<Element>,
|
|
76
|
+
readonly findAllByText: (matcher: Matcher, options?: MatcherOptions) => Promise<Array<Element>>,
|
|
77
|
+
|
|
78
|
+
readonly getByRole: (role: string, options?: RoleOptions) => Element,
|
|
79
|
+
readonly getAllByRole: (role: string, options?: RoleOptions) => Array<Element>,
|
|
80
|
+
readonly queryByRole: (role: string, options?: RoleOptions) => Element | null,
|
|
81
|
+
readonly queryAllByRole: (role: string, options?: RoleOptions) => Array<Element>,
|
|
82
|
+
readonly findByRole: (role: string, options?: RoleOptions) => Promise<Element>,
|
|
83
|
+
readonly findAllByRole: (role: string, options?: RoleOptions) => Promise<Array<Element>>,
|
|
84
|
+
|
|
85
|
+
readonly getByLabelText: (matcher: Matcher, options?: MatcherOptions) => Element,
|
|
86
|
+
readonly getAllByLabelText: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
|
|
87
|
+
readonly queryByLabelText: (matcher: Matcher, options?: MatcherOptions) => Element | null,
|
|
88
|
+
readonly queryAllByLabelText: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
|
|
89
|
+
readonly findByLabelText: (matcher: Matcher, options?: MatcherOptions) => Promise<Element>,
|
|
90
|
+
readonly findAllByLabelText: (
|
|
91
|
+
matcher: Matcher,
|
|
92
|
+
options?: MatcherOptions,
|
|
93
|
+
) => Promise<Array<Element>>,
|
|
94
|
+
|
|
95
|
+
readonly getByPlaceholderText: (matcher: Matcher, options?: MatcherOptions) => Element,
|
|
96
|
+
readonly getAllByPlaceholderText: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
|
|
97
|
+
readonly queryByPlaceholderText: (matcher: Matcher, options?: MatcherOptions) => Element | null,
|
|
98
|
+
readonly queryAllByPlaceholderText: (
|
|
99
|
+
matcher: Matcher,
|
|
100
|
+
options?: MatcherOptions,
|
|
101
|
+
) => Array<Element>,
|
|
102
|
+
readonly findByPlaceholderText: (matcher: Matcher, options?: MatcherOptions) => Promise<Element>,
|
|
103
|
+
readonly findAllByPlaceholderText: (
|
|
104
|
+
matcher: Matcher,
|
|
105
|
+
options?: MatcherOptions,
|
|
106
|
+
) => Promise<Array<Element>>,
|
|
107
|
+
|
|
108
|
+
readonly getByTestId: (matcher: Matcher, options?: MatcherOptions) => Element,
|
|
109
|
+
readonly getAllByTestId: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
|
|
110
|
+
readonly queryByTestId: (matcher: Matcher, options?: MatcherOptions) => Element | null,
|
|
111
|
+
readonly queryAllByTestId: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
|
|
112
|
+
readonly findByTestId: (matcher: Matcher, options?: MatcherOptions) => Promise<Element>,
|
|
113
|
+
readonly findAllByTestId: (matcher: Matcher, options?: MatcherOptions) => Promise<Array<Element>>,
|
|
114
|
+
|
|
115
|
+
readonly getByDisplayValue: (matcher: Matcher, options?: MatcherOptions) => Element,
|
|
116
|
+
readonly getAllByDisplayValue: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
|
|
117
|
+
readonly queryByDisplayValue: (matcher: Matcher, options?: MatcherOptions) => Element | null,
|
|
118
|
+
readonly queryAllByDisplayValue: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
|
|
119
|
+
readonly findByDisplayValue: (matcher: Matcher, options?: MatcherOptions) => Promise<Element>,
|
|
120
|
+
readonly findAllByDisplayValue: (
|
|
121
|
+
matcher: Matcher,
|
|
122
|
+
options?: MatcherOptions,
|
|
123
|
+
) => Promise<Array<Element>>,
|
|
124
|
+
|};
|
|
39
125
|
|
|
40
126
|
/**
|
|
41
127
|
* The six forms of one finder, bound to a root.
|
|
@@ -43,58 +129,112 @@ const FINDERS = {
|
|
|
43
129
|
* `getBy` fails when there is not exactly one, and says how many it saw and
|
|
44
130
|
* what the markup looked like, because "found 3 elements" and "found nothing"
|
|
45
131
|
* are different bugs and a test that reports neither wastes the reader's time.
|
|
132
|
+
*
|
|
133
|
+
* `TTarget` is bounded by `Matcher` rather than left free because
|
|
134
|
+
* `queryFailure` has to describe what was asked for, and it describes the
|
|
135
|
+
* three things a matcher can be. A role is a string, so the bound holds and
|
|
136
|
+
* the failure message is the same one it always was.
|
|
46
137
|
*/
|
|
47
|
-
function forms
|
|
48
|
-
|
|
138
|
+
function forms<TTarget extends Matcher, TOptions>(
|
|
139
|
+
name: string,
|
|
140
|
+
find: (root: Element, target: TTarget, options?: TOptions) => Array<Element>,
|
|
141
|
+
root: () => Element,
|
|
142
|
+
): Forms<TTarget, TOptions> {
|
|
143
|
+
const all = (target: TTarget, options?: TOptions) => find(root(), target, options);
|
|
49
144
|
|
|
50
145
|
return {
|
|
51
|
-
|
|
52
|
-
const found = all(
|
|
146
|
+
getAll: (target, options) => {
|
|
147
|
+
const found = all(target, options);
|
|
53
148
|
if (found.length === 0) {
|
|
54
|
-
throw queryFailure(`getAllBy${name}`,
|
|
149
|
+
throw queryFailure(`getAllBy${name}`, target, root(), 0);
|
|
55
150
|
}
|
|
56
151
|
return found;
|
|
57
152
|
},
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const found = all(
|
|
153
|
+
queryAll: all,
|
|
154
|
+
get: (target, options) => {
|
|
155
|
+
const found = all(target, options);
|
|
61
156
|
if (found.length !== 1) {
|
|
62
|
-
throw queryFailure(`getBy${name}`,
|
|
157
|
+
throw queryFailure(`getBy${name}`, target, root(), found.length);
|
|
63
158
|
}
|
|
64
159
|
return found[0];
|
|
65
160
|
},
|
|
66
|
-
|
|
67
|
-
const found = all(
|
|
161
|
+
query: (target, options) => {
|
|
162
|
+
const found = all(target, options);
|
|
68
163
|
if (found.length > 1) {
|
|
69
|
-
throw queryFailure(`queryBy${name}`,
|
|
164
|
+
throw queryFailure(`queryBy${name}`, target, root(), found.length);
|
|
70
165
|
}
|
|
71
166
|
return found[0] ?? null;
|
|
72
167
|
},
|
|
73
|
-
|
|
168
|
+
find: (target, options) =>
|
|
74
169
|
waitFor(() => {
|
|
75
|
-
const found = all(
|
|
170
|
+
const found = all(target, options);
|
|
76
171
|
if (found.length !== 1) {
|
|
77
|
-
throw queryFailure(`findBy${name}`,
|
|
172
|
+
throw queryFailure(`findBy${name}`, target, root(), found.length);
|
|
78
173
|
}
|
|
79
174
|
return found[0];
|
|
80
175
|
}),
|
|
81
|
-
|
|
176
|
+
findAll: (target, options) =>
|
|
82
177
|
waitFor(() => {
|
|
83
|
-
const found = all(
|
|
178
|
+
const found = all(target, options);
|
|
84
179
|
if (found.length === 0) {
|
|
85
|
-
throw queryFailure(`findAllBy${name}`,
|
|
180
|
+
throw queryFailure(`findAllBy${name}`, target, root(), 0);
|
|
86
181
|
}
|
|
87
182
|
return found;
|
|
88
183
|
}),
|
|
89
184
|
};
|
|
90
185
|
}
|
|
91
186
|
|
|
92
|
-
function queriesFor(root: () =>
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
187
|
+
function queriesFor(root: () => Element): Queries {
|
|
188
|
+
const text = forms("Text", allByText, root);
|
|
189
|
+
const role = forms("Role", allByRole, root);
|
|
190
|
+
const labelText = forms("LabelText", allByLabelText, root);
|
|
191
|
+
const placeholderText = forms("PlaceholderText", allByPlaceholderText, root);
|
|
192
|
+
const testId = forms("TestId", allByTestId, root);
|
|
193
|
+
const displayValue = forms("DisplayValue", allByDisplayValue, root);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
getByText: text.get,
|
|
197
|
+
getAllByText: text.getAll,
|
|
198
|
+
queryByText: text.query,
|
|
199
|
+
queryAllByText: text.queryAll,
|
|
200
|
+
findByText: text.find,
|
|
201
|
+
findAllByText: text.findAll,
|
|
202
|
+
|
|
203
|
+
getByRole: role.get,
|
|
204
|
+
getAllByRole: role.getAll,
|
|
205
|
+
queryByRole: role.query,
|
|
206
|
+
queryAllByRole: role.queryAll,
|
|
207
|
+
findByRole: role.find,
|
|
208
|
+
findAllByRole: role.findAll,
|
|
209
|
+
|
|
210
|
+
getByLabelText: labelText.get,
|
|
211
|
+
getAllByLabelText: labelText.getAll,
|
|
212
|
+
queryByLabelText: labelText.query,
|
|
213
|
+
queryAllByLabelText: labelText.queryAll,
|
|
214
|
+
findByLabelText: labelText.find,
|
|
215
|
+
findAllByLabelText: labelText.findAll,
|
|
216
|
+
|
|
217
|
+
getByPlaceholderText: placeholderText.get,
|
|
218
|
+
getAllByPlaceholderText: placeholderText.getAll,
|
|
219
|
+
queryByPlaceholderText: placeholderText.query,
|
|
220
|
+
queryAllByPlaceholderText: placeholderText.queryAll,
|
|
221
|
+
findByPlaceholderText: placeholderText.find,
|
|
222
|
+
findAllByPlaceholderText: placeholderText.findAll,
|
|
223
|
+
|
|
224
|
+
getByTestId: testId.get,
|
|
225
|
+
getAllByTestId: testId.getAll,
|
|
226
|
+
queryByTestId: testId.query,
|
|
227
|
+
queryAllByTestId: testId.queryAll,
|
|
228
|
+
findByTestId: testId.find,
|
|
229
|
+
findAllByTestId: testId.findAll,
|
|
230
|
+
|
|
231
|
+
getByDisplayValue: displayValue.get,
|
|
232
|
+
getAllByDisplayValue: displayValue.getAll,
|
|
233
|
+
queryByDisplayValue: displayValue.query,
|
|
234
|
+
queryAllByDisplayValue: displayValue.queryAll,
|
|
235
|
+
findByDisplayValue: displayValue.find,
|
|
236
|
+
findAllByDisplayValue: displayValue.findAll,
|
|
237
|
+
};
|
|
98
238
|
}
|
|
99
239
|
|
|
100
240
|
/**
|
|
@@ -105,9 +245,9 @@ function queriesFor(root: () => ParentNode): Queries {
|
|
|
105
245
|
* not see them would be unable to assert on the components most likely to have
|
|
106
246
|
* a bug.
|
|
107
247
|
*/
|
|
108
|
-
export const screen: Queries = queriesFor(() =>
|
|
248
|
+
export const screen: Queries = queriesFor(() => bodyOf());
|
|
109
249
|
|
|
110
250
|
/** The same queries, restricted to one element's subtree. */
|
|
111
|
-
export function within(element:
|
|
251
|
+
export function within(element: Element): Queries {
|
|
112
252
|
return queriesFor(() => element);
|
|
113
253
|
}
|
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.6",
|
|
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.6",
|
|
22
|
+
"@uniflowed/react": "0.0.0-alpha.6",
|
|
23
23
|
"happy-dom": "^20.13.2"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|