@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,503 @@
1
+ // @flow
2
+ //
3
+ // Making things happen to the page.
4
+ //
5
+ // Two layers, because tests want two different things.
6
+ //
7
+ // `fireEvent` dispatches one event. It is the right tool when the test is
8
+ // about the handler: "clicking calls onSelect once".
9
+ //
10
+ // `userEvent` performs what a person did, which is almost never one event.
11
+ // Clicking a button is pointerdown, mousedown, focus, pointerup, mouseup and
12
+ // click; typing is a keydown, an input and a keyup per character, with the
13
+ // value updated in between. A component that listens for `mousedown` — a menu
14
+ // that closes on outside press, say — behaves correctly under a real click and
15
+ // not at all under a bare `click` event, and a test that only fires `click`
16
+ // would pass while the feature was broken.
17
+
18
+ import { bodyOf, documentOf } from "./dom.js";
19
+ import { displayValue } from "./queries.js";
20
+ import { actively } from "./render.js";
21
+
22
+ /**
23
+ * What a caller wants the event to carry.
24
+ *
25
+ * An indexer, because which properties are meaningful is decided by the event's
26
+ * *interface* and the interface is decided by the name — `{ key: "Escape" }`
27
+ * for a `keydown`, `{ clientX: 40 }` for a `pointermove` — and the name is a
28
+ * string a caller computes. There is no type that says "the initialisers of
29
+ * whichever interface `name` maps to", so this says what is true: names in,
30
+ * and what each one means is the DOM's business.
31
+ */
32
+ export type EventInit = { readonly [string]: mixed };
33
+
34
+ /** Event constructors by DOM event name, with the right interface for each. */
35
+ const EVENT_TYPES: { readonly [string]: string } = {
36
+ click: "MouseEvent",
37
+ dblclick: "MouseEvent",
38
+ mousedown: "MouseEvent",
39
+ mouseup: "MouseEvent",
40
+ mouseover: "MouseEvent",
41
+ mouseout: "MouseEvent",
42
+ mouseenter: "MouseEvent",
43
+ mouseleave: "MouseEvent",
44
+ mousemove: "MouseEvent",
45
+ contextmenu: "MouseEvent",
46
+ keydown: "KeyboardEvent",
47
+ keyup: "KeyboardEvent",
48
+ keypress: "KeyboardEvent",
49
+ focus: "FocusEvent",
50
+ blur: "FocusEvent",
51
+ focusin: "FocusEvent",
52
+ focusout: "FocusEvent",
53
+ input: "InputEvent",
54
+ pointerdown: "PointerEvent",
55
+ pointerup: "PointerEvent",
56
+ pointermove: "PointerEvent",
57
+ };
58
+
59
+ /** Events that do not bubble, whatever else is said about them. */
60
+ const NON_BUBBLING = new Set(["focus", "blur", "mouseenter", "mouseleave"]);
61
+
62
+ /**
63
+ * The bubbling event React actually listens for, for each one that does not
64
+ * bubble.
65
+ *
66
+ * React attaches every listener to the root container rather than to the
67
+ * element, so it can only hear events that reach the root. `focus` and `blur`
68
+ * never do. React's answer is to listen for `focusin` and `focusout` — which
69
+ * are the same moments and do bubble — and surface them to a component as
70
+ * `onFocus` and `onBlur`.
71
+ *
72
+ * So dispatching a bare `focus` calls nothing: the element's own listener, if
73
+ * a test added one directly, and no React handler at all. The test then
74
+ * asserts on a component that never re-rendered and reads as a component bug.
75
+ * Firing the pair is what a browser does anyway — a real focus is a `focus`
76
+ * and a `focusin` — so this is less a workaround than the missing half.
77
+ */
78
+ const ALSO_BUBBLES: { readonly [string]: string } = {
79
+ focus: "focusin",
80
+ blur: "focusout",
81
+ };
82
+
83
+ /**
84
+ * The event a name asks for, built by the document's own class for it.
85
+ *
86
+ * # Why `Constructor` is `any`
87
+ *
88
+ * Neither half of this can be typed.
89
+ *
90
+ * Reading it: `globalThis` is a namespace to the checker rather than an
91
+ * object, and the value under a computed name is `mixed`, which cannot be
92
+ * `new`ed — refining a `mixed` with `typeof x === "function"` gives a function
93
+ * whose signature Flow says it does not know.
94
+ *
95
+ * Calling it: this is the half that is not uf's to fix. Flow's library
96
+ * definitions declare every event initialiser dictionary with *writable*
97
+ * properties — `MouseEvent$MouseEventInit` has `clientX?: number`, not
98
+ * `readonly clientX?: number` — so those properties are invariant, and no
99
+ * value whose type was computed rather than written inline can be passed to
100
+ * one. `{ readonly [string]: mixed }` fails on variance before it fails on
101
+ * anything else:
102
+ *
103
+ * error[incompatible-variance]: property `bubbles` is read-only in
104
+ * `EventInit` but writable in `Event$Init`
105
+ * error[incompatible-type]: in property `clientX`: `unknown` is not
106
+ * exactly the same as `number`
107
+ *
108
+ * — twelve of those for `MouseEvent` alone, and Flow's own advice in the
109
+ * message is to make the library definition's property readonly. So an
110
+ * `EventInit` cannot reach an event constructor under any spelling, and this
111
+ * stays one cast at one line rather than a dozen errors at every call.
112
+ */
113
+ function construct(name: string, init: EventInit): Event {
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
118
+ const classes: any = globalThis;
119
+ const Constructor = classes[EVENT_TYPES[name] ?? "Event"] ?? classes.Event;
120
+ const options = optionsFor(name, init);
121
+ try {
122
+ return new Constructor(name, options);
123
+ } catch {
124
+ // A host whose constructor is stricter than the init we were handed.
125
+ return new classes.Event(name, options);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * The defaults an event is built with, under whatever the caller asked for.
131
+ *
132
+ * Written as a loop rather than `{ bubbles, cancelable, ...init }` because
133
+ * spreading an indexer is something Flow declines to compute a type for —
134
+ * "the indexer `string` may overwrite properties with explicit keys in a way
135
+ * that Flow cannot track", which is precisely what this is for. Its
136
+ * suggestion, spreading `init` first, would reverse the precedence and stop a
137
+ * caller from passing `bubbles: false`; the loop keeps the caller on top,
138
+ * which is what the spread said.
139
+ */
140
+ function optionsFor(name: string, init: EventInit): EventInit {
141
+ const options: { [string]: mixed } = {
142
+ bubbles: !NON_BUBBLING.has(name),
143
+ cancelable: true,
144
+ };
145
+ for (const key of Object.keys(init)) {
146
+ options[key] = init[key];
147
+ }
148
+ return options;
149
+ }
150
+
151
+ /**
152
+ * Dispatch one event, inside `act`.
153
+ *
154
+ * Returns whether the event ran to completion — `false` when a handler called
155
+ * `preventDefault`, which is what `dispatchEvent` reports and what a test
156
+ * asserting "the form did not submit" needs.
157
+ */
158
+ export function dispatch(target: EventTarget, name: string, init?: EventInit): boolean {
159
+ const event = construct(name, init ?? {});
160
+ const paired = ALSO_BUBBLES[name];
161
+ let ran = true;
162
+ actively(() => {
163
+ ran = target.dispatchEvent(event);
164
+ if (paired != null) {
165
+ // Both, in the order a browser sends them, and inside the same `act` so
166
+ // the component re-renders once rather than twice.
167
+ target.dispatchEvent(construct(paired, init ?? {}));
168
+ }
169
+ });
170
+ return ran;
171
+ }
172
+
173
+ /**
174
+ * `fireEvent.click(element)`, and one entry per event name.
175
+ *
176
+ * A proxy rather than a written-out table: the set of DOM events is long,
177
+ * grows, and every entry would be the same line. `fireEvent(target, name)`
178
+ * also works, for an event whose name is computed.
179
+ *
180
+ * # Why the type is still `any`, and what was tried
181
+ *
182
+ * The type this wants is a function that also answers to every event name.
183
+ * Written with an indexer:
184
+ *
185
+ * type FireEvent = {
186
+ * (target: EventTarget, name: string, init?: EventInit): boolean,
187
+ * readonly [string]: (target: EventTarget, init?: EventInit) => boolean,
188
+ * };
189
+ *
190
+ * Flow declines the indexer, and is right to. As an `interface`, so the
191
+ * assignment gets far enough to say why, it reads "an unknown property that
192
+ * may exist on the inexact function is incompatible with `Firer`" — the value
193
+ * is a function, a function has `name`, `length`, `call`, `apply` and `bind`,
194
+ * and the trap below hands those back as themselves because `property in base`
195
+ * is true for them. None of the five is a DOM event, so the lie is unreachable
196
+ * from any real call, but a type is not something to be right about on
197
+ * average.
198
+ *
199
+ * The written-out table the runtime deliberately is not — a call signature
200
+ * plus a named property per event — fails earlier and for a reason no list of
201
+ * names would fix:
202
+ *
203
+ * error[incompatible-type]: Cannot assign `new Proxy(...)` to `fireEvent`
204
+ * because `(target: EventTarget, name: string, init?: EventInit) =>
205
+ * boolean` is incompatible with `FireEvent`.
206
+ * Functions without statics are not compatible with objects.
207
+ *
208
+ * A `Proxy` over a function *is* a function, and Flow will not treat a
209
+ * function with no statics as an object with properties whatever those
210
+ * properties are. So no type at all can be assigned to this value: typing
211
+ * `fireEvent` means changing what it is — a function carrying real static
212
+ * properties, one per event name, which is a table of a hundred-odd entries
213
+ * that stops answering to the hundred-and-first. That is a design decision
214
+ * about a published API and not a cast to remove in passing.
215
+ *
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.
222
+ */
223
+ // uf-lint-disable-next-line flow/unclear-type
224
+ export const fireEvent: any = new Proxy(
225
+ (target: EventTarget, name: string, init?: EventInit) => dispatch(target, name, init),
226
+ {
227
+ get(base, property) {
228
+ if (typeof property !== "string") {
229
+ return Reflect.get(base, property);
230
+ }
231
+ if (property in base) {
232
+ return Reflect.get(base, property);
233
+ }
234
+ return (target: EventTarget, init?: EventInit) =>
235
+ dispatch(target, property.toLowerCase(), init);
236
+ },
237
+ },
238
+ );
239
+
240
+ /** Set a control's value the way a browser does, so React sees the change. */
241
+ function setValue(element: HTMLElement, value: string): void {
242
+ // React tracks the last value it wrote on the node and skips an `input`
243
+ // event whose value it believes it already knows. Writing through the
244
+ // prototype's setter is what a browser does and what clears that.
245
+ const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), "value");
246
+ const set = descriptor?.set;
247
+ if (set != null) {
248
+ set.call(element, value);
249
+ } else if (
250
+ element instanceof HTMLInputElement ||
251
+ element instanceof HTMLTextAreaElement ||
252
+ element instanceof HTMLSelectElement
253
+ ) {
254
+ // The fallback, for a host whose control classes keep `value` as an own
255
+ // property rather than an accessor on the prototype. The three classes are
256
+ // the ones `displayValue` reads, so what a test writes is what a
257
+ // `ByDisplayValue` query can find.
258
+ element.value = value;
259
+ }
260
+ }
261
+
262
+ /** A key's `key`, `code` and printable text. */
263
+ function describeKey(key: string): {| key: string, code: string, text: string | null |} {
264
+ const named: { readonly [string]: {| code: string, text: string | null |} } = {
265
+ Enter: { code: "Enter", text: "\n" },
266
+ Tab: { code: "Tab", text: null },
267
+ Escape: { code: "Escape", text: null },
268
+ Backspace: { code: "Backspace", text: null },
269
+ Delete: { code: "Delete", text: null },
270
+ ArrowUp: { code: "ArrowUp", text: null },
271
+ ArrowDown: { code: "ArrowDown", text: null },
272
+ ArrowLeft: { code: "ArrowLeft", text: null },
273
+ ArrowRight: { code: "ArrowRight", text: null },
274
+ Home: { code: "Home", text: null },
275
+ End: { code: "End", text: null },
276
+ " ": { code: "Space", text: " " },
277
+ };
278
+ const entry = named[key];
279
+ if (entry != null) {
280
+ return { key, code: entry.code, text: entry.text };
281
+ }
282
+ return { key, code: `Key${key.toUpperCase()}`, text: key };
283
+ }
284
+
285
+ /**
286
+ * Elements the tab order includes, in document order.
287
+ *
288
+ * `instanceof HTMLElement` rather than a cast, and it is not only the
289
+ * checker's question: the next thing done to one of these is `focus`, and
290
+ * `focus` is a method of `HTMLElement`. The declared return type has always
291
+ * said `HTMLElement` while the selector could match an `Element` — Flow said
292
+ * so, "in array element: `Element` is incompatible with `HTMLElement`" — and
293
+ * anything that reached here without being one would have been handed to a
294
+ * `(element as any).focus?.()` that silently did nothing, swallowing the Tab.
295
+ */
296
+ function tabbable(): Array<HTMLElement> {
297
+ const selector =
298
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
299
+ const order = [];
300
+ for (const element of documentOf().querySelectorAll(selector)) {
301
+ if (element instanceof HTMLElement && element.getAttribute("aria-hidden") !== "true") {
302
+ order.push(element);
303
+ }
304
+ }
305
+ return order;
306
+ }
307
+
308
+ /**
309
+ * Whether a control is disabled, for the elements that can be.
310
+ *
311
+ * The classes rather than `(element as any).disabled === true`, which was the
312
+ * same question asked in a way that answered `any`. `disabled` belongs to the
313
+ * form controls and to `fieldset`; on a `div` it is an attribute React put in
314
+ * the markup and not a property, which is why the old test was `=== true`
315
+ * rather than truthiness, and why the answer for one is still `false`.
316
+ */
317
+ function isDisabled(element: HTMLElement): boolean {
318
+ return (
319
+ (element instanceof HTMLButtonElement ||
320
+ element instanceof HTMLInputElement ||
321
+ element instanceof HTMLSelectElement ||
322
+ element instanceof HTMLTextAreaElement ||
323
+ element instanceof HTMLFieldSetElement) &&
324
+ element.disabled
325
+ );
326
+ }
327
+
328
+ /**
329
+ * What a person did, rather than what the DOM emitted.
330
+ *
331
+ * Every method is async because that is what makes a test written with it
332
+ * correct as it grows: the moment an interaction leads to something awaited —
333
+ * a fetch, a transition, a lazily loaded panel — a synchronous helper would
334
+ * return before the result existed, and the test would need a sleep. Awaiting
335
+ * from the start means adding that behaviour later changes nothing.
336
+ */
337
+ export const userEvent = {
338
+ /** Press and release, with the events a real click produces, in order. */
339
+ async click(element: HTMLElement, init?: EventInit): Promise<void> {
340
+ if (isDisabled(element)) {
341
+ return;
342
+ }
343
+ dispatch(element, "pointerdown", init);
344
+ dispatch(element, "mousedown", init);
345
+ focus(element);
346
+ dispatch(element, "pointerup", init);
347
+ dispatch(element, "mouseup", init);
348
+ dispatch(element, "click", init);
349
+ await settle();
350
+ },
351
+
352
+ /** Two clicks and a dblclick. */
353
+ async dblClick(element: HTMLElement): Promise<void> {
354
+ await userEvent.click(element);
355
+ await userEvent.click(element);
356
+ dispatch(element, "dblclick");
357
+ await settle();
358
+ },
359
+
360
+ /**
361
+ * Type into a control, one character at a time.
362
+ *
363
+ * Per character rather than setting the value once, because a component
364
+ * that reacts to each keystroke — a search box that filters, a field that
365
+ * rejects a character — behaves differently, and the difference is the thing
366
+ * usually being tested.
367
+ */
368
+ async type(element: HTMLElement, text: string): Promise<void> {
369
+ focus(element);
370
+ for (const character of text) {
371
+ const { key, code, text: printable } = describeKey(character);
372
+ dispatch(element, "keydown", { key, code });
373
+ // Nothing is typed into an element that shows no value; see `keyboard`
374
+ // for what that used to do instead.
375
+ const current = displayValue(element);
376
+ if (printable != null && printable !== "\n" && current != null) {
377
+ setValue(element, `${current}${printable}`);
378
+ dispatch(element, "input", { data: printable });
379
+ }
380
+ dispatch(element, "keyup", { key, code });
381
+ }
382
+ await settle();
383
+ },
384
+
385
+ /** Empty a control, the way selecting everything and deleting would. */
386
+ async clear(element: HTMLElement): Promise<void> {
387
+ focus(element);
388
+ setValue(element, "");
389
+ dispatch(element, "input", {});
390
+ await settle();
391
+ },
392
+
393
+ /** Press keys at whatever has focus. Named keys go in braces: `{Enter}`. */
394
+ async keyboard(sequence: string): Promise<void> {
395
+ const target = documentOf().activeElement ?? bodyOf();
396
+ for (const token of parseKeys(sequence)) {
397
+ const { key, code, text } = describeKey(token);
398
+ dispatch(target, "keydown", { key, code });
399
+ // `displayValue` rather than `target.value !== undefined`.
400
+ //
401
+ // The two agree about every control a person can type into, and differ
402
+ // about `<button>`, `<option>`, `<progress>` and the rest of the
403
+ // elements that have a `value` property without showing one: pressing
404
+ // Space at a focused button used to write `button.value = " "` and
405
+ // dispatch an `input` event at it, which no browser does — Space on a
406
+ // button is a click. Nothing in this repository's suite depended on it,
407
+ // and `ui.test.js` presses Space at a switch on the way past.
408
+ const current = displayValue(target);
409
+ if (text != null && text !== "\n" && current != null) {
410
+ setValue(target, `${current}${text}`);
411
+ dispatch(target, "input", { data: text });
412
+ }
413
+ dispatch(target, "keyup", { key, code });
414
+ }
415
+ await settle();
416
+ },
417
+
418
+ /** Move focus the way the Tab key does. */
419
+ async tab(options?: {| readonly shift?: boolean |}): Promise<void> {
420
+ const order = tabbable();
421
+ if (order.length === 0) {
422
+ return;
423
+ }
424
+ const active = documentOf().activeElement;
425
+ // `indexOf` needs an element; a document with nothing focused is the same
426
+ // "not in the order" that `indexOf` answers `-1` to, said in front.
427
+ const at = active == null ? -1 : order.indexOf(active);
428
+ const shift = options?.shift ?? false;
429
+ const next =
430
+ at < 0
431
+ ? shift
432
+ ? order[order.length - 1]
433
+ : order[0]
434
+ : order[(at + (shift ? -1 : 1) + order.length) % order.length];
435
+ focus(next);
436
+ await settle();
437
+ },
438
+
439
+ /** Choose options in a select. */
440
+ async selectOptions(
441
+ element: HTMLElement,
442
+ values: string | $ReadOnlyArray<string>,
443
+ ): Promise<void> {
444
+ const wanted = typeof values === "string" ? [values] : values;
445
+ // Only a `select` has options; anything else has none, which is what
446
+ // `select.options ?? []` used to say. The events are dispatched either
447
+ // way, because a component listening for `change` on something that is not
448
+ // a select is a component under test and not this function's business.
449
+ if (element instanceof HTMLSelectElement) {
450
+ for (const option of Array.from(element.options)) {
451
+ option.selected = wanted.includes(option.value);
452
+ }
453
+ }
454
+ dispatch(element, "input");
455
+ dispatch(element, "change");
456
+ await settle();
457
+ },
458
+
459
+ /** Move focus away, which is what makes a blur-validated field validate. */
460
+ async tabAway(element: HTMLElement): Promise<void> {
461
+ dispatch(element, "blur");
462
+ element.blur();
463
+ await settle();
464
+ },
465
+ };
466
+
467
+ function focus(element: HTMLElement): void {
468
+ if (documentOf().activeElement === element) {
469
+ return;
470
+ }
471
+ actively(() => {
472
+ element.focus();
473
+ });
474
+ if (documentOf().activeElement !== element) {
475
+ // A host whose `focus` does not move `activeElement`; the events are what
476
+ // components listen for, so dispatch them regardless.
477
+ dispatch(element, "focus");
478
+ }
479
+ }
480
+
481
+ /** `{Enter}` and `{Escape}` as single tokens; everything else per character. */
482
+ function parseKeys(sequence: string): Array<string> {
483
+ const keys = [];
484
+ let index = 0;
485
+ while (index < sequence.length) {
486
+ if (sequence[index] === "{") {
487
+ const close = sequence.indexOf("}", index);
488
+ if (close > index) {
489
+ keys.push(sequence.slice(index + 1, close));
490
+ index = close + 1;
491
+ continue;
492
+ }
493
+ }
494
+ keys.push(sequence[index]);
495
+ index += 1;
496
+ }
497
+ return keys;
498
+ }
499
+
500
+ /** Let React finish anything the interaction started. */
501
+ async function settle(): Promise<void> {
502
+ await actively(() => Promise.resolve());
503
+ }