slopkit 0.3.0 → 0.4.0-rc.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.
@@ -0,0 +1,763 @@
1
+ /**
2
+ * An app's real page, headless, for its tests: the app's own page entry (components, runtime, replica) in a DOM shim
3
+ * (`happy-dom`), over the app's real server running in this process on loopback, signed in through the kit's
4
+ * approve-all OpenID stand-in. Tests drive it as a person does, by accessible name, and read what it says; every action
5
+ * ends when the page and the server are quiet, and the view is then checked by the kit's rules (`viewProblems`). No
6
+ * browser and no real login. For vitest; one page at a time per process.
7
+ *
8
+ * - **One clock.** The page and the server see the driver's clock as `Date`, and their `setTimeout`/`setInterval` are
9
+ * virtual; Node's own I/O (fetch, sockets, IndexedDB) keeps real time. Only `settle` and `advance` move the clock.
10
+ * - **Settle is a person who stops and waits:** every pending timeout within `idle` of the clock runs, the clock moving
11
+ * to each in turn, intervals crossed on the way running once, the server's due jobs after each move; in-flight fetches
12
+ * and socket messages finish. Bounded: past 200 rounds it throws, naming what stayed busy.
13
+ * - **The page's own commands:** the driver only types, presses and selects in the DOM; what reaches the server is what
14
+ * the page sends.
15
+ */
16
+ import { AsyncLocalStorage } from "node:async_hooks";
17
+ import { mkdtemp, rm } from "node:fs/promises";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { IDBFactory } from "fake-indexeddb";
21
+ import { Window } from "happy-dom";
22
+ // A fresh module graph for each page load, as a browser gives; this resets modules, it mocks nothing.
23
+ // oxlint-disable-next-line no-restricted-imports
24
+ import { vi } from "vitest";
25
+ import { WebSocket as NodeSocket } from "ws";
26
+ import { parseWebConfig } from "../server/config.js";
27
+ import { Log, scoped } from "../server/log.js";
28
+ import { isLine, MEANINGS } from "../states.js";
29
+ import { identityProvider } from "./oidc.js";
30
+ import { viewProblems } from "./states.js";
31
+ /** Whether code runs for the server or the page: timers and their owner follow async context. */
32
+ const owner = new AsyncLocalStorage();
33
+ /** The longest real time a settle waits for I/O to finish before it says what stayed busy. */
34
+ const QUIET_LIMIT_MS = 10_000;
35
+ /** The most timeouts one move of the clock runs before it says which keeps re-arming. */
36
+ const FIRES = 200;
37
+ const ROUNDS = 200;
38
+ const QUIET_CHECKS = 3;
39
+ const QUIET_MS = 2;
40
+ const realSetTimeout = globalThis.setTimeout;
41
+ const realSetInterval = globalThis.setInterval;
42
+ const realClearTimeout = globalThis.clearTimeout;
43
+ const RealDate = Date;
44
+ const VIRTUAL = Symbol("virtual timer");
45
+ /**
46
+ * Whether a timer is Node's own (fetch's parser and its tick, from `node:internal`): those keep real time, since they
47
+ * are the I/O the page waits on, not the page's or the server's time.
48
+ */
49
+ function internal() {
50
+ // The frame that called setTimeout: this function, the wrapper, then the caller.
51
+ const caller = new Error("stack").stack?.split("\n")[3] ?? "";
52
+ return caller.includes("node:internal") || caller.includes("(node:");
53
+ }
54
+ let open = false;
55
+ function matches(name, value) {
56
+ return typeof name === "string" ? value === name : name.test(value);
57
+ }
58
+ export async function openPage(options) {
59
+ if (open) {
60
+ throw new Error("openPage: another page is open in this process; close it first");
61
+ }
62
+ const clock = { now: typeof options.at === "number" ? options.at : RealDate.parse(options.at) };
63
+ if (!Number.isFinite(clock.now)) {
64
+ throw new TypeError(`openPage: at ${String(options.at)} isn't an instant`);
65
+ }
66
+ open = true;
67
+ const idle = options.idle ?? 5000;
68
+ const order = options.order ?? MEANINGS;
69
+ const timers = new Map();
70
+ let nextTimer = 0;
71
+ let activity = 0;
72
+ let inflight = 0;
73
+ const errors = [];
74
+ const restore = [];
75
+ const set = (key, value) => {
76
+ const before = Object.getOwnPropertyDescriptor(globalThis, key);
77
+ Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
78
+ restore.push(() => {
79
+ if (before === undefined) {
80
+ Reflect.deleteProperty(globalThis, key);
81
+ }
82
+ else {
83
+ Object.defineProperty(globalThis, key, before);
84
+ }
85
+ });
86
+ };
87
+ // The clock as Date, for page and server alike: `new Date()` and `Date.now()` read it, `Date()` says it, and every
88
+ // other use is the real Date's, so a Date from structuredClone or IndexedDB is still `instanceof Date`.
89
+ const Clocked = new Proxy(RealDate, {
90
+ construct: (target, args, newTarget) =>
91
+ // oxlint-disable-next-line typescript/no-unsafe-return -- the real Date's own construction, passed through
92
+ Reflect.construct(target, args.length === 0 ? [clock.now] : args, newTarget),
93
+ apply: () => new RealDate(clock.now).toString(),
94
+ get: (target, key, receiver) =>
95
+ // oxlint-disable-next-line typescript/no-unsafe-return -- the real Date's own members, passed through
96
+ key === "now" ? () => clock.now : Reflect.get(target, key, receiver),
97
+ });
98
+ set("Date", Clocked);
99
+ const handle = (timer) => ({
100
+ [VIRTUAL]: true,
101
+ id: timer.id,
102
+ ref() {
103
+ return this;
104
+ },
105
+ unref() {
106
+ return this;
107
+ },
108
+ hasRef: () => true,
109
+ refresh() {
110
+ timer.at = clock.now + timer.delay;
111
+ timers.set(timer.id, timer);
112
+ return this;
113
+ },
114
+ [Symbol.toPrimitive]: () => timer.id,
115
+ });
116
+ const schedule = (run, ms, every, rest) => {
117
+ const delay = Math.max(0, Number(ms) || 0);
118
+ const id = ++nextTimer;
119
+ const timer = {
120
+ id,
121
+ at: clock.now + delay,
122
+ delay,
123
+ every: every ? Math.max(1, delay) : 0,
124
+ owner: owner.getStore() ?? "server",
125
+ run: () => {
126
+ owner.run(timer.owner, () => run(...rest));
127
+ },
128
+ label: `${every ? "interval" : "timeout"} ${delay} ms${run.name ? ` (${run.name})` : ""}`,
129
+ };
130
+ timers.set(id, timer);
131
+ return handle(timer);
132
+ };
133
+ const clear = (value) => {
134
+ if (typeof value === "object" && value !== null && !(VIRTUAL in value)) {
135
+ // A real timer of Node's own.
136
+ // SAFETY: only Node's real timers are objects without the virtual mark.
137
+ // oxlint-disable-next-line typescript/consistent-type-assertions, typescript/no-unsafe-type-assertion
138
+ realClearTimeout(value);
139
+ return;
140
+ }
141
+ const id = typeof value === "object" && value !== null && "id" in value
142
+ ? Number(value.id)
143
+ : Number(value);
144
+ timers.delete(id);
145
+ };
146
+ set("setTimeout", (run, ms, ...rest) => internal() ? realSetTimeout(run, ms, ...rest) : schedule(run, ms, false, rest));
147
+ set("setInterval", (run, ms, ...rest) => internal() ? realSetInterval(run, ms, ...rest) : schedule(run, ms, true, rest));
148
+ set("clearTimeout", clear);
149
+ set("clearInterval", clear);
150
+ const fire = (timer) => {
151
+ if (timer.every > 0) {
152
+ timer.at = Math.max(timer.at + timer.every, clock.now + timer.every);
153
+ }
154
+ else {
155
+ timers.delete(timer.id);
156
+ }
157
+ activity++;
158
+ try {
159
+ timer.run();
160
+ }
161
+ catch (error) {
162
+ errors.push(`${timer.label}: ${error instanceof Error ? error.message : String(error)}`);
163
+ }
164
+ };
165
+ /** The pending timeout that comes first, at or before `to`. */
166
+ const first = (to) => [...timers.values()]
167
+ .filter((each) => each.every === 0 && each.at <= to)
168
+ .toSorted((a, b) => a.at - b.at || a.id - b.id)[0];
169
+ /** Drops the page's timers; the server's go on. */
170
+ const pages = () => {
171
+ for (const timer of [...timers.values()].filter((each) => each.owner === "page")) {
172
+ timers.delete(timer.id);
173
+ }
174
+ };
175
+ /** Runs intervals due by now once each. */
176
+ const intervals = () => {
177
+ for (const each of [...timers.values()].filter((timer) => timer.every > 0 && timer.at <= clock.now)) {
178
+ fire(each);
179
+ }
180
+ };
181
+ const onRejection = (reason) => {
182
+ errors.push(`unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
183
+ };
184
+ process.on("unhandledRejection", onRejection);
185
+ restore.push(() => {
186
+ process.off("unhandledRejection", onRejection);
187
+ });
188
+ set("reportError", (error) => {
189
+ errors.push(`reported: ${error instanceof Error ? error.message : String(error)}`);
190
+ });
191
+ const quiet = async () => {
192
+ let still = 0;
193
+ let seen = activity;
194
+ const until = RealDate.now() + QUIET_LIMIT_MS;
195
+ while (still < QUIET_CHECKS) {
196
+ if (RealDate.now() > until) {
197
+ throw new Error(`the page never settles: ${inflight} requests or sockets still in flight after ${QUIET_LIMIT_MS} ms`);
198
+ }
199
+ // oxlint-disable-next-line no-await-in-loop -- each check waits out real I/O before the next
200
+ await new Promise((resolve) => {
201
+ realSetTimeout(resolve, QUIET_MS);
202
+ });
203
+ if (inflight === 0 && activity === seen) {
204
+ still++;
205
+ }
206
+ else {
207
+ still = 0;
208
+ seen = activity;
209
+ }
210
+ }
211
+ };
212
+ // What to undo on close, or when opening fails part way: newest first.
213
+ const undo = [
214
+ () => {
215
+ for (const each of restore.toReversed()) {
216
+ each();
217
+ }
218
+ open = false;
219
+ },
220
+ ];
221
+ // Every step runs even when one fails, so a failed close never leaves the clock, the timers or the slot behind.
222
+ const teardown = async () => {
223
+ const failed = [];
224
+ for (const each of undo.splice(0).toReversed()) {
225
+ try {
226
+ // oxlint-disable-next-line no-await-in-loop -- in order, newest first
227
+ await each();
228
+ }
229
+ catch (error) {
230
+ failed.push(error);
231
+ }
232
+ }
233
+ if (failed.length > 0) {
234
+ throw new AggregateError(failed, "openPage: closing failed");
235
+ }
236
+ };
237
+ const opened = async () => {
238
+ const home = await mkdtemp(join(tmpdir(), "slopkit-page-"));
239
+ undo.push(() => rm(home, { recursive: true, force: true }));
240
+ const provider = await identityProvider();
241
+ undo.push(() => provider.close());
242
+ const web = parseWebConfig({
243
+ port: 0,
244
+ bind: "127.0.0.1",
245
+ oidc: { issuer: provider.issuer, clientId: "page" },
246
+ allow: ["sub:owner"],
247
+ }, []);
248
+ const log = new Log(home, "page", { echo: false });
249
+ undo.push(() => log.flushed());
250
+ // The server runs in its own async context: its timers outlive a page load.
251
+ const server = await owner.run("server", () => options.server({ home, web, now: () => clock.now, log: scoped(log, "server") }));
252
+ undo.push(() => owner.run("server", () => server.close()));
253
+ /** Real I/O, the server's jobs due by now, then their I/O. */
254
+ const step = async () => {
255
+ await quiet();
256
+ await owner.run("server", () => server.due?.());
257
+ await quiet();
258
+ };
259
+ /** Moves the clock to `to`: each timeout due by then at its own instant, with I/O and due jobs after each, then
260
+ * the intervals crossed, once each. */
261
+ const move = async (to) => {
262
+ for (let fired = 0;; fired++) {
263
+ const next = first(to);
264
+ if (next === undefined) {
265
+ break;
266
+ }
267
+ if (fired > FIRES) {
268
+ throw new Error(`the page never settles: ${next.label} keeps re-arming`);
269
+ }
270
+ clock.now = Math.max(clock.now, next.at);
271
+ fire(next);
272
+ // oxlint-disable-next-line no-await-in-loop -- each timeout's effects land before the next runs
273
+ await step();
274
+ }
275
+ clock.now = Math.max(clock.now, to);
276
+ intervals();
277
+ await step();
278
+ };
279
+ const base = new URL(server.url);
280
+ const origin = base.origin;
281
+ let cookie = "";
282
+ // The server's own login, through the stand-in: its redirects followed by hand, the last session cookie kept.
283
+ const signIn = async () => {
284
+ let at = new URL("/auth/login", base);
285
+ let found = "";
286
+ for (let hop = 0; hop < 5; hop++) {
287
+ // oxlint-disable-next-line no-await-in-loop -- each redirect follows the one before
288
+ const response = await fetch(at, {
289
+ redirect: "manual",
290
+ headers: found === "" ? {} : { cookie: found },
291
+ });
292
+ const pair = (response.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
293
+ if (/^[^=]+=[^=]/.test(pair)) {
294
+ found = pair;
295
+ }
296
+ const location = response.headers.get("location");
297
+ if (location === null) {
298
+ break;
299
+ }
300
+ at = new URL(location, at);
301
+ }
302
+ if (found === "") {
303
+ throw new Error("openPage: the stand-in sign-in gave no session");
304
+ }
305
+ cookie = found;
306
+ };
307
+ await signIn();
308
+ const nodeFetch = globalThis.fetch.bind(globalThis);
309
+ let device = new IDBFactory();
310
+ /** The device's Web Storage, kept across page loads as a browser keeps it. */
311
+ let stored = { local: new Map(), session: new Map() };
312
+ let address = new URL(options.path ?? "/", base).href;
313
+ /**
314
+ * The tab's history, as a browser keeps it across a reload: each entry's address and state, and where the tab is.
315
+ * Recorded, never driven: the page's own history (happy-dom's) moves; a reload restores the entries up to here.
316
+ */
317
+ let trail = [];
318
+ let at = 0;
319
+ let window;
320
+ let entry;
321
+ let pageGlobals = [];
322
+ const pageFetch = async (input, init = {}) => {
323
+ const url = new URL(input instanceof Request ? input.url : String(input), base);
324
+ const headers = new Headers(input instanceof Request ? input.headers : undefined);
325
+ for (const [key, value] of new Headers(init.headers)) {
326
+ headers.set(key, value);
327
+ }
328
+ if (url.origin === origin) {
329
+ if (cookie !== "") {
330
+ headers.set("cookie", cookie);
331
+ }
332
+ headers.set("origin", origin);
333
+ }
334
+ inflight++;
335
+ activity++;
336
+ try {
337
+ const method = init.method ?? (input instanceof Request ? input.method : "GET");
338
+ const response = await nodeFetch(url, { ...init, method, headers });
339
+ // The body is read here, so the request counts as in flight until it has all arrived.
340
+ const body = response.status === 204 || response.status === 304 ? null : await response.arrayBuffer();
341
+ const copy = new Response(body, {
342
+ status: response.status,
343
+ statusText: response.statusText,
344
+ headers: response.headers,
345
+ });
346
+ Object.defineProperty(copy, "url", { value: response.url });
347
+ return copy;
348
+ }
349
+ finally {
350
+ inflight--;
351
+ activity++;
352
+ }
353
+ };
354
+ const sockets = new Set();
355
+ class PageSocket extends NodeSocket {
356
+ constructor(url) {
357
+ super(String(url), { headers: cookie === "" ? { origin } : { cookie, origin } });
358
+ sockets.add(this);
359
+ this.once("close", () => {
360
+ sockets.delete(this);
361
+ });
362
+ inflight++;
363
+ // The handshake is in flight until it opens, fails or closes, whichever comes first; a refused upgrade fails
364
+ // and closes, as in a browser, so the page's retry runs.
365
+ let pending = true;
366
+ const done = () => {
367
+ if (pending) {
368
+ pending = false;
369
+ inflight--;
370
+ }
371
+ activity++;
372
+ };
373
+ this.once("open", done);
374
+ this.once("error", done);
375
+ this.once("close", done);
376
+ this.on("message", () => {
377
+ activity++;
378
+ });
379
+ }
380
+ }
381
+ const unload = async () => {
382
+ // The page goes as a closed tab does: it hears it is hidden and going (visibilitychange, pagehide) and may save,
383
+ // then its timers stop, its sockets close, and what it had in flight lands before its globals go, so nothing of
384
+ // it runs afterwards. The server's timers go on.
385
+ const going = window;
386
+ if (going !== undefined) {
387
+ Object.defineProperty(going.document, "visibilityState", {
388
+ value: "hidden",
389
+ configurable: true,
390
+ });
391
+ Object.defineProperty(going.document, "hidden", { value: true, configurable: true });
392
+ owner.run("page", () => {
393
+ going.document.dispatchEvent(new going.Event("visibilitychange"));
394
+ going.dispatchEvent(new going.PageTransitionEvent("pagehide"));
395
+ });
396
+ await quiet();
397
+ }
398
+ pages();
399
+ for (const socket of sockets) {
400
+ socket.close();
401
+ }
402
+ await quiet();
403
+ for (const each of pageGlobals.toReversed()) {
404
+ each();
405
+ }
406
+ pageGlobals = [];
407
+ if (window !== undefined) {
408
+ address = window.location.href;
409
+ trail = trail.slice(0, at + 1);
410
+ for (const [into, from] of [
411
+ [stored.local, window.localStorage],
412
+ [stored.session, window.sessionStorage],
413
+ ]) {
414
+ into.clear();
415
+ for (let index = 0; index < from.length; index++) {
416
+ const key = from.key(index);
417
+ if (key !== null) {
418
+ into.set(key, from.getItem(key) ?? "");
419
+ }
420
+ }
421
+ }
422
+ await window.happyDOM.abort();
423
+ await window.happyDOM.close();
424
+ window = undefined;
425
+ }
426
+ pages();
427
+ };
428
+ const load = async () => {
429
+ window = new Window({
430
+ url: trail[0]?.url ?? address,
431
+ width: options.viewport?.width ?? 412,
432
+ height: options.viewport?.height ?? 915,
433
+ });
434
+ // The entries a reload keeps, back to the one the tab is on: same document, so going back fires popstate.
435
+ const history = window.history;
436
+ const push = history.pushState.bind(history);
437
+ const replace = history.replaceState.bind(history);
438
+ for (const kept of trail.slice(1)) {
439
+ push(kept.state, "", kept.url);
440
+ }
441
+ if (trail.length === 0) {
442
+ trail = [{ url: address, state: null }];
443
+ }
444
+ at = trail.length - 1;
445
+ history.pushState = (state, unused, url) => {
446
+ push(state, unused, url);
447
+ trail = [...trail.slice(0, at + 1), { url: window?.location.href ?? address, state }];
448
+ at = trail.length - 1;
449
+ };
450
+ history.replaceState = (state, unused, url) => {
451
+ replace(state, unused, url);
452
+ trail[at] = { url: window?.location.href ?? address, state };
453
+ };
454
+ window.addEventListener("popstate", () => {
455
+ const href = window?.location.href;
456
+ const near = [at - 1, at + 1].find((index) => trail[index]?.url === href);
457
+ at =
458
+ near ??
459
+ Math.max(0, trail.findIndex((each) => each.url === href));
460
+ });
461
+ // A hash the page set is a new entry; one that back or forward reached is already where popstate put the tab.
462
+ window.addEventListener("hashchange", () => {
463
+ if (trail[at]?.url === window?.location.href) {
464
+ return;
465
+ }
466
+ trail = [...trail.slice(0, at + 1), { url: window?.location.href ?? address, state: null }];
467
+ at = trail.length - 1;
468
+ });
469
+ for (const [from, into] of [
470
+ [stored.local, window.localStorage],
471
+ [stored.session, window.sessionStorage],
472
+ ]) {
473
+ for (const [key, value] of from) {
474
+ into.setItem(key, value);
475
+ }
476
+ }
477
+ // The window's own timers and Date are the driver's, as the bare globals are.
478
+ for (const key of ["setTimeout", "setInterval", "clearTimeout", "clearInterval", "Date"]) {
479
+ Object.defineProperty(window, key, {
480
+ value: Reflect.get(globalThis, key),
481
+ configurable: true,
482
+ writable: true,
483
+ });
484
+ }
485
+ // An error a listener throws: the DOM reports it on the window rather than to the caller.
486
+ window.addEventListener("error", (event) => {
487
+ const error = "error" in event ? event.error : undefined;
488
+ errors.push(`page error: ${error instanceof Error ? error.message : String(error)}`);
489
+ });
490
+ const own = [];
491
+ const put = (key, value) => {
492
+ const before = Object.getOwnPropertyDescriptor(globalThis, key);
493
+ Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
494
+ own.push(() => {
495
+ if (before === undefined) {
496
+ Reflect.deleteProperty(globalThis, key);
497
+ }
498
+ else {
499
+ Object.defineProperty(globalThis, key, before);
500
+ }
501
+ });
502
+ };
503
+ const shim = Object.fromEntries(Object.getOwnPropertyNames(window).flatMap((key) => {
504
+ try {
505
+ return [[key, Reflect.get(window ?? {}, key)]];
506
+ }
507
+ catch {
508
+ return [];
509
+ }
510
+ }));
511
+ for (const [key, value] of Object.entries(shim)) {
512
+ // The DOM's classes and objects, where Node has none of its own; Node keeps its timers, fetch and streams.
513
+ if (!(key in globalThis) || ["Event", "CustomEvent"].includes(key)) {
514
+ put(key, value);
515
+ }
516
+ }
517
+ for (const [key, value] of Object.entries({
518
+ window,
519
+ self: window,
520
+ document: window.document,
521
+ location: window.location,
522
+ navigator: window.navigator,
523
+ localStorage: window.localStorage,
524
+ sessionStorage: window.sessionStorage,
525
+ indexedDB: device,
526
+ fetch: pageFetch,
527
+ WebSocket: PageSocket,
528
+ })) {
529
+ put(key, value);
530
+ }
531
+ pageGlobals = own;
532
+ window.document.body.innerHTML = '<div id="app"></div>';
533
+ vi.resetModules();
534
+ entry = await owner.run("page", () => options.page());
535
+ };
536
+ const doc = () => {
537
+ if (window === undefined) {
538
+ throw new Error("the page isn't loaded");
539
+ }
540
+ return window.document;
541
+ };
542
+ /** An element's accessible name: aria-label, aria-labelledby, its <label>, or its text. */
543
+ const nameOf = (element) => {
544
+ const label = element.getAttribute("aria-label");
545
+ if (label !== null) {
546
+ return label.trim();
547
+ }
548
+ const by = element.getAttribute("aria-labelledby");
549
+ if (by !== null) {
550
+ return by
551
+ .split(/\s+/)
552
+ .map((id) => doc().querySelector(`#${CSS.escape(id)}`)?.textContent ?? "")
553
+ .join(" ")
554
+ .trim();
555
+ }
556
+ const id = element.getAttribute("id");
557
+ const labelled = id === null ? null : doc().querySelector(`label[for="${id}"]`);
558
+ const wrapping = element.closest("label");
559
+ const from = labelled ?? (wrapping === element ? null : wrapping);
560
+ if (from !== null && element.tagName !== "BUTTON") {
561
+ const copy = from.cloneNode(true);
562
+ for (const control of copy.querySelectorAll("input, select, textarea, button")) {
563
+ control.remove();
564
+ }
565
+ return (copy.textContent ?? "").replaceAll(/\s+/g, " ").trim();
566
+ }
567
+ if (element.tagName === "INPUT" &&
568
+ /^(submit|button|reset)$/i.test(element.getAttribute("type") ?? "")) {
569
+ return (element.getAttribute("value") ?? "").trim();
570
+ }
571
+ return (element.textContent ?? "").replaceAll(/\s+/g, " ").trim();
572
+ };
573
+ const find = (selector, name, what) => {
574
+ const found = [...doc().querySelectorAll(selector)].filter((each) => matches(name, nameOf(each)));
575
+ if (found.length !== 1) {
576
+ const names = [...doc().querySelectorAll(selector)].map((each) => `"${nameOf(each)}"`);
577
+ throw new Error(`${found.length === 0 ? "no" : `${found.length}`} ${what} named ${String(name)}; there are: ${names.join(", ") || "none"}`);
578
+ }
579
+ // SAFETY: happy-dom's HTML elements are the DOM's, under its own type names.
580
+ // oxlint-disable-next-line typescript/consistent-type-assertions, typescript/no-unsafe-type-assertion, anti-slop/no-chained-type-assertions
581
+ return found[0];
582
+ };
583
+ const event = (target, type) => {
584
+ if (window === undefined) {
585
+ return;
586
+ }
587
+ // SAFETY: happy-dom's Event is the DOM's Event, under its own type name.
588
+ // oxlint-disable-next-line typescript/consistent-type-assertions, typescript/no-unsafe-type-assertion, anti-slop/no-chained-type-assertions
589
+ const made = new window.Event(type, { bubbles: true });
590
+ // The page's handlers run as the page: what they start is the page's, and dies with a reload.
591
+ owner.run("page", () => target.dispatchEvent(made));
592
+ };
593
+ const read = () => entry === undefined || options.read === undefined
594
+ ? { shown: undefined }
595
+ : options.read(entry);
596
+ const lines = () => {
597
+ const found = [];
598
+ const seen = new Set();
599
+ const visit = (value) => {
600
+ if (typeof value !== "object" || value === null || seen.has(value)) {
601
+ return;
602
+ }
603
+ seen.add(value);
604
+ if (isLine(value)) {
605
+ found.push({
606
+ subject: value.subject,
607
+ kind: value.kind,
608
+ meaning: value.meaning,
609
+ text: value.text,
610
+ owed: value.owed,
611
+ });
612
+ return;
613
+ }
614
+ for (const each of value instanceof Map
615
+ ? [...value.keys(), ...value.values()]
616
+ : value instanceof Set || Array.isArray(value)
617
+ ? value
618
+ : Object.values(value)) {
619
+ visit(each);
620
+ }
621
+ };
622
+ visit(read().shown);
623
+ return found;
624
+ };
625
+ const settleWithin = async (within, settling = {}) => {
626
+ const horizon = clock.now + within;
627
+ let rounds = 0;
628
+ for (;;) {
629
+ if (++rounds > ROUNDS) {
630
+ const busy = [...timers.values()].filter((each) => each.every === 0 && each.at <= horizon);
631
+ throw new Error(`the page never settles: after ${ROUNDS} rounds still ${[
632
+ ...busy.map((each) => each.label),
633
+ ...(inflight > 0 ? [`${inflight} requests in flight`] : []),
634
+ ].join(", ")}`);
635
+ }
636
+ // Each round waits for the last one's work: real I/O, then the jobs it made due, then their I/O.
637
+ // oxlint-disable-next-line no-await-in-loop -- in order, by design
638
+ await step();
639
+ const next = first(horizon);
640
+ if (next === undefined) {
641
+ break;
642
+ }
643
+ // oxlint-disable-next-line no-await-in-loop -- in order, by design
644
+ await move(next.at);
645
+ }
646
+ const problems = errors.splice(0);
647
+ const now = read();
648
+ problems.push(...viewProblems(now.shown, {
649
+ order,
650
+ world: {
651
+ now: clock.now,
652
+ ...(server.jobs === undefined ? {} : { jobs: server.jobs() ?? [] }),
653
+ ...(now.model === undefined ? {} : { model: now.model }),
654
+ },
655
+ }));
656
+ if (problems.length > 0 && settling.lenient !== true) {
657
+ throw new Error(`the page settled with problems:\n- ${problems.join("\n- ")}`);
658
+ }
659
+ return { lines: lines(), problems };
660
+ };
661
+ const settle = (settling) => settleWithin(idle, settling);
662
+ undo.push(unload);
663
+ await load();
664
+ // The first screen is the one at `at`: what loads and syncs now, with no time passing.
665
+ await settleWithin(0);
666
+ return {
667
+ async type(name, text) {
668
+ const field = find("input, textarea, [contenteditable], [role=textbox], [role=searchbox]", name, "text field");
669
+ if ("value" in field) {
670
+ // SAFETY: an input or textarea takes a value.
671
+ // oxlint-disable-next-line typescript/consistent-type-assertions, typescript/no-unsafe-type-assertion
672
+ field.value = text;
673
+ }
674
+ else {
675
+ field.textContent = text;
676
+ }
677
+ event(field, "input");
678
+ event(field, "change");
679
+ await settle();
680
+ },
681
+ async press(name) {
682
+ const button = find("button, input[type=submit], input[type=button], input[type=reset], a[href], [role=button], [role=link], [role=tab], [role=menuitem]", name, "button");
683
+ owner.run("page", () => {
684
+ button.click();
685
+ });
686
+ await settle();
687
+ },
688
+ async select(name, option) {
689
+ // SAFETY: matched by the select selector, so a select element.
690
+ // oxlint-disable-next-line typescript/consistent-type-assertions, typescript/no-unsafe-type-assertion, anti-slop/no-chained-type-assertions
691
+ const field = find("select", name, "select");
692
+ const chosen = [...field.options].find((each) => each.value === option || each.text.trim() === option);
693
+ if (chosen === undefined) {
694
+ throw new Error(`select ${String(name)} has no option ${option}`);
695
+ }
696
+ field.value = chosen.value;
697
+ event(field, "input");
698
+ event(field, "change");
699
+ await settle();
700
+ },
701
+ async advance(ms) {
702
+ await move(clock.now + Math.max(0, ms));
703
+ await settle();
704
+ },
705
+ settle,
706
+ text(name) {
707
+ return (name === undefined
708
+ ? (doc().querySelector("#app")?.textContent ?? "")
709
+ : (find("[aria-label], [aria-labelledby]", name, "named element").textContent ?? ""))
710
+ .replaceAll(/\s+/g, " ")
711
+ .trim();
712
+ },
713
+ lines,
714
+ location() {
715
+ return window?.location.href ?? address;
716
+ },
717
+ async back() {
718
+ const from = window?.location.href;
719
+ let heard = false;
720
+ window?.addEventListener("popstate", () => {
721
+ heard = true;
722
+ }, { once: true });
723
+ owner.run("page", () => doc().defaultView?.history.back());
724
+ await settle();
725
+ if (!heard && window?.location.href === from) {
726
+ throw new Error("openPage: back() with nowhere to go back to");
727
+ }
728
+ },
729
+ async reload(reloading = {}) {
730
+ await unload();
731
+ if (reloading.device === "new") {
732
+ device = new IDBFactory();
733
+ stored = { local: new Map(), session: new Map() };
734
+ }
735
+ await load();
736
+ await settle();
737
+ },
738
+ async signOut() {
739
+ cookie = "";
740
+ const { problems } = await settle({ lenient: true });
741
+ // Page errors in signing out fail the next settle; its view is checked again there.
742
+ errors.push(...problems.filter((each) => !each.includes(" (working) but owes ")));
743
+ },
744
+ async signIn() {
745
+ await signIn();
746
+ await settle();
747
+ },
748
+ get now() {
749
+ return clock.now;
750
+ },
751
+ async close() {
752
+ await teardown();
753
+ },
754
+ };
755
+ };
756
+ try {
757
+ return await opened();
758
+ }
759
+ catch (error) {
760
+ await teardown().catch(() => undefined);
761
+ throw error;
762
+ }
763
+ }