@uniflowed/react-testing 0.0.0-alpha.5 → 0.0.0-alpha.7

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.
@@ -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 found = candidates(root, "*").filter((element) => roleOf(element) === role);
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 =
@@ -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: all,
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
- find: (target, options) =>
169
- waitFor(() => {
170
- const found = all(target, options);
171
- if (found.length !== 1) {
172
- throw queryFailure(`findBy${name}`, target, root(), found.length);
173
- }
174
- return found[0];
175
- }),
176
- findAll: (target, options) =>
177
- waitFor(() => {
178
- const found = all(target, options);
179
- if (found.length === 0) {
180
- throw queryFailure(`findAllBy${name}`, target, root(), 0);
181
- }
182
- return found;
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.5",
3
+ "version": "0.0.0-alpha.7",
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.5",
22
- "@uniflowed/react": "0.0.0-alpha.5",
21
+ "@uniflowed/core": "0.0.0-alpha.7",
22
+ "@uniflowed/react": "0.0.0-alpha.7",
23
23
  "happy-dom": "^20.13.2"
24
24
  },
25
25
  "peerDependencies": {