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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/internal/dom.js CHANGED
@@ -14,6 +14,67 @@
14
14
 
15
15
  import { Window } from "happy-dom";
16
16
 
17
+ /**
18
+ * A function this module found on a window and knows nothing else about.
19
+ *
20
+ * `mixed` in and `mixed` out is the whole contract: these are copied across
21
+ * for React and for components to call, and nothing here ever calls one.
22
+ */
23
+ type HostFunction = (...args: $ReadOnlyArray<mixed>) => mixed;
24
+
25
+ /**
26
+ * Values kept by name, which is all this module knows about a window, the
27
+ * global object, or a Storage.
28
+ *
29
+ * The indexer is the honest shape rather than a placeholder for a type nobody
30
+ * wrote: the work this module does is to read names out of a list and hand the
31
+ * values to `Object.defineProperty`. It does not call them, construct them, or
32
+ * look inside them, so `mixed` is the amount it knows.
33
+ */
34
+ type Named = { readonly [string]: mixed };
35
+
36
+ /**
37
+ * A window, as this module uses one.
38
+ *
39
+ * A table of globals to copy, plus the three functions that are *bound* rather
40
+ * than copied — and those are named because binding is the one thing an
41
+ * indexer cannot describe. `typeof win[name] === "function"` refines a `mixed`
42
+ * to a function whose parameters Flow does not know, and `.bind` is not
43
+ * something that can be done to one of those:
44
+ *
45
+ * error[incompatible-use]: Cannot call `value.bind` because
46
+ * `unknown function` is not a function type.
47
+ *
48
+ * So the list of functions to bind, which used to be a `FUNCTIONS` array
49
+ * beside the other three arrays, is this part of the type instead. One list
50
+ * rather than two, and it is the one the checker reads.
51
+ *
52
+ * `happy-dom` ships TypeScript rather than Flow, so `new Window(…)` is `any`.
53
+ * This annotation is the first statement anywhere about what comes back, not a
54
+ * cast that discards one.
55
+ */
56
+ type HostWindow = {
57
+ readonly getComputedStyle?: HostFunction,
58
+ readonly requestAnimationFrame?: HostFunction,
59
+ readonly cancelAnimationFrame?: HostFunction,
60
+ readonly [string]: mixed,
61
+ };
62
+
63
+ /**
64
+ * The global object, under the one description this module has of it.
65
+ *
66
+ * `globalThis` is a namespace to the checker rather than an object, so
67
+ * `globalThis[name]` is not an expression that can be written —
68
+ * `Cannot access namespace globalThis with computed property using string` —
69
+ * and reading an installed global by a name from a list is what deciding
70
+ * whether to install one requires. Naming the same object as a table says what
71
+ * those reads are: a name in, and no claim at all about what comes out.
72
+ *
73
+ * Writes still go through `define`, because they have to be
74
+ * `Object.defineProperty`; see the reason there.
75
+ */
76
+ const globals: Named = globalThis;
77
+
17
78
  /**
18
79
  * The document's own classes, which always replace whatever the host had.
19
80
  *
@@ -22,6 +83,14 @@ import { Window } from "happy-dom";
22
83
  * "parameter 1 is not of type 'Event'" for every event this module had no more
23
84
  * specific constructor for. Whatever the host already had, the document's own
24
85
  * classes are the ones that work with the document.
86
+ *
87
+ * The list is also the list of questions the rest of the package can ask.
88
+ * `internal/queries.js` and `internal/events.js` narrow an `Element` with
89
+ * `instanceof HTMLInputElement` rather than reading `.value` off a cast, and
90
+ * an `instanceof` against a name that was never installed is not a `false` —
91
+ * it is `ReferenceError: HTMLFieldSetElement is not defined`, from a line
92
+ * about clicking a tab. So a class this package needs to *recognise* belongs
93
+ * here as much as one the document needs to accept.
25
94
  */
26
95
  const CLASSES = [
27
96
  "Node",
@@ -31,6 +100,7 @@ const CLASSES = [
31
100
  "HTMLTextAreaElement",
32
101
  "HTMLSelectElement",
33
102
  "HTMLButtonElement",
103
+ "HTMLFieldSetElement",
34
104
  "HTMLAnchorElement",
35
105
  "SVGElement",
36
106
  "Event",
@@ -47,11 +117,6 @@ const CLASSES = [
47
117
  "IntersectionObserver",
48
118
  ];
49
119
 
50
- /**
51
- * Functions that read the window they came from, so they are bound to it.
52
- */
53
- const FUNCTIONS = ["getComputedStyle", "requestAnimationFrame", "cancelAnimationFrame"];
54
-
55
120
  /**
56
121
  * Objects a page has, installed only where the host has none.
57
122
  *
@@ -59,9 +124,47 @@ const FUNCTIONS = ["getComputedStyle", "requestAnimationFrame", "cancelAnimation
59
124
  * with no setter, and assigning to it throws. A test does not need it
60
125
  * replaced — it needs it to exist.
61
126
  */
62
- const OBJECTS = ["location", "history", "localStorage", "sessionStorage", "navigator"];
127
+ const OBJECTS = ["location", "history", "navigator"];
63
128
 
64
- let installed: mixed = null;
129
+ /**
130
+ * Storage, which is installed where the host has none *or has one that does
131
+ * not work*.
132
+ *
133
+ * Node defines `globalThis.localStorage` and leaves it empty unless the
134
+ * process was started with `--localstorage-file`:
135
+ *
136
+ * ```text
137
+ * typeof globalThis.localStorage // "object"
138
+ * globalThis.localStorage.setItem // undefined
139
+ * ```
140
+ *
141
+ * So "the host already has one" is the wrong question, and asking it left
142
+ * every `useStorage` test writing into an object with no `setItem` —
143
+ * `globalThis.localStorage.setItem is not a function`, from a line that had
144
+ * nothing to do with the hook under test. The question is whether it works.
145
+ */
146
+ const STORAGE = ["localStorage", "sessionStorage"];
147
+
148
+ /**
149
+ * Whether a value is a Storage a test can actually use.
150
+ *
151
+ * The four methods, not one: a half-implemented shim that has `getItem` and
152
+ * no `removeItem` fails later and further away than one that is absent.
153
+ */
154
+ function isUsableStorage(value: mixed): boolean {
155
+ if (value == null || typeof value !== "object") {
156
+ return false;
157
+ }
158
+ const storage: Named = value;
159
+ return (
160
+ typeof storage.getItem === "function" &&
161
+ typeof storage.setItem === "function" &&
162
+ typeof storage.removeItem === "function" &&
163
+ typeof storage.clear === "function"
164
+ );
165
+ }
166
+
167
+ let installed: HostWindow | null = null;
65
168
 
66
169
  /**
67
170
  * Install a DOM on the global object, once.
@@ -71,7 +174,8 @@ let installed: mixed = null;
71
174
  * not replace the document — replacing it mid-process would strand every React
72
175
  * root already mounted in the old one.
73
176
  */
74
- export function installDom(): mixed {
177
+ export function installDom(): HostWindow {
178
+ installActEnvironment();
75
179
  if (installed != null) {
76
180
  return installed;
77
181
  }
@@ -83,36 +187,83 @@ export function installDom(): mixed {
83
187
  return installed;
84
188
  }
85
189
 
86
- const win = new Window({ url: "http://localhost/" });
190
+ const win: HostWindow = new Window({ url: "http://localhost/" });
87
191
 
88
192
  for (const name of CLASSES) {
89
- const value = (win as any)[name];
193
+ const value = win[name];
90
194
  if (value !== undefined) {
91
195
  define(name, value);
92
196
  }
93
197
  }
94
- for (const name of FUNCTIONS) {
95
- const value = (win as any)[name];
96
- if (typeof value === "function") {
97
- define(name, value.bind(win));
198
+ // The three by name rather than from a list: see `HostWindow`.
199
+ defineBound("getComputedStyle", win.getComputedStyle, win);
200
+ defineBound("requestAnimationFrame", win.requestAnimationFrame, win);
201
+ defineBound("cancelAnimationFrame", win.cancelAnimationFrame, win);
202
+ for (const name of OBJECTS) {
203
+ const value = win[name];
204
+ if (value !== undefined && globals[name] === undefined) {
205
+ define(name, value);
98
206
  }
99
207
  }
100
- for (const name of OBJECTS) {
101
- const value = (win as any)[name];
102
- if (value !== undefined && globalThis[name] === undefined) {
208
+ for (const name of STORAGE) {
209
+ if (isUsableStorage(globals[name])) {
210
+ continue;
211
+ }
212
+ const value = win[name];
213
+ if (isUsableStorage(value)) {
103
214
  define(name, value);
104
215
  }
105
216
  }
106
217
 
107
218
  // React reads these to decide it is in a browser and to pick its event
108
219
  // system, and they must be the objects the elements belong to.
109
- define("window", win as any);
110
- define("document", (win as any).document);
220
+ define("window", win);
221
+ define("document", win.document);
111
222
 
112
223
  installed = win;
113
224
  return installed;
114
225
  }
115
226
 
227
+ /**
228
+ * Tell React that this process is running tests.
229
+ *
230
+ * React cannot tell a test from a production render, so `act` warns "The
231
+ * current testing environment is not configured to support act(...)" unless
232
+ * the harness says so. Every render in this package goes through `act`, so
233
+ * without this every component test printed the warning — 73 times in one
234
+ * file of this repository — and a warning worth reading was lost among them.
235
+ *
236
+ * Separate from the document because the two are independent: a project
237
+ * already running in a browser has a DOM and still has to say it is testing.
238
+ */
239
+ export function installActEnvironment(): void {
240
+ if (declared) {
241
+ return;
242
+ }
243
+ declared = true;
244
+ define("IS_REACT_ACT_ENVIRONMENT", true);
245
+ }
246
+
247
+ /**
248
+ * Turn the act environment on or off.
249
+ *
250
+ * `waitFor` stands it down for the length of a wait; see the reason there.
251
+ */
252
+ export function setActEnvironment(active: boolean): void {
253
+ declared = true;
254
+ define("IS_REACT_ACT_ENVIRONMENT", active);
255
+ }
256
+
257
+ /**
258
+ * Whether the flag has been installed, tracked separately from its value.
259
+ *
260
+ * Every query calls `installDom`, which installs the act environment, and
261
+ * every query inside a `waitFor` therefore ran while `waitFor` had stood the
262
+ * environment down. Reading the flag to decide whether to set it turned it
263
+ * back on at the first assertion, so only the first poll of a wait was quiet.
264
+ */
265
+ let declared = false;
266
+
116
267
  /**
117
268
  * Assign a global, even where the host declared it as a getter.
118
269
  *
@@ -128,8 +279,34 @@ function define(name: string, value: mixed): void {
128
279
  });
129
280
  }
130
281
 
282
+ /** Install one of the window's own functions, still reading that window. */
283
+ function defineBound(name: string, fn: HostFunction | void, win: HostWindow): void {
284
+ if (typeof fn === "function") {
285
+ define(name, fn.bind(win));
286
+ }
287
+ }
288
+
131
289
  /** The document tests query, installing one if the process has none. */
132
290
  export function documentOf(): Document {
133
291
  installDom();
134
- return globalThis.document as any;
292
+ return globalThis.document;
293
+ }
294
+
295
+ /**
296
+ * The body a test renders into and queries, installing a document first.
297
+ *
298
+ * Separate from `documentOf` because `Document.body` is `HTMLBodyElement |
299
+ * null` — a document with no `<body>` is a document a parser can produce — and
300
+ * both callers want the element rather than the question. A document this
301
+ * module installed has a body, and a document the host already had is a page,
302
+ * which also has one; the throw is for the third case, and it says what is
303
+ * missing rather than leaving `Cannot read properties of null (reading
304
+ * 'appendChild')` to be read at a line about rendering.
305
+ */
306
+ export function bodyOf(): HTMLElement {
307
+ const body = documentOf().body;
308
+ if (body == null) {
309
+ throw new Error("@uniflowed/react-testing: the document has no <body> to render into");
310
+ }
311
+ return body;
135
312
  }
@@ -15,8 +15,22 @@
15
15
  // not at all under a bare `click` event, and a test that only fires `click`
16
16
  // would pass while the feature was broken.
17
17
 
18
+ import { bodyOf, documentOf } from "./dom.js";
19
+ import { displayValue } from "./queries.js";
18
20
  import { actively } from "./render.js";
19
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
+
20
34
  /** Event constructors by DOM event name, with the right interface for each. */
21
35
  const EVENT_TYPES: { readonly [string]: string } = {
22
36
  click: "MouseEvent",
@@ -66,22 +80,71 @@ const ALSO_BUBBLES: { readonly [string]: string } = {
66
80
  blur: "focusout",
67
81
  };
68
82
 
69
- function construct(name: string, init: { readonly [string]: mixed }): Event {
70
- const interfaceName = EVENT_TYPES[name] ?? "Event";
71
- const Constructor = (globalThis as any)[interfaceName] ?? globalThis.Event;
72
- const options = {
73
- bubbles: !NON_BUBBLING.has(name),
74
- cancelable: true,
75
- ...init,
76
- };
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.
115
+ const classes: any = globalThis;
116
+ const Constructor = classes[EVENT_TYPES[name] ?? "Event"] ?? classes.Event;
117
+ const options = optionsFor(name, init);
77
118
  try {
78
119
  return new Constructor(name, options);
79
120
  } catch {
80
121
  // A host whose constructor is stricter than the init we were handed.
81
- return new globalThis.Event(name, options);
122
+ return new classes.Event(name, options);
82
123
  }
83
124
  }
84
125
 
126
+ /**
127
+ * The defaults an event is built with, under whatever the caller asked for.
128
+ *
129
+ * Written as a loop rather than `{ bubbles, cancelable, ...init }` because
130
+ * spreading an indexer is something Flow declines to compute a type for —
131
+ * "the indexer `string` may overwrite properties with explicit keys in a way
132
+ * that Flow cannot track", which is precisely what this is for. Its
133
+ * suggestion, spreading `init` first, would reverse the precedence and stop a
134
+ * caller from passing `bubbles: false`; the loop keeps the caller on top,
135
+ * which is what the spread said.
136
+ */
137
+ function optionsFor(name: string, init: EventInit): EventInit {
138
+ const options: { [string]: mixed } = {
139
+ bubbles: !NON_BUBBLING.has(name),
140
+ cancelable: true,
141
+ };
142
+ for (const key of Object.keys(init)) {
143
+ options[key] = init[key];
144
+ }
145
+ return options;
146
+ }
147
+
85
148
  /**
86
149
  * Dispatch one event, inside `act`.
87
150
  *
@@ -89,11 +152,7 @@ function construct(name: string, init: { readonly [string]: mixed }): Event {
89
152
  * `preventDefault`, which is what `dispatchEvent` reports and what a test
90
153
  * asserting "the form did not submit" needs.
91
154
  */
92
- export function dispatch(
93
- target: EventTarget,
94
- name: string,
95
- init?: { readonly [string]: mixed },
96
- ): boolean {
155
+ export function dispatch(target: EventTarget, name: string, init?: EventInit): boolean {
97
156
  const event = construct(name, init ?? {});
98
157
  const paired = ALSO_BUBBLES[name];
99
158
  let ran = true;
@@ -114,19 +173,57 @@ export function dispatch(
114
173
  * A proxy rather than a written-out table: the set of DOM events is long,
115
174
  * grows, and every entry would be the same line. `fireEvent(target, name)`
116
175
  * also works, for an event whose name is computed.
176
+ *
177
+ * # Why the type is still `any`, and what was tried
178
+ *
179
+ * The type this wants is a function that also answers to every event name.
180
+ * Written with an indexer:
181
+ *
182
+ * type FireEvent = {
183
+ * (target: EventTarget, name: string, init?: EventInit): boolean,
184
+ * readonly [string]: (target: EventTarget, init?: EventInit) => boolean,
185
+ * };
186
+ *
187
+ * Flow declines the indexer, and is right to. As an `interface`, so the
188
+ * assignment gets far enough to say why, it reads "an unknown property that
189
+ * may exist on the inexact function is incompatible with `Firer`" — the value
190
+ * is a function, a function has `name`, `length`, `call`, `apply` and `bind`,
191
+ * and the trap below hands those back as themselves because `property in base`
192
+ * is true for them. None of the five is a DOM event, so the lie is unreachable
193
+ * from any real call, but a type is not something to be right about on
194
+ * average.
195
+ *
196
+ * The written-out table the runtime deliberately is not — a call signature
197
+ * plus a named property per event — fails earlier and for a reason no list of
198
+ * names would fix:
199
+ *
200
+ * error[incompatible-type]: Cannot assign `new Proxy(...)` to `fireEvent`
201
+ * because `(target: EventTarget, name: string, init?: EventInit) =>
202
+ * boolean` is incompatible with `FireEvent`.
203
+ * Functions without statics are not compatible with objects.
204
+ *
205
+ * A `Proxy` over a function *is* a function, and Flow will not treat a
206
+ * function with no statics as an object with properties whatever those
207
+ * properties are. So no type at all can be assigned to this value: typing
208
+ * `fireEvent` means changing what it is — a function carrying real static
209
+ * properties, one per event name, which is a table of a hundred-odd entries
210
+ * that stops answering to the hundred-and-first. That is a design decision
211
+ * about a published API and not a cast to remove in passing.
212
+ *
213
+ * Left as `any` rather than renamed to `$FlowFixMe`, which would move it out
214
+ * of `flow/unclear-type`'s sight without moving it out of the package.
117
215
  */
118
216
  export const fireEvent: any = new Proxy(
119
- (target: EventTarget, name: string, init?: { readonly [string]: mixed }) =>
120
- dispatch(target, name, init),
217
+ (target: EventTarget, name: string, init?: EventInit) => dispatch(target, name, init),
121
218
  {
122
219
  get(base, property) {
123
220
  if (typeof property !== "string") {
124
- return (base as any)[property];
221
+ return Reflect.get(base, property);
125
222
  }
126
223
  if (property in base) {
127
- return (base as any)[property];
224
+ return Reflect.get(base, property);
128
225
  }
129
- return (target: EventTarget, init?: { readonly [string]: mixed }) =>
226
+ return (target: EventTarget, init?: EventInit) =>
130
227
  dispatch(target, property.toLowerCase(), init);
131
228
  },
132
229
  },
@@ -134,16 +231,23 @@ export const fireEvent: any = new Proxy(
134
231
 
135
232
  /** Set a control's value the way a browser does, so React sees the change. */
136
233
  function setValue(element: HTMLElement, value: string): void {
137
- const target: any = element;
138
234
  // React tracks the last value it wrote on the node and skips an `input`
139
235
  // event whose value it believes it already knows. Writing through the
140
236
  // prototype's setter is what a browser does and what clears that.
141
- const prototype = Object.getPrototypeOf(target);
142
- const descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
143
- if (descriptor?.set != null) {
144
- descriptor.set.call(target, value);
145
- } else {
146
- target.value = value;
237
+ const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), "value");
238
+ const set = descriptor?.set;
239
+ if (set != null) {
240
+ set.call(element, value);
241
+ } else if (
242
+ element instanceof HTMLInputElement ||
243
+ element instanceof HTMLTextAreaElement ||
244
+ element instanceof HTMLSelectElement
245
+ ) {
246
+ // The fallback, for a host whose control classes keep `value` as an own
247
+ // property rather than an accessor on the prototype. The three classes are
248
+ // the ones `displayValue` reads, so what a test writes is what a
249
+ // `ByDisplayValue` query can find.
250
+ element.value = value;
147
251
  }
148
252
  }
149
253
 
@@ -170,12 +274,46 @@ function describeKey(key: string): {| key: string, code: string, text: string |
170
274
  return { key, code: `Key${key.toUpperCase()}`, text: key };
171
275
  }
172
276
 
173
- /** Elements the tab order includes, in document order. */
277
+ /**
278
+ * Elements the tab order includes, in document order.
279
+ *
280
+ * `instanceof HTMLElement` rather than a cast, and it is not only the
281
+ * checker's question: the next thing done to one of these is `focus`, and
282
+ * `focus` is a method of `HTMLElement`. The declared return type has always
283
+ * said `HTMLElement` while the selector could match an `Element` — Flow said
284
+ * so, "in array element: `Element` is incompatible with `HTMLElement`" — and
285
+ * anything that reached here without being one would have been handed to a
286
+ * `(element as any).focus?.()` that silently did nothing, swallowing the Tab.
287
+ */
174
288
  function tabbable(): Array<HTMLElement> {
175
289
  const selector =
176
290
  'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
177
- return Array.from(globalThis.document.querySelectorAll(selector)).filter(
178
- (element: any) => element.getAttribute("aria-hidden") !== "true",
291
+ const order = [];
292
+ for (const element of documentOf().querySelectorAll(selector)) {
293
+ if (element instanceof HTMLElement && element.getAttribute("aria-hidden") !== "true") {
294
+ order.push(element);
295
+ }
296
+ }
297
+ return order;
298
+ }
299
+
300
+ /**
301
+ * Whether a control is disabled, for the elements that can be.
302
+ *
303
+ * The classes rather than `(element as any).disabled === true`, which was the
304
+ * same question asked in a way that answered `any`. `disabled` belongs to the
305
+ * form controls and to `fieldset`; on a `div` it is an attribute React put in
306
+ * the markup and not a property, which is why the old test was `=== true`
307
+ * rather than truthiness, and why the answer for one is still `false`.
308
+ */
309
+ function isDisabled(element: HTMLElement): boolean {
310
+ return (
311
+ (element instanceof HTMLButtonElement ||
312
+ element instanceof HTMLInputElement ||
313
+ element instanceof HTMLSelectElement ||
314
+ element instanceof HTMLTextAreaElement ||
315
+ element instanceof HTMLFieldSetElement) &&
316
+ element.disabled
179
317
  );
180
318
  }
181
319
 
@@ -190,8 +328,8 @@ function tabbable(): Array<HTMLElement> {
190
328
  */
191
329
  export const userEvent = {
192
330
  /** Press and release, with the events a real click produces, in order. */
193
- async click(element: HTMLElement, init?: { readonly [string]: mixed }): Promise<void> {
194
- if ((element as any).disabled === true) {
331
+ async click(element: HTMLElement, init?: EventInit): Promise<void> {
332
+ if (isDisabled(element)) {
195
333
  return;
196
334
  }
197
335
  dispatch(element, "pointerdown", init);
@@ -224,8 +362,11 @@ export const userEvent = {
224
362
  for (const character of text) {
225
363
  const { key, code, text: printable } = describeKey(character);
226
364
  dispatch(element, "keydown", { key, code });
227
- if (printable != null && printable !== "\n") {
228
- setValue(element, `${(element as any).value ?? ""}${printable}`);
365
+ // Nothing is typed into an element that shows no value; see `keyboard`
366
+ // for what that used to do instead.
367
+ const current = displayValue(element);
368
+ if (printable != null && printable !== "\n" && current != null) {
369
+ setValue(element, `${current}${printable}`);
229
370
  dispatch(element, "input", { data: printable });
230
371
  }
231
372
  dispatch(element, "keyup", { key, code });
@@ -243,12 +384,22 @@ export const userEvent = {
243
384
 
244
385
  /** Press keys at whatever has focus. Named keys go in braces: `{Enter}`. */
245
386
  async keyboard(sequence: string): Promise<void> {
246
- const target: any = globalThis.document.activeElement ?? globalThis.document.body;
387
+ const target = documentOf().activeElement ?? bodyOf();
247
388
  for (const token of parseKeys(sequence)) {
248
389
  const { key, code, text } = describeKey(token);
249
390
  dispatch(target, "keydown", { key, code });
250
- if (text != null && text !== "\n" && target.value !== undefined) {
251
- setValue(target, `${target.value ?? ""}${text}`);
391
+ // `displayValue` rather than `target.value !== undefined`.
392
+ //
393
+ // The two agree about every control a person can type into, and differ
394
+ // about `<button>`, `<option>`, `<progress>` and the rest of the
395
+ // elements that have a `value` property without showing one: pressing
396
+ // Space at a focused button used to write `button.value = " "` and
397
+ // dispatch an `input` event at it, which no browser does — Space on a
398
+ // button is a click. Nothing in this repository's suite depended on it,
399
+ // and `ui.test.js` presses Space at a switch on the way past.
400
+ const current = displayValue(target);
401
+ if (text != null && text !== "\n" && current != null) {
402
+ setValue(target, `${current}${text}`);
252
403
  dispatch(target, "input", { data: text });
253
404
  }
254
405
  dispatch(target, "keyup", { key, code });
@@ -262,8 +413,10 @@ export const userEvent = {
262
413
  if (order.length === 0) {
263
414
  return;
264
415
  }
265
- const active: any = globalThis.document.activeElement;
266
- const at = order.indexOf(active);
416
+ const active = documentOf().activeElement;
417
+ // `indexOf` needs an element; a document with nothing focused is the same
418
+ // "not in the order" that `indexOf` answers `-1` to, said in front.
419
+ const at = active == null ? -1 : order.indexOf(active);
267
420
  const shift = options?.shift ?? false;
268
421
  const next =
269
422
  at < 0
@@ -281,9 +434,14 @@ export const userEvent = {
281
434
  values: string | $ReadOnlyArray<string>,
282
435
  ): Promise<void> {
283
436
  const wanted = typeof values === "string" ? [values] : values;
284
- const select: any = element;
285
- for (const option of Array.from(select.options ?? [])) {
286
- (option as any).selected = wanted.includes((option as any).value);
437
+ // Only a `select` has options; anything else has none, which is what
438
+ // `select.options ?? []` used to say. The events are dispatched either
439
+ // way, because a component listening for `change` on something that is not
440
+ // a select is a component under test and not this function's business.
441
+ if (element instanceof HTMLSelectElement) {
442
+ for (const option of Array.from(element.options)) {
443
+ option.selected = wanted.includes(option.value);
444
+ }
287
445
  }
288
446
  dispatch(element, "input");
289
447
  dispatch(element, "change");
@@ -293,20 +451,19 @@ export const userEvent = {
293
451
  /** Move focus away, which is what makes a blur-validated field validate. */
294
452
  async tabAway(element: HTMLElement): Promise<void> {
295
453
  dispatch(element, "blur");
296
- (element as any).blur?.();
454
+ element.blur();
297
455
  await settle();
298
456
  },
299
457
  };
300
458
 
301
459
  function focus(element: HTMLElement): void {
302
- const previous: any = globalThis.document.activeElement;
303
- if (previous === element) {
460
+ if (documentOf().activeElement === element) {
304
461
  return;
305
462
  }
306
463
  actively(() => {
307
- (element as any).focus?.();
464
+ element.focus();
308
465
  });
309
- if (globalThis.document.activeElement !== element) {
466
+ if (documentOf().activeElement !== element) {
310
467
  // A host whose `focus` does not move `activeElement`; the events are what
311
468
  // components listen for, so dispatch them regardless.
312
469
  dispatch(element, "focus");