@uniflowed/react-testing 0.0.0-alpha.10
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 +312 -0
- package/internal/events.js +503 -0
- package/internal/queries.js +635 -0
- package/internal/render.js +298 -0
- package/internal/screen.js +294 -0
- package/package.json +29 -0
|
@@ -0,0 +1,298 @@
|
|
|
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 { bodyOf, installActEnvironment, installDom, setActEnvironment } 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: ReactRoot,
|
|
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: bodyOf(),
|
|
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
|
+
bodyOf().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: ReactDomClient | null = null;
|
|
118
|
+
function requireClient(): ReactDomClient {
|
|
119
|
+
if (client == null) {
|
|
120
|
+
const load = createRequire(import.meta.url);
|
|
121
|
+
client = load("react-dom/client");
|
|
122
|
+
}
|
|
123
|
+
return client;
|
|
124
|
+
}
|
|
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
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Run `body`, letting React flush everything it queues.
|
|
146
|
+
*
|
|
147
|
+
* Exported because a test that changes state outside an event — a timer
|
|
148
|
+
* firing, a promise settling — has to tell React that the change happened,
|
|
149
|
+
* and this is how.
|
|
150
|
+
*/
|
|
151
|
+
export function actively<T>(body: () => T): T {
|
|
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;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const held = produced.current;
|
|
177
|
+
if (held == null) {
|
|
178
|
+
throw new Error("act(...) did not run its scope, so there is no result to return");
|
|
179
|
+
}
|
|
180
|
+
const result = held.value;
|
|
181
|
+
|
|
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, suppressed by name rather than
|
|
216
|
+
// renamed to `$FlowFixMe`: the directive says which rule it is escaping and
|
|
217
|
+
// this comment says why, which a rename says nothing about.
|
|
218
|
+
// uf-lint-disable-next-line flow/unclear-type
|
|
219
|
+
return Promise.resolve(scope).then(() => result) as any;
|
|
220
|
+
}
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Whether `value` is something to await. */
|
|
225
|
+
function isThenable(value: mixed): boolean {
|
|
226
|
+
if (value == null || typeof value !== "object") {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
const object: { readonly [string]: mixed } = value;
|
|
230
|
+
return typeof object.then === "function";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Wait until `body` stops throwing, or give up.
|
|
235
|
+
*
|
|
236
|
+
* Polling rather than observing mutations, because what a test waits for is
|
|
237
|
+
* usually not a DOM change at all — it is a promise resolving, a fetch
|
|
238
|
+
* settling, a timer firing — and a mutation observer sees none of those.
|
|
239
|
+
*/
|
|
240
|
+
export async function waitFor<T>(
|
|
241
|
+
body: () => T | Promise<T>,
|
|
242
|
+
options?: {| readonly timeout?: number, readonly interval?: number |},
|
|
243
|
+
): Promise<T> {
|
|
244
|
+
installActEnvironment();
|
|
245
|
+
const timeout = options?.timeout ?? 1000;
|
|
246
|
+
const interval = options?.interval ?? 20;
|
|
247
|
+
|
|
248
|
+
// React is told this is not an act environment for as long as the wait
|
|
249
|
+
// lasts, and told again afterwards.
|
|
250
|
+
//
|
|
251
|
+
// The update a test waits for arrives between two polls, and React reports
|
|
252
|
+
// it as "an update to X inside a test was not wrapped in act(...)" —
|
|
253
|
+
// correctly, since nothing was there to flush it. The fix cannot be to put
|
|
254
|
+
// the polling loop inside an `act` scope: `act` holds updates back until
|
|
255
|
+
// the scope closes, so the loop would poll a tree that cannot change and
|
|
256
|
+
// every `waitFor` would run to its timeout.
|
|
257
|
+
//
|
|
258
|
+
// So the scope is stood down instead. The warning exists to catch an update
|
|
259
|
+
// a test did not know it was causing; a test that wrote `waitFor` knows.
|
|
260
|
+
// Counted rather than saved and restored, because waits nest: every
|
|
261
|
+
// `findBy…` is a `waitFor`, and a test may put one inside another. The
|
|
262
|
+
// outermost wait stands the environment down and the outermost restores it.
|
|
263
|
+
waits += 1;
|
|
264
|
+
if (waits === 1) {
|
|
265
|
+
setActEnvironment(false);
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
return await poll(body, timeout, interval);
|
|
269
|
+
} finally {
|
|
270
|
+
waits -= 1;
|
|
271
|
+
if (waits === 0) {
|
|
272
|
+
setActEnvironment(true);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** How many waits are in progress. */
|
|
278
|
+
let waits = 0;
|
|
279
|
+
|
|
280
|
+
/** Call `body` until it stops throwing, or give up after `timeout`. */
|
|
281
|
+
async function poll<T>(body: () => T | Promise<T>, timeout: number, interval: number): Promise<T> {
|
|
282
|
+
const deadline = Date.now() + timeout;
|
|
283
|
+
let lastError: mixed = null;
|
|
284
|
+
|
|
285
|
+
while (true) {
|
|
286
|
+
try {
|
|
287
|
+
return await body();
|
|
288
|
+
} catch (error) {
|
|
289
|
+
lastError = error;
|
|
290
|
+
}
|
|
291
|
+
if (Date.now() >= deadline) {
|
|
292
|
+
throw lastError instanceof Error
|
|
293
|
+
? lastError
|
|
294
|
+
: new Error(`waitFor timed out after ${timeout}ms: ${String(lastError)}`);
|
|
295
|
+
}
|
|
296
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
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 six queries, is thirty-six
|
|
7
|
+
// functions that differ in two decisions: whether finding nothing is an error,
|
|
8
|
+
// and whether to wait. So the two decisions are written once and the thirty-six
|
|
9
|
+
// are derived — which also means a new query is one entry rather than six
|
|
10
|
+
// functions.
|
|
11
|
+
//
|
|
12
|
+
// # The names are written down, and the behaviour is not
|
|
13
|
+
//
|
|
14
|
+
// `Queries` used to be `{ readonly [string]: (matcher: mixed, options?: mixed)
|
|
15
|
+
// => any }`, which is a way of writing "this object has whatever you ask it
|
|
16
|
+
// for, and it is whatever you like". That is a hole in the published type of a
|
|
17
|
+
// package whose entire purpose is testing *typed* components:
|
|
18
|
+
// `screen.getByRole("button").valeu` was not a mistake anybody's checker would
|
|
19
|
+
// find, `screen.getByTest("save")` was not a misspelling, and
|
|
20
|
+
// `await screen.getByText("Save")` — the missing `find`, which is the single
|
|
21
|
+
// most common mistake this library invites — was fine.
|
|
22
|
+
//
|
|
23
|
+
// So the thirty-six names are written out below. Flow has no template literal
|
|
24
|
+
// types, so `getBy${Name}` is not something a type can compute; the names have
|
|
25
|
+
// to be listed for the type to exist at all. What is *not* repeated is any
|
|
26
|
+
// behaviour: `forms` is still the one place the six decisions are made, and the
|
|
27
|
+
// listing below is a naming, six lines per query, which is the part a reader
|
|
28
|
+
// wants to be able to check against the runtime by eye.
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
MATCHER_OPTION_KEYS,
|
|
32
|
+
ROLE_OPTION_KEYS,
|
|
33
|
+
allByDisplayValue,
|
|
34
|
+
allByLabelText,
|
|
35
|
+
allByPlaceholderText,
|
|
36
|
+
allByRole,
|
|
37
|
+
allByTestId,
|
|
38
|
+
allByText,
|
|
39
|
+
atCallSite,
|
|
40
|
+
queryFailure,
|
|
41
|
+
rejectUnknownOptions,
|
|
42
|
+
} from "./queries.js";
|
|
43
|
+
import type { Matcher, MatcherOptions, RoleOptions } from "./queries.js";
|
|
44
|
+
import { bodyOf } from "./dom.js";
|
|
45
|
+
import { waitFor } from "./render.js";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One query's six forms, over whatever that query matches on.
|
|
49
|
+
*
|
|
50
|
+
* Generic in the target because `ByRole` does not take a `Matcher` — it takes
|
|
51
|
+
* a role, which is a string and only a string, and a regular expression over
|
|
52
|
+
* role names is a query that would silently match nothing. Generic in the
|
|
53
|
+
* options because `ByRole` is also the only query with more than `exact` to
|
|
54
|
+
* say.
|
|
55
|
+
*/
|
|
56
|
+
type Forms<TTarget, TOptions> = {|
|
|
57
|
+
readonly get: (target: TTarget, options?: TOptions) => Element,
|
|
58
|
+
readonly getAll: (target: TTarget, options?: TOptions) => Array<Element>,
|
|
59
|
+
readonly query: (target: TTarget, options?: TOptions) => Element | null,
|
|
60
|
+
readonly queryAll: (target: TTarget, options?: TOptions) => Array<Element>,
|
|
61
|
+
readonly find: (target: TTarget, options?: TOptions) => Promise<Element>,
|
|
62
|
+
readonly findAll: (target: TTarget, options?: TOptions) => Promise<Array<Element>>,
|
|
63
|
+
|};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The queries available on `screen` and on `within(element)`.
|
|
67
|
+
*
|
|
68
|
+
* Read down one column and the four questions of the module comment are the
|
|
69
|
+
* four return types: `getBy…` is an `Element` because it throws rather than
|
|
70
|
+
* hand back nothing, `queryBy…` is `Element | null` because its whole purpose
|
|
71
|
+
* is asking about absence, and the `findBy…` pair are promises because they
|
|
72
|
+
* wait.
|
|
73
|
+
*/
|
|
74
|
+
export type Queries = {|
|
|
75
|
+
readonly getByText: (matcher: Matcher, options?: MatcherOptions) => Element,
|
|
76
|
+
readonly getAllByText: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
|
|
77
|
+
readonly queryByText: (matcher: Matcher, options?: MatcherOptions) => Element | null,
|
|
78
|
+
readonly queryAllByText: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
|
|
79
|
+
readonly findByText: (matcher: Matcher, options?: MatcherOptions) => Promise<Element>,
|
|
80
|
+
readonly findAllByText: (matcher: Matcher, options?: MatcherOptions) => Promise<Array<Element>>,
|
|
81
|
+
|
|
82
|
+
readonly getByRole: (role: string, options?: RoleOptions) => Element,
|
|
83
|
+
readonly getAllByRole: (role: string, options?: RoleOptions) => Array<Element>,
|
|
84
|
+
readonly queryByRole: (role: string, options?: RoleOptions) => Element | null,
|
|
85
|
+
readonly queryAllByRole: (role: string, options?: RoleOptions) => Array<Element>,
|
|
86
|
+
readonly findByRole: (role: string, options?: RoleOptions) => Promise<Element>,
|
|
87
|
+
readonly findAllByRole: (role: string, options?: RoleOptions) => Promise<Array<Element>>,
|
|
88
|
+
|
|
89
|
+
readonly getByLabelText: (matcher: Matcher, options?: MatcherOptions) => Element,
|
|
90
|
+
readonly getAllByLabelText: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
|
|
91
|
+
readonly queryByLabelText: (matcher: Matcher, options?: MatcherOptions) => Element | null,
|
|
92
|
+
readonly queryAllByLabelText: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
|
|
93
|
+
readonly findByLabelText: (matcher: Matcher, options?: MatcherOptions) => Promise<Element>,
|
|
94
|
+
readonly findAllByLabelText: (
|
|
95
|
+
matcher: Matcher,
|
|
96
|
+
options?: MatcherOptions,
|
|
97
|
+
) => Promise<Array<Element>>,
|
|
98
|
+
|
|
99
|
+
readonly getByPlaceholderText: (matcher: Matcher, options?: MatcherOptions) => Element,
|
|
100
|
+
readonly getAllByPlaceholderText: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
|
|
101
|
+
readonly queryByPlaceholderText: (matcher: Matcher, options?: MatcherOptions) => Element | null,
|
|
102
|
+
readonly queryAllByPlaceholderText: (
|
|
103
|
+
matcher: Matcher,
|
|
104
|
+
options?: MatcherOptions,
|
|
105
|
+
) => Array<Element>,
|
|
106
|
+
readonly findByPlaceholderText: (matcher: Matcher, options?: MatcherOptions) => Promise<Element>,
|
|
107
|
+
readonly findAllByPlaceholderText: (
|
|
108
|
+
matcher: Matcher,
|
|
109
|
+
options?: MatcherOptions,
|
|
110
|
+
) => Promise<Array<Element>>,
|
|
111
|
+
|
|
112
|
+
readonly getByTestId: (matcher: Matcher, options?: MatcherOptions) => Element,
|
|
113
|
+
readonly getAllByTestId: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
|
|
114
|
+
readonly queryByTestId: (matcher: Matcher, options?: MatcherOptions) => Element | null,
|
|
115
|
+
readonly queryAllByTestId: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
|
|
116
|
+
readonly findByTestId: (matcher: Matcher, options?: MatcherOptions) => Promise<Element>,
|
|
117
|
+
readonly findAllByTestId: (matcher: Matcher, options?: MatcherOptions) => Promise<Array<Element>>,
|
|
118
|
+
|
|
119
|
+
readonly getByDisplayValue: (matcher: Matcher, options?: MatcherOptions) => Element,
|
|
120
|
+
readonly getAllByDisplayValue: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
|
|
121
|
+
readonly queryByDisplayValue: (matcher: Matcher, options?: MatcherOptions) => Element | null,
|
|
122
|
+
readonly queryAllByDisplayValue: (matcher: Matcher, options?: MatcherOptions) => Array<Element>,
|
|
123
|
+
readonly findByDisplayValue: (matcher: Matcher, options?: MatcherOptions) => Promise<Element>,
|
|
124
|
+
readonly findAllByDisplayValue: (
|
|
125
|
+
matcher: Matcher,
|
|
126
|
+
options?: MatcherOptions,
|
|
127
|
+
) => Promise<Array<Element>>,
|
|
128
|
+
|};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The six forms of one finder, bound to a root.
|
|
132
|
+
*
|
|
133
|
+
* `getBy` fails when there is not exactly one, and says how many it saw and
|
|
134
|
+
* what the markup looked like, because "found 3 elements" and "found nothing"
|
|
135
|
+
* are different bugs and a test that reports neither wastes the reader's time.
|
|
136
|
+
*
|
|
137
|
+
* `TTarget` is bounded by `Matcher` rather than left free because
|
|
138
|
+
* `queryFailure` has to describe what was asked for, and it describes the
|
|
139
|
+
* three things a matcher can be. A role is a string, so the bound holds and
|
|
140
|
+
* the failure message is the same one it always was.
|
|
141
|
+
*
|
|
142
|
+
* `known` is the keys the query's options may have, and each of the six checks
|
|
143
|
+
* before it looks at anything. Six lines rather than one inside `all`, because
|
|
144
|
+
* the message has to name the function the reader typed — `getByRole`, not
|
|
145
|
+
* "a role query" — and because the two waiting forms have to raise now rather
|
|
146
|
+
* than a second from now: an option a query does not take is a mistake in the
|
|
147
|
+
* test, not a condition that is about to come true.
|
|
148
|
+
*/
|
|
149
|
+
function forms<TTarget extends Matcher, TOptions>(
|
|
150
|
+
name: string,
|
|
151
|
+
find: (root: Element, target: TTarget, options?: TOptions) => Array<Element>,
|
|
152
|
+
root: () => Element,
|
|
153
|
+
known: $ReadOnlyArray<string>,
|
|
154
|
+
): Forms<TTarget, TOptions> {
|
|
155
|
+
const all = (target: TTarget, options?: TOptions) => find(root(), target, options);
|
|
156
|
+
const check = (form: string, options?: TOptions) => {
|
|
157
|
+
rejectUnknownOptions(`${form}By${name}`, options, known);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
getAll: (target, options) => {
|
|
162
|
+
check("getAll", options);
|
|
163
|
+
const found = all(target, options);
|
|
164
|
+
if (found.length === 0) {
|
|
165
|
+
throw queryFailure(`getAllBy${name}`, target, root(), 0);
|
|
166
|
+
}
|
|
167
|
+
return found;
|
|
168
|
+
},
|
|
169
|
+
queryAll: (target, options) => {
|
|
170
|
+
check("queryAll", options);
|
|
171
|
+
return all(target, options);
|
|
172
|
+
},
|
|
173
|
+
get: (target, options) => {
|
|
174
|
+
check("get", options);
|
|
175
|
+
const found = all(target, options);
|
|
176
|
+
if (found.length !== 1) {
|
|
177
|
+
throw queryFailure(`getBy${name}`, target, root(), found.length);
|
|
178
|
+
}
|
|
179
|
+
return found[0];
|
|
180
|
+
},
|
|
181
|
+
query: (target, options) => {
|
|
182
|
+
check("query", options);
|
|
183
|
+
const found = all(target, options);
|
|
184
|
+
if (found.length > 1) {
|
|
185
|
+
throw queryFailure(`queryBy${name}`, target, root(), found.length);
|
|
186
|
+
}
|
|
187
|
+
return found[0] ?? null;
|
|
188
|
+
},
|
|
189
|
+
// The two waiting forms build an error at the call and hand it to the
|
|
190
|
+
// failure on the way out. A wait keeps the *last* attempt's failure, and
|
|
191
|
+
// the last attempt runs from a timer: by then the stack under it is the
|
|
192
|
+
// poll loop and nothing else, so the failure has no line of the test left
|
|
193
|
+
// in it to report. The synchronous four need none of this — they throw
|
|
194
|
+
// while the caller is still on the stack.
|
|
195
|
+
find: async (target, options) => {
|
|
196
|
+
check("find", options);
|
|
197
|
+
const asked = new Error("asked here");
|
|
198
|
+
try {
|
|
199
|
+
return await waitFor(() => {
|
|
200
|
+
const found = all(target, options);
|
|
201
|
+
if (found.length !== 1) {
|
|
202
|
+
throw queryFailure(`findBy${name}`, target, root(), found.length);
|
|
203
|
+
}
|
|
204
|
+
return found[0];
|
|
205
|
+
});
|
|
206
|
+
} catch (error) {
|
|
207
|
+
throw atCallSite(error, asked);
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
findAll: async (target, options) => {
|
|
211
|
+
check("findAll", options);
|
|
212
|
+
const asked = new Error("asked here");
|
|
213
|
+
try {
|
|
214
|
+
return await waitFor(() => {
|
|
215
|
+
const found = all(target, options);
|
|
216
|
+
if (found.length === 0) {
|
|
217
|
+
throw queryFailure(`findAllBy${name}`, target, root(), 0);
|
|
218
|
+
}
|
|
219
|
+
return found;
|
|
220
|
+
});
|
|
221
|
+
} catch (error) {
|
|
222
|
+
throw atCallSite(error, asked);
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function queriesFor(root: () => Element): Queries {
|
|
229
|
+
const text = forms("Text", allByText, root, MATCHER_OPTION_KEYS);
|
|
230
|
+
const role = forms("Role", allByRole, root, ROLE_OPTION_KEYS);
|
|
231
|
+
const labelText = forms("LabelText", allByLabelText, root, MATCHER_OPTION_KEYS);
|
|
232
|
+
const placeholderText = forms("PlaceholderText", allByPlaceholderText, root, MATCHER_OPTION_KEYS);
|
|
233
|
+
const testId = forms("TestId", allByTestId, root, MATCHER_OPTION_KEYS);
|
|
234
|
+
const displayValue = forms("DisplayValue", allByDisplayValue, root, MATCHER_OPTION_KEYS);
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
getByText: text.get,
|
|
238
|
+
getAllByText: text.getAll,
|
|
239
|
+
queryByText: text.query,
|
|
240
|
+
queryAllByText: text.queryAll,
|
|
241
|
+
findByText: text.find,
|
|
242
|
+
findAllByText: text.findAll,
|
|
243
|
+
|
|
244
|
+
getByRole: role.get,
|
|
245
|
+
getAllByRole: role.getAll,
|
|
246
|
+
queryByRole: role.query,
|
|
247
|
+
queryAllByRole: role.queryAll,
|
|
248
|
+
findByRole: role.find,
|
|
249
|
+
findAllByRole: role.findAll,
|
|
250
|
+
|
|
251
|
+
getByLabelText: labelText.get,
|
|
252
|
+
getAllByLabelText: labelText.getAll,
|
|
253
|
+
queryByLabelText: labelText.query,
|
|
254
|
+
queryAllByLabelText: labelText.queryAll,
|
|
255
|
+
findByLabelText: labelText.find,
|
|
256
|
+
findAllByLabelText: labelText.findAll,
|
|
257
|
+
|
|
258
|
+
getByPlaceholderText: placeholderText.get,
|
|
259
|
+
getAllByPlaceholderText: placeholderText.getAll,
|
|
260
|
+
queryByPlaceholderText: placeholderText.query,
|
|
261
|
+
queryAllByPlaceholderText: placeholderText.queryAll,
|
|
262
|
+
findByPlaceholderText: placeholderText.find,
|
|
263
|
+
findAllByPlaceholderText: placeholderText.findAll,
|
|
264
|
+
|
|
265
|
+
getByTestId: testId.get,
|
|
266
|
+
getAllByTestId: testId.getAll,
|
|
267
|
+
queryByTestId: testId.query,
|
|
268
|
+
queryAllByTestId: testId.queryAll,
|
|
269
|
+
findByTestId: testId.find,
|
|
270
|
+
findAllByTestId: testId.findAll,
|
|
271
|
+
|
|
272
|
+
getByDisplayValue: displayValue.get,
|
|
273
|
+
getAllByDisplayValue: displayValue.getAll,
|
|
274
|
+
queryByDisplayValue: displayValue.query,
|
|
275
|
+
queryAllByDisplayValue: displayValue.queryAll,
|
|
276
|
+
findByDisplayValue: displayValue.find,
|
|
277
|
+
findAllByDisplayValue: displayValue.findAll,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Queries over the whole document.
|
|
283
|
+
*
|
|
284
|
+
* The document rather than the rendered container, because a dialog, a tooltip
|
|
285
|
+
* and a toast are rendered into a portal outside it — and a test that could
|
|
286
|
+
* not see them would be unable to assert on the components most likely to have
|
|
287
|
+
* a bug.
|
|
288
|
+
*/
|
|
289
|
+
export const screen: Queries = queriesFor(() => bodyOf());
|
|
290
|
+
|
|
291
|
+
/** The same queries, restricted to one element's subtree. */
|
|
292
|
+
export function within(element: Element): Queries {
|
|
293
|
+
return queriesFor(() => element);
|
|
294
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uniflowed/react-testing",
|
|
3
|
+
"version": "0.0.0-alpha.10",
|
|
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.10",
|
|
22
|
+
"@uniflowed/react": "0.0.0-alpha.10",
|
|
23
|
+
"happy-dom": "^20.13.2"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"react": ">=19",
|
|
27
|
+
"react-dom": ">=19"
|
|
28
|
+
}
|
|
29
|
+
}
|