@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.
@@ -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: ParentNode, selector: string): Array<Element> {
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: ParentNode,
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: ParentNode,
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: ParentNode,
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: ParentNode,
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: ParentNode,
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 as any).value ?? ""), element, matcher, options),
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: ParentNode, label: Element): Element | null {
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 = (root as any).querySelector?.(`#${cssEscape(id)}`);
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
- const parts = labelledBy
267
- .split(/\s+/)
268
- .map((id) => element.ownerDocument?.getElementById(id))
269
- .filter(Boolean)
270
- .map((target) => textOf(target as any));
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 as any).value ?? "");
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 = (root as any).innerHTML ?? "";
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}`);
@@ -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: { render(node: React.Node): void, unmount(): void },
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: globalThis.document.body as any,
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
- globalThis.document.body.appendChild(container);
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: mixed = null;
118
- function requireClient(): { createRoot: (Element) => any } {
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") as any;
121
+ client = load("react-dom/client");
122
122
  }
123
- return client as any;
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
- let result: T;
135
- act(() => {
136
- result = body();
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
- return result as any;
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