@uniflowed/react-testing 0.0.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,32 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/react-testing`: React Testing Library's surface, over a real DOM.
4
+ //
5
+ // The library's premise is that a test should reach for an element the way a
6
+ // person does — by the text on it, by the role it plays, by the label next to
7
+ // it — so a test keeps passing when the markup is refactored and stops passing
8
+ // when the thing a user relies on breaks. That premise is worth keeping, so
9
+ // this is the same shape: `render`, `screen`, `fireEvent`, `userEvent`,
10
+ // `waitFor`.
11
+ //
12
+ // It used to be a declaration whose every function threw. `uf test` runs on
13
+ // Node.js, Bun or Deno and none of them has a DOM, so one is installed on
14
+ // first render — which is why a component test needs nothing configured.
15
+ //
16
+ // # Queries
17
+ //
18
+ // Every query is `getBy`, `queryBy`, `findBy`, `getAllBy`, `queryAllBy` or
19
+ // `findAllBy` over the same matcher, and the choice says what the test means:
20
+ // `getBy` asserts presence now, `queryBy` is for asking about absence, and
21
+ // `findBy` waits. They are on `screen`, which searches the whole document so a
22
+ // portal is found, and on the result of `render`, which searches only what it
23
+ // mounted.
24
+
25
+ export type { Matcher, MatcherOptions } from "./internal/queries.js";
26
+ export type { RenderResult } from "./internal/render.js";
27
+ export type { Queries } from "./internal/screen.js";
28
+
29
+ export { normalize, accessibleName, roleOf } from "./internal/queries.js";
30
+ export { actively as act, cleanup, render, waitFor } from "./internal/render.js";
31
+ export { dispatch, fireEvent, userEvent } from "./internal/events.js";
32
+ export { screen, within } from "./internal/screen.js";
@@ -0,0 +1,135 @@
1
+ // @flow
2
+ //
3
+ // A document for a test process that has none.
4
+ //
5
+ // `uf test` runs on Node.js, Bun or Deno, and none of them has a DOM. React
6
+ // needs one before `react-dom` is imported, not after: `react-dom/client`
7
+ // reads `document` while it is being evaluated, so installing the globals has
8
+ // to happen first and exactly once per process.
9
+ //
10
+ // The window is created lazily rather than at import time, because a test file
11
+ // that imports this module and never renders should not pay for a DOM, and
12
+ // because `@uniflowed/lib`'s invariants forbid a package from doing work while
13
+ // it is being imported.
14
+
15
+ import { Window } from "happy-dom";
16
+
17
+ /**
18
+ * The document's own classes, which always replace whatever the host had.
19
+ *
20
+ * A document rejects an event built by a different implementation, and Node
21
+ * defines `Event` and `CustomEvent` itself — `dispatchEvent` refused them with
22
+ * "parameter 1 is not of type 'Event'" for every event this module had no more
23
+ * specific constructor for. Whatever the host already had, the document's own
24
+ * classes are the ones that work with the document.
25
+ */
26
+ const CLASSES = [
27
+ "Node",
28
+ "Element",
29
+ "HTMLElement",
30
+ "HTMLInputElement",
31
+ "HTMLTextAreaElement",
32
+ "HTMLSelectElement",
33
+ "HTMLButtonElement",
34
+ "HTMLAnchorElement",
35
+ "SVGElement",
36
+ "Event",
37
+ "CustomEvent",
38
+ "MouseEvent",
39
+ "KeyboardEvent",
40
+ "InputEvent",
41
+ "FocusEvent",
42
+ "PointerEvent",
43
+ "SubmitEvent",
44
+ "DOMParser",
45
+ "MutationObserver",
46
+ "ResizeObserver",
47
+ "IntersectionObserver",
48
+ ];
49
+
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
+ /**
56
+ * Objects a page has, installed only where the host has none.
57
+ *
58
+ * `navigator` is the reason for the distinction: on Node it is an accessor
59
+ * with no setter, and assigning to it throws. A test does not need it
60
+ * replaced — it needs it to exist.
61
+ */
62
+ const OBJECTS = ["location", "history", "localStorage", "sessionStorage", "navigator"];
63
+
64
+ let installed: mixed = null;
65
+
66
+ /**
67
+ * Install a DOM on the global object, once.
68
+ *
69
+ * Returns the window, so a caller that wants the document can have it without
70
+ * reaching through `globalThis`. Calling this a second time is free and does
71
+ * not replace the document — replacing it mid-process would strand every React
72
+ * root already mounted in the old one.
73
+ */
74
+ export function installDom(): mixed {
75
+ if (installed != null) {
76
+ return installed;
77
+ }
78
+
79
+ // A real browser is not required to be absent — a project may already be
80
+ // running these tests in one, and then the page's own DOM is the right one.
81
+ if (typeof globalThis.document !== "undefined") {
82
+ installed = globalThis.window ?? globalThis;
83
+ return installed;
84
+ }
85
+
86
+ const win = new Window({ url: "http://localhost/" });
87
+
88
+ for (const name of CLASSES) {
89
+ const value = (win as any)[name];
90
+ if (value !== undefined) {
91
+ define(name, value);
92
+ }
93
+ }
94
+ for (const name of FUNCTIONS) {
95
+ const value = (win as any)[name];
96
+ if (typeof value === "function") {
97
+ define(name, value.bind(win));
98
+ }
99
+ }
100
+ for (const name of OBJECTS) {
101
+ const value = (win as any)[name];
102
+ if (value !== undefined && globalThis[name] === undefined) {
103
+ define(name, value);
104
+ }
105
+ }
106
+
107
+ // React reads these to decide it is in a browser and to pick its event
108
+ // system, and they must be the objects the elements belong to.
109
+ define("window", win as any);
110
+ define("document", (win as any).document);
111
+
112
+ installed = win;
113
+ return installed;
114
+ }
115
+
116
+ /**
117
+ * Assign a global, even where the host declared it as a getter.
118
+ *
119
+ * `navigator` on Node is an accessor with no setter, so a plain assignment
120
+ * throws; anything installed here has to be defined rather than assigned.
121
+ */
122
+ function define(name: string, value: mixed): void {
123
+ Object.defineProperty(globalThis, name, {
124
+ value,
125
+ writable: true,
126
+ configurable: true,
127
+ enumerable: true,
128
+ });
129
+ }
130
+
131
+ /** The document tests query, installing one if the process has none. */
132
+ export function documentOf(): Document {
133
+ installDom();
134
+ return globalThis.document as any;
135
+ }
@@ -0,0 +1,338 @@
1
+ // @flow
2
+ //
3
+ // Making things happen to the page.
4
+ //
5
+ // Two layers, because tests want two different things.
6
+ //
7
+ // `fireEvent` dispatches one event. It is the right tool when the test is
8
+ // about the handler: "clicking calls onSelect once".
9
+ //
10
+ // `userEvent` performs what a person did, which is almost never one event.
11
+ // Clicking a button is pointerdown, mousedown, focus, pointerup, mouseup and
12
+ // click; typing is a keydown, an input and a keyup per character, with the
13
+ // value updated in between. A component that listens for `mousedown` — a menu
14
+ // that closes on outside press, say — behaves correctly under a real click and
15
+ // not at all under a bare `click` event, and a test that only fires `click`
16
+ // would pass while the feature was broken.
17
+
18
+ import { actively } from "./render.js";
19
+
20
+ /** Event constructors by DOM event name, with the right interface for each. */
21
+ const EVENT_TYPES: { readonly [string]: string } = {
22
+ click: "MouseEvent",
23
+ dblclick: "MouseEvent",
24
+ mousedown: "MouseEvent",
25
+ mouseup: "MouseEvent",
26
+ mouseover: "MouseEvent",
27
+ mouseout: "MouseEvent",
28
+ mouseenter: "MouseEvent",
29
+ mouseleave: "MouseEvent",
30
+ mousemove: "MouseEvent",
31
+ contextmenu: "MouseEvent",
32
+ keydown: "KeyboardEvent",
33
+ keyup: "KeyboardEvent",
34
+ keypress: "KeyboardEvent",
35
+ focus: "FocusEvent",
36
+ blur: "FocusEvent",
37
+ focusin: "FocusEvent",
38
+ focusout: "FocusEvent",
39
+ input: "InputEvent",
40
+ pointerdown: "PointerEvent",
41
+ pointerup: "PointerEvent",
42
+ pointermove: "PointerEvent",
43
+ };
44
+
45
+ /** Events that do not bubble, whatever else is said about them. */
46
+ const NON_BUBBLING = new Set(["focus", "blur", "mouseenter", "mouseleave"]);
47
+
48
+ /**
49
+ * The bubbling event React actually listens for, for each one that does not
50
+ * bubble.
51
+ *
52
+ * React attaches every listener to the root container rather than to the
53
+ * element, so it can only hear events that reach the root. `focus` and `blur`
54
+ * never do. React's answer is to listen for `focusin` and `focusout` — which
55
+ * are the same moments and do bubble — and surface them to a component as
56
+ * `onFocus` and `onBlur`.
57
+ *
58
+ * So dispatching a bare `focus` calls nothing: the element's own listener, if
59
+ * a test added one directly, and no React handler at all. The test then
60
+ * asserts on a component that never re-rendered and reads as a component bug.
61
+ * Firing the pair is what a browser does anyway — a real focus is a `focus`
62
+ * and a `focusin` — so this is less a workaround than the missing half.
63
+ */
64
+ const ALSO_BUBBLES: { readonly [string]: string } = {
65
+ focus: "focusin",
66
+ blur: "focusout",
67
+ };
68
+
69
+ function construct(name: string, init: { readonly [string]: mixed }): Event {
70
+ const interfaceName = EVENT_TYPES[name] ?? "Event";
71
+ const Constructor = (globalThis as any)[interfaceName] ?? globalThis.Event;
72
+ const options = {
73
+ bubbles: !NON_BUBBLING.has(name),
74
+ cancelable: true,
75
+ ...init,
76
+ };
77
+ try {
78
+ return new Constructor(name, options);
79
+ } catch {
80
+ // A host whose constructor is stricter than the init we were handed.
81
+ return new globalThis.Event(name, options);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Dispatch one event, inside `act`.
87
+ *
88
+ * Returns whether the event ran to completion — `false` when a handler called
89
+ * `preventDefault`, which is what `dispatchEvent` reports and what a test
90
+ * asserting "the form did not submit" needs.
91
+ */
92
+ export function dispatch(
93
+ target: EventTarget,
94
+ name: string,
95
+ init?: { readonly [string]: mixed },
96
+ ): boolean {
97
+ const event = construct(name, init ?? {});
98
+ const paired = ALSO_BUBBLES[name];
99
+ let ran = true;
100
+ actively(() => {
101
+ ran = target.dispatchEvent(event);
102
+ if (paired != null) {
103
+ // Both, in the order a browser sends them, and inside the same `act` so
104
+ // the component re-renders once rather than twice.
105
+ target.dispatchEvent(construct(paired, init ?? {}));
106
+ }
107
+ });
108
+ return ran;
109
+ }
110
+
111
+ /**
112
+ * `fireEvent.click(element)`, and one entry per event name.
113
+ *
114
+ * A proxy rather than a written-out table: the set of DOM events is long,
115
+ * grows, and every entry would be the same line. `fireEvent(target, name)`
116
+ * also works, for an event whose name is computed.
117
+ */
118
+ export const fireEvent: any = new Proxy(
119
+ (target: EventTarget, name: string, init?: { readonly [string]: mixed }) =>
120
+ dispatch(target, name, init),
121
+ {
122
+ get(base, property) {
123
+ if (typeof property !== "string") {
124
+ return (base as any)[property];
125
+ }
126
+ if (property in base) {
127
+ return (base as any)[property];
128
+ }
129
+ return (target: EventTarget, init?: { readonly [string]: mixed }) =>
130
+ dispatch(target, property.toLowerCase(), init);
131
+ },
132
+ },
133
+ );
134
+
135
+ /** Set a control's value the way a browser does, so React sees the change. */
136
+ function setValue(element: HTMLElement, value: string): void {
137
+ const target: any = element;
138
+ // React tracks the last value it wrote on the node and skips an `input`
139
+ // event whose value it believes it already knows. Writing through the
140
+ // prototype's setter is what a browser does and what clears that.
141
+ const prototype = Object.getPrototypeOf(target);
142
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
143
+ if (descriptor?.set != null) {
144
+ descriptor.set.call(target, value);
145
+ } else {
146
+ target.value = value;
147
+ }
148
+ }
149
+
150
+ /** A key's `key`, `code` and printable text. */
151
+ function describeKey(key: string): {| key: string, code: string, text: string | null |} {
152
+ const named: { readonly [string]: {| code: string, text: string | null |} } = {
153
+ Enter: { code: "Enter", text: "\n" },
154
+ Tab: { code: "Tab", text: null },
155
+ Escape: { code: "Escape", text: null },
156
+ Backspace: { code: "Backspace", text: null },
157
+ Delete: { code: "Delete", text: null },
158
+ ArrowUp: { code: "ArrowUp", text: null },
159
+ ArrowDown: { code: "ArrowDown", text: null },
160
+ ArrowLeft: { code: "ArrowLeft", text: null },
161
+ ArrowRight: { code: "ArrowRight", text: null },
162
+ Home: { code: "Home", text: null },
163
+ End: { code: "End", text: null },
164
+ " ": { code: "Space", text: " " },
165
+ };
166
+ const entry = named[key];
167
+ if (entry != null) {
168
+ return { key, code: entry.code, text: entry.text };
169
+ }
170
+ return { key, code: `Key${key.toUpperCase()}`, text: key };
171
+ }
172
+
173
+ /** Elements the tab order includes, in document order. */
174
+ function tabbable(): Array<HTMLElement> {
175
+ const selector =
176
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
177
+ return Array.from(globalThis.document.querySelectorAll(selector)).filter(
178
+ (element: any) => element.getAttribute("aria-hidden") !== "true",
179
+ );
180
+ }
181
+
182
+ /**
183
+ * What a person did, rather than what the DOM emitted.
184
+ *
185
+ * Every method is async because that is what makes a test written with it
186
+ * correct as it grows: the moment an interaction leads to something awaited —
187
+ * a fetch, a transition, a lazily loaded panel — a synchronous helper would
188
+ * return before the result existed, and the test would need a sleep. Awaiting
189
+ * from the start means adding that behaviour later changes nothing.
190
+ */
191
+ export const userEvent = {
192
+ /** Press and release, with the events a real click produces, in order. */
193
+ async click(element: HTMLElement, init?: { readonly [string]: mixed }): Promise<void> {
194
+ if ((element as any).disabled === true) {
195
+ return;
196
+ }
197
+ dispatch(element, "pointerdown", init);
198
+ dispatch(element, "mousedown", init);
199
+ focus(element);
200
+ dispatch(element, "pointerup", init);
201
+ dispatch(element, "mouseup", init);
202
+ dispatch(element, "click", init);
203
+ await settle();
204
+ },
205
+
206
+ /** Two clicks and a dblclick. */
207
+ async dblClick(element: HTMLElement): Promise<void> {
208
+ await userEvent.click(element);
209
+ await userEvent.click(element);
210
+ dispatch(element, "dblclick");
211
+ await settle();
212
+ },
213
+
214
+ /**
215
+ * Type into a control, one character at a time.
216
+ *
217
+ * Per character rather than setting the value once, because a component
218
+ * that reacts to each keystroke — a search box that filters, a field that
219
+ * rejects a character — behaves differently, and the difference is the thing
220
+ * usually being tested.
221
+ */
222
+ async type(element: HTMLElement, text: string): Promise<void> {
223
+ focus(element);
224
+ for (const character of text) {
225
+ const { key, code, text: printable } = describeKey(character);
226
+ dispatch(element, "keydown", { key, code });
227
+ if (printable != null && printable !== "\n") {
228
+ setValue(element, `${(element as any).value ?? ""}${printable}`);
229
+ dispatch(element, "input", { data: printable });
230
+ }
231
+ dispatch(element, "keyup", { key, code });
232
+ }
233
+ await settle();
234
+ },
235
+
236
+ /** Empty a control, the way selecting everything and deleting would. */
237
+ async clear(element: HTMLElement): Promise<void> {
238
+ focus(element);
239
+ setValue(element, "");
240
+ dispatch(element, "input", {});
241
+ await settle();
242
+ },
243
+
244
+ /** Press keys at whatever has focus. Named keys go in braces: `{Enter}`. */
245
+ async keyboard(sequence: string): Promise<void> {
246
+ const target: any = globalThis.document.activeElement ?? globalThis.document.body;
247
+ for (const token of parseKeys(sequence)) {
248
+ const { key, code, text } = describeKey(token);
249
+ dispatch(target, "keydown", { key, code });
250
+ if (text != null && text !== "\n" && target.value !== undefined) {
251
+ setValue(target, `${target.value ?? ""}${text}`);
252
+ dispatch(target, "input", { data: text });
253
+ }
254
+ dispatch(target, "keyup", { key, code });
255
+ }
256
+ await settle();
257
+ },
258
+
259
+ /** Move focus the way the Tab key does. */
260
+ async tab(options?: {| readonly shift?: boolean |}): Promise<void> {
261
+ const order = tabbable();
262
+ if (order.length === 0) {
263
+ return;
264
+ }
265
+ const active: any = globalThis.document.activeElement;
266
+ const at = order.indexOf(active);
267
+ const shift = options?.shift ?? false;
268
+ const next =
269
+ at < 0
270
+ ? shift
271
+ ? order[order.length - 1]
272
+ : order[0]
273
+ : order[(at + (shift ? -1 : 1) + order.length) % order.length];
274
+ focus(next);
275
+ await settle();
276
+ },
277
+
278
+ /** Choose options in a select. */
279
+ async selectOptions(
280
+ element: HTMLElement,
281
+ values: string | $ReadOnlyArray<string>,
282
+ ): Promise<void> {
283
+ const wanted = typeof values === "string" ? [values] : values;
284
+ const select: any = element;
285
+ for (const option of Array.from(select.options ?? [])) {
286
+ (option as any).selected = wanted.includes((option as any).value);
287
+ }
288
+ dispatch(element, "input");
289
+ dispatch(element, "change");
290
+ await settle();
291
+ },
292
+
293
+ /** Move focus away, which is what makes a blur-validated field validate. */
294
+ async tabAway(element: HTMLElement): Promise<void> {
295
+ dispatch(element, "blur");
296
+ (element as any).blur?.();
297
+ await settle();
298
+ },
299
+ };
300
+
301
+ function focus(element: HTMLElement): void {
302
+ const previous: any = globalThis.document.activeElement;
303
+ if (previous === element) {
304
+ return;
305
+ }
306
+ actively(() => {
307
+ (element as any).focus?.();
308
+ });
309
+ if (globalThis.document.activeElement !== element) {
310
+ // A host whose `focus` does not move `activeElement`; the events are what
311
+ // components listen for, so dispatch them regardless.
312
+ dispatch(element, "focus");
313
+ }
314
+ }
315
+
316
+ /** `{Enter}` and `{Escape}` as single tokens; everything else per character. */
317
+ function parseKeys(sequence: string): Array<string> {
318
+ const keys = [];
319
+ let index = 0;
320
+ while (index < sequence.length) {
321
+ if (sequence[index] === "{") {
322
+ const close = sequence.indexOf("}", index);
323
+ if (close > index) {
324
+ keys.push(sequence.slice(index + 1, close));
325
+ index = close + 1;
326
+ continue;
327
+ }
328
+ }
329
+ keys.push(sequence[index]);
330
+ index += 1;
331
+ }
332
+ return keys;
333
+ }
334
+
335
+ /** Let React finish anything the interaction started. */
336
+ async function settle(): Promise<void> {
337
+ await actively(() => Promise.resolve());
338
+ }
@@ -0,0 +1,311 @@
1
+ // @flow
2
+ //
3
+ // Finding an element the way a person would.
4
+ //
5
+ // Every query takes a *matcher* — a string, a regular expression, or a
6
+ // predicate — and every one comes in four forms, because those are the four
7
+ // different questions a test asks:
8
+ //
9
+ // getBy… it is there now, and there is one. Anything else is a failure.
10
+ // queryBy… it may not be there, and its absence is the thing being asked.
11
+ // findBy… it will be there shortly. Waits.
12
+ // getAllBy… there are several, and how many matters.
13
+ //
14
+ // The distinction matters because `getBy` failing with "found none" is a much
15
+ // better test failure than `queryBy` returning null and the assertion failing
16
+ // three lines later on `null.textContent`.
17
+
18
+ /** What a query will accept as a description of the thing to find. */
19
+ export type Matcher = string | RegExp | ((content: string, element: Element) => boolean);
20
+
21
+ /** How exactly a string matcher has to match. */
22
+ export type MatcherOptions = {|
23
+ /** `false` matches a substring, case-insensitively. Defaults to `true`. */
24
+ readonly exact?: boolean,
25
+ |};
26
+
27
+ /**
28
+ * Collapse whitespace the way a browser does when it lays text out.
29
+ *
30
+ * A test asks for "Save changes"; the markup may hold a newline and eleven
31
+ * spaces between the two words because that is how the JSX was indented. The
32
+ * reader sees one space, so the query matches one space.
33
+ */
34
+ export function normalize(text: string): string {
35
+ return text.replace(/\s+/g, " ").trim();
36
+ }
37
+
38
+ function matches(
39
+ content: string,
40
+ element: Element,
41
+ matcher: Matcher,
42
+ options?: MatcherOptions,
43
+ ): boolean {
44
+ if (typeof matcher === "function") {
45
+ return matcher(content, element);
46
+ }
47
+ if (matcher instanceof RegExp) {
48
+ return matcher.test(content);
49
+ }
50
+ const exact = options?.exact ?? true;
51
+ return exact
52
+ ? content === normalize(matcher)
53
+ : content.toLowerCase().includes(normalize(matcher).toLowerCase());
54
+ }
55
+
56
+ /** The text a reader would see in this element, whitespace collapsed. */
57
+ export function textOf(element: Element): string {
58
+ return normalize(element.textContent ?? "");
59
+ }
60
+
61
+ function candidates(root: ParentNode, selector: string): Array<Element> {
62
+ return Array.from(root.querySelectorAll(selector));
63
+ }
64
+
65
+ /** Elements whose own visible text matches. */
66
+ export function allByText(
67
+ root: ParentNode,
68
+ matcher: Matcher,
69
+ options?: MatcherOptions,
70
+ ): Array<Element> {
71
+ // Only the element closest to the text, not every ancestor that contains it:
72
+ // asking for "Save" should find the button, not the button and the form and
73
+ // the body.
74
+ return candidates(root, "*").filter((element) => {
75
+ if (!matches(textOf(element), element, matcher, options)) {
76
+ return false;
77
+ }
78
+ return !Array.from(element.children).some((child) =>
79
+ matches(textOf(child), child, matcher, options),
80
+ );
81
+ });
82
+ }
83
+
84
+ /** 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> {
90
+ const found = candidates(root, "*").filter((element) => roleOf(element) === role);
91
+ const name = options?.name;
92
+ if (name == null) {
93
+ return found;
94
+ }
95
+ return found.filter((element) =>
96
+ matches(accessibleName(element), element, name, { exact: options?.exact ?? true }),
97
+ );
98
+ }
99
+
100
+ /** Form controls labelled by this text. */
101
+ export function allByLabelText(
102
+ root: ParentNode,
103
+ matcher: Matcher,
104
+ options?: MatcherOptions,
105
+ ): Array<Element> {
106
+ const found = [];
107
+ for (const label of candidates(root, "label")) {
108
+ if (!matches(textOf(label), label, matcher, options)) {
109
+ continue;
110
+ }
111
+ const control = controlFor(root, label);
112
+ if (control != null) {
113
+ found.push(control);
114
+ }
115
+ }
116
+ // `aria-label` names a control with no label element of its own.
117
+ for (const element of candidates(root, "[aria-label]")) {
118
+ const label = element.getAttribute("aria-label") ?? "";
119
+ if (matches(normalize(label), element, matcher, options) && !found.includes(element)) {
120
+ found.push(element);
121
+ }
122
+ }
123
+ return found;
124
+ }
125
+
126
+ /** Elements with this placeholder. */
127
+ export function allByPlaceholderText(
128
+ root: ParentNode,
129
+ matcher: Matcher,
130
+ options?: MatcherOptions,
131
+ ): Array<Element> {
132
+ return candidates(root, "[placeholder]").filter((element) =>
133
+ matches(normalize(element.getAttribute("placeholder") ?? ""), element, matcher, options),
134
+ );
135
+ }
136
+
137
+ /** Elements marked for tests, which is the query of last resort. */
138
+ export function allByTestId(
139
+ root: ParentNode,
140
+ matcher: Matcher,
141
+ options?: MatcherOptions,
142
+ ): Array<Element> {
143
+ return candidates(root, "[data-testid]").filter((element) =>
144
+ matches(normalize(element.getAttribute("data-testid") ?? ""), element, matcher, options),
145
+ );
146
+ }
147
+
148
+ /** Elements whose value matches, for inputs and selects. */
149
+ export function allByDisplayValue(
150
+ root: ParentNode,
151
+ matcher: Matcher,
152
+ options?: MatcherOptions,
153
+ ): Array<Element> {
154
+ return candidates(root, "input, textarea, select").filter((element) =>
155
+ matches(normalize((element as any).value ?? ""), element, matcher, options),
156
+ );
157
+ }
158
+
159
+ /**
160
+ * The control a label labels.
161
+ *
162
+ * `for` first, because it is explicit; then a control nested inside the label,
163
+ * which is the other way HTML allows it.
164
+ */
165
+ function controlFor(root: ParentNode, label: Element): Element | null {
166
+ const id = label.getAttribute("for");
167
+ if (id != null && id !== "") {
168
+ const byId = (root as any).querySelector?.(`#${cssEscape(id)}`);
169
+ if (byId != null) {
170
+ return byId;
171
+ }
172
+ }
173
+ return label.querySelector("input, textarea, select, button, [role]");
174
+ }
175
+
176
+ /** Escape an id for use in a selector, since an id may contain anything. */
177
+ function cssEscape(value: string): string {
178
+ return value.replace(/([^\w-])/g, "\\$1");
179
+ }
180
+
181
+ /** Roles a tag has without being told. */
182
+ const IMPLICIT_ROLES: { readonly [string]: string } = {
183
+ a: "link",
184
+ article: "article",
185
+ aside: "complementary",
186
+ button: "button",
187
+ dialog: "dialog",
188
+ footer: "contentinfo",
189
+ form: "form",
190
+ h1: "heading",
191
+ h2: "heading",
192
+ h3: "heading",
193
+ h4: "heading",
194
+ h5: "heading",
195
+ h6: "heading",
196
+ header: "banner",
197
+ hr: "separator",
198
+ img: "img",
199
+ li: "listitem",
200
+ main: "main",
201
+ nav: "navigation",
202
+ ol: "list",
203
+ option: "option",
204
+ progress: "progressbar",
205
+ section: "region",
206
+ select: "combobox",
207
+ table: "table",
208
+ tbody: "rowgroup",
209
+ td: "cell",
210
+ textarea: "textbox",
211
+ th: "columnheader",
212
+ tr: "row",
213
+ ul: "list",
214
+ };
215
+
216
+ /** The input types that are not a textbox. */
217
+ const INPUT_ROLES: { readonly [string]: string } = {
218
+ button: "button",
219
+ checkbox: "checkbox",
220
+ email: "textbox",
221
+ image: "button",
222
+ number: "spinbutton",
223
+ radio: "radio",
224
+ range: "slider",
225
+ reset: "button",
226
+ search: "searchbox",
227
+ submit: "button",
228
+ tel: "textbox",
229
+ text: "textbox",
230
+ url: "textbox",
231
+ };
232
+
233
+ /** This element's role: what it says, or what its tag implies. */
234
+ export function roleOf(element: Element): string | null {
235
+ const explicit = element.getAttribute("role");
236
+ if (explicit != null && explicit !== "") {
237
+ return explicit.trim().split(/\s+/)[0];
238
+ }
239
+ const tag = element.tagName.toLowerCase();
240
+ if (tag === "input") {
241
+ const type = (element.getAttribute("type") ?? "text").toLowerCase();
242
+ return INPUT_ROLES[type] ?? "textbox";
243
+ }
244
+ if (tag === "a" && element.getAttribute("href") == null) {
245
+ // A link without a destination is not a link.
246
+ return "generic";
247
+ }
248
+ return IMPLICIT_ROLES[tag] ?? null;
249
+ }
250
+
251
+ /**
252
+ * The name a screen reader would announce.
253
+ *
254
+ * `aria-label`, then the element `aria-labelledby` points at, then a label
255
+ * element, then the element's own text. Not the whole specification — that is
256
+ * a document of its own — but the order that decides almost every real case.
257
+ */
258
+ export function accessibleName(element: Element): string {
259
+ const label = element.getAttribute("aria-label");
260
+ if (label != null && label !== "") {
261
+ return normalize(label);
262
+ }
263
+
264
+ const labelledBy = element.getAttribute("aria-labelledby");
265
+ 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));
271
+ if (parts.length > 0) {
272
+ return normalize(parts.join(" "));
273
+ }
274
+ }
275
+
276
+ const id = element.getAttribute("id");
277
+ if (id != null && id !== "") {
278
+ const own = element.ownerDocument?.querySelector(`label[for="${cssEscape(id)}"]`);
279
+ if (own != null) {
280
+ return textOf(own);
281
+ }
282
+ }
283
+
284
+ if (element.tagName.toLowerCase() === "input") {
285
+ const type = (element.getAttribute("type") ?? "").toLowerCase();
286
+ if (type === "submit" || type === "button" || type === "reset") {
287
+ return normalize((element as any).value ?? "");
288
+ }
289
+ }
290
+
291
+ return textOf(element);
292
+ }
293
+
294
+ /** 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 {
301
+ const description =
302
+ typeof matcher === "function"
303
+ ? "the given predicate"
304
+ : matcher instanceof RegExp
305
+ ? String(matcher)
306
+ : JSON.stringify(matcher);
307
+ const html = (root as any).innerHTML ?? "";
308
+ const shown = html.length > 2000 ? `${html.slice(0, 2000)}\n…` : html;
309
+ const count = found === 0 ? "found nothing" : `found ${found} elements and needed exactly one`;
310
+ return new Error(`${kind} ${description}: ${count}\n\n${shown}`);
311
+ }
@@ -0,0 +1,170 @@
1
+ // @flow
2
+ //
3
+ // Mounting a component into a real document.
4
+ //
5
+ // Everything that changes the tree goes through React's `act`, which is what
6
+ // makes a test able to assert immediately afterwards: `act` runs the render,
7
+ // the effects, and the microtasks React queued, then returns. Without it a
8
+ // test asserts against the DOM as it was before React got to it, and the fix
9
+ // people reach for is a sleep.
10
+
11
+ import { createRequire } from "node:module";
12
+
13
+ // A type-only import: `React.Node` is the only thing this module needs from
14
+ // React itself, and importing the namespace as a value left an unused binding
15
+ // in the bundle.
16
+ import type * as React from "@uniflowed/react";
17
+ import { act } from "@uniflowed/react";
18
+
19
+ import { installDom } from "./dom.js";
20
+
21
+ /** What `render` hands back. */
22
+ export type RenderResult = {|
23
+ /** The element the tree was mounted into. */
24
+ readonly container: Element,
25
+ /** The document body, which is where a portal ends up. */
26
+ readonly baseElement: Element,
27
+ /** Render different elements into the same container. */
28
+ readonly rerender: (ui: React.Node) => void,
29
+ /** Take the tree down and remove the container. */
30
+ readonly unmount: () => void,
31
+ /** The container's markup, for a failure message. */
32
+ readonly asFragment: () => string,
33
+ |};
34
+
35
+ type Mounted = {|
36
+ container: Element,
37
+ root: { render(node: React.Node): void, unmount(): void },
38
+ |};
39
+
40
+ const mounted: Array<Mounted> = [];
41
+
42
+ /**
43
+ * Render `ui` into a fresh container in the document body.
44
+ *
45
+ * Anything still mounted from an earlier `render` is taken down first. `screen`
46
+ * queries the whole document, so a tree left over from the previous test would
47
+ * make "there is exactly one Save button" false for reasons that have nothing
48
+ * to do with the test being read.
49
+ */
50
+ export function render(ui: React.Node, options?: {| readonly container?: Element |}): RenderResult {
51
+ installDom();
52
+ cleanup();
53
+
54
+ const { createRoot } = requireClient();
55
+ const container = options?.container ?? createContainer();
56
+ const root = createRoot(container);
57
+ mounted.push({ container, root });
58
+
59
+ act(() => {
60
+ root.render(ui);
61
+ });
62
+
63
+ return {
64
+ container,
65
+ baseElement: globalThis.document.body as any,
66
+ rerender: (next: React.Node) => {
67
+ act(() => {
68
+ root.render(next);
69
+ });
70
+ },
71
+ unmount: () => {
72
+ act(() => {
73
+ root.unmount();
74
+ });
75
+ container.remove();
76
+ const index = mounted.findIndex((entry) => entry.container === container);
77
+ if (index >= 0) {
78
+ mounted.splice(index, 1);
79
+ }
80
+ },
81
+ asFragment: () => container.innerHTML,
82
+ };
83
+ }
84
+
85
+ /** Unmount everything this module has mounted. */
86
+ export function cleanup(): void {
87
+ while (mounted.length > 0) {
88
+ const entry = mounted.pop();
89
+ if (entry == null) {
90
+ break;
91
+ }
92
+ act(() => {
93
+ entry.root.unmount();
94
+ });
95
+ entry.container.remove();
96
+ }
97
+ }
98
+
99
+ function createContainer(): Element {
100
+ const container = globalThis.document.createElement("div");
101
+ globalThis.document.body.appendChild(container);
102
+ return container;
103
+ }
104
+
105
+ /**
106
+ * `react-dom/client`, loaded only once a test renders.
107
+ *
108
+ * It reads `document` while it is being evaluated, so it cannot be a static
109
+ * import of this module: ESM evaluates every import before the module body,
110
+ * and the DOM has to exist first. `render` is synchronous — a test asserts on
111
+ * the line after it — so this cannot be `await import(…)` either.
112
+ *
113
+ * That leaves a synchronous require, through `node:module`. All three hosts
114
+ * uf supports provide it, and React ships a CommonJS build for exactly this
115
+ * kind of caller.
116
+ */
117
+ let client: mixed = null;
118
+ function requireClient(): { createRoot: (Element) => any } {
119
+ if (client == null) {
120
+ const load = createRequire(import.meta.url);
121
+ client = load("react-dom/client") as any;
122
+ }
123
+ return client as any;
124
+ }
125
+
126
+ /**
127
+ * Run `body`, letting React flush everything it queues.
128
+ *
129
+ * Exported because a test that changes state outside an event — a timer
130
+ * firing, a promise settling — has to tell React that the change happened,
131
+ * and this is how.
132
+ */
133
+ export function actively<T>(body: () => T): T {
134
+ let result: T;
135
+ act(() => {
136
+ result = body();
137
+ });
138
+ return result as any;
139
+ }
140
+
141
+ /**
142
+ * Wait until `body` stops throwing, or give up.
143
+ *
144
+ * Polling rather than observing mutations, because what a test waits for is
145
+ * usually not a DOM change at all — it is a promise resolving, a fetch
146
+ * settling, a timer firing — and a mutation observer sees none of those.
147
+ */
148
+ export async function waitFor<T>(
149
+ body: () => T | Promise<T>,
150
+ options?: {| readonly timeout?: number, readonly interval?: number |},
151
+ ): Promise<T> {
152
+ const timeout = options?.timeout ?? 1000;
153
+ const interval = options?.interval ?? 20;
154
+ const deadline = Date.now() + timeout;
155
+ let lastError: mixed = null;
156
+
157
+ while (true) {
158
+ try {
159
+ return await body();
160
+ } catch (error) {
161
+ lastError = error;
162
+ }
163
+ if (Date.now() >= deadline) {
164
+ throw lastError instanceof Error
165
+ ? lastError
166
+ : new Error(`waitFor timed out after ${timeout}ms: ${String(lastError)}`);
167
+ }
168
+ await new Promise((resolve) => setTimeout(resolve, interval));
169
+ }
170
+ }
@@ -0,0 +1,113 @@
1
+ // @flow
2
+ //
3
+ // The six forms of every query, generated once.
4
+ //
5
+ // Writing `getByText`, `queryByText`, `findByText`, `getAllByText`,
6
+ // `queryAllByText` and `findAllByText` by hand, for seven queries, is
7
+ // forty-two functions that differ in two decisions: whether finding nothing is
8
+ // an error, and whether to wait. So the two decisions are written once and the
9
+ // forty-two are derived — which also means a new query is one entry rather
10
+ // than six functions.
11
+
12
+ import {
13
+ allByDisplayValue,
14
+ allByLabelText,
15
+ allByPlaceholderText,
16
+ allByRole,
17
+ allByTestId,
18
+ allByText,
19
+ queryFailure,
20
+ } from "./queries.js";
21
+ import type { Matcher, MatcherOptions } from "./queries.js";
22
+ import { documentOf } from "./dom.js";
23
+ import { waitFor } from "./render.js";
24
+
25
+ /** The queries available on `screen` and on `within(element)`. */
26
+ export type Queries = {
27
+ readonly [string]: (matcher: mixed, options?: mixed) => any,
28
+ };
29
+
30
+ /** Every query, as the one function each needs. */
31
+ const FINDERS = {
32
+ Text: allByText,
33
+ Role: allByRole,
34
+ LabelText: allByLabelText,
35
+ PlaceholderText: allByPlaceholderText,
36
+ TestId: allByTestId,
37
+ DisplayValue: allByDisplayValue,
38
+ };
39
+
40
+ /**
41
+ * The six forms of one finder, bound to a root.
42
+ *
43
+ * `getBy` fails when there is not exactly one, and says how many it saw and
44
+ * what the markup looked like, because "found 3 elements" and "found nothing"
45
+ * are different bugs and a test that reports neither wastes the reader's time.
46
+ */
47
+ function forms(name: string, find: Function, root: () => ParentNode): { [string]: Function } {
48
+ const all = (matcher: Matcher, options?: MatcherOptions) => find(root(), matcher, options);
49
+
50
+ return {
51
+ [`getAllBy${name}`]: (matcher: Matcher, options?: MatcherOptions) => {
52
+ const found = all(matcher, options);
53
+ if (found.length === 0) {
54
+ throw queryFailure(`getAllBy${name}`, matcher, root(), 0);
55
+ }
56
+ return found;
57
+ },
58
+ [`queryAllBy${name}`]: all,
59
+ [`getBy${name}`]: (matcher: Matcher, options?: MatcherOptions) => {
60
+ const found = all(matcher, options);
61
+ if (found.length !== 1) {
62
+ throw queryFailure(`getBy${name}`, matcher, root(), found.length);
63
+ }
64
+ return found[0];
65
+ },
66
+ [`queryBy${name}`]: (matcher: Matcher, options?: MatcherOptions) => {
67
+ const found = all(matcher, options);
68
+ if (found.length > 1) {
69
+ throw queryFailure(`queryBy${name}`, matcher, root(), found.length);
70
+ }
71
+ return found[0] ?? null;
72
+ },
73
+ [`findBy${name}`]: (matcher: Matcher, options?: MatcherOptions) =>
74
+ waitFor(() => {
75
+ const found = all(matcher, options);
76
+ if (found.length !== 1) {
77
+ throw queryFailure(`findBy${name}`, matcher, root(), found.length);
78
+ }
79
+ return found[0];
80
+ }),
81
+ [`findAllBy${name}`]: (matcher: Matcher, options?: MatcherOptions) =>
82
+ waitFor(() => {
83
+ const found = all(matcher, options);
84
+ if (found.length === 0) {
85
+ throw queryFailure(`findAllBy${name}`, matcher, root(), 0);
86
+ }
87
+ return found;
88
+ }),
89
+ };
90
+ }
91
+
92
+ function queriesFor(root: () => ParentNode): Queries {
93
+ const queries = {};
94
+ for (const name of Object.keys(FINDERS)) {
95
+ Object.assign(queries, forms(name, (FINDERS as any)[name], root));
96
+ }
97
+ return queries as any;
98
+ }
99
+
100
+ /**
101
+ * Queries over the whole document.
102
+ *
103
+ * The document rather than the rendered container, because a dialog, a tooltip
104
+ * and a toast are rendered into a portal outside it — and a test that could
105
+ * not see them would be unable to assert on the components most likely to have
106
+ * a bug.
107
+ */
108
+ export const screen: Queries = queriesFor(() => documentOf().body);
109
+
110
+ /** The same queries, restricted to one element's subtree. */
111
+ export function within(element: ParentNode): Queries {
112
+ return queriesFor(() => element);
113
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@uniflowed/react-testing",
3
+ "version": "0.0.0-alpha.2",
4
+ "description": "React Testing Library over a real DOM, part of the Unified Toolchain for Flow.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ubugeeei-prod/uf.git",
11
+ "directory": "packages/react-testing"
12
+ },
13
+ "exports": {
14
+ ".": "./index.js"
15
+ },
16
+ "files": [
17
+ "index.js",
18
+ "internal"
19
+ ],
20
+ "dependencies": {
21
+ "@uniflowed/core": "0.0.0-alpha.2",
22
+ "@uniflowed/react": "0.0.0-alpha.2",
23
+ "happy-dom": "^20.13.2"
24
+ },
25
+ "peerDependencies": {
26
+ "react": ">=19",
27
+ "react-dom": ">=19"
28
+ }
29
+ }