@uniflowed/test 0.0.0-alpha.9 → 0.2.0

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.
@@ -11,10 +11,66 @@
11
11
  // `.resolves` and `.rejects` settle the promise first and then apply the same
12
12
  // matcher table to what came out, so `await expect(p).resolves.toBe(1)` reads
13
13
  // the way the synchronous form does.
14
+ //
15
+ // # The names are written down, and the behaviour is not
16
+ //
17
+ // `expect` used to be `$FlowFixMe`, and so was everything it handed back. That
18
+ // is a hole in the published type of the package a project writes every one of
19
+ // its assertions against: `expect(user).toBaa(1)` was not a misspelling
20
+ // anybody's checker would find, `expect(list).toHaveLength("3")` was not a type
21
+ // error, and `expect(p).resolves` on a value that is not a promise was fine
22
+ // until it ran. Every test in this repository is written against `expect`,
23
+ // which is the largest surface in the packages and was the least checked.
24
+ //
25
+ // So [`Matchers`] below writes the names out, one signature per matcher, the
26
+ // way `@uniflowed/react-testing`'s `Queries` writes out its thirty-six. Flow
27
+ // has no template literal types and no way to read a name out of a value, so
28
+ // the listing has to exist for the type to exist at all. What is *not*
29
+ // repeated is any behaviour: [`verdicts`] is still the one place a matcher is
30
+ // decided, and the listing is a naming that a reader can check against it by
31
+ // eye.
32
+ //
33
+ // [`Matchers`] is generic in what a matcher *returns*, which is what lets
34
+ // `.resolves` reuse the one listing: the same forty-one names, each handing
35
+ // back a promise.
36
+ //
37
+ // # The received value's type is not carried, and that was tried
38
+ //
39
+ // ubugeeei-prod/uf#402 asked for a second parameter as well — the type handed
40
+ // to `expect`, carried through the matchers so that `expect(count).toBe("two")`
41
+ // is an error at the call. It is written here rather than left for somebody to
42
+ // discover, because it looks obviously right and is not.
43
+ //
44
+ // `toBe` is `Object.is`, and identity is not assignability. Typing it
45
+ // `(expected: T)` demands that the expected value be a subtype of the received
46
+ // one, which is a direction the runtime has no opinion about and which ordinary
47
+ // assertions fail in both:
48
+ // `expect(document.activeElement).toBe(screen.getByRole("button"))` compares an
49
+ // `HTMLElement | null` with an `Element`, neither is the other's subtype, and
50
+ // that is the most common assertion in a DOM test. Jest and Vitest both type
51
+ // this argument as `unknown` for the same reason.
52
+ //
53
+ // Worse, the parameter has to be *inferred*, and `expect(x)` is where a lot of
54
+ // otherwise unconstrained expressions sit. `await
55
+ // expect(client.request("/users/1")).resolves.toEqual({ id: 1 })` stops
56
+ // checking and starts reporting that `request`'s own type parameter is
57
+ // underconstrained, because a `mixed` parameter asked nothing of the argument
58
+ // and a generic one asks it to be solved. `expect([])` becomes "cannot
59
+ // determine type of empty array literal". Both are correct tests, and the type
60
+ // that rejects them is worse than the type that missed a mistyped comparison.
61
+ //
62
+ // So the received value arrives as `mixed`, and what makes a matcher checked is
63
+ // its own signature: `toHaveLength` wants a number, `toMatch` a pattern,
64
+ // `toBeTypeOf` one of the eight words `typeof` answers with, and every name is
65
+ // a name. The comparison between two unrelated types stays unchecked, and it is
66
+ // the only part of the issue that does.
14
67
 
68
+ import type { AsymmetricMatcher } from "./asymmetric.js";
69
+ import type { AxeOptions } from "./axe.js";
15
70
  import type { SpyCall } from "./spy.js";
16
71
  import * as asymmetric from "./asymmetric.js";
17
72
  import * as snapshot from "./snapshot.js";
73
+ import { auditElement, describeViolations, violationIds } from "./axe.js";
18
74
  import { isSpy } from "./spy.js";
19
75
  import { equals, matchesObject, render } from "./equality.js";
20
76
 
@@ -36,7 +92,14 @@ export class AssertionError extends Error {
36
92
  }
37
93
  }
38
94
 
39
- /** What a matcher decided, and how to say it either way. */
95
+ /**
96
+ * What a matcher decided, and how to say it either way.
97
+ *
98
+ * A matcher may answer with a promise of one. Only one does — the accessibility
99
+ * audit, whose engine has no synchronous entry point — and [`bind`] is where
100
+ * the two cases are told apart; see the note there for why the promise is not
101
+ * hidden from the caller.
102
+ */
40
103
  type Verdict = {|
41
104
  readonly pass: boolean,
42
105
  readonly failure: () => string,
@@ -45,6 +108,166 @@ type Verdict = {|
45
108
  readonly received?: string,
46
109
  |};
47
110
 
111
+ /** What `typeof` can answer, for the matcher that compares against it. */
112
+ type TypeName =
113
+ | "bigint"
114
+ | "boolean"
115
+ | "function"
116
+ | "number"
117
+ | "object"
118
+ | "string"
119
+ | "symbol"
120
+ | "undefined";
121
+
122
+ /**
123
+ * Every matcher, each returning `R`.
124
+ *
125
+ * Generic in the return type because the surface exists twice and that is the
126
+ * only thing that differs: `expect(x)` raises where a matcher fails and hands
127
+ * back nothing, while `expect(p).resolves` settles first and so hands back a
128
+ * promise. Writing the forty-one names once and saying what changes is the
129
+ * whole reason for the parameter — the alternative was the same list twice,
130
+ * with `=> void` on one copy and `=> Promise<void>` on the other, and a reader
131
+ * left to diff them.
132
+ *
133
+ * Where an argument is `mixed` it is because the runtime genuinely takes
134
+ * anything there and the checker would be lying to say otherwise: `toEqual`
135
+ * accepts an asymmetric matcher standing in for a value at any depth, and
136
+ * `toHaveValue` compares whatever a control is holding. Where it is not —
137
+ * `toHaveLength` wants a number, `toMatch` a string or a pattern, `toBeTypeOf`
138
+ * one of the eight words `typeof` produces — the narrower type is what the
139
+ * implementation already assumes, and saying it out loud is the point of the
140
+ * exercise.
141
+ *
142
+ * `not` is the same list again because negation is the only thing it changes.
143
+ * `resolves` and `rejects` are deliberately not here: they belong to
144
+ * [`Expectation`], because `expect(p).resolves.not` exists and
145
+ * `expect(x).not.resolves` does not.
146
+ */
147
+ export type Matchers<R> = {
148
+ readonly toBe: (expected: mixed) => R,
149
+ readonly toEqual: (expected: mixed) => R,
150
+ readonly toStrictEqual: (expected: mixed) => R,
151
+ readonly toBeTruthy: () => R,
152
+ readonly toBeFalsy: () => R,
153
+ readonly toBeNull: () => R,
154
+ readonly toBeUndefined: () => R,
155
+ readonly toBeDefined: () => R,
156
+ readonly toBeNaN: () => R,
157
+ readonly toBeGreaterThan: (expected: number) => R,
158
+ readonly toBeGreaterThanOrEqual: (expected: number) => R,
159
+ readonly toBeLessThan: (expected: number) => R,
160
+ readonly toBeLessThanOrEqual: (expected: number) => R,
161
+ readonly toBeCloseTo: (expected: number, digits?: number) => R,
162
+ readonly toContain: (expected: mixed) => R,
163
+ readonly toContainEqual: (expected: mixed) => R,
164
+ readonly toHaveLength: (expected: number) => R,
165
+ readonly toHaveProperty: (path: string, ...rest: $ReadOnlyArray<mixed>) => R,
166
+ readonly toMatch: (expected: string | RegExp) => R,
167
+ readonly toMatchObject: (expected: mixed) => R,
168
+ readonly toBeInstanceOf: (expected: mixed) => R,
169
+ readonly toBeTypeOf: (expected: TypeName) => R,
170
+ readonly toSatisfy: (predicate: (value: mixed) => boolean) => R,
171
+ readonly toMatchSnapshot: (hint?: string) => R,
172
+ readonly toMatchInlineSnapshot: (expected?: string) => R,
173
+ readonly toThrow: (...rest: $ReadOnlyArray<mixed>) => R,
174
+ readonly toHaveBeenCalled: () => R,
175
+ readonly toHaveBeenCalledTimes: (count: number) => R,
176
+ readonly toHaveBeenCalledWith: (...args: $ReadOnlyArray<mixed>) => R,
177
+ readonly toHaveBeenLastCalledWith: (...args: $ReadOnlyArray<mixed>) => R,
178
+ readonly toBeInTheDocument: () => R,
179
+ readonly toBeVisible: () => R,
180
+ readonly toBeDisabled: () => R,
181
+ readonly toBeEnabled: () => R,
182
+ readonly toBeChecked: () => R,
183
+ readonly toBeRequired: () => R,
184
+ readonly toHaveFocus: () => R,
185
+ readonly toHaveAttribute: (name: string, value?: mixed) => R,
186
+ readonly toHaveClass: (...names: $ReadOnlyArray<string>) => R,
187
+ readonly toHaveTextContent: (expected: string | RegExp) => R,
188
+ readonly toHaveValue: (expected: mixed) => R,
189
+ /**
190
+ * Run axe-core over this element's subtree and require it to find nothing.
191
+ *
192
+ * `Promise<void>` rather than `R`, and it is the one matcher in this listing
193
+ * that is not generic: axe has no synchronous entry point, so the answer is
194
+ * a promise however the expectation was reached, and `await` is not optional.
195
+ *
196
+ * await expect(container).toHaveNoAxeViolations();
197
+ * await expect(container).toHaveNoAxeViolations({ tags: ["wcag2a"] });
198
+ *
199
+ * The rule set comes from `accessibility.axe` in `uf.config.js`; the argument
200
+ * narrows it for one assertion. See `./axe.js`.
201
+ */
202
+ readonly toHaveNoAxeViolations: (options?: AxeOptions) => Promise<void>,
203
+ readonly not: Matchers<R>,
204
+ ...
205
+ };
206
+
207
+ /**
208
+ * What `expect(received)` hands back.
209
+ *
210
+ * The matchers, plus the two that settle a promise before applying them. An
211
+ * intersection rather than a copy of the list with two lines added, and
212
+ * rather than an object spread, because a spread of an object type drops the
213
+ * `readonly` off every property it carries over — Flow computes a fresh object
214
+ * from the spread and the fresh one is writable, which would publish forty-one
215
+ * assignable matchers.
216
+ *
217
+ * # What is still not checked
218
+ *
219
+ * `expect(5).resolves` types, and fails when it runs. Saying otherwise needs
220
+ * `expect` to have two call signatures — one for a promise handing back a
221
+ * shape with `resolves`, one for everything else handing back a shape without
222
+ * — and Flow then requires the single function behind them to satisfy both,
223
+ * which no single function does. The overload is written down here rather than
224
+ * attempted because "it did not type" is the kind of thing that gets tried
225
+ * twice.
226
+ */
227
+ export type Expectation = Matchers<void> & {
228
+ readonly resolves: Matchers<Promise<void>>,
229
+ readonly rejects: Matchers<Promise<void>>,
230
+ ...
231
+ };
232
+
233
+ /**
234
+ * `expect` itself: callable, and carrying the matchers that stand in for a
235
+ * value instead of being one.
236
+ *
237
+ * Inexact, and it has to be. The value is a function, every function has
238
+ * `name`, `length`, `call`, `apply` and `bind`, and an exact object type
239
+ * refuses one for exactly that reason. Inexactness costs nothing that matters
240
+ * here: Flow still reports a read of a property this type does not list, which
241
+ * is what makes `expect.anythign()` an error.
242
+ */
243
+ export type Expect = {
244
+ (received: mixed): Expectation,
245
+ // `flow/unclear-type` reads source text rather than an AST, and the shape it
246
+ // recognises as a property key rather than a type is a name at the start of
247
+ // a line or straight after `{`, `,` or `;`. `readonly any:` is neither, so
248
+ // the rule reports Jest's, Vitest's and Sinon's name for this matcher as an
249
+ // `any` type. The rule's own comment already lists `@uniflowed/test`'s
250
+ // `expect.any` among the false positives it exists to avoid; this is the one
251
+ // spelling it still cannot see past.
252
+ // uf-lint-disable-next-line flow/unclear-type
253
+ readonly any: (constructor: mixed) => AsymmetricMatcher,
254
+ readonly anything: () => AsymmetricMatcher,
255
+ readonly objectContaining: (expected: interface {}) => AsymmetricMatcher,
256
+ readonly arrayContaining: (expected: $ReadOnlyArray<mixed>) => AsymmetricMatcher,
257
+ readonly stringContaining: (substring: string) => AsymmetricMatcher,
258
+ readonly stringMatching: (pattern: string | RegExp) => AsymmetricMatcher,
259
+ readonly closeTo: (value: number, digits?: number) => AsymmetricMatcher,
260
+ readonly not: {
261
+ readonly objectContaining: (expected: interface {}) => AsymmetricMatcher,
262
+ readonly arrayContaining: (expected: $ReadOnlyArray<mixed>) => AsymmetricMatcher,
263
+ readonly stringContaining: (substring: string) => AsymmetricMatcher,
264
+ readonly stringMatching: (pattern: string | RegExp) => AsymmetricMatcher,
265
+ readonly closeTo: (value: number, digits?: number) => AsymmetricMatcher,
266
+ ...
267
+ },
268
+ ...
269
+ };
270
+
48
271
  function propertyAt(
49
272
  value: mixed,
50
273
  path: string,
@@ -89,28 +312,36 @@ function matchesThrown(thrown: mixed, expected: mixed): boolean {
89
312
  * Every entry returns a [`Verdict`] rather than throwing, which is what lets
90
313
  * `.not` reuse all of them.
91
314
  *
92
- * # The `any` in the indexer
315
+ * # Every entry takes `mixed`, and that is what makes the indexer sayable
316
+ *
317
+ * [`bind`] reaches an entry by a computed key and applies it to the
318
+ * `$ReadOnlyArray<mixed>` it collected from the caller, so the indexer has to
319
+ * describe a function that will accept those arguments. Parameters are
320
+ * contravariant, so an entry that demanded a `number` could not be described
321
+ * by one — `(...args: $ReadOnlyArray<mixed>)` rejects it, and
322
+ * `(...args: $ReadOnlyArray<empty>)` accepts it and rejects the call. That
323
+ * disagreement is why this indexer used to be written `$ReadOnlyArray<any>`,
324
+ * with a `flow/unclear-type` suppression on it.
93
325
  *
94
- * The entries do not agree about their arguments — `toBe` takes a `mixed`,
95
- * `toHaveLength` takes a `number`, `toBeCloseTo` takes two — and [`bind`]
96
- * applies whichever one it was asked for to a `$ReadOnlyArray<mixed>` it
97
- * collected from a caller. Parameters are contravariant, so one indexer cannot
98
- * describe both ends: `(...args: $ReadOnlyArray<mixed>)` rejects every entry
99
- * that wants a `number`, and `(...args: $ReadOnlyArray<empty>)` accepts every
100
- * entry and rejects the call.
326
+ * So the disagreement is gone instead of papered over: every entry here takes
327
+ * `mixed` and coerces what it needs, the way most of them — `toBe`,
328
+ * `toBeGreaterThan`, `toHaveAttribute` — already did. Nothing a caller can see
329
+ * got wider: `toHaveLength` still refuses a string and `toBeTypeOf` still
330
+ * refuses a word `typeof` never says, because those are [`Matchers`]'s
331
+ * signatures and [`Matchers`] is the published type. What changed is that the
332
+ * table behind them stopped claiming a narrower argument than the one `bind`
333
+ * can hand it, which is a claim that was never true. The `String(…)` and
334
+ * `Number(…)` calls that appeared with it are the coercion the runtime was
335
+ * already doing, said out loud, and each produces the same message the implicit
336
+ * one did for the same input.
101
337
  *
102
- * `mixed` with a cast at the call would move the same unsoundness one line
103
- * without checking anything, because the caller is `bind`, whose result is
104
- * `$FlowFixMe` and whose result's result is `expect`, also `$FlowFixMe`. The
105
- * type that makes any of this checked is a written-out matcher interface —
106
- * one signature per matcher, plus `.not`, `.resolves` and `.rejects` — which
107
- * is what `expect`'s own annotation is waiting for, and is
108
- * ubugeeei-prod/uf#402. Until that exists, a narrower type here would be
109
- * precision nobody can reach.
338
+ * The alternative — narrowing the indexer by writing `bind`'s forty-one
339
+ * wrappers out to avoid the computed lookup — is a second copy of the listing
340
+ * to keep in step with [`Matchers`], and is a worse trade than either. See
341
+ * ubugeeei-prod/uf#402.
110
342
  */
111
343
  function verdicts(received: mixed): {
112
- // uf-lint-disable-next-line flow/unclear-type
113
- readonly [string]: (...args: $ReadOnlyArray<any>) => Verdict,
344
+ readonly [string]: (...args: $ReadOnlyArray<mixed>) => Verdict | Promise<Verdict>,
114
345
  } {
115
346
  const shown = () => render(received);
116
347
  const simple = (pass: boolean, what: string, expected?: mixed): Verdict => ({
@@ -185,14 +416,15 @@ function verdicts(received: mixed): {
185
416
  `to be at most ${render(expected)}`,
186
417
  expected,
187
418
  ),
188
- toBeCloseTo: (expected: number, digits?: number) => {
189
- const places = digits ?? 2;
419
+ toBeCloseTo: (expected: mixed, digits?: mixed) => {
420
+ const target = Number(expected);
421
+ const places = digits === undefined ? 2 : Number(digits);
190
422
  const tolerance = 10 ** -places / 2;
191
- const difference = Math.abs((received as $FlowFixMe) - expected);
423
+ const difference = Math.abs((received as $FlowFixMe) - target);
192
424
  return simple(
193
425
  difference < tolerance,
194
- `to be within ${tolerance} of ${expected}, but it is off by ${difference}`,
195
- expected,
426
+ `to be within ${tolerance} of ${target}, but it is off by ${difference}`,
427
+ target,
196
428
  );
197
429
  },
198
430
  toContain: (expected: mixed) => {
@@ -218,22 +450,23 @@ function verdicts(received: mixed): {
218
450
  expected,
219
451
  );
220
452
  },
221
- toHaveLength: (expected: number) => {
453
+ toHaveLength: (expected: mixed) => {
222
454
  const length = received == null ? undefined : (received as $FlowFixMe).length;
223
455
  return simple(
224
456
  length === expected,
225
- `to have length ${expected}, not ${render(length)}`,
457
+ `to have length ${String(expected)}, not ${render(length)}`,
226
458
  expected,
227
459
  );
228
460
  },
229
- toHaveProperty: (path: string, ...rest: $ReadOnlyArray<mixed>) => {
230
- const found = propertyAt(received, path);
461
+ toHaveProperty: (path: mixed, ...rest: $ReadOnlyArray<mixed>) => {
462
+ const at = String(path);
463
+ const found = propertyAt(received, at);
231
464
  if (rest.length === 0) {
232
- return simple(found.found, `to have a property at \`${path}\``);
465
+ return simple(found.found, `to have a property at \`${at}\``);
233
466
  }
234
467
  return simple(
235
468
  found.found && equals(found.value, rest[0]),
236
- `to have \`${path}\` equal to ${render(rest[0])}, not ${render(found.value)}`,
469
+ `to have \`${at}\` equal to ${render(rest[0])}, not ${render(found.value)}`,
237
470
  rest[0],
238
471
  );
239
472
  },
@@ -253,16 +486,24 @@ function verdicts(received: mixed): {
253
486
  `to be an instance of ${render(expected)}`,
254
487
  expected,
255
488
  ),
256
- toBeTypeOf: (expected: string) =>
489
+ toBeTypeOf: (expected: mixed) => {
490
+ const name = String(expected);
491
+ return simple(
492
+ typeof received === name,
493
+ `to be of type ${name}, not ${typeof received}`,
494
+ name,
495
+ );
496
+ },
497
+ toSatisfy: (predicate: mixed) =>
257
498
  simple(
258
- typeof received === expected,
259
- `to be of type ${expected}, not ${typeof received}`,
260
- expected,
499
+ typeof predicate === "function" && predicate(received) === true,
500
+ "to satisfy the predicate",
261
501
  ),
262
- toSatisfy: (predicate: (value: mixed) => boolean) =>
263
- simple(predicate(received) === true, "to satisfy the predicate"),
264
- toMatchSnapshot: (hint?: string): Verdict => {
265
- const verdict = snapshot.matchSnapshot(received, hint);
502
+ toMatchSnapshot: (hint?: mixed): Verdict => {
503
+ const verdict = snapshot.matchSnapshot(
504
+ received,
505
+ hint === undefined ? undefined : String(hint),
506
+ );
266
507
  return {
267
508
  pass: verdict.pass,
268
509
  expected: verdict.expected ?? "(no snapshot yet)",
@@ -276,8 +517,11 @@ function verdicts(received: mixed): {
276
517
  negatedFailure: () => "expected the value not to match its snapshot",
277
518
  };
278
519
  },
279
- toMatchInlineSnapshot: (expected?: string): Verdict => {
280
- const verdict = snapshot.matchInlineSnapshot(received, expected);
520
+ toMatchInlineSnapshot: (expected?: mixed): Verdict => {
521
+ const verdict = snapshot.matchInlineSnapshot(
522
+ received,
523
+ expected === undefined ? undefined : String(expected),
524
+ );
281
525
  return {
282
526
  pass: verdict.pass,
283
527
  expected: verdict.expected ?? "(no inline snapshot yet)",
@@ -328,10 +572,14 @@ function verdicts(received: mixed): {
328
572
  requireSpy("toHaveBeenCalled");
329
573
  return simple(spyCalls().length > 0, "to have been called");
330
574
  },
331
- toHaveBeenCalledTimes: (count: number) => {
575
+ toHaveBeenCalledTimes: (count: mixed) => {
332
576
  requireSpy("toHaveBeenCalledTimes");
333
577
  const actual = spyCalls().length;
334
- return simple(actual === count, `to have been called ${count} times, not ${actual}`, count);
578
+ return simple(
579
+ actual === count,
580
+ `to have been called ${String(count)} times, not ${actual}`,
581
+ count,
582
+ );
335
583
  },
336
584
  toHaveBeenCalledWith: (...args: $ReadOnlyArray<mixed>) => {
337
585
  requireSpy("toHaveBeenCalledWith");
@@ -442,6 +690,23 @@ function verdicts(received: mixed): {
442
690
  failure: () => `expected the value ${render(actual)} to be ${render(expected)}`,
443
691
  };
444
692
  },
693
+ toHaveNoAxeViolations: async (options: mixed) => {
694
+ const node = element("toHaveNoAxeViolations");
695
+ const found = await auditElement(node, (options: $FlowFixMe));
696
+ const named = violationIds(found);
697
+ return {
698
+ pass: found.length === 0,
699
+ expected: "no accessibility violations",
700
+ received: found.length === 0 ? "none" : named,
701
+ failure: () =>
702
+ `expected no accessibility violations, and axe-core reported ` +
703
+ `${String(found.length)}:\n${describeViolations(found)}`,
704
+ // A passing audit is not proof of an accessible component — axe finds
705
+ // what a machine can find — so the negated message says what was
706
+ // actually established rather than implying the opposite verdict.
707
+ negatedFailure: () => "expected axe-core to report a violation, and it reported none",
708
+ };
709
+ },
445
710
  };
446
711
 
447
712
  /**
@@ -454,7 +719,16 @@ function verdicts(received: mixed): {
454
719
  function element(matcher: string): Element {
455
720
  const node: $FlowFixMe = received;
456
721
  if (node == null || typeof node.getAttribute !== "function") {
457
- throw new AssertionError(`${matcher} needs an element, and received ${render(received)}`);
722
+ // All four arguments, unlike the first version of this: the runner
723
+ // renders `expected` and `received` beside the message, and a one
724
+ // argument call left both of them `undefined` on screen for the one
725
+ // failure whose whole content is what was received instead.
726
+ throw new AssertionError(
727
+ `${matcher} needs an element, and received ${render(received)}`,
728
+ matcher,
729
+ "an element",
730
+ render(received),
731
+ );
458
732
  }
459
733
  return node;
460
734
  }
@@ -466,14 +740,42 @@ function verdicts(received: mixed): {
466
740
  * Walks the ancestors, because `display: none` on a parent hides a child whose
467
741
  * own style says nothing. `hidden`, `aria-hidden` and a `details` that is not
468
742
  * open each hide their subtree too.
743
+ *
744
+ * # The one thing a closed `<details>` still shows
745
+ *
746
+ * Its `<summary>`. A closed disclosure renders exactly one child and hides the
747
+ * rest, so the rule is "everything under a closed `<details>` except its
748
+ * summary" — and saying that needs the child the walk arrived from, not only
749
+ * the ancestor it is standing on. Without it the rule was written as "unless
750
+ * the `<details>` is the element being asked about", which exempted the
751
+ * disclosure from its own rule and left the summary inside it invisible:
752
+ * `expect(screen.getByText("More")).toBeVisible()` failed for the one thing on
753
+ * the screen, while the reader was looking at it.
754
+ *
755
+ * # Why this is not the walk in `react-testing`
756
+ *
757
+ * `packages/react-testing/internal/queries.js` has one that looks like this
758
+ * and answers a different question. `exposed` asks whether the accessibility
759
+ * tree announces the element, so it ignores `opacity: 0` — a screen reader
760
+ * reads text at zero opacity, which is exactly why hiding text that way is a
761
+ * bug rather than a technique — and it takes `aria-hidden` as decisive. This
762
+ * one asks whether a reader would *see* it, so the two answers part company
763
+ * there on purpose. The `<details>` half is the half they agree on, and it is
764
+ * written the same way in both.
469
765
  */
470
766
  function isVisible(node: Element): boolean {
767
+ let child: $FlowFixMe = null;
471
768
  let current: $FlowFixMe = node;
472
769
  while (current != null && current.nodeType === 1) {
473
770
  if (current.hasAttribute("hidden") || current.getAttribute("aria-hidden") === "true") {
474
771
  return false;
475
772
  }
476
- if (current.tagName === "DETAILS" && !current.hasAttribute("open") && current !== node) {
773
+ if (
774
+ child != null &&
775
+ current.tagName === "DETAILS" &&
776
+ !current.hasAttribute("open") &&
777
+ child.tagName !== "SUMMARY"
778
+ ) {
477
779
  return false;
478
780
  }
479
781
  const style = current.ownerDocument?.defaultView?.getComputedStyle?.(current);
@@ -485,6 +787,7 @@ function isVisible(node: Element): boolean {
485
787
  return false;
486
788
  }
487
789
  }
790
+ child = current;
488
791
  current = current.parentElement;
489
792
  }
490
793
  return true;
@@ -510,26 +813,115 @@ function isDisabled(node: Element): boolean {
510
813
  *
511
814
  * `negated` decides which message a failing verdict raises, which is all of
512
815
  * what `.not` is.
816
+ *
817
+ * # Why the matchers live on a prototype
818
+ *
819
+ * `expect` is called once per assertion, and building its answer used to be
820
+ * the largest single cost of a passing assertion: the whole verdict table and a
821
+ * wrapper for each of its forty-one entries, eighty-odd closures, to call one
822
+ * of them. On a suite of 1,000 cases and 2,000 assertions that was about a
823
+ * twentieth of a worker's CPU, spent on functions nobody called.
824
+ *
825
+ * So the object carries only what differs between two assertions — the value
826
+ * and the polarity — and every matcher is a getter on one shared prototype,
827
+ * built the first time `expect` is used. The getter hands back a function
828
+ * closed over the object it was read from, rather than being a method that
829
+ * reads `this`: `const { toBe } = expect(1)` and `[1, 2].forEach(expect(n).not.toBe)`
830
+ * keep working, because the function a destructuring or a callback receives is
831
+ * already bound, which is what they got when every matcher was an own closure.
832
+ * The table is still built per *call*, from [`verdicts`], so a matcher still
833
+ * sees the one received value it was asked about and nothing else.
834
+ *
835
+ * `.not` is a getter on the same prototype for the reason it always was
836
+ * lazy: an expectation that built its negation would build *its* negation,
837
+ * forever. `.resolves` and `.rejects` are on a second prototype that only
838
+ * [`expectValue`]'s object has, so `expect(p).not.resolves` stays what it was —
839
+ * not a thing.
513
840
  */
514
- function bind(received: mixed, negated: boolean): $FlowFixMe {
515
- const table = verdicts(received);
841
+ type Bound = { readonly received: mixed, readonly negated: boolean, ... };
842
+
843
+ /** The two prototypes, built on first use; see [`bind`]. */
844
+ type Prototypes = {| readonly bound: interface {}, readonly root: interface {} |};
845
+ let prototypes: Prototypes | null = null;
846
+
847
+ /** A getter for matcher `name`, handing back a function bound to its object. */
848
+ function matcherGetter(name: string): (this: Bound) => (...args: $ReadOnlyArray<mixed>) => mixed {
849
+ return function (this: Bound) {
850
+ const self = this;
851
+ return (...args: $ReadOnlyArray<mixed>) => apply(self, name, args);
852
+ };
853
+ }
854
+
855
+ function negation(this: Bound): mixed {
856
+ return bind(this.received, !this.negated);
857
+ }
858
+
859
+ function resolution(this: Bound): mixed {
860
+ return settled(this.received, "resolve", false);
861
+ }
862
+
863
+ function rejection(this: Bound): mixed {
864
+ return settled(this.received, "reject", false);
865
+ }
866
+
867
+ function matcherPrototypes(): Prototypes {
868
+ if (prototypes != null) {
869
+ return prototypes;
870
+ }
516
871
  const bound: $FlowFixMe = {};
517
- for (const name of Object.keys(table)) {
518
- bound[name] = (...args: $ReadOnlyArray<mixed>) => {
519
- const verdict = table[name](...args);
520
- if (verdict.pass !== negated) {
521
- return undefined;
522
- }
523
- const message = negated ? verdict.negatedFailure() : verdict.failure();
524
- throw new AssertionError(
525
- message,
526
- name,
527
- verdict.expected ?? "",
528
- verdict.received ?? render(received),
529
- );
530
- };
872
+ for (const name of Object.keys(verdicts(undefined))) {
873
+ Object.defineProperty(bound, name, { get: matcherGetter(name) });
531
874
  }
532
- Object.defineProperty(bound, "not", { get: () => bind(received, !negated) });
875
+ Object.defineProperty(bound, "not", { get: negation });
876
+ const root: $FlowFixMe = Object.create(bound);
877
+ Object.defineProperty(root, "resolves", { get: resolution });
878
+ Object.defineProperty(root, "rejects", { get: rejection });
879
+ prototypes = { bound, root };
880
+ return prototypes;
881
+ }
882
+
883
+ /** Decide matcher `name` on `bound`'s value, raising when it does not hold. */
884
+ function apply(bound: Bound, name: string, args: $ReadOnlyArray<mixed>): mixed {
885
+ const { received, negated } = bound;
886
+ const decide = (verdict: Verdict) => {
887
+ if (verdict.pass !== negated) {
888
+ return undefined;
889
+ }
890
+ const message = negated ? verdict.negatedFailure() : verdict.failure();
891
+ throw new AssertionError(
892
+ message,
893
+ name,
894
+ verdict.expected ?? "",
895
+ verdict.received ?? render(received),
896
+ );
897
+ };
898
+ const verdict = verdicts(received)[name](...args);
899
+ // A matcher whose engine is asynchronous answers with a promise of a
900
+ // verdict, and the promise is handed straight back rather than hidden.
901
+ //
902
+ // Hiding it was the alternative and it cannot be done: the only way to
903
+ // present an asynchronous answer synchronously is to decide before it
904
+ // arrives, which is deciding without it. What the promise costs is a
905
+ // forgotten `await`, and that case is not silent either — the rejection
906
+ // reaches the worker's unhandled-rejection handler, which fails the file
907
+ // the promise was created in and prints this same message. A missing
908
+ // `await` on a passing audit is the one case nothing reports, and it is
909
+ // the case where nothing happened.
910
+ //
911
+ // `instanceof Promise` rather than a `then` test, and it is safe for a
912
+ // reason that would not survive being generalised: every entry in the
913
+ // table is written in this file, so the only promise that can arrive
914
+ // here is one an `async` function in this module made, in this realm. A
915
+ // matcher registered from outside — which `@uniflowed/test` has no API
916
+ // for, deliberately — could hand back a foreign thenable, and this line
917
+ // would be the thing to revisit.
918
+ return verdict instanceof Promise ? verdict.then(decide) : decide(verdict);
919
+ }
920
+
921
+ function bind(received: mixed, negated: boolean): $FlowFixMe {
922
+ const bound: $FlowFixMe = Object.create(matcherPrototypes().bound);
923
+ bound.received = received;
924
+ bound.negated = negated;
533
925
  return bound;
534
926
  }
535
927
 
@@ -575,7 +967,12 @@ function settled(promise: mixed, wanted: "resolve" | "reject", negated: boolean)
575
967
  throw value;
576
968
  }
577
969
  : value;
578
- bind(subject, negated)[name](...args);
970
+ // Returned rather than called and dropped: the wrapper is `async`, so
971
+ // returning an asynchronous matcher's promise is what awaits it. Without
972
+ // this, `await expect(p).resolves.toHaveNoAxeViolations()` awaited the
973
+ // settling and not the audit, and a violation surfaced as an unhandled
974
+ // rejection under whichever file was running by then.
975
+ return bind(subject, negated)[name](...args);
579
976
  };
580
977
  }
581
978
  Object.defineProperty(bound, "not", { get: () => settled(promise, wanted, !negated) });
@@ -591,22 +988,58 @@ function settled(promise: mixed, wanted: "resolve" | "reject", negated: boolean)
591
988
  * expect(() => parse("")).toThrow(/empty/);
592
989
  * await expect(load()).resolves.toHaveLength(3);
593
990
  * ```
991
+ *
992
+ * Takes a `mixed` and hands back a written-out [`Expectation`]. What each
993
+ * matcher will accept is decided by its own signature rather than by what was
994
+ * received, for the reasons this module's header sets out.
594
995
  */
595
- function expectValue(received: mixed): $FlowFixMe {
596
- const expectation: $FlowFixMe = bind(received, false);
597
- Object.defineProperty(expectation, "resolves", {
598
- get: () => settled(received, "resolve", false),
599
- });
600
- Object.defineProperty(expectation, "rejects", { get: () => settled(received, "reject", false) });
996
+ function expectValue(received: mixed): Expectation {
997
+ const expectation: $FlowFixMe = Object.create(matcherPrototypes().root);
998
+ expectation.received = received;
999
+ expectation.negated = false;
601
1000
  return expectation;
602
1001
  }
603
1002
 
604
1003
  /**
605
- * Assert about a value.
1004
+ * Build the callable that carries the asymmetric matchers.
606
1005
  *
607
- * Declared with its matchers attached rather than assigned afterwards: a
608
- * shipped module may only declare, import and export at its top level, and
609
- * `expect.any = …` is a statement that runs when the module is imported.
1006
+ * The statics are attached inside a builder rather than at the module's top
1007
+ * level, the way `internal/registry.js` builds `describe`: a shipped module
1008
+ * may only declare, import and export at its top level, and `expect.any = …`
1009
+ * out here is a statement that runs when the module is imported.
1010
+ *
1011
+ * It was `Object.assign(expectValue, { … })`, which is what a reader expects
1012
+ * and what does not type. Flow models `Object.assign` as returning the
1013
+ * *target*, so the result of assigning matchers onto a function is still a
1014
+ * function with no matchers on it — eight `prop-missing` errors saying so, and
1015
+ * a `flow/unsafe-object-assign` suppression on top of them. Attaching to a
1016
+ * local before it is returned is the same runtime value with none of that: the
1017
+ * checker sees the statics arrive and holds the result to [`Expect`].
1018
+ */
1019
+ function expecting(): Expect {
1020
+ const api = (received: mixed): Expectation => expectValue(received);
1021
+ api.any = asymmetric.any;
1022
+ api.anything = asymmetric.anything;
1023
+ api.objectContaining = asymmetric.objectContaining;
1024
+ api.arrayContaining = asymmetric.arrayContaining;
1025
+ api.stringContaining = asymmetric.stringContaining;
1026
+ api.stringMatching = asymmetric.stringMatching;
1027
+ api.closeTo = asymmetric.closeTo;
1028
+ api.not = {
1029
+ objectContaining: (expected: interface {}) =>
1030
+ asymmetric.not(asymmetric.objectContaining(expected)),
1031
+ arrayContaining: (expected: $ReadOnlyArray<mixed>) =>
1032
+ asymmetric.not(asymmetric.arrayContaining(expected)),
1033
+ stringContaining: (substring: string) => asymmetric.not(asymmetric.stringContaining(substring)),
1034
+ stringMatching: (pattern: string | RegExp) =>
1035
+ asymmetric.not(asymmetric.stringMatching(pattern)),
1036
+ closeTo: (value: number, digits?: number) => asymmetric.not(asymmetric.closeTo(value, digits)),
1037
+ };
1038
+ return api;
1039
+ }
1040
+
1041
+ /**
1042
+ * Assert about a value.
610
1043
  *
611
1044
  * The `expect.*` half are the matchers that stand in for a value instead of
612
1045
  * being one. `expect(user).toEqual({ id: expect.any(String), name: "uf" })`
@@ -619,31 +1052,4 @@ function expectValue(received: mixed): $FlowFixMe {
619
1052
  * than a negated assertion around the whole object, and is the form a suite
620
1053
  * being ported will already have.
621
1054
  */
622
- // `flow/unsafe-object-assign` asks for an object spread, and a spread cannot
623
- // produce this value: `expect` is a *function* with matchers hanging off it,
624
- // and `{ ...expectValue, ...matchers }` is a plain object that a test cannot
625
- // call. `Object.assign` onto a callable is the only expression that makes one,
626
- // and the alternative the rule is really warning about — `expect.any = …`
627
- // afterwards — is the top-level statement the comment above rules out. What it
628
- // mutates is a function this module declared six lines up and exports here; no
629
- // object belonging to anybody else is touched.
630
- // uf-lint-disable-next-line flow/unsafe-object-assign
631
- export const expect: $FlowFixMe = Object.assign(expectValue, {
632
- any: asymmetric.any,
633
- anything: asymmetric.anything,
634
- objectContaining: asymmetric.objectContaining,
635
- arrayContaining: asymmetric.arrayContaining,
636
- stringContaining: asymmetric.stringContaining,
637
- stringMatching: asymmetric.stringMatching,
638
- closeTo: asymmetric.closeTo,
639
- not: {
640
- objectContaining: (expected: interface {}) =>
641
- asymmetric.not(asymmetric.objectContaining(expected)),
642
- arrayContaining: (expected: $ReadOnlyArray<mixed>) =>
643
- asymmetric.not(asymmetric.arrayContaining(expected)),
644
- stringContaining: (substring: string) => asymmetric.not(asymmetric.stringContaining(substring)),
645
- stringMatching: (pattern: string | RegExp) =>
646
- asymmetric.not(asymmetric.stringMatching(pattern)),
647
- closeTo: (value: number, digits?: number) => asymmetric.not(asymmetric.closeTo(value, digits)),
648
- },
649
- });
1055
+ export const expect: Expect = expecting();