@uniflowed/react-testing 0.0.0-alpha.6 → 0.0.0-alpha.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/internal/events.js +11 -3
- package/internal/queries.js +266 -1
- package/internal/render.js +4 -2
- package/internal/screen.js +64 -23
- package/package.json +3 -3
package/internal/events.js
CHANGED
|
@@ -111,7 +111,10 @@ const ALSO_BUBBLES: { readonly [string]: string } = {
|
|
|
111
111
|
* stays one cast at one line rather than a dozen errors at every call.
|
|
112
112
|
*/
|
|
113
113
|
function construct(name: string, init: EventInit): Event {
|
|
114
|
-
// One cast, covering the lookup and both constructions; see above.
|
|
114
|
+
// One cast, covering the lookup and both constructions; see above. Suppressed
|
|
115
|
+
// rather than left to fail `check:lib`, because the half that would have to
|
|
116
|
+
// change is Flow's library definition for the event initialiser dictionaries.
|
|
117
|
+
// uf-lint-disable-next-line flow/unclear-type
|
|
115
118
|
const classes: any = globalThis;
|
|
116
119
|
const Constructor = classes[EVENT_TYPES[name] ?? "Event"] ?? classes.Event;
|
|
117
120
|
const options = optionsFor(name, init);
|
|
@@ -210,9 +213,14 @@ export function dispatch(target: EventTarget, name: string, init?: EventInit): b
|
|
|
210
213
|
* that stops answering to the hundred-and-first. That is a design decision
|
|
211
214
|
* about a published API and not a cast to remove in passing.
|
|
212
215
|
*
|
|
213
|
-
*
|
|
214
|
-
* of `flow/unclear-type`'s sight
|
|
216
|
+
* Suppressed by name rather than renamed to `$FlowFixMe`. The rename would
|
|
217
|
+
* move it out of `flow/unclear-type`'s sight and say nothing; the directive
|
|
218
|
+
* below names the rule it is escaping, is checked by
|
|
219
|
+
* `uniflowed/unknown-lint-suppression`, and can be counted — this is one of
|
|
220
|
+
* the seven in the packages, and ubugeeei-prod/uf#401 is the design decision
|
|
221
|
+
* that would remove it.
|
|
215
222
|
*/
|
|
223
|
+
// uf-lint-disable-next-line flow/unclear-type
|
|
216
224
|
export const fireEvent: any = new Proxy(
|
|
217
225
|
(target: EventTarget, name: string, init?: EventInit) => dispatch(target, name, init),
|
|
218
226
|
{
|
package/internal/queries.js
CHANGED
|
@@ -40,6 +40,16 @@ export type MatcherOptions = {|
|
|
|
40
40
|
readonly exact?: boolean,
|
|
41
41
|
|};
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The keys of `MatcherOptions`, for the check that refuses the others.
|
|
45
|
+
*
|
|
46
|
+
* Written out beside the type rather than derived from it because Flow has no
|
|
47
|
+
* way to produce one from the other: `$Keys` of an exact object is a type, and
|
|
48
|
+
* this has to exist while the program runs. The two are short and adjacent so
|
|
49
|
+
* that a reader can check them against each other by eye.
|
|
50
|
+
*/
|
|
51
|
+
export const MATCHER_OPTION_KEYS: $ReadOnlyArray<string> = ["exact"];
|
|
52
|
+
|
|
43
53
|
/**
|
|
44
54
|
* How to narrow a role query.
|
|
45
55
|
*
|
|
@@ -52,8 +62,66 @@ export type RoleOptions = {|
|
|
|
52
62
|
readonly name?: Matcher,
|
|
53
63
|
/** `false` matches a substring of the name. Defaults to `true`. */
|
|
54
64
|
readonly exact?: boolean,
|
|
65
|
+
/**
|
|
66
|
+
* `true` also returns what the accessibility tree does not expose.
|
|
67
|
+
*
|
|
68
|
+
* Defaults to `false`, which is the question a role query is asking. The
|
|
69
|
+
* other question — "is it in the document at all" — is a real one, and this
|
|
70
|
+
* is how a test says it means that one.
|
|
71
|
+
*/
|
|
72
|
+
readonly hidden?: boolean,
|
|
73
|
+
/** The level a heading is announced at. Only a heading has one. */
|
|
74
|
+
readonly level?: number,
|
|
75
|
+
/**
|
|
76
|
+
* What `aria-current` has to say: one of its tokens, `true`, or `false` for
|
|
77
|
+
* the elements that are not the current one.
|
|
78
|
+
*/
|
|
79
|
+
readonly current?: boolean | string,
|
|
55
80
|
|};
|
|
56
81
|
|
|
82
|
+
/** The keys of `RoleOptions`. See [`MATCHER_OPTION_KEYS`]. */
|
|
83
|
+
export const ROLE_OPTION_KEYS: $ReadOnlyArray<string> = [
|
|
84
|
+
"current",
|
|
85
|
+
"exact",
|
|
86
|
+
"hidden",
|
|
87
|
+
"level",
|
|
88
|
+
"name",
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Raise unless every key of `options` is one this query takes.
|
|
93
|
+
*
|
|
94
|
+
* An option a query does not understand is the failure this module was worst
|
|
95
|
+
* at: `getByRole("heading", { level: 3 })` read as an assertion about a
|
|
96
|
+
* heading level, asserted nothing at all, and nothing anywhere said so. A
|
|
97
|
+
* silently ignored option is worse than an unsupported one, because the test
|
|
98
|
+
* that passes because of it is the test nobody looks at again.
|
|
99
|
+
*
|
|
100
|
+
* This is the half that can be right without a checker. The option types are
|
|
101
|
+
* exact, so `uf check` will refuse the same key once ubugeeei-prod/uf#248
|
|
102
|
+
* stops typing this package as `any` — and a test written against a published
|
|
103
|
+
* build has no checker in the loop at all.
|
|
104
|
+
*/
|
|
105
|
+
export function rejectUnknownOptions(
|
|
106
|
+
query: string,
|
|
107
|
+
options: mixed,
|
|
108
|
+
known: $ReadOnlyArray<string>,
|
|
109
|
+
): void {
|
|
110
|
+
if (options == null) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (typeof options !== "object") {
|
|
114
|
+
throw new Error(`${query}: the options are ${String(options)}, and an object was expected`);
|
|
115
|
+
}
|
|
116
|
+
for (const key of Object.keys(options)) {
|
|
117
|
+
if (!known.includes(key)) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`${query}: "${key}" is not an option this query takes. It takes ${known.join(", ")}.`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
57
125
|
/**
|
|
58
126
|
* Collapse whitespace the way a browser does when it lays text out.
|
|
59
127
|
*
|
|
@@ -113,7 +181,34 @@ export function allByText(
|
|
|
113
181
|
|
|
114
182
|
/** Elements with this ARIA role, whether written down or implied by the tag. */
|
|
115
183
|
export function allByRole(root: Element, role: string, options?: RoleOptions): Array<Element> {
|
|
116
|
-
const
|
|
184
|
+
const level = options?.level;
|
|
185
|
+
if (level != null && role !== "heading") {
|
|
186
|
+
// Refused rather than ignored. An option a query accepts and does nothing
|
|
187
|
+
// with turns a test into a decoration: it reads as if it checks the thing
|
|
188
|
+
// it was written for and checks nothing.
|
|
189
|
+
throw new Error(
|
|
190
|
+
`getByRole("${role}", { level }): a level narrows a heading, and this query asked for "${role}"`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Elements the accessibility tree does not expose are dropped, because a
|
|
195
|
+
// role query asks what a reader is told and those are told to nobody: a
|
|
196
|
+
// closed accordion panel, a `display: none` menu, the page behind an open
|
|
197
|
+
// dialog. Returning them made "is this announced" unaskable, which is the
|
|
198
|
+
// one thing the query is for.
|
|
199
|
+
let found = candidates(root, "*").filter(
|
|
200
|
+
(element) => roleOf(element) === role && (options?.hidden === true || exposed(element)),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
if (level != null) {
|
|
204
|
+
found = found.filter((element) => headingLevel(element) === level);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const current = options?.current;
|
|
208
|
+
if (current != null) {
|
|
209
|
+
found = found.filter((element) => currentOf(element) === current);
|
|
210
|
+
}
|
|
211
|
+
|
|
117
212
|
const name = options?.name;
|
|
118
213
|
if (name == null) {
|
|
119
214
|
return found;
|
|
@@ -123,6 +218,106 @@ export function allByRole(root: Element, role: string, options?: RoleOptions): A
|
|
|
123
218
|
);
|
|
124
219
|
}
|
|
125
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Whether the accessibility tree exposes this element.
|
|
223
|
+
*
|
|
224
|
+
* Up the ancestors, because each of these hides a subtree: an element under a
|
|
225
|
+
* `display: none` parent is announced by nobody however plain its own style
|
|
226
|
+
* is.
|
|
227
|
+
*
|
|
228
|
+
* `packages/test/internal/expect.js` carries a walk that looks like this one
|
|
229
|
+
* and answers a different question. `toBeVisible` asks whether a reader would
|
|
230
|
+
* *see* the element, so it counts `opacity: 0` as hidden — and a screen reader
|
|
231
|
+
* announces an element at zero opacity, which is exactly why hiding text that
|
|
232
|
+
* way is a bug rather than a technique. The two rules part company there, and
|
|
233
|
+
* a role query wants this one. `aria-hidden` is the mirror image: the element
|
|
234
|
+
* is on the screen and out of the tree.
|
|
235
|
+
*
|
|
236
|
+
* `hidden` is taken in every spelling, `hidden="until-found"` included. That
|
|
237
|
+
* one applies `content-visibility: hidden`, whose subtree is not in the
|
|
238
|
+
* accessibility tree; find-in-page being able to reach it does not make it
|
|
239
|
+
* announced, and a closed accordion panel is the case that raised this.
|
|
240
|
+
*/
|
|
241
|
+
function exposed(element: Element): boolean {
|
|
242
|
+
let child: Element | null = null;
|
|
243
|
+
let current: Element | null = element;
|
|
244
|
+
while (current != null) {
|
|
245
|
+
if (current.hasAttribute("hidden") || current.getAttribute("aria-hidden") === "true") {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
// A closed `<details>` renders its summary and nothing else, so the
|
|
249
|
+
// summary is still announced and everything beside it is not — the half
|
|
250
|
+
// that a walk looking only at the ancestor gets wrong.
|
|
251
|
+
if (
|
|
252
|
+
child != null &&
|
|
253
|
+
current.tagName.toLowerCase() === "details" &&
|
|
254
|
+
!current.hasAttribute("open") &&
|
|
255
|
+
child.tagName.toLowerCase() !== "summary"
|
|
256
|
+
) {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
const style = current.ownerDocument?.defaultView?.getComputedStyle?.(current);
|
|
260
|
+
if (style != null && (style.display === "none" || style.visibility === "hidden")) {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
child = current;
|
|
264
|
+
current = current.parentElement;
|
|
265
|
+
}
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The level a heading is announced at, or `null` when it has none.
|
|
271
|
+
*
|
|
272
|
+
* `aria-level` first, because the ARIA attribute overrides what the host
|
|
273
|
+
* language implies: `<h2 aria-level="4">` is a level four heading. Then the
|
|
274
|
+
* tag. Then two, which is what browsers fall back to when `role="heading"` is
|
|
275
|
+
* written without the `aria-level` ARIA requires with it — an authoring
|
|
276
|
+
* mistake, and a query has to answer the way a reader would be told rather
|
|
277
|
+
* than the way the author meant.
|
|
278
|
+
*
|
|
279
|
+
* `null` for an `aria-level` that is not a whole number of at least one, which
|
|
280
|
+
* is what ARIA says the value is. A level of "big" is not a level, and
|
|
281
|
+
* matching nothing says so.
|
|
282
|
+
*/
|
|
283
|
+
function headingLevel(element: Element): number | null {
|
|
284
|
+
const written = element.getAttribute("aria-level");
|
|
285
|
+
if (written != null && written.trim() !== "") {
|
|
286
|
+
const level = Number(written);
|
|
287
|
+
return Number.isInteger(level) && level >= 1 ? level : null;
|
|
288
|
+
}
|
|
289
|
+
const tag = element.tagName.toLowerCase();
|
|
290
|
+
return tag.length === 2 && tag[0] === "h" && tag[1] >= "1" && tag[1] <= "6" ? Number(tag[1]) : 2;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The tokens `aria-current` is defined for, beside `true` and `false`. */
|
|
294
|
+
const CURRENT_TOKENS = ["date", "location", "page", "step", "time"];
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* What `aria-current` says about this element.
|
|
298
|
+
*
|
|
299
|
+
* Absent, empty and `"false"` are one answer — not current — which is why the
|
|
300
|
+
* option's `false` has to mean "and carries no such attribute" rather than
|
|
301
|
+
* "and the attribute says false". Every element on a page is not-current.
|
|
302
|
+
*
|
|
303
|
+
* A token nobody has heard of is `true`. That is ARIA's rule rather than a
|
|
304
|
+
* guess: any value outside the list is treated as if `aria-current="true"` had
|
|
305
|
+
* been written, not as the default `false`. So a misspelt `aria-current="pge"`
|
|
306
|
+
* *is* announced as the current item, `{ current: true }` is the query that
|
|
307
|
+
* finds it, and `{ current: "pge" }` finds nothing — which is the answer a
|
|
308
|
+
* person looking for their typo needs.
|
|
309
|
+
*/
|
|
310
|
+
function currentOf(element: Element): boolean | string {
|
|
311
|
+
const written = element.getAttribute("aria-current");
|
|
312
|
+
if (written == null || written === "" || written === "false") {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
if (written === "true") {
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
return CURRENT_TOKENS.includes(written) ? written : true;
|
|
319
|
+
}
|
|
320
|
+
|
|
126
321
|
/** Form controls labelled by this text. */
|
|
127
322
|
export function allByLabelText(
|
|
128
323
|
root: Element,
|
|
@@ -303,6 +498,15 @@ export function roleOf(element: Element): string | null {
|
|
|
303
498
|
// A link without a destination is not a link.
|
|
304
499
|
return "generic";
|
|
305
500
|
}
|
|
501
|
+
if (tag === "th") {
|
|
502
|
+
// A `<th>` is a `columnheader` or a `rowheader` depending on what it
|
|
503
|
+
// heads, and `scope` is how the document says which. Mapping every `th`
|
|
504
|
+
// to `columnheader` made `getByRole("rowheader")` find nothing in a table
|
|
505
|
+
// of records — where every row has one, and where it is the cell that
|
|
506
|
+
// makes a screen reader say "Ada Lovelace, 1815" instead of "1815".
|
|
507
|
+
const scope = (element.getAttribute("scope") ?? "").toLowerCase();
|
|
508
|
+
return scope === "row" || scope === "rowgroup" ? "rowheader" : "columnheader";
|
|
509
|
+
}
|
|
306
510
|
return IMPLICIT_ROLES[tag] ?? null;
|
|
307
511
|
}
|
|
308
512
|
|
|
@@ -352,9 +556,70 @@ export function accessibleName(element: Element): string {
|
|
|
352
556
|
}
|
|
353
557
|
}
|
|
354
558
|
|
|
559
|
+
const naming = namingChild(element);
|
|
560
|
+
if (naming != null) {
|
|
561
|
+
return textOf(naming);
|
|
562
|
+
}
|
|
563
|
+
|
|
355
564
|
return textOf(element);
|
|
356
565
|
}
|
|
357
566
|
|
|
567
|
+
/** The child that names its parent, for the three elements HTML-AAM gives one. */
|
|
568
|
+
const NAMING_CHILDREN: { readonly [string]: string } = {
|
|
569
|
+
fieldset: "legend",
|
|
570
|
+
figure: "figcaption",
|
|
571
|
+
table: "caption",
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* The element that names this one from inside it, or `null`.
|
|
576
|
+
*
|
|
577
|
+
* A `<table>` is named by its `<caption>`, a `<fieldset>` by its `<legend>`
|
|
578
|
+
* and a `<figure>` by its `<figcaption>`. HTML-AAM says so and every browser
|
|
579
|
+
* does it, and without it "the element's own text" is what a table falls back
|
|
580
|
+
* to — which for a table is every cell in it, so `getByRole("table", { name:
|
|
581
|
+
* "People" })` was asking whether the name was `"People Name Born Ada Lovelace
|
|
582
|
+
* 1815 …"` and finding nothing.
|
|
583
|
+
*
|
|
584
|
+
* A direct child, which is what the three rules say: the `<caption>` of a
|
|
585
|
+
* table rather than of a table nested in one of its cells.
|
|
586
|
+
*/
|
|
587
|
+
function namingChild(element: Element): Element | null {
|
|
588
|
+
const wanted = NAMING_CHILDREN[element.tagName.toLowerCase()];
|
|
589
|
+
if (wanted == null) {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
return (
|
|
593
|
+
Array.from(element.children).find((child) => child.tagName.toLowerCase() === wanted) ?? null
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* `error`, reported where the query was written rather than where it gave up.
|
|
599
|
+
*
|
|
600
|
+
* A `findBy…` polls, and the attempt whose failure it keeps is the last one —
|
|
601
|
+
* which runs from a timer, with nothing of the test on the stack under it. The
|
|
602
|
+
* error is the right error; only its position is missing, and a failure with
|
|
603
|
+
* no position is the one thing `ubugeeei-prod/uf#319` was about. `asked` is an
|
|
604
|
+
* error built at the call, so its frames are the caller's; rebuilding the
|
|
605
|
+
* stack from the name and message is what the engine itself would have written
|
|
606
|
+
* had the failure been raised there.
|
|
607
|
+
*
|
|
608
|
+
* `mixed` rather than `Error` because a wait rethrows whatever the body threw,
|
|
609
|
+
* and a body may throw a string. One that is not an error carries no stack to
|
|
610
|
+
* correct and is handed back untouched.
|
|
611
|
+
*/
|
|
612
|
+
export function atCallSite(error: mixed, asked: Error): mixed {
|
|
613
|
+
if (!(error instanceof Error)) {
|
|
614
|
+
return error;
|
|
615
|
+
}
|
|
616
|
+
const frames = (asked.stack ?? "").split("\n").slice(1);
|
|
617
|
+
if (frames.length > 0) {
|
|
618
|
+
error.stack = [`${error.name}: ${error.message}`, ...frames].join("\n");
|
|
619
|
+
}
|
|
620
|
+
return error;
|
|
621
|
+
}
|
|
622
|
+
|
|
358
623
|
/** Why a query failed, with enough of the DOM to see why. */
|
|
359
624
|
export function queryFailure(kind: string, matcher: Matcher, root: Element, found: number): Error {
|
|
360
625
|
const description =
|
package/internal/render.js
CHANGED
|
@@ -212,8 +212,10 @@ export function actively<T>(body: () => T): T {
|
|
|
212
212
|
// for every call would hand `act(() => {})` a floating promise, and
|
|
213
213
|
// returning the scope itself would depend on React's thenable passing the
|
|
214
214
|
// callback's value through, which is the assumption the comment above
|
|
215
|
-
// records going wrong. So the cast stays,
|
|
216
|
-
//
|
|
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
|
|
217
219
|
return Promise.resolve(scope).then(() => result) as any;
|
|
218
220
|
}
|
|
219
221
|
return result;
|
package/internal/screen.js
CHANGED
|
@@ -28,13 +28,17 @@
|
|
|
28
28
|
// wants to be able to check against the runtime by eye.
|
|
29
29
|
|
|
30
30
|
import {
|
|
31
|
+
MATCHER_OPTION_KEYS,
|
|
32
|
+
ROLE_OPTION_KEYS,
|
|
31
33
|
allByDisplayValue,
|
|
32
34
|
allByLabelText,
|
|
33
35
|
allByPlaceholderText,
|
|
34
36
|
allByRole,
|
|
35
37
|
allByTestId,
|
|
36
38
|
allByText,
|
|
39
|
+
atCallSite,
|
|
37
40
|
queryFailure,
|
|
41
|
+
rejectUnknownOptions,
|
|
38
42
|
} from "./queries.js";
|
|
39
43
|
import type { Matcher, MatcherOptions, RoleOptions } from "./queries.js";
|
|
40
44
|
import { bodyOf } from "./dom.js";
|
|
@@ -134,24 +138,40 @@ export type Queries = {|
|
|
|
134
138
|
* `queryFailure` has to describe what was asked for, and it describes the
|
|
135
139
|
* three things a matcher can be. A role is a string, so the bound holds and
|
|
136
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.
|
|
137
148
|
*/
|
|
138
149
|
function forms<TTarget extends Matcher, TOptions>(
|
|
139
150
|
name: string,
|
|
140
151
|
find: (root: Element, target: TTarget, options?: TOptions) => Array<Element>,
|
|
141
152
|
root: () => Element,
|
|
153
|
+
known: $ReadOnlyArray<string>,
|
|
142
154
|
): Forms<TTarget, TOptions> {
|
|
143
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
|
+
};
|
|
144
159
|
|
|
145
160
|
return {
|
|
146
161
|
getAll: (target, options) => {
|
|
162
|
+
check("getAll", options);
|
|
147
163
|
const found = all(target, options);
|
|
148
164
|
if (found.length === 0) {
|
|
149
165
|
throw queryFailure(`getAllBy${name}`, target, root(), 0);
|
|
150
166
|
}
|
|
151
167
|
return found;
|
|
152
168
|
},
|
|
153
|
-
queryAll:
|
|
169
|
+
queryAll: (target, options) => {
|
|
170
|
+
check("queryAll", options);
|
|
171
|
+
return all(target, options);
|
|
172
|
+
},
|
|
154
173
|
get: (target, options) => {
|
|
174
|
+
check("get", options);
|
|
155
175
|
const found = all(target, options);
|
|
156
176
|
if (found.length !== 1) {
|
|
157
177
|
throw queryFailure(`getBy${name}`, target, root(), found.length);
|
|
@@ -159,38 +179,59 @@ function forms<TTarget extends Matcher, TOptions>(
|
|
|
159
179
|
return found[0];
|
|
160
180
|
},
|
|
161
181
|
query: (target, options) => {
|
|
182
|
+
check("query", options);
|
|
162
183
|
const found = all(target, options);
|
|
163
184
|
if (found.length > 1) {
|
|
164
185
|
throw queryFailure(`queryBy${name}`, target, root(), found.length);
|
|
165
186
|
}
|
|
166
187
|
return found[0] ?? null;
|
|
167
188
|
},
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
+
},
|
|
184
225
|
};
|
|
185
226
|
}
|
|
186
227
|
|
|
187
228
|
function queriesFor(root: () => Element): Queries {
|
|
188
|
-
const text = forms("Text", allByText, root);
|
|
189
|
-
const role = forms("Role", allByRole, root);
|
|
190
|
-
const labelText = forms("LabelText", allByLabelText, root);
|
|
191
|
-
const placeholderText = forms("PlaceholderText", allByPlaceholderText, root);
|
|
192
|
-
const testId = forms("TestId", allByTestId, root);
|
|
193
|
-
const displayValue = forms("DisplayValue", allByDisplayValue, root);
|
|
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);
|
|
194
235
|
|
|
195
236
|
return {
|
|
196
237
|
getByText: text.get,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/react-testing",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.8",
|
|
4
4
|
"description": "React Testing Library over a real DOM, part of the Unified Toolchain for Flow.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
"internal"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@uniflowed/core": "0.0.0-alpha.
|
|
22
|
-
"@uniflowed/react": "0.0.0-alpha.
|
|
21
|
+
"@uniflowed/core": "0.0.0-alpha.8",
|
|
22
|
+
"@uniflowed/react": "0.0.0-alpha.8",
|
|
23
23
|
"happy-dom": "^20.13.2"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|