@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.
@@ -0,0 +1,635 @@
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
+ // # Where a query looks
19
+ //
20
+ // `Element`. Every function here used to say `ParentNode`, which reads as the
21
+ // right name — "something with children to search" is exactly what a root is —
22
+ // and is not a type: the DOM specification has a `ParentNode` mixin, and Flow
23
+ // folds it into `Document`, `DocumentFragment` and `Element` as comments
24
+ // rather than declaring anything by that name. So it was twelve
25
+ // `cannot-resolve-name` errors between here and `internal/screen.js`, and an
26
+ // unresolvable name is `any`: `root` answered every question, which is why the
27
+ // casts below it existed at all.
28
+ //
29
+ // `Element` is what the two callers actually pass — the document's body, and
30
+ // an element a test already found — and it carries `querySelector`,
31
+ // `querySelectorAll` and `innerHTML`, which is everything this module asks of
32
+ // a root.
33
+
34
+ /** What a query will accept as a description of the thing to find. */
35
+ export type Matcher = string | RegExp | ((content: string, element: Element) => boolean);
36
+
37
+ /** How exactly a string matcher has to match. */
38
+ export type MatcherOptions = {|
39
+ /** `false` matches a substring, case-insensitively. Defaults to `true`. */
40
+ readonly exact?: boolean,
41
+ |};
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
+
53
+ /**
54
+ * How to narrow a role query.
55
+ *
56
+ * A role is shared by every button on the page, so `name` is the option that
57
+ * makes the query mean something: "the button called Save", which is how a
58
+ * person would say it and what a screen reader announces.
59
+ */
60
+ export type RoleOptions = {|
61
+ /** The accessible name the element must have. */
62
+ readonly name?: Matcher,
63
+ /** `false` matches a substring of the name. Defaults to `true`. */
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,
80
+ |};
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
+
125
+ /**
126
+ * Collapse whitespace the way a browser does when it lays text out.
127
+ *
128
+ * A test asks for "Save changes"; the markup may hold a newline and eleven
129
+ * spaces between the two words because that is how the JSX was indented. The
130
+ * reader sees one space, so the query matches one space.
131
+ */
132
+ export function normalize(text: string): string {
133
+ return text.replace(/\s+/g, " ").trim();
134
+ }
135
+
136
+ function matches(
137
+ content: string,
138
+ element: Element,
139
+ matcher: Matcher,
140
+ options?: MatcherOptions,
141
+ ): boolean {
142
+ if (typeof matcher === "function") {
143
+ return matcher(content, element);
144
+ }
145
+ if (matcher instanceof RegExp) {
146
+ return matcher.test(content);
147
+ }
148
+ const exact = options?.exact ?? true;
149
+ return exact
150
+ ? content === normalize(matcher)
151
+ : content.toLowerCase().includes(normalize(matcher).toLowerCase());
152
+ }
153
+
154
+ /** The text a reader would see in this element, whitespace collapsed. */
155
+ export function textOf(element: Element): string {
156
+ return normalize(element.textContent ?? "");
157
+ }
158
+
159
+ function candidates(root: Element, selector: string): Array<Element> {
160
+ return Array.from(root.querySelectorAll(selector));
161
+ }
162
+
163
+ /** Elements whose own visible text matches. */
164
+ export function allByText(
165
+ root: Element,
166
+ matcher: Matcher,
167
+ options?: MatcherOptions,
168
+ ): Array<Element> {
169
+ // Only the element closest to the text, not every ancestor that contains it:
170
+ // asking for "Save" should find the button, not the button and the form and
171
+ // the body.
172
+ return candidates(root, "*").filter((element) => {
173
+ if (!matches(textOf(element), element, matcher, options)) {
174
+ return false;
175
+ }
176
+ return !Array.from(element.children).some((child) =>
177
+ matches(textOf(child), child, matcher, options),
178
+ );
179
+ });
180
+ }
181
+
182
+ /** Elements with this ARIA role, whether written down or implied by the tag. */
183
+ export function allByRole(root: Element, role: string, options?: RoleOptions): Array<Element> {
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
+
212
+ const name = options?.name;
213
+ if (name == null) {
214
+ return found;
215
+ }
216
+ return found.filter((element) =>
217
+ matches(accessibleName(element), element, name, { exact: options?.exact ?? true }),
218
+ );
219
+ }
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
+
321
+ /** Form controls labelled by this text. */
322
+ export function allByLabelText(
323
+ root: Element,
324
+ matcher: Matcher,
325
+ options?: MatcherOptions,
326
+ ): Array<Element> {
327
+ const found = [];
328
+ for (const label of candidates(root, "label")) {
329
+ if (!matches(textOf(label), label, matcher, options)) {
330
+ continue;
331
+ }
332
+ const control = controlFor(root, label);
333
+ if (control != null) {
334
+ found.push(control);
335
+ }
336
+ }
337
+ // `aria-label` names a control with no label element of its own.
338
+ for (const element of candidates(root, "[aria-label]")) {
339
+ const label = element.getAttribute("aria-label") ?? "";
340
+ if (matches(normalize(label), element, matcher, options) && !found.includes(element)) {
341
+ found.push(element);
342
+ }
343
+ }
344
+ return found;
345
+ }
346
+
347
+ /** Elements with this placeholder. */
348
+ export function allByPlaceholderText(
349
+ root: Element,
350
+ matcher: Matcher,
351
+ options?: MatcherOptions,
352
+ ): Array<Element> {
353
+ return candidates(root, "[placeholder]").filter((element) =>
354
+ matches(normalize(element.getAttribute("placeholder") ?? ""), element, matcher, options),
355
+ );
356
+ }
357
+
358
+ /** Elements marked for tests, which is the query of last resort. */
359
+ export function allByTestId(
360
+ root: Element,
361
+ matcher: Matcher,
362
+ options?: MatcherOptions,
363
+ ): Array<Element> {
364
+ return candidates(root, "[data-testid]").filter((element) =>
365
+ matches(normalize(element.getAttribute("data-testid") ?? ""), element, matcher, options),
366
+ );
367
+ }
368
+
369
+ /** Elements whose value matches, for inputs and selects. */
370
+ export function allByDisplayValue(
371
+ root: Element,
372
+ matcher: Matcher,
373
+ options?: MatcherOptions,
374
+ ): Array<Element> {
375
+ return candidates(root, "input, textarea, select").filter((element) =>
376
+ matches(normalize(displayValue(element) ?? ""), element, matcher, options),
377
+ );
378
+ }
379
+
380
+ /**
381
+ * The value a control is showing, or `null` for an element that has none.
382
+ *
383
+ * The three classes rather than `element.value`, because `value` is not a
384
+ * property of `Element` — it belongs to each control class — and the selector
385
+ * that produced this element is a string the checker cannot read. An
386
+ * `instanceof` is the same fact stated where the checker can see it, and it is
387
+ * true of the elements this is called with for the reason `internal/dom.js`
388
+ * installs the document's own classes as the global ones: every element in the
389
+ * document under test is an instance of them.
390
+ *
391
+ * A cast was the other answer, and it is what was here. `(element as any).value`
392
+ * types this function's whole result as `any`, which then flows into
393
+ * `normalize` and out through `accessibleName` — a published function whose
394
+ * return type stopped being checked because of an expression three calls away.
395
+ *
396
+ * Exported so that `internal/events.js` asks the same question the same way:
397
+ * typing into a control and finding a control by its value have to agree about
398
+ * which elements have one, or `userEvent.type` would write a value that
399
+ * `getByDisplayValue` could not then find.
400
+ */
401
+ export function displayValue(element: Element): string | null {
402
+ if (
403
+ element instanceof HTMLInputElement ||
404
+ element instanceof HTMLTextAreaElement ||
405
+ element instanceof HTMLSelectElement
406
+ ) {
407
+ return element.value;
408
+ }
409
+ return null;
410
+ }
411
+
412
+ /**
413
+ * The control a label labels.
414
+ *
415
+ * `for` first, because it is explicit; then a control nested inside the label,
416
+ * which is the other way HTML allows it.
417
+ */
418
+ function controlFor(root: Element, label: Element): Element | null {
419
+ const id = label.getAttribute("for");
420
+ if (id != null && id !== "") {
421
+ const byId = root.querySelector(`#${cssEscape(id)}`);
422
+ if (byId != null) {
423
+ return byId;
424
+ }
425
+ }
426
+ return label.querySelector("input, textarea, select, button, [role]");
427
+ }
428
+
429
+ /** Escape an id for use in a selector, since an id may contain anything. */
430
+ function cssEscape(value: string): string {
431
+ return value.replace(/([^\w-])/g, "\\$1");
432
+ }
433
+
434
+ /** Roles a tag has without being told. */
435
+ const IMPLICIT_ROLES: { readonly [string]: string } = {
436
+ a: "link",
437
+ article: "article",
438
+ aside: "complementary",
439
+ button: "button",
440
+ dialog: "dialog",
441
+ footer: "contentinfo",
442
+ form: "form",
443
+ h1: "heading",
444
+ h2: "heading",
445
+ h3: "heading",
446
+ h4: "heading",
447
+ h5: "heading",
448
+ h6: "heading",
449
+ header: "banner",
450
+ hr: "separator",
451
+ img: "img",
452
+ li: "listitem",
453
+ main: "main",
454
+ nav: "navigation",
455
+ ol: "list",
456
+ option: "option",
457
+ progress: "progressbar",
458
+ section: "region",
459
+ select: "combobox",
460
+ table: "table",
461
+ tbody: "rowgroup",
462
+ td: "cell",
463
+ textarea: "textbox",
464
+ th: "columnheader",
465
+ tr: "row",
466
+ ul: "list",
467
+ };
468
+
469
+ /** The input types that are not a textbox. */
470
+ const INPUT_ROLES: { readonly [string]: string } = {
471
+ button: "button",
472
+ checkbox: "checkbox",
473
+ email: "textbox",
474
+ image: "button",
475
+ number: "spinbutton",
476
+ radio: "radio",
477
+ range: "slider",
478
+ reset: "button",
479
+ search: "searchbox",
480
+ submit: "button",
481
+ tel: "textbox",
482
+ text: "textbox",
483
+ url: "textbox",
484
+ };
485
+
486
+ /** This element's role: what it says, or what its tag implies. */
487
+ export function roleOf(element: Element): string | null {
488
+ const explicit = element.getAttribute("role");
489
+ if (explicit != null && explicit !== "") {
490
+ return explicit.trim().split(/\s+/)[0];
491
+ }
492
+ const tag = element.tagName.toLowerCase();
493
+ if (tag === "input") {
494
+ const type = (element.getAttribute("type") ?? "text").toLowerCase();
495
+ return INPUT_ROLES[type] ?? "textbox";
496
+ }
497
+ if (tag === "a" && element.getAttribute("href") == null) {
498
+ // A link without a destination is not a link.
499
+ return "generic";
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
+ }
510
+ return IMPLICIT_ROLES[tag] ?? null;
511
+ }
512
+
513
+ /**
514
+ * The name a screen reader would announce.
515
+ *
516
+ * `aria-label`, then the element `aria-labelledby` points at, then a label
517
+ * element, then the element's own text. Not the whole specification — that is
518
+ * a document of its own — but the order that decides almost every real case.
519
+ */
520
+ export function accessibleName(element: Element): string {
521
+ const label = element.getAttribute("aria-label");
522
+ if (label != null && label !== "") {
523
+ return normalize(label);
524
+ }
525
+
526
+ const labelledBy = element.getAttribute("aria-labelledby");
527
+ if (labelledBy != null && labelledBy !== "") {
528
+ // A loop rather than `.map().filter(Boolean).map()`: `filter(Boolean)`
529
+ // removes the nulls at runtime and not from the type, so the second `map`
530
+ // saw `HTMLElement | null` and the cast that hid it also hid whether
531
+ // `textOf` was being handed an element at all.
532
+ const parts = [];
533
+ for (const id of labelledBy.split(/\s+/)) {
534
+ const target = element.ownerDocument.getElementById(id);
535
+ if (target != null) {
536
+ parts.push(textOf(target));
537
+ }
538
+ }
539
+ if (parts.length > 0) {
540
+ return normalize(parts.join(" "));
541
+ }
542
+ }
543
+
544
+ const id = element.getAttribute("id");
545
+ if (id != null && id !== "") {
546
+ const own = element.ownerDocument?.querySelector(`label[for="${cssEscape(id)}"]`);
547
+ if (own != null) {
548
+ return textOf(own);
549
+ }
550
+ }
551
+
552
+ if (element.tagName.toLowerCase() === "input") {
553
+ const type = (element.getAttribute("type") ?? "").toLowerCase();
554
+ if (type === "submit" || type === "button" || type === "reset") {
555
+ return normalize(displayValue(element) ?? "");
556
+ }
557
+ }
558
+
559
+ const naming = namingChild(element);
560
+ if (naming != null) {
561
+ return textOf(naming);
562
+ }
563
+
564
+ return textOf(element);
565
+ }
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
+
623
+ /** Why a query failed, with enough of the DOM to see why. */
624
+ export function queryFailure(kind: string, matcher: Matcher, root: Element, found: number): Error {
625
+ const description =
626
+ typeof matcher === "function"
627
+ ? "the given predicate"
628
+ : matcher instanceof RegExp
629
+ ? String(matcher)
630
+ : JSON.stringify(matcher);
631
+ const html = root.innerHTML;
632
+ const shown = html.length > 2000 ? `${html.slice(0, 2000)}\n…` : html;
633
+ const count = found === 0 ? "found nothing" : `found ${found} elements and needed exactly one`;
634
+ return new Error(`${kind} ${description}: ${count}\n\n${shown}`);
635
+ }