@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 +32 -0
- package/internal/dom.js +135 -0
- package/internal/events.js +338 -0
- package/internal/queries.js +311 -0
- package/internal/render.js +170 -0
- package/internal/screen.js +113 -0
- package/package.json +29 -0
package/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/react-testing`: React Testing Library's surface, over a real DOM.
|
|
4
|
+
//
|
|
5
|
+
// The library's premise is that a test should reach for an element the way a
|
|
6
|
+
// person does — by the text on it, by the role it plays, by the label next to
|
|
7
|
+
// it — so a test keeps passing when the markup is refactored and stops passing
|
|
8
|
+
// when the thing a user relies on breaks. That premise is worth keeping, so
|
|
9
|
+
// this is the same shape: `render`, `screen`, `fireEvent`, `userEvent`,
|
|
10
|
+
// `waitFor`.
|
|
11
|
+
//
|
|
12
|
+
// It used to be a declaration whose every function threw. `uf test` runs on
|
|
13
|
+
// Node.js, Bun or Deno and none of them has a DOM, so one is installed on
|
|
14
|
+
// first render — which is why a component test needs nothing configured.
|
|
15
|
+
//
|
|
16
|
+
// # Queries
|
|
17
|
+
//
|
|
18
|
+
// Every query is `getBy`, `queryBy`, `findBy`, `getAllBy`, `queryAllBy` or
|
|
19
|
+
// `findAllBy` over the same matcher, and the choice says what the test means:
|
|
20
|
+
// `getBy` asserts presence now, `queryBy` is for asking about absence, and
|
|
21
|
+
// `findBy` waits. They are on `screen`, which searches the whole document so a
|
|
22
|
+
// portal is found, and on the result of `render`, which searches only what it
|
|
23
|
+
// mounted.
|
|
24
|
+
|
|
25
|
+
export type { Matcher, MatcherOptions } from "./internal/queries.js";
|
|
26
|
+
export type { RenderResult } from "./internal/render.js";
|
|
27
|
+
export type { Queries } from "./internal/screen.js";
|
|
28
|
+
|
|
29
|
+
export { normalize, accessibleName, roleOf } from "./internal/queries.js";
|
|
30
|
+
export { actively as act, cleanup, render, waitFor } from "./internal/render.js";
|
|
31
|
+
export { dispatch, fireEvent, userEvent } from "./internal/events.js";
|
|
32
|
+
export { screen, within } from "./internal/screen.js";
|
package/internal/dom.js
ADDED
|
@@ -0,0 +1,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
|
+
}
|