@uniflowed/react-testing 0.0.0-alpha.2 → 0.0.0-alpha.5
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 +197 -20
- package/internal/events.js +203 -46
- package/internal/queries.js +86 -27
- package/internal/render.js +138 -12
- package/internal/screen.js +187 -47
- package/package.json +3 -3
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 { installDom } 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
|
*
|
|
@@ -131,11 +149,83 @@ function requireClient(): { createRoot: (Element) => any } {
|
|
|
131
149
|
* and this is how.
|
|
132
150
|
*/
|
|
133
151
|
export function actively<T>(body: () => T): T {
|
|
134
|
-
|
|
135
|
-
act
|
|
136
|
-
|
|
152
|
+
// A test that acts without having rendered — a timer firing in a hook test
|
|
153
|
+
// — reaches `act` without going through `render`, and `act` still has to
|
|
154
|
+
// know it is being called by a test.
|
|
155
|
+
installActEnvironment();
|
|
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 };
|
|
167
|
+
const scope: mixed = act(() => {
|
|
168
|
+
produced.current = { value: body() };
|
|
169
|
+
// Handed back so React keeps the scope open until an async body settles.
|
|
170
|
+
// Without this the scope closed on the first tick and every update the
|
|
171
|
+
// body was still waiting for landed outside it, which React reports as
|
|
172
|
+
// "an update was not wrapped in act(...)".
|
|
173
|
+
return produced.current.value;
|
|
137
174
|
});
|
|
138
|
-
|
|
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
|
+
|
|
182
|
+
if (isThenable(result) && isThenable(scope)) {
|
|
183
|
+
// `Promise.resolve`, not `scope.then(…)`: `act` hands back a bare thenable
|
|
184
|
+
// — an object with a `then` and nothing else — whose `then` returns
|
|
185
|
+
// `undefined` rather than a promise. Chaining off it directly produced an
|
|
186
|
+
// `undefined` that `await` resolved immediately, so the caller carried on
|
|
187
|
+
// while the scope was still open: the body's timers had not fired, and
|
|
188
|
+
// every later `act` nested inside the scope that was never closed and
|
|
189
|
+
// flushed nothing. `render` after one of those returned an empty
|
|
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.
|
|
217
|
+
return Promise.resolve(scope).then(() => result) as any;
|
|
218
|
+
}
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Whether `value` is something to await. */
|
|
223
|
+
function isThenable(value: mixed): boolean {
|
|
224
|
+
if (value == null || typeof value !== "object") {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
const object: { readonly [string]: mixed } = value;
|
|
228
|
+
return typeof object.then === "function";
|
|
139
229
|
}
|
|
140
230
|
|
|
141
231
|
/**
|
|
@@ -149,8 +239,44 @@ export async function waitFor<T>(
|
|
|
149
239
|
body: () => T | Promise<T>,
|
|
150
240
|
options?: {| readonly timeout?: number, readonly interval?: number |},
|
|
151
241
|
): Promise<T> {
|
|
242
|
+
installActEnvironment();
|
|
152
243
|
const timeout = options?.timeout ?? 1000;
|
|
153
244
|
const interval = options?.interval ?? 20;
|
|
245
|
+
|
|
246
|
+
// React is told this is not an act environment for as long as the wait
|
|
247
|
+
// lasts, and told again afterwards.
|
|
248
|
+
//
|
|
249
|
+
// The update a test waits for arrives between two polls, and React reports
|
|
250
|
+
// it as "an update to X inside a test was not wrapped in act(...)" —
|
|
251
|
+
// correctly, since nothing was there to flush it. The fix cannot be to put
|
|
252
|
+
// the polling loop inside an `act` scope: `act` holds updates back until
|
|
253
|
+
// the scope closes, so the loop would poll a tree that cannot change and
|
|
254
|
+
// every `waitFor` would run to its timeout.
|
|
255
|
+
//
|
|
256
|
+
// So the scope is stood down instead. The warning exists to catch an update
|
|
257
|
+
// a test did not know it was causing; a test that wrote `waitFor` knows.
|
|
258
|
+
// Counted rather than saved and restored, because waits nest: every
|
|
259
|
+
// `findBy…` is a `waitFor`, and a test may put one inside another. The
|
|
260
|
+
// outermost wait stands the environment down and the outermost restores it.
|
|
261
|
+
waits += 1;
|
|
262
|
+
if (waits === 1) {
|
|
263
|
+
setActEnvironment(false);
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
return await poll(body, timeout, interval);
|
|
267
|
+
} finally {
|
|
268
|
+
waits -= 1;
|
|
269
|
+
if (waits === 0) {
|
|
270
|
+
setActEnvironment(true);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** How many waits are in progress. */
|
|
276
|
+
let waits = 0;
|
|
277
|
+
|
|
278
|
+
/** Call `body` until it stops throwing, or give up after `timeout`. */
|
|
279
|
+
async function poll<T>(body: () => T | Promise<T>, timeout: number, interval: number): Promise<T> {
|
|
154
280
|
const deadline = Date.now() + timeout;
|
|
155
281
|
let lastError: mixed = null;
|
|
156
282
|
|