@orkestrel/browser 0.0.18 → 0.0.19

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,1640 @@
1
+ import { attempt, isInteger, isObject, isString } from "@orkestrel/contract";
2
+ import { BROWSER_DEFAULT_TIMEOUT_MS, BROWSER_INTERACTIVE_ROLES, BROWSER_OUTLINE_LIMIT, BROWSER_REFERENCE_PREFIX, BROWSER_RESULT_LIMIT, BrowserConnectionError, BrowserElementError, BrowserError, BrowserResultLimitError, BrowserToolset, BrowserTransition, createBrowserReading, filterBrowserOutline, normalizeBrowserName, parseBrowserReference, renderBrowserOutline, validateBrowserTimeout } from "../core/index.js";
3
+ import { Emitter } from "@orkestrel/emitter";
4
+ //#region src/browser/constants.ts
5
+ /**
6
+ * Sets the default `wait` deadline for DOM waits in a browser document toolset, `5_000`
7
+ * milliseconds.
8
+ */
9
+ var BROWSER_DOCUMENT_TIMEOUT_MS = 5e3;
10
+ /**
11
+ * Maps an HTML element to the ARIA role the HTML Accessibility API Mappings specification gives
12
+ * it when it carries no `role` attribute.
13
+ *
14
+ * @remarks
15
+ * A key is a lowercase element name, or `input:` followed by the input's `type` state. An element
16
+ * whose mapping depends on more than its name — `a` and `area` without `href`, `select` with
17
+ * `multiple` or a `size` above 1, an `img` with an empty `alt`, a scoped `header` or `footer`, a
18
+ * `section` or `form` without an accessible name, and a text input with a `list` — is resolved
19
+ * by `computeBrowserRole` before this table is read. An element or input state the mapping gives
20
+ * no role has no entry.
21
+ */
22
+ var BROWSER_IMPLICIT_ROLES = Object.freeze(/* @__PURE__ */ new Map([
23
+ ["a", "link"],
24
+ ["area", "link"],
25
+ ["article", "article"],
26
+ ["aside", "complementary"],
27
+ ["blockquote", "blockquote"],
28
+ ["button", "button"],
29
+ ["caption", "caption"],
30
+ ["code", "code"],
31
+ ["datalist", "listbox"],
32
+ ["dd", "definition"],
33
+ ["del", "deletion"],
34
+ ["details", "group"],
35
+ ["dfn", "term"],
36
+ ["dialog", "dialog"],
37
+ ["dt", "term"],
38
+ ["em", "emphasis"],
39
+ ["fieldset", "group"],
40
+ ["figure", "figure"],
41
+ ["footer", "contentinfo"],
42
+ ["form", "form"],
43
+ ["h1", "heading"],
44
+ ["h2", "heading"],
45
+ ["h3", "heading"],
46
+ ["h4", "heading"],
47
+ ["h5", "heading"],
48
+ ["h6", "heading"],
49
+ ["header", "banner"],
50
+ ["hr", "separator"],
51
+ ["html", "document"],
52
+ ["img", "img"],
53
+ ["ins", "insertion"],
54
+ ["li", "listitem"],
55
+ ["main", "main"],
56
+ ["math", "math"],
57
+ ["menu", "list"],
58
+ ["meter", "meter"],
59
+ ["nav", "navigation"],
60
+ ["ol", "list"],
61
+ ["optgroup", "group"],
62
+ ["option", "option"],
63
+ ["output", "status"],
64
+ ["p", "paragraph"],
65
+ ["progress", "progressbar"],
66
+ ["search", "search"],
67
+ ["section", "region"],
68
+ ["select", "combobox"],
69
+ ["strong", "strong"],
70
+ ["sub", "subscript"],
71
+ ["sup", "superscript"],
72
+ ["table", "table"],
73
+ ["tbody", "rowgroup"],
74
+ ["td", "cell"],
75
+ ["textarea", "textbox"],
76
+ ["tfoot", "rowgroup"],
77
+ ["th", "columnheader"],
78
+ ["thead", "rowgroup"],
79
+ ["time", "time"],
80
+ ["tr", "row"],
81
+ ["ul", "list"],
82
+ ["input:button", "button"],
83
+ ["input:checkbox", "checkbox"],
84
+ ["input:email", "textbox"],
85
+ ["input:image", "button"],
86
+ ["input:number", "spinbutton"],
87
+ ["input:password", "textbox"],
88
+ ["input:radio", "radio"],
89
+ ["input:range", "slider"],
90
+ ["input:reset", "button"],
91
+ ["input:search", "searchbox"],
92
+ ["input:submit", "button"],
93
+ ["input:tel", "textbox"],
94
+ ["input:text", "textbox"],
95
+ ["input:url", "textbox"]
96
+ ]));
97
+ /**
98
+ * Names the ARIA roles whose accessible name the accessible-name computation takes from the
99
+ * element's content when no attribute or label names it.
100
+ */
101
+ var BROWSER_CONTENT_NAMED_ROLES = Object.freeze(/* @__PURE__ */ new Set([
102
+ "button",
103
+ "cell",
104
+ "checkbox",
105
+ "columnheader",
106
+ "gridcell",
107
+ "heading",
108
+ "link",
109
+ "menuitem",
110
+ "menuitemcheckbox",
111
+ "menuitemradio",
112
+ "option",
113
+ "radio",
114
+ "row",
115
+ "rowheader",
116
+ "switch",
117
+ "tab",
118
+ "tooltip",
119
+ "treeitem"
120
+ ]));
121
+ /**
122
+ * Names the link and form targets that navigate the current browsing context or one of its
123
+ * ancestors. `_blank` opens another browsing context, and any other name targets the browsing
124
+ * context carrying that name, which `matchesBrowserPopup` resolves.
125
+ */
126
+ var BROWSER_CONTEXT_TARGETS = Object.freeze(/* @__PURE__ */ new Set([
127
+ "",
128
+ "_self",
129
+ "_parent",
130
+ "_top"
131
+ ]));
132
+ /** Names the `input` type states whose value an untrusted `fill` sets as typed text. */
133
+ var BROWSER_TYPED_INPUTS = Object.freeze(/* @__PURE__ */ new Set([
134
+ "date",
135
+ "datetime-local",
136
+ "email",
137
+ "month",
138
+ "number",
139
+ "password",
140
+ "search",
141
+ "tel",
142
+ "text",
143
+ "time",
144
+ "url",
145
+ "week"
146
+ ]));
147
+ /**
148
+ * Selects the HTML interactive content a click inside a `label` can land on without activating
149
+ * the label, as the HTML interactive-content category lists it: `a` with `href`, `audio` and
150
+ * `video` with `controls`, `button`, `details`, `embed`, `iframe`, `img` with `usemap` or
151
+ * `controls`, `input` other than `hidden`, `label`, `select`, and `textarea`.
152
+ */
153
+ var BROWSER_INTERACTIVE_CONTENT = "a[href], audio[controls], button, details, embed, iframe, img[usemap], img[controls], input:not([type=\"hidden\" i]), label, select, textarea, video[controls]";
154
+ //#endregion
155
+ //#region src/browser/helpers.ts
156
+ /**
157
+ * Narrows a value to a `Document` attached to a window.
158
+ *
159
+ * @param value - The value to test
160
+ * @returns `true` for an object that is a `Document` with a `defaultView`, otherwise `false`
161
+ */
162
+ function isBrowserDocument(value) {
163
+ return isObject(value) && Reflect.get(value, "nodeType") === 9 && isObject(Reflect.get(value, "defaultView"));
164
+ }
165
+ /**
166
+ * Reads the URL, title, and serialized markup a DOM reading is built from, refusing a capture
167
+ * over the result limit before any parse.
168
+ *
169
+ * @remarks
170
+ * The measure is the length of `JSON.stringify({ url, title, html })`, the form the CDP capture
171
+ * guard compares with `BROWSER_RESULT_LIMIT`, so a character JSON escapes counts as its escape
172
+ * sequence. A reading a caller builds with `createBrowserReading` passes through no limit.
173
+ *
174
+ * @param node - The element whose `outerHTML` is captured; its owner document supplies `URL`
175
+ * and `title`
176
+ * @returns The capture's `url`, `title`, and `html`
177
+ * @throws Thrown as a `BrowserResultLimitError` when the serialized capture is longer than
178
+ * `BROWSER_RESULT_LIMIT` characters, with `length` and `limit` in its context.
179
+ */
180
+ function readBrowserCapture(node) {
181
+ const document = node.ownerDocument;
182
+ const capture = {
183
+ url: document.URL,
184
+ title: document.title,
185
+ html: node.outerHTML
186
+ };
187
+ const length = JSON.stringify(capture).length;
188
+ if (length > BROWSER_RESULT_LIMIT) throw new BrowserResultLimitError("Document capture exceeds BROWSER_RESULT_LIMIT", {
189
+ length,
190
+ limit: BROWSER_RESULT_LIMIT
191
+ });
192
+ return capture;
193
+ }
194
+ /**
195
+ * Computes the ARIA role of an element from its `role` attribute or its implicit mapping.
196
+ *
197
+ * @remarks
198
+ * The explicit role is the first token of the `role` attribute, with `presentation` folded into
199
+ * its synonym `none`. Without one, the role follows the HTML Accessibility API Mappings
200
+ * specification through `BROWSER_IMPLICIT_ROLES`, after the element-specific conditions that
201
+ * table cannot express: a link needs `href`, a `select` with `multiple` or a `size` above 1 is a
202
+ * `listbox`, an `img` with an empty `alt` is `none`, a `header` or `footer` inside sectioning
203
+ * content has no landmark role, a `section` or `form` needs an accessible name, a text input with
204
+ * a `list` is a `combobox`, and a `th` with `scope="row"` is a `rowheader`.
205
+ *
206
+ * @param element - The element to classify
207
+ * @returns The role, or `undefined` when the element maps to no role
208
+ *
209
+ * @example
210
+ * ```ts
211
+ * const input = document.createElement('input')
212
+ * input.type = 'checkbox'
213
+ * computeBrowserRole(input) // 'checkbox'
214
+ * ```
215
+ */
216
+ function computeBrowserRole(element) {
217
+ const explicit = (element.getAttribute("role") ?? "").trim().toLowerCase().split(/\s+/)[0] ?? "";
218
+ if (explicit !== "") return explicit === "presentation" ? "none" : explicit;
219
+ const tag = element.localName;
220
+ if (tag === "a" || tag === "area") return element.hasAttribute("href") ? "link" : void 0;
221
+ if (tag === "input") {
222
+ const type = Reflect.get(element, "type");
223
+ const role = BROWSER_IMPLICIT_ROLES.get(`input:${isString(type) ? type : "text"}`);
224
+ return element.hasAttribute("list") && (role === "textbox" || role === "searchbox") ? "combobox" : role;
225
+ }
226
+ if (tag === "select") return element.hasAttribute("multiple") || Number(element.getAttribute("size")) > 1 ? "listbox" : "combobox";
227
+ if (tag === "img" && element.getAttribute("alt") === "") return "none";
228
+ if ((tag === "header" || tag === "footer") && (element.parentElement?.closest("article, aside, main, nav, section") ?? null) !== null) return void 0;
229
+ if ((tag === "section" || tag === "form") && !element.hasAttribute("aria-label") && !element.hasAttribute("aria-labelledby") && !element.hasAttribute("title")) return void 0;
230
+ if (tag === "th" && element.getAttribute("scope")?.toLowerCase() === "row") return "rowheader";
231
+ return BROWSER_IMPLICIT_ROLES.get(tag);
232
+ }
233
+ /**
234
+ * Computes the accessible name of an element, trimmed and whitespace-collapsed.
235
+ *
236
+ * @remarks
237
+ * The steps follow the accessible-name computation, first match wins: the elements
238
+ * that `aria-labelledby` references in the element's own tree, each through
239
+ * `computeBrowserAlternative` with hidden content admitted when the referenced element is itself
240
+ * hidden (`matchesBrowserOmitted` or `matchesBrowserInvisible`), `aria-label`, the `label` of an
241
+ * `option`, the value of an `input` button, the `alt` of an
242
+ * image, the text of every associated `label`, the element's own content for a role in
243
+ * `BROWSER_CONTENT_NAMED_ROLES`, then the `title` attribute, then the `placeholder` of a text
244
+ * control. Every traversal carries the element as its `target`, so an embedded control
245
+ * contributes its value unless it is the element being named. The `title` step follows content because a tooltip names an element only when nothing
246
+ * else does.
247
+ *
248
+ * @param element - The element to name
249
+ * @param role - The element's role. Default: `computeBrowserRole(element)`
250
+ * @returns The name, or the empty string when no step names the element
251
+ *
252
+ * @example
253
+ * ```ts
254
+ * const button = document.createElement('button')
255
+ * button.textContent = ' Save draft '
256
+ * computeBrowserName(button) // 'Save draft'
257
+ * ```
258
+ */
259
+ function computeBrowserName(element, role = computeBrowserRole(element)) {
260
+ const view = element.ownerDocument.defaultView;
261
+ const tree = element.getRootNode();
262
+ const scope = view !== null && tree instanceof view.ShadowRoot ? tree : element.ownerDocument;
263
+ const referenced = normalizeBrowserName((element.getAttribute("aria-labelledby") ?? "").split(/\s+/).flatMap((id) => {
264
+ const target = id === "" ? null : scope.getElementById(id);
265
+ return target === null ? [] : [computeBrowserAlternative(target, {
266
+ hidden: matchesBrowserOmitted(target) || matchesBrowserInvisible(target),
267
+ target: element
268
+ })];
269
+ }).join(" "));
270
+ if (referenced !== "") return referenced;
271
+ const label = normalizeBrowserName(element.getAttribute("aria-label") ?? "");
272
+ if (label !== "") return label;
273
+ if (view !== null && element instanceof view.HTMLOptionElement) {
274
+ const option = normalizeBrowserName(element.label);
275
+ if (option !== "") return option;
276
+ }
277
+ const tag = element.localName;
278
+ const type = tag === "input" ? Reflect.get(element, "type") : void 0;
279
+ if (type === "button" || type === "submit" || type === "reset") {
280
+ const value = normalizeBrowserName(element.getAttribute("value") ?? (type === "submit" ? "Submit" : type === "reset" ? "Reset" : ""));
281
+ if (value !== "") return value;
282
+ }
283
+ if (type === "image" || tag === "img" || tag === "area") {
284
+ const alt = normalizeBrowserName(element.getAttribute("alt") ?? "");
285
+ if (alt !== "") return alt;
286
+ }
287
+ const labelled = normalizeBrowserName(Array.from(scope.querySelectorAll("label")).filter((candidate) => candidate.control === element).map((candidate) => computeBrowserText(candidate, { target: element })).join(" "));
288
+ if (labelled !== "") return labelled;
289
+ if (role !== void 0 && BROWSER_CONTENT_NAMED_ROLES.has(role)) {
290
+ const content = computeBrowserText(element, { target: element });
291
+ if (content !== "") return content;
292
+ }
293
+ const title = normalizeBrowserName(element.getAttribute("title") ?? "");
294
+ if (title !== "") return title;
295
+ return tag === "input" || tag === "textarea" ? normalizeBrowserName(element.getAttribute("placeholder") ?? "") : "";
296
+ }
297
+ /**
298
+ * Computes the text an element's content contributes to an accessible name, in the flat tree,
299
+ * trimmed and whitespace-collapsed.
300
+ *
301
+ * @remarks
302
+ * The text is what `readBrowserContent` reads, normalized.
303
+ *
304
+ * @param root - The element or shadow root whose content is read
305
+ * @param context - The traversal context. Default: no hidden content and no target
306
+ * @returns The trimmed, whitespace-collapsed text
307
+ *
308
+ * @example
309
+ * ```ts
310
+ * const link = document.createElement('a')
311
+ * link.innerHTML = '<div>Cars</div><div>Search the fleet</div>'
312
+ * document.body.append(link)
313
+ * computeBrowserText(link) // 'Cars Search the fleet'
314
+ * ```
315
+ */
316
+ function computeBrowserText(root, context) {
317
+ return normalizeBrowserName(readBrowserContent(root, context));
318
+ }
319
+ /**
320
+ * Reads the untrimmed text an element's content contributes to an accessible name, in the flat
321
+ * tree.
322
+ *
323
+ * @remarks
324
+ * The content is the open shadow root's children for a shadow host, the flattened assigned nodes
325
+ * for a `slot`, and the child nodes otherwise. Each child contributes what
326
+ * `computeBrowserAlternative` returns for it, and a child that `matchesBrowserBlock` reports is
327
+ * set apart from its neighbours by a space. Whitespace at the edges of an inline child is kept,
328
+ * so `<b>Go </b>home` reads `Go home`.
329
+ *
330
+ * @param root - The element or shadow root whose content is read
331
+ * @param context - The traversal context. Default: no hidden content and no target
332
+ * @returns The untrimmed text
333
+ *
334
+ * @example
335
+ * ```ts
336
+ * const label = document.createElement('span')
337
+ * label.innerHTML = '<b>Go </b>home'
338
+ * document.body.append(label)
339
+ * readBrowserContent(label) // 'Go home'
340
+ * ```
341
+ */
342
+ function readBrowserContent(root, context) {
343
+ const view = root.ownerDocument?.defaultView ?? null;
344
+ const shadow = view !== null && root instanceof view.Element ? root.shadowRoot : null;
345
+ const children = shadow !== null ? Array.from(shadow.childNodes) : view !== null && root instanceof view.HTMLSlotElement ? root.assignedNodes({ flatten: true }) : Array.from(root.childNodes);
346
+ let text = "";
347
+ let previous = false;
348
+ for (const child of children) {
349
+ const piece = computeBrowserAlternative(child, context);
350
+ const block = view !== null && child instanceof view.Element && matchesBrowserBlock(child);
351
+ if (piece !== "") text += text !== "" && (block || previous) ? ` ${piece}` : piece;
352
+ previous = block;
353
+ }
354
+ return text;
355
+ }
356
+ /**
357
+ * Computes the text one node contributes to an accessible name: its own text alternative, or its
358
+ * content.
359
+ *
360
+ * @remarks
361
+ * A text node contributes its data unless its parent is invisible; a parent without a box of its
362
+ * own, such as a `slot` or a `display: contents` element, is not invisible. An element
363
+ * contributes nothing when it is hidden or is a `script` or `style`, and an element that
364
+ * `matchesBrowserInvisible` reports contributes only what its content contributes, never its
365
+ * value, `aria-label`, `alt`, or `title`. The context's `hidden` admits both. An embedded control
366
+ * that is not the context's `target` contributes its value: a text field or a range its `value`,
367
+ * a `select` its selected options' labels; a password field and the target itself contribute
368
+ * nothing. Any other element contributes its `aria-label`, the `alt` of an image, or its content
369
+ * through `readBrowserContent`, and its `title` when those are empty.
370
+ *
371
+ * @param node - The node whose contribution is computed
372
+ * @param context - The traversal context. Default: no hidden content and no target
373
+ * @returns The untrimmed contribution
374
+ *
375
+ * @example
376
+ * ```ts
377
+ * const image = document.createElement('img')
378
+ * image.alt = 'Save'
379
+ * computeBrowserAlternative(image) // 'Save'
380
+ * ```
381
+ */
382
+ function computeBrowserAlternative(node, context) {
383
+ const view = node.ownerDocument?.defaultView ?? null;
384
+ const hidden = context?.hidden === true;
385
+ if (view === null) return node.textContent ?? "";
386
+ if (!(node instanceof view.Element)) {
387
+ const parent = node.parentElement;
388
+ if (node.nodeType !== view.Node.TEXT_NODE) return "";
389
+ return !hidden && parent !== null && matchesBrowserInvisible(parent) ? "" : node.textContent ?? "";
390
+ }
391
+ const tag = node.localName;
392
+ if (!hidden && matchesBrowserHidden(node)) return "";
393
+ if (tag === "script" || tag === "style") return "";
394
+ if (!hidden && matchesBrowserInvisible(node)) return readBrowserContent(node, context);
395
+ const embedded = node !== context?.target;
396
+ if (node instanceof view.HTMLTextAreaElement) return embedded ? node.value : "";
397
+ if (node instanceof view.HTMLSelectElement) return embedded ? Array.from(node.selectedOptions, (option) => option.label).join(" ") : "";
398
+ if (node instanceof view.HTMLInputElement && node.type !== "password" && (BROWSER_TYPED_INPUTS.has(node.type) || node.type === "range")) return embedded ? node.value : "";
399
+ const label = normalizeBrowserName(node.getAttribute("aria-label") ?? "");
400
+ if (label !== "") return label;
401
+ const content = tag === "img" || tag === "area" || tag === "input" && Reflect.get(node, "type") === "image" ? node.getAttribute("alt") ?? "" : readBrowserContent(node, context);
402
+ return content.trim() !== "" ? content : node.getAttribute("title") ?? "";
403
+ }
404
+ /**
405
+ * Checks whether an element and its subtree are hidden from the rendered page.
406
+ *
407
+ * @param element - The element to test
408
+ * @returns True if the element carries `hidden` or `aria-hidden="true"`, or its computed
409
+ * `display` is `none`; false otherwise
410
+ *
411
+ * @example
412
+ * ```ts
413
+ * const note = document.createElement('p')
414
+ * note.hidden = true
415
+ * matchesBrowserHidden(note) // true
416
+ * ```
417
+ */
418
+ function matchesBrowserHidden(element) {
419
+ return element.hasAttribute("hidden") || element.getAttribute("aria-hidden") === "true" || element.ownerDocument.defaultView?.getComputedStyle(element).display === "none";
420
+ }
421
+ /**
422
+ * Checks whether an element renders nothing visible of its own.
423
+ *
424
+ * @remarks
425
+ * An element whose computed `visibility` is `hidden` or `collapse` is invisible. An element with
426
+ * `display: contents` has no box of its own but renders its descendants, so it is not invisible.
427
+ * Any other element is invisible when `checkVisibility` with `visibilityProperty` and
428
+ * `contentVisibilityAuto` reports it, which covers an element without a box and one skipped by
429
+ * `content-visibility: auto`. A descendant can still be visible, so the rule omits the element's
430
+ * own row and text and keeps its subtree. An `option` or `optgroup` inside a `select` is
431
+ * invisible where the `select` is, because a drop-down renders its options outside the page's
432
+ * boxes. Zero-size and off-screen elements stay visible. An
433
+ * element in a document without a window is never invisible, because nothing renders it.
434
+ *
435
+ * @param element - The element to test
436
+ * @returns True if the element renders nothing visible of its own; false otherwise
437
+ *
438
+ * @example
439
+ * ```ts
440
+ * const button = document.createElement('button')
441
+ * button.style.visibility = 'hidden'
442
+ * document.body.append(button)
443
+ * matchesBrowserInvisible(button) // true
444
+ * ```
445
+ */
446
+ function matchesBrowserInvisible(element) {
447
+ const style = element.ownerDocument.defaultView?.getComputedStyle(element);
448
+ if (style === void 0) return false;
449
+ if (style.visibility === "hidden" || style.visibility === "collapse") return true;
450
+ const select = element.localName === "option" || element.localName === "optgroup" ? element.closest("select") : null;
451
+ if (select !== null) return matchesBrowserInvisible(select);
452
+ return style.display !== "contents" && !element.checkVisibility({
453
+ visibilityProperty: true,
454
+ contentVisibilityAuto: true
455
+ });
456
+ }
457
+ /**
458
+ * Checks whether an element sits in an omitted subtree: the element or a flat-tree ancestor that
459
+ * `readBrowserParent` reaches matches `matchesBrowserHidden`, through assigned slots, shadow
460
+ * hosts, and the frame elements of same-origin documents.
461
+ *
462
+ * @param element - The element to test
463
+ * @returns True if the element or an ancestor is hidden; false otherwise
464
+ *
465
+ * @example
466
+ * ```ts
467
+ * const section = document.createElement('section')
468
+ * section.hidden = true
469
+ * const button = section.appendChild(document.createElement('button'))
470
+ * matchesBrowserOmitted(button) // true
471
+ * ```
472
+ */
473
+ function matchesBrowserOmitted(element) {
474
+ for (let current = element; current !== null; current = readBrowserParent(current)) if (matchesBrowserHidden(current)) return true;
475
+ return false;
476
+ }
477
+ /**
478
+ * Reads an element's parent in the flat tree: the slot it is assigned to in an open shadow root,
479
+ * its parent element, the host of the open shadow root it sits at the top of, or the frame
480
+ * element of its same-origin document.
481
+ *
482
+ * @param element - The element whose parent is read
483
+ * @returns The parent, or `null` at the top of a top-level or cross-origin-framed document
484
+ *
485
+ * @example
486
+ * ```ts
487
+ * readBrowserParent(document.body) // document.documentElement
488
+ * readBrowserParent(document.documentElement) // null in a top-level document
489
+ * ```
490
+ */
491
+ function readBrowserParent(element) {
492
+ if (element.assignedSlot !== null) return element.assignedSlot;
493
+ if (element.parentElement !== null) return element.parentElement;
494
+ const view = element.ownerDocument.defaultView;
495
+ if (view === null) return null;
496
+ const tree = element.getRootNode();
497
+ return tree instanceof view.ShadowRoot ? tree.host : view.frameElement;
498
+ }
499
+ /**
500
+ * Checks whether an element starts a block of its own, the unit that separates runs of text.
501
+ *
502
+ * @param element - The element to test
503
+ * @returns True if the element is a `br`, or its computed `display` is neither inline-level,
504
+ * `contents`, nor `none`; false otherwise
505
+ *
506
+ * @example
507
+ * ```ts
508
+ * const paragraph = document.createElement('p')
509
+ * document.body.append(paragraph)
510
+ * matchesBrowserBlock(paragraph) // true
511
+ * ```
512
+ */
513
+ function matchesBrowserBlock(element) {
514
+ if (element.localName === "br") return true;
515
+ const display = element.ownerDocument.defaultView?.getComputedStyle(element).display ?? "";
516
+ return !display.startsWith("inline") && display !== "contents" && display !== "none";
517
+ }
518
+ /**
519
+ * Collects the tree roots a wait observes under a node: the node's own root, then every open
520
+ * shadow root and every same-origin frame document at or beneath it, recursively.
521
+ *
522
+ * @param root - The document, shadow root, or element to collect under
523
+ * @returns The roots, the node's own root first; a cross-origin frame contributes none
524
+ *
525
+ * @example
526
+ * ```ts
527
+ * collectBrowserRoots(document) // [document, ...each open shadow root and frame document]
528
+ * ```
529
+ */
530
+ function collectBrowserRoots(root) {
531
+ const roots = [root.getRootNode()];
532
+ const queue = [root];
533
+ for (let current = queue.shift(); current !== void 0; current = queue.shift()) {
534
+ const view = (isBrowserDocument(current) ? current : current.ownerDocument)?.defaultView ?? null;
535
+ const own = view !== null && current instanceof view.Element ? [current] : [];
536
+ for (const element of [...own, ...current.querySelectorAll("*")]) {
537
+ const shadow = element.shadowRoot;
538
+ if (shadow !== null) {
539
+ roots.push(shadow);
540
+ queue.push(shadow);
541
+ }
542
+ const child = view !== null && element instanceof view.HTMLIFrameElement ? element.contentDocument : null;
543
+ if (child !== null) {
544
+ roots.push(child);
545
+ queue.push(child);
546
+ }
547
+ }
548
+ }
549
+ return roots;
550
+ }
551
+ /**
552
+ * Checks whether activating an element opens another browsing context.
553
+ *
554
+ * @remarks
555
+ * A link with `href` navigates its `target`, and a form, or a submit button inside one, navigates
556
+ * the submitter's `formtarget` or the form's `target`; either falls back to the target of the
557
+ * document's `base` element. `_blank` opens another context, and a target in
558
+ * `BROWSER_CONTEXT_TARGETS` does not. A name reuses the browsing context carrying it when the
559
+ * element's window, an ancestor window, or a frame element in one of their documents carries it,
560
+ * and opens another context otherwise; an ancestor this realm cannot read is skipped.
561
+ *
562
+ * @param element - The element an untrusted activation would target
563
+ * @returns True if the activation opens another browsing context; false otherwise, including
564
+ * for an element whose activation navigates nothing
565
+ *
566
+ * @example
567
+ * ```ts
568
+ * const link = document.createElement('a')
569
+ * link.href = '/cars'
570
+ * link.target = '_blank'
571
+ * matchesBrowserPopup(link) // true
572
+ * ```
573
+ */
574
+ function matchesBrowserPopup(element) {
575
+ const document = element.ownerDocument;
576
+ const view = document.defaultView;
577
+ const base = document.querySelector("base[target]")?.getAttribute("target") ?? "";
578
+ const tag = element.localName;
579
+ const type = Reflect.get(element, "type");
580
+ const submitter = tag === "button" && type === "submit" || tag === "input" && (type === "submit" || type === "image");
581
+ const form = tag === "form" ? element : Reflect.get(element, "form");
582
+ let target;
583
+ if ((tag === "a" || tag === "area") && element.hasAttribute("href")) target = element.getAttribute("target") ?? base;
584
+ else if (view !== null && form instanceof view.HTMLFormElement && (tag === "form" || submitter)) target = (submitter ? element.getAttribute("formtarget") : null) ?? form.getAttribute("target") ?? base;
585
+ if (target === void 0) return false;
586
+ const keyword = target.trim().toLowerCase();
587
+ if (BROWSER_CONTEXT_TARGETS.has(keyword)) return false;
588
+ if (keyword === "_blank") return true;
589
+ const selector = `iframe[name="${CSS.escape(target)}"], frame[name="${CSS.escape(target)}"]`;
590
+ let current = view;
591
+ while (current !== null) {
592
+ const frame = current;
593
+ const found = attempt(() => frame.name === target || frame.document.querySelector(selector) !== null);
594
+ if (found.success && found.value) return false;
595
+ current = frame.parent === frame ? null : frame.parent;
596
+ }
597
+ return true;
598
+ }
599
+ /**
600
+ * Checks whether a click on an element activates the `label` that contains it, following the
601
+ * HTML label activation rule.
602
+ *
603
+ * @remarks
604
+ * The click activates the label when the target is the label itself, or when no element on the
605
+ * path from the target up to the label, the target included, matches
606
+ * `BROWSER_INTERACTIVE_CONTENT`.
607
+ *
608
+ * @param target - The element the click lands on
609
+ * @param label - The label that contains the target, or the target itself
610
+ * @returns True if the click activates the label; false otherwise
611
+ *
612
+ * @example
613
+ * ```ts
614
+ * const label = document.createElement('label')
615
+ * label.innerHTML = '<a href="#help">Help</a> <span>Photo</span>'
616
+ * matchesBrowserActivation(label.querySelector('a'), label) // false
617
+ * matchesBrowserActivation(label.querySelector('span'), label) // true
618
+ * ```
619
+ */
620
+ function matchesBrowserActivation(target, label) {
621
+ for (let current = target; current !== null && current !== label; current = current.parentElement) if (current.matches("a[href], audio[controls], button, details, embed, iframe, img[usemap], img[controls], input:not([type=\"hidden\" i]), label, select, textarea, video[controls]")) return false;
622
+ return true;
623
+ }
624
+ /**
625
+ * Moves a tree walker past the subtree of its current node.
626
+ *
627
+ * @param walker - The walker to move; its root bounds the move
628
+ * @returns The first node after the subtree in document order, or `null` when the walk is done
629
+ *
630
+ * @example
631
+ * ```ts
632
+ * const walker = document.createTreeWalker(document.body)
633
+ * walker.nextNode()
634
+ * skipBrowserSubtree(walker) // the first node after the body's first child and its descendants
635
+ * ```
636
+ */
637
+ function skipBrowserSubtree(walker) {
638
+ let node = walker.nextSibling();
639
+ while (node === null) {
640
+ if (walker.parentNode() === null) return null;
641
+ node = walker.nextSibling();
642
+ }
643
+ return node;
644
+ }
645
+ /**
646
+ * Reads the nearest block container of an element, the unit that separates runs of text.
647
+ *
648
+ * @param element - The element to start from, or `null` for a node without a parent element
649
+ * @param root - The node that bounds the search
650
+ * @returns The nearest inclusive ancestor, below `root`, that `matchesBrowserBlock` reports;
651
+ * `root` when none is
652
+ *
653
+ * @example
654
+ * ```ts
655
+ * const strong = document.createElement('strong')
656
+ * const paragraph = document.createElement('p')
657
+ * paragraph.append(strong)
658
+ * document.body.append(paragraph)
659
+ * readBrowserBlock(strong, document.body) // paragraph
660
+ * ```
661
+ */
662
+ function readBrowserBlock(element, root) {
663
+ let current = element;
664
+ while (current !== null && current !== root) {
665
+ if (matchesBrowserBlock(current)) return current;
666
+ current = current.parentElement;
667
+ }
668
+ return root;
669
+ }
670
+ /**
671
+ * Subscribes one listener to a window's navigation events until a signal aborts.
672
+ *
673
+ * @remarks
674
+ * The listener receives the Navigation API's `navigatesuccess` where the window has that API, and
675
+ * `popstate` and `hashchange` where it does not, plus `pagehide` in both cases. A `pushState` in a
676
+ * window without the Navigation API fires none of those events.
677
+ *
678
+ * @param window - The window whose navigation events are observed
679
+ * @param listener - Receives each event; `event.type` tells an unload from a same-document
680
+ * navigation
681
+ * @param signal - Removes every subscription when it aborts
682
+ *
683
+ * @example
684
+ * ```ts
685
+ * const release = new AbortController()
686
+ * listenBrowserNavigation(frame.contentWindow, (event) => console.log(event.type), release.signal)
687
+ * ```
688
+ */
689
+ function listenBrowserNavigation(window, listener, signal) {
690
+ const options = { signal };
691
+ if (Reflect.has(window, "navigation")) window.navigation.addEventListener("navigatesuccess", listener, options);
692
+ else {
693
+ window.addEventListener("popstate", listener, options);
694
+ window.addEventListener("hashchange", listener, options);
695
+ }
696
+ window.addEventListener("pagehide", listener, options);
697
+ }
698
+ //#endregion
699
+ //#region src/browser/elements/BrowserDOMElement.ts
700
+ /**
701
+ * Drives a referenced element of a DOM document without trusted input.
702
+ *
703
+ * @remarks
704
+ * `click` runs `HTMLElement.click()`; `fill` and `select` set the control through its native
705
+ * prototype setter and dispatch `input` and `change`, and `fill` refuses `UNKNOWN` an element
706
+ * that is not a text control and a read-only one; `submit` runs `form.requestSubmit()` and
707
+ * observes the outcome through a `submit` listener registered across the call. Each action
708
+ * refuses with a `BrowserElementError` naming the reason: `DISABLED` for a disabled control,
709
+ * `HIDDEN` for an element that does not render, and `UNTRUSTED` for what an untrusted event
710
+ * cannot do, which is a click or submission that opens another browsing context, a click that
711
+ * opens a file chooser, and typing into a contenteditable element. A click that activates a
712
+ * `label`, on the label itself or on a descendant that `matchesBrowserActivation` admits, is
713
+ * judged by the label's control as well: a disabled control refuses `DISABLED`, and a file input
714
+ * refuses `UNTRUSTED` as a direct click on it does. A click on an interactive descendant of a
715
+ * label keeps only its own checks. `role` and `name` read the description the latest outline or
716
+ * query that encountered the element captured, so a change to the element shows after the next
717
+ * capture, and a dropped reference keeps its last capture. Every action on an element removed
718
+ * from its document, collected, or held across a navigation reports `GONE`.
719
+ *
720
+ * @example
721
+ * ```ts
722
+ * const view = createBrowserDOMView({ document: frame.contentDocument })
723
+ * await view.elements.outline()
724
+ * await view.elements.element('e1')?.click()
725
+ * ```
726
+ */
727
+ var BrowserDOMElement = class {
728
+ #input;
729
+ constructor(input) {
730
+ this.#input = input;
731
+ }
732
+ get reference() {
733
+ return this.#input.reference;
734
+ }
735
+ get role() {
736
+ return this.#input.description().role;
737
+ }
738
+ get name() {
739
+ return this.#input.description().name;
740
+ }
741
+ async click(options) {
742
+ const [node, view] = this.#actionable(options);
743
+ const label = node instanceof view.HTMLLabelElement ? node : node.closest("label");
744
+ const control = label !== null && matchesBrowserActivation(node, label) ? label.control : null;
745
+ if (control !== null && control !== node) {
746
+ if (control.matches(":disabled")) throw new BrowserElementError(this.reference, "DISABLED", "labels a disabled control");
747
+ this.#refuse(control);
748
+ }
749
+ this.#refuse(node);
750
+ if (node instanceof view.HTMLElement) {
751
+ node.click();
752
+ return;
753
+ }
754
+ node.dispatchEvent(new view.MouseEvent("click", {
755
+ bubbles: true,
756
+ cancelable: true,
757
+ composed: true
758
+ }));
759
+ }
760
+ async fill(value, options) {
761
+ const [node, view] = this.#actionable(options);
762
+ if (node instanceof view.HTMLElement && node.isContentEditable) throw new BrowserElementError(this.reference, "UNTRUSTED", "is contenteditable, which an untrusted event cannot type into");
763
+ const input = node instanceof view.HTMLInputElement ? node : void 0;
764
+ const control = input ?? (node instanceof view.HTMLTextAreaElement ? node : void 0);
765
+ const setter = Reflect.getOwnPropertyDescriptor(input === void 0 ? view.HTMLTextAreaElement.prototype : view.HTMLInputElement.prototype, "value")?.set;
766
+ if (control === void 0 || input !== void 0 && !BROWSER_TYPED_INPUTS.has(input.type)) throw new BrowserElementError(this.reference, "UNKNOWN", "is not a text control");
767
+ if (control.readOnly || setter === void 0) throw new BrowserElementError(this.reference, "UNKNOWN", "is not editable");
768
+ control.focus();
769
+ Reflect.apply(setter, control, [value]);
770
+ control.dispatchEvent(new view.InputEvent("input", {
771
+ bubbles: true,
772
+ composed: true,
773
+ inputType: "insertReplacementText",
774
+ data: value
775
+ }));
776
+ control.dispatchEvent(new view.Event("change", { bubbles: true }));
777
+ }
778
+ async select(values, options) {
779
+ const [node, view] = this.#actionable(options);
780
+ if (!(node instanceof view.HTMLSelectElement)) throw new BrowserElementError(this.reference, "UNKNOWN", "is not a select control");
781
+ const choices = Array.from(node.options);
782
+ const selected = values.map((value) => choices.find((option) => option.value === value) ?? choices.find((option) => option.label === value));
783
+ if (values.find((_value, index) => selected[index] === void 0) !== void 0) throw new BrowserElementError(this.reference, "UNKNOWN", "has no such option");
784
+ for (const option of choices) option.selected = selected.includes(option);
785
+ node.dispatchEvent(new view.Event("input", {
786
+ bubbles: true,
787
+ composed: true
788
+ }));
789
+ node.dispatchEvent(new view.Event("change", { bubbles: true }));
790
+ }
791
+ async focus(options) {
792
+ const [node, view] = this.#current(options);
793
+ if (node instanceof view.HTMLElement || node instanceof view.SVGElement) node.focus();
794
+ }
795
+ async read(options) {
796
+ const [node] = this.#current(options);
797
+ return createBrowserReading({
798
+ ...readBrowserCapture(node),
799
+ navigation: this.#input.navigation
800
+ });
801
+ }
802
+ async submit(options) {
803
+ const [node, view] = this.#current(options);
804
+ const owner = node instanceof view.HTMLFormElement ? node : Reflect.get(node, "form");
805
+ if (!(owner instanceof view.HTMLFormElement)) throw new BrowserElementError(this.reference, "UNKNOWN", "is not in a form");
806
+ const type = Reflect.get(node, "type");
807
+ const submitter = node instanceof view.HTMLButtonElement && type === "submit" || node instanceof view.HTMLInputElement && (type === "submit" || type === "image") ? node : void 0;
808
+ if (submitter?.matches(":disabled") === true) throw new BrowserElementError(this.reference, "DISABLED");
809
+ if (matchesBrowserPopup(submitter ?? owner)) throw new BrowserElementError(this.reference, "UNTRUSTED", "submits into another browsing context, which an untrusted submission cannot open");
810
+ let submitted = false;
811
+ const release = new AbortController();
812
+ owner.addEventListener("submit", () => {
813
+ submitted = true;
814
+ }, { signal: release.signal });
815
+ try {
816
+ owner.requestSubmit(submitter ?? null);
817
+ } finally {
818
+ release.abort();
819
+ }
820
+ if (submitted) return;
821
+ const invalid = Array.from(owner.elements).find((control) => control.localName !== "fieldset" && control.matches(":invalid"));
822
+ if (invalid === void 0) throw new BrowserError("did not submit", "BROWSER_DOCUMENT_SUBMIT", { reference: this.reference });
823
+ const field = computeBrowserName(invalid) || (invalid.getAttribute("name") ?? invalid.localName);
824
+ const reason = Reflect.get(invalid, "validationMessage");
825
+ const message = isString(reason) ? reason : "";
826
+ throw new BrowserError(`did not submit: ${field} — ${message}`, "BROWSER_DOCUMENT_SUBMIT", {
827
+ reference: this.reference,
828
+ field,
829
+ message
830
+ });
831
+ }
832
+ #current(options) {
833
+ options?.signal?.throwIfAborted();
834
+ const node = this.#input.node.deref();
835
+ const view = node?.ownerDocument.defaultView ?? null;
836
+ if (!this.#input.current() || node === void 0 || !node.isConnected || view === null) throw new BrowserElementError(this.reference, "GONE");
837
+ return [node, view];
838
+ }
839
+ #actionable(options) {
840
+ const current = this.#current(options);
841
+ const [node] = current;
842
+ if (!node.checkVisibility()) throw new BrowserElementError(this.reference, "HIDDEN");
843
+ if (node.matches(":disabled")) throw new BrowserElementError(this.reference, "DISABLED");
844
+ return current;
845
+ }
846
+ #refuse(target) {
847
+ if (matchesBrowserPopup(target)) throw new BrowserElementError(this.reference, "UNTRUSTED", "opens another browsing context, which an untrusted click cannot do");
848
+ if (target.localName === "input" && Reflect.get(target, "type") === "file") throw new BrowserElementError(this.reference, "UNTRUSTED", "opens a file chooser, which an untrusted click cannot do");
849
+ }
850
+ };
851
+ //#endregion
852
+ //#region src/browser/BrowserDOMWait.ts
853
+ /**
854
+ * Parks one condition on DOM mutations until it holds, with one deadline and no other timer.
855
+ *
856
+ * @remarks
857
+ * The wait observes every root `roots` returns, each through a `MutationObserver` of that root's
858
+ * realm, and re-reads the roots after every mutation batch and every capture-phase `load` in an
859
+ * observed root: a same-origin frame or an open shadow root that appears joins the observation,
860
+ * and a root that departed (a replaced frame document, a removed shadow root) leaves it, with its
861
+ * observer disconnected and its `load` listener removed. The check runs before the wait parks and
862
+ * again after every subscription, and no check runs once the deadline, counted from `start`, has
863
+ * passed, which the wait reads again after `roots` returns. The wait settles one time: on the
864
+ * check's first value, on the signal's abort with its reason, at the deadline with
865
+ * `BROWSER_WAIT_TIMEOUT`, on a `pagehide` in the first root's window with `GONE`, or with whatever
866
+ * the check or the roots function throws. A signal that aborts while `check` or `roots` runs
867
+ * settles the wait with its reason, and wins over a value that same check returns. Settlement
868
+ * disconnects every observer and removes every listener synchronously, so a mutation queued
869
+ * before it runs no check, and a `roots` or `check` that settles the wait from inside leaves no
870
+ * observer, listener, or further check behind.
871
+ *
872
+ * A shadow root attached after the wait began is a declared limit: attaching one fires no
873
+ * mutation record and no event, so the wait discovers it at the next mutation or `load` it
874
+ * observes, and a match inserted into it with no other observed change is not seen before the
875
+ * deadline. The wait never polls to close that gap.
876
+ *
877
+ * @example
878
+ * ```ts
879
+ * const ready = await new BrowserDOMWait({
880
+ * roots: () => [document],
881
+ * check: () => document.querySelector('#ready') ?? undefined,
882
+ * timeout: 1_000,
883
+ * start: performance.now(),
884
+ * subject: 'Ready wait',
885
+ * }).execute()
886
+ * ```
887
+ */
888
+ var BrowserDOMWait = class {
889
+ #wait;
890
+ #settled = Promise.withResolvers();
891
+ #release = new AbortController();
892
+ #observers = /* @__PURE__ */ new Map();
893
+ #main;
894
+ #timer;
895
+ #recheckHandler = this.#recheck.bind(this);
896
+ #unloadHandler = this.#unload.bind(this);
897
+ constructor(wait) {
898
+ this.#wait = wait;
899
+ }
900
+ get roots() {
901
+ return [...this.#observers.keys()];
902
+ }
903
+ async execute() {
904
+ const signal = this.#wait.signal;
905
+ signal?.throwIfAborted();
906
+ const first = this.#wait.check();
907
+ signal?.throwIfAborted();
908
+ if (first !== void 0) return first;
909
+ this.#timer = setTimeout(this.#expire.bind(this), Math.max(0, this.#remaining()));
910
+ signal?.addEventListener("abort", () => this.#fail(signal.reason), { signal: this.#release.signal });
911
+ this.#recheck();
912
+ return await this.#settled.promise;
913
+ }
914
+ #recheck() {
915
+ if (this.#release.signal.aborted) return;
916
+ if (this.#remaining() <= 0) {
917
+ this.#expire();
918
+ return;
919
+ }
920
+ const result = attempt(() => {
921
+ this.#reconcile();
922
+ if (this.#release.signal.aborted) return void 0;
923
+ if (this.#remaining() <= 0) {
924
+ this.#expire();
925
+ return;
926
+ }
927
+ return this.#wait.check();
928
+ });
929
+ if (!result.success) this.#fail(result.error);
930
+ else if (result.value !== void 0) this.#succeed(result.value);
931
+ }
932
+ #reconcile() {
933
+ const live = new Set(this.#wait.roots());
934
+ if (this.#release.signal.aborted) return;
935
+ for (const [root, entry] of this.#observers) {
936
+ if (live.has(root)) continue;
937
+ entry.observer.disconnect();
938
+ entry.release.abort();
939
+ this.#observers.delete(root);
940
+ }
941
+ for (const root of live) {
942
+ if (this.#observers.has(root)) continue;
943
+ const view = (isBrowserDocument(root) ? root : root.ownerDocument)?.defaultView ?? null;
944
+ if (view === null) continue;
945
+ const observer = new view.MutationObserver(this.#recheckHandler);
946
+ observer.observe(root, {
947
+ subtree: true,
948
+ childList: true,
949
+ attributes: true,
950
+ characterData: true
951
+ });
952
+ const release = new AbortController();
953
+ root.addEventListener("load", this.#recheckHandler, {
954
+ capture: true,
955
+ signal: release.signal
956
+ });
957
+ this.#observers.set(root, {
958
+ observer,
959
+ release
960
+ });
961
+ if (this.#main !== void 0) continue;
962
+ this.#main = view;
963
+ view.addEventListener("pagehide", this.#unloadHandler, { signal: this.#release.signal });
964
+ }
965
+ }
966
+ #remaining() {
967
+ const now = this.#wait.now;
968
+ return this.#wait.start + this.#wait.timeout - (now === void 0 ? performance.now() : now());
969
+ }
970
+ #expire() {
971
+ this.#fail(new BrowserError(`${this.#wait.subject} timed out`, "BROWSER_WAIT_TIMEOUT", { timeout: this.#wait.timeout }));
972
+ }
973
+ #unload() {
974
+ this.#fail(new BrowserElementError({ subject: "document" }, "GONE"));
975
+ }
976
+ #succeed(value) {
977
+ this.#stop();
978
+ this.#settled.resolve(value);
979
+ }
980
+ #fail(error) {
981
+ this.#stop();
982
+ this.#settled.reject(error);
983
+ }
984
+ #stop() {
985
+ clearTimeout(this.#timer);
986
+ this.#release.abort();
987
+ for (const entry of this.#observers.values()) {
988
+ entry.observer.disconnect();
989
+ entry.release.abort();
990
+ }
991
+ this.#observers.clear();
992
+ }
993
+ };
994
+ //#endregion
995
+ //#region src/browser/elements/BrowserDOMElementManager.ts
996
+ /**
997
+ * Outlines a DOM document from its elements and binds stable references to the interactive ones.
998
+ *
999
+ * @remarks
1000
+ * Rows come in document order from a `TreeWalker` over the flat tree: an open shadow root
1001
+ * contributes its rows in place of its host's children, a `slot` its assigned nodes, and a
1002
+ * same-origin `iframe` its document's rows, while a cross-origin one renders
1003
+ * `iframe "NAME" (cross-origin, not readable)`. An element that `matchesBrowserHidden` reports is
1004
+ * omitted with its subtree, a scope root in such a subtree yields no row, and an element that
1005
+ * `matchesBrowserInvisible` reports yields no row of its own. An element whose role
1006
+ * `BROWSER_INTERACTIVE_ROLES` names receives a reference bound through a `WeakRef`, which never
1007
+ * changes while the manager holds it, and a reference number is never reused. Every outline or
1008
+ * query that encounters a bound element records its role and name afresh, and the element's
1009
+ * wrapper reports that latest capture, also after the manager drops the reference. A reference
1010
+ * and a reading from any walked document record that document's epoch, which advances on its own
1011
+ * navigation events and on the view's; that document's `pagehide` drops its references. A form
1012
+ * carrying a `toolname` attribute renders `[tool=NAME]` after its role and name. A `select` row
1013
+ * is followed by one `option` row per option, named by its label. Whitespace between two texts of
1014
+ * one block renders as one space, as a rendered `br` between them does, and texts with neither
1015
+ * between them stay joined. A CSS query
1016
+ * searches the document the view drives, not its child documents or shadow trees. After the
1017
+ * input's `signal` aborts, `outline`, `find`, and `wait` reject with its reason.
1018
+ *
1019
+ * `wait` parks a `BrowserDOMWait` bound to the view's lifetime over every root that
1020
+ * `collectBrowserRoots` reaches from its scope, reconciled at every observed mutation and frame
1021
+ * `load`. A shadow root attached after the wait began is a declared limit: the wait discovers it
1022
+ * at the next observed mutation or `load`, so a match inserted there with no other observed
1023
+ * change is not seen before the deadline.
1024
+ *
1025
+ * @example
1026
+ * ```ts
1027
+ * const view = createBrowserDOMView({ document: frame.contentDocument })
1028
+ * const outline = await view.elements.outline()
1029
+ * const [save] = await view.elements.find({ role: 'button', name: 'save' })
1030
+ * ```
1031
+ */
1032
+ var BrowserDOMElementManager = class {
1033
+ #input;
1034
+ #records = /* @__PURE__ */ new Map();
1035
+ #identities = /* @__PURE__ */ new WeakMap();
1036
+ #descriptions = /* @__PURE__ */ new WeakMap();
1037
+ #epochs = /* @__PURE__ */ new WeakMap();
1038
+ #watched = /* @__PURE__ */ new WeakSet();
1039
+ #count = 0;
1040
+ constructor(input) {
1041
+ this.#input = input;
1042
+ }
1043
+ async outline(options) {
1044
+ this.#input.signal.throwIfAborted();
1045
+ const limit = options?.limit ?? BROWSER_OUTLINE_LIMIT;
1046
+ if (!isInteger(limit) || limit < 0) throw new BrowserError("Outline limit must be a nonnegative integer", void 0, { limit });
1047
+ options?.signal?.throwIfAborted();
1048
+ const document = this.#input.document();
1049
+ const rows = this.#capture(this.#root(document, options?.within), options?.within);
1050
+ return renderBrowserOutline(document.URL, document.title, rows, limit);
1051
+ }
1052
+ async find(query, options) {
1053
+ this.#input.signal.throwIfAborted();
1054
+ options?.signal?.throwIfAborted();
1055
+ return this.#find(query);
1056
+ }
1057
+ async wait(query, options) {
1058
+ this.#input.signal.throwIfAborted();
1059
+ const start = performance.now();
1060
+ const timeout = options?.timeout ?? 5e3;
1061
+ validateBrowserTimeout(timeout);
1062
+ const signal = options?.signal === void 0 ? this.#input.signal : AbortSignal.any([options.signal, this.#input.signal]);
1063
+ return await new BrowserDOMWait({
1064
+ roots: this.#roots.bind(this, query.within),
1065
+ check: this.#match.bind(this, query, options?.absent === true),
1066
+ timeout,
1067
+ start,
1068
+ signal,
1069
+ subject: "Element wait"
1070
+ }).execute();
1071
+ }
1072
+ element(reference) {
1073
+ return this.#records.get(parseBrowserReference(reference) ?? "")?.element;
1074
+ }
1075
+ elements() {
1076
+ for (const [reference, record] of this.#records) if (record.node.deref() === void 0) this.#records.delete(reference);
1077
+ return [...this.#records.values()].map((record) => record.element);
1078
+ }
1079
+ clear() {
1080
+ this.#records.clear();
1081
+ }
1082
+ #match(query, absent) {
1083
+ const found = this.#find(query);
1084
+ return (absent ? found.length === 0 : found.length > 0) ? found : void 0;
1085
+ }
1086
+ #find(query) {
1087
+ const root = this.#root(this.#input.document(), query.within);
1088
+ const matches = filterBrowserOutline(this.#capture(root, query.within), query).flatMap((row) => {
1089
+ const record = this.#records.get(row.reference ?? "");
1090
+ return record === void 0 ? [] : [record.element];
1091
+ });
1092
+ const css = query.css;
1093
+ if (css === void 0) return matches;
1094
+ const selected = attempt(() => Array.from(root.querySelectorAll(css)));
1095
+ if (!selected.success) throw new BrowserError(`CSS query is invalid: ${css}`, "BROWSER_ELEMENT_QUERY", { css });
1096
+ const bound = selected.value.map((element) => {
1097
+ const role = computeBrowserRole(element) ?? "generic";
1098
+ return this.#bind(element, role, computeBrowserName(element, role));
1099
+ });
1100
+ if (query.role === void 0 && query.name === void 0) return bound;
1101
+ return matches.filter((element) => bound.includes(element));
1102
+ }
1103
+ #capture(root, within) {
1104
+ const rows = [];
1105
+ if (within === void 0 || !matchesBrowserOmitted(root)) this.#walk(root, rows, false);
1106
+ return rows;
1107
+ }
1108
+ #root(document, within) {
1109
+ if (within === void 0) return document.documentElement;
1110
+ const node = this.#records.get(parseBrowserReference(within) ?? "")?.node.deref();
1111
+ if (node === void 0 || !node.isConnected || node.ownerDocument.defaultView === null) throw new BrowserElementError(within, "GONE");
1112
+ return node;
1113
+ }
1114
+ #roots(within) {
1115
+ return collectBrowserRoots(this.#root(this.#input.document(), within));
1116
+ }
1117
+ #walk(root, rows, muted) {
1118
+ const document = root.ownerDocument;
1119
+ const view = document.defaultView;
1120
+ if (view === null) return;
1121
+ if (root instanceof view.Element && matchesBrowserHidden(root)) return;
1122
+ this.#watch(document);
1123
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, (node) => node instanceof view.Element && matchesBrowserHidden(node) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT);
1124
+ let text = "";
1125
+ let block;
1126
+ let silenced;
1127
+ let node = root instanceof view.Element ? root : walker.nextNode();
1128
+ while (node !== null) {
1129
+ if (silenced !== void 0 && !silenced.contains(node)) silenced = void 0;
1130
+ const quiet = muted || silenced !== void 0;
1131
+ if (!(node instanceof view.Element)) {
1132
+ const parent = node.parentElement;
1133
+ const data = node.textContent ?? "";
1134
+ const blank = data.trim() === "";
1135
+ if (!quiet && (!blank || text !== "") && (parent === null || !matchesBrowserInvisible(parent))) {
1136
+ const container = readBrowserBlock(parent, root);
1137
+ if (!blank) {
1138
+ if (block !== void 0 && block !== container) text = this.#flush(rows, text);
1139
+ text += data;
1140
+ block = container;
1141
+ } else if (container === block) text += data;
1142
+ }
1143
+ node = walker.nextNode();
1144
+ continue;
1145
+ }
1146
+ const invisible = matchesBrowserInvisible(node);
1147
+ if (node.localName === "br") {
1148
+ if (!quiet && text !== "") text += "\n";
1149
+ node = walker.nextNode();
1150
+ continue;
1151
+ }
1152
+ if (node instanceof view.HTMLIFrameElement) {
1153
+ text = this.#flush(rows, text);
1154
+ const child = node.contentDocument;
1155
+ if (invisible) {
1156
+ node = skipBrowserSubtree(walker);
1157
+ continue;
1158
+ }
1159
+ if (child === null) this.#text(rows, `iframe ${JSON.stringify(computeBrowserName(node))} (cross-origin, not readable)`);
1160
+ else this.#walk(child.documentElement, rows, quiet);
1161
+ node = skipBrowserSubtree(walker);
1162
+ continue;
1163
+ }
1164
+ if (node instanceof view.HTMLSlotElement) {
1165
+ text = this.#flush(rows, text);
1166
+ for (const assigned of node.assignedNodes({ flatten: true })) if (assigned instanceof view.Element) this.#walk(assigned, rows, quiet);
1167
+ else if (!quiet) this.#flush(rows, assigned.textContent ?? "");
1168
+ node = skipBrowserSubtree(walker);
1169
+ continue;
1170
+ }
1171
+ const tool = node.localName === "form" ? node.getAttribute("toolname") ?? "" : "";
1172
+ const role = tool === "" ? computeBrowserRole(node) : "form";
1173
+ if (!invisible && role !== void 0 && (BROWSER_INTERACTIVE_ROLES.has(role) || role === "heading" || tool !== "")) {
1174
+ text = this.#flush(rows, text);
1175
+ rows.push(this.#row(node, role, view, String(rows.length)));
1176
+ if (silenced === void 0 && BROWSER_CONTENT_NAMED_ROLES.has(role)) silenced = node;
1177
+ if (node.localName === "textarea") {
1178
+ node = skipBrowserSubtree(walker);
1179
+ continue;
1180
+ }
1181
+ }
1182
+ const shadow = node.shadowRoot;
1183
+ if (shadow !== null) {
1184
+ text = this.#flush(rows, text);
1185
+ this.#walk(shadow, rows, muted || silenced !== void 0);
1186
+ node = skipBrowserSubtree(walker);
1187
+ continue;
1188
+ }
1189
+ node = walker.nextNode();
1190
+ }
1191
+ this.#flush(rows, text);
1192
+ }
1193
+ #watch(document) {
1194
+ const view = document.defaultView;
1195
+ if (this.#watched.has(document) || view === null) return;
1196
+ this.#watched.add(document);
1197
+ listenBrowserNavigation(view, this.#advance.bind(this, new WeakRef(document)), this.#input.signal);
1198
+ }
1199
+ #advance(document, event) {
1200
+ const target = document.deref();
1201
+ if (target === void 0) return;
1202
+ this.#epochs.set(target, (this.#epochs.get(target) ?? 0) + 1);
1203
+ if (event.type !== "pagehide") return;
1204
+ for (const [reference, record] of this.#records) {
1205
+ const node = record.node.deref();
1206
+ if (node === void 0 || node.ownerDocument === target) this.#records.delete(reference);
1207
+ }
1208
+ }
1209
+ #epoch(document) {
1210
+ const target = document.deref();
1211
+ return target === void 0 ? -1 : this.#input.navigation() + (this.#epochs.get(target) ?? 0);
1212
+ }
1213
+ #flush(rows, text) {
1214
+ if (normalizeBrowserName(text) !== "") this.#text(rows, text);
1215
+ return "";
1216
+ }
1217
+ #text(rows, text) {
1218
+ rows.push({
1219
+ id: String(rows.length),
1220
+ parent: void 0,
1221
+ children: [],
1222
+ backend: void 0,
1223
+ frame: void 0,
1224
+ ignored: false,
1225
+ role: "StaticText",
1226
+ name: normalizeBrowserName(text),
1227
+ description: void 0,
1228
+ value: void 0,
1229
+ properties: {},
1230
+ session: "",
1231
+ reference: void 0
1232
+ });
1233
+ }
1234
+ #row(element, role, view, id) {
1235
+ const name = computeBrowserName(element, role);
1236
+ const tool = element.localName === "form" ? element.getAttribute("toolname") : null;
1237
+ const reference = role === "heading" ? void 0 : this.#bind(element, role, name).reference;
1238
+ const input = element instanceof view.HTMLInputElement ? element : void 0;
1239
+ const checked = input !== void 0 && (input.type === "checkbox" || input.type === "radio") ? input.checked : element.getAttribute("aria-checked") === "true";
1240
+ let value;
1241
+ if (element instanceof view.HTMLSelectElement) value = Array.from(element.selectedOptions, (option) => option.label).join(", ");
1242
+ else if (element instanceof view.HTMLTextAreaElement) value = element.value;
1243
+ else if (input !== void 0 && input.type !== "password" && (BROWSER_TYPED_INPUTS.has(input.type) || input.type === "range")) value = input.value;
1244
+ return {
1245
+ id: reference ?? id,
1246
+ parent: void 0,
1247
+ children: [],
1248
+ backend: void 0,
1249
+ frame: void 0,
1250
+ ignored: false,
1251
+ role,
1252
+ name,
1253
+ description: void 0,
1254
+ value,
1255
+ properties: {
1256
+ checked,
1257
+ disabled: element.matches(":disabled")
1258
+ },
1259
+ ...tool === null || tool === "" ? {} : { tool },
1260
+ session: "",
1261
+ reference
1262
+ };
1263
+ }
1264
+ #bind(element, role, name) {
1265
+ const known = this.#identities.get(element);
1266
+ const existing = known === void 0 ? void 0 : this.#records.get(known);
1267
+ if (existing !== void 0) {
1268
+ this.#descriptions.set(existing.node, {
1269
+ role,
1270
+ name
1271
+ });
1272
+ return existing.element;
1273
+ }
1274
+ this.#count += 1;
1275
+ const reference = `${BROWSER_REFERENCE_PREFIX}${this.#count}`;
1276
+ const node = new WeakRef(element);
1277
+ const description = {
1278
+ role,
1279
+ name
1280
+ };
1281
+ this.#descriptions.set(node, description);
1282
+ const created = new BrowserDOMElement({
1283
+ reference,
1284
+ description: this.#description.bind(this, node, description),
1285
+ node,
1286
+ current: this.#holds.bind(this, reference),
1287
+ navigation: this.#epoch.bind(this, new WeakRef(element.ownerDocument))
1288
+ });
1289
+ this.#records.set(reference, {
1290
+ node,
1291
+ element: created
1292
+ });
1293
+ this.#identities.set(element, reference);
1294
+ return created;
1295
+ }
1296
+ #holds(reference) {
1297
+ return this.#records.has(reference);
1298
+ }
1299
+ #description(node, captured) {
1300
+ return this.#descriptions.get(node) ?? captured;
1301
+ }
1302
+ };
1303
+ //#endregion
1304
+ //#region src/browser/BrowserDOMView.ts
1305
+ /**
1306
+ * Reads and drives a DOM document from a realm that can reach it, without trusted input.
1307
+ *
1308
+ * @remarks
1309
+ * The view follows the window of the document it was given, so after that window navigates it
1310
+ * drives the window's next document. A reading records the view's navigation epoch, and reports
1311
+ * stale after the epoch advances. The epoch advances on the Navigation API's `navigatesuccess`
1312
+ * where the window has that API, on `popstate` and `hashchange` where it does not, on `pagehide`,
1313
+ * and when the window's document changes. A `pushState` in a window without the Navigation API
1314
+ * fires none of those events, so a reading there stays current across it. A cross-document
1315
+ * navigation also clears every element reference. `destroy()` releases the listeners and every
1316
+ * reference, and fails every pending wait with a `BrowserError` coded
1317
+ * `BROWSER_DOCUMENT_DESTROYED`; after it, `title()`, `read()`, `wait()`, and the element manager's
1318
+ * `outline()`, `find()`, and `wait()` reject with that error, and a second `destroy()` does
1319
+ * nothing more. `trusted` is `false`: no action grants user activation, and
1320
+ * every event the view's actions dispatch themselves carries `isTrusted` `false`. The `submit`
1321
+ * event that `requestSubmit()` fires during a `submit` is generated by the browser and carries
1322
+ * `isTrusted` `true`.
1323
+ *
1324
+ * @example
1325
+ * ```ts
1326
+ * const view = createBrowserDOMView({ document: frame.contentDocument })
1327
+ * const reading = await view.read()
1328
+ * reading.text().text
1329
+ * ```
1330
+ */
1331
+ var BrowserDOMView = class {
1332
+ #window;
1333
+ #elements;
1334
+ #release = new AbortController();
1335
+ #navigateHandler = this.#navigate.bind(this);
1336
+ #document;
1337
+ #epoch = 0;
1338
+ #listeners = new AbortController();
1339
+ constructor(options) {
1340
+ const document = options.document;
1341
+ if (!isBrowserDocument(document) || document.defaultView === null) throw new BrowserError("Browser DOM view requires a document attached to a window", "BROWSER_DOCUMENT");
1342
+ if (document === globalThis.document && options.own !== true) throw new BrowserError("Browser DOM view drives globalThis.document only with own: true, because an action that navigates the realm it runs in cannot return", "BROWSER_DOCUMENT_OWN");
1343
+ this.#window = document.defaultView;
1344
+ this.#document = document;
1345
+ this.#elements = new BrowserDOMElementManager({
1346
+ document: this.#current.bind(this),
1347
+ navigation: this.#navigation.bind(this),
1348
+ signal: this.#release.signal
1349
+ });
1350
+ this.#listen();
1351
+ }
1352
+ get url() {
1353
+ return this.#current().URL;
1354
+ }
1355
+ get trusted() {
1356
+ return false;
1357
+ }
1358
+ get elements() {
1359
+ return this.#elements;
1360
+ }
1361
+ async title(options) {
1362
+ this.#release.signal.throwIfAborted();
1363
+ options?.signal?.throwIfAborted();
1364
+ return this.#current().title;
1365
+ }
1366
+ async read(options) {
1367
+ this.#release.signal.throwIfAborted();
1368
+ options?.signal?.throwIfAborted();
1369
+ return createBrowserReading({
1370
+ ...readBrowserCapture(this.#current().documentElement),
1371
+ navigation: this.#navigation.bind(this)
1372
+ });
1373
+ }
1374
+ async wait(text, options) {
1375
+ this.#release.signal.throwIfAborted();
1376
+ const start = performance.now();
1377
+ const timeout = options?.timeout ?? 5e3;
1378
+ validateBrowserTimeout(timeout);
1379
+ const document = this.#current();
1380
+ await new BrowserDOMWait({
1381
+ roots: collectBrowserRoots.bind(void 0, document),
1382
+ check: this.#contains.bind(this, document, text),
1383
+ timeout,
1384
+ start,
1385
+ signal: options?.signal === void 0 ? this.#release.signal : AbortSignal.any([options.signal, this.#release.signal]),
1386
+ subject: "Browser text wait"
1387
+ }).execute();
1388
+ }
1389
+ destroy() {
1390
+ this.#release.abort(new BrowserError("Browser DOM view was destroyed", "BROWSER_DOCUMENT_DESTROYED"));
1391
+ this.#listeners.abort();
1392
+ this.#elements.clear();
1393
+ }
1394
+ #current() {
1395
+ const document = this.#window.document;
1396
+ if (document !== this.#document && !this.#release.signal.aborted) {
1397
+ this.#document = document;
1398
+ this.#navigate();
1399
+ this.#listen();
1400
+ }
1401
+ return this.#document;
1402
+ }
1403
+ #navigation() {
1404
+ this.#current();
1405
+ return this.#epoch;
1406
+ }
1407
+ #navigate(event) {
1408
+ this.#epoch += 1;
1409
+ if (event === void 0 || event.type === "pagehide") this.#elements.clear();
1410
+ }
1411
+ #contains(document, text) {
1412
+ return (document.body?.innerText ?? "").includes(text) ? true : void 0;
1413
+ }
1414
+ #listen() {
1415
+ this.#listeners.abort();
1416
+ this.#listeners = new AbortController();
1417
+ listenBrowserNavigation(this.#window, this.#navigateHandler, this.#listeners.signal);
1418
+ }
1419
+ };
1420
+ //#endregion
1421
+ //#region src/browser/transports/SocketCDPTransport.ts
1422
+ /**
1423
+ * Provides a raw CDP text transport over the browser's native `WebSocket`.
1424
+ *
1425
+ * @remarks
1426
+ * `start()` resolves on the socket's `open` event and rejects with a `BrowserConnectionError`
1427
+ * carrying `url` for a URL whose scheme is not `ws:` or `wss:`, for a URL the `WebSocket`
1428
+ * constructor refuses (one with a fragment), for a socket that fails or closes before it opens,
1429
+ * and at the `timeout` deadline. Concurrent and repeated `start()` and `close()`
1430
+ * calls share their active transition, and a later `start()` opens a fresh socket after the
1431
+ * prior one closes. A close the remote end initiates emits `close`. The driven Chromium must
1432
+ * allow the caller's origin through `--remote-allow-origins`.
1433
+ *
1434
+ * @example
1435
+ * ```ts
1436
+ * const transport = new SocketCDPTransport({ url: 'ws://127.0.0.1:9222/devtools/browser/abc' })
1437
+ * await transport.start()
1438
+ * await transport.close()
1439
+ * ```
1440
+ */
1441
+ var SocketCDPTransport = class {
1442
+ #emitter;
1443
+ #url;
1444
+ #timeout;
1445
+ #starting = new BrowserTransition();
1446
+ #closing = new BrowserTransition();
1447
+ #socket;
1448
+ #opening;
1449
+ constructor(options) {
1450
+ this.#emitter = new Emitter({
1451
+ ...options.on !== void 0 ? { on: options.on } : {},
1452
+ ...options.error !== void 0 ? { error: options.error } : {}
1453
+ });
1454
+ this.#url = options.url;
1455
+ this.#timeout = options.timeout ?? BROWSER_DEFAULT_TIMEOUT_MS;
1456
+ }
1457
+ get emitter() {
1458
+ return this.#emitter;
1459
+ }
1460
+ async start() {
1461
+ const closing = this.#closing.pending;
1462
+ if (closing !== void 0) await closing;
1463
+ if (this.#socket?.readyState === WebSocket.OPEN) return;
1464
+ const active = this.#starting.pending;
1465
+ if (active !== void 0) {
1466
+ await active;
1467
+ return;
1468
+ }
1469
+ await this.#starting.execute(this.#start.bind(this));
1470
+ }
1471
+ async send(data) {
1472
+ const socket = this.#socket;
1473
+ if (socket === void 0 || socket.readyState !== WebSocket.OPEN) throw new BrowserConnectionError("Socket CDP transport is not open", { url: this.#url });
1474
+ socket.send(data);
1475
+ }
1476
+ async close() {
1477
+ await this.#closing.execute(this.#close.bind(this));
1478
+ }
1479
+ async #start() {
1480
+ const parsed = attempt(() => new URL(this.#url));
1481
+ if (!parsed.success) throw new BrowserConnectionError(`Socket CDP URL is invalid: ${this.#url}`, {
1482
+ url: this.#url,
1483
+ error: parsed.error
1484
+ });
1485
+ if (parsed.value.protocol !== "ws:" && parsed.value.protocol !== "wss:") throw new BrowserConnectionError(`Socket CDP connection requires a ws: or wss: URL: ${this.#url}`, { url: this.#url });
1486
+ const created = attempt(() => new WebSocket(this.#url));
1487
+ if (!created.success) throw new BrowserConnectionError(`Socket CDP connection to ${this.#url} could not open`, {
1488
+ url: this.#url,
1489
+ error: created.error
1490
+ });
1491
+ const socket = created.value;
1492
+ this.#socket = socket;
1493
+ const opened = Promise.withResolvers();
1494
+ this.#opening = opened;
1495
+ const release = new AbortController();
1496
+ const options = { signal: release.signal };
1497
+ socket.addEventListener("open", () => opened.resolve(), options);
1498
+ socket.addEventListener("error", () => opened.reject(this.#refuse("failed")), options);
1499
+ socket.addEventListener("close", () => opened.reject(this.#refuse("closed before it opened")), options);
1500
+ const timer = setTimeout(() => {
1501
+ opened.reject(this.#refuse(`timed out after ${this.#timeout}ms`));
1502
+ }, this.#timeout);
1503
+ try {
1504
+ await opened.promise;
1505
+ } catch (error) {
1506
+ if (this.#socket === socket) this.#socket = void 0;
1507
+ socket.close();
1508
+ throw error;
1509
+ } finally {
1510
+ if (this.#opening === opened) this.#opening = void 0;
1511
+ clearTimeout(timer);
1512
+ release.abort();
1513
+ }
1514
+ this.#bind(socket);
1515
+ }
1516
+ async #close() {
1517
+ this.#opening?.reject(this.#refuse("was closed before it finished connecting"));
1518
+ const socket = this.#socket;
1519
+ this.#socket = void 0;
1520
+ if (socket === void 0 || socket.readyState === WebSocket.CLOSED) {
1521
+ await this.#starting.pending?.catch(() => void 0);
1522
+ return;
1523
+ }
1524
+ const closed = Promise.withResolvers();
1525
+ socket.addEventListener("close", () => closed.resolve(), { once: true });
1526
+ socket.close();
1527
+ await this.#starting.pending?.catch(() => void 0);
1528
+ await closed.promise;
1529
+ }
1530
+ #bind(socket) {
1531
+ socket.binaryType = "arraybuffer";
1532
+ socket.addEventListener("message", (event) => {
1533
+ const data = event.data;
1534
+ if (isString(data)) this.#emitter.emit("message", data);
1535
+ else if (data instanceof ArrayBuffer) this.#emitter.emit("message", new TextDecoder().decode(data));
1536
+ });
1537
+ socket.addEventListener("close", () => {
1538
+ if (this.#socket === socket) this.#socket = void 0;
1539
+ this.#emitter.emit("close");
1540
+ });
1541
+ socket.addEventListener("error", () => {
1542
+ this.#emitter.emit("error", this.#refuse("failed"));
1543
+ });
1544
+ }
1545
+ #refuse(reason) {
1546
+ return new BrowserConnectionError(`Socket CDP connection to ${this.#url} ${reason}`, { url: this.#url });
1547
+ }
1548
+ };
1549
+ //#endregion
1550
+ //#region src/browser/factories.ts
1551
+ /**
1552
+ * Creates a view that reads and drives a DOM document without trusted input.
1553
+ *
1554
+ * @param options - The driven document, and `own` to admit the realm's own document
1555
+ * @returns A {@link BrowserDOMViewInterface} over the document's window
1556
+ * @throws Thrown when `document` is not a document attached to a window, with the code
1557
+ * `BROWSER_DOCUMENT`, and when it is `globalThis.document` without `own: true`, with the code
1558
+ * `BROWSER_DOCUMENT_OWN`.
1559
+ *
1560
+ * @example
1561
+ * ```ts
1562
+ * const frame = document.createElement('iframe')
1563
+ * document.body.append(frame)
1564
+ * const view = createBrowserDOMView({ document: frame.contentDocument })
1565
+ * await view.elements.outline() // { text: 'page "" about:blank\n(0 of 0 elements)', … }
1566
+ * ```
1567
+ */
1568
+ function createBrowserDOMView(options) {
1569
+ return new BrowserDOMView(options);
1570
+ }
1571
+ /**
1572
+ * Creates a `BrowserToolsetInterface` that publishes the five view tools over a DOM document and
1573
+ * adopts a source's page tools beside them.
1574
+ *
1575
+ * @remarks
1576
+ * The toolset runs over a view {@link createBrowserDOMView} creates for `document`, so it
1577
+ * advertises `look`, `read`, `click`, `type`, and `wait`, and a click or type receipt ends with
1578
+ * ` (untrusted event)`. `source` supplies page tools, which the toolset adopts during `start()`
1579
+ * and again on every `change`; `toolset.native` never includes them, so publishing it to a
1580
+ * registry leaves that registry's own tools alone. The view belongs to the toolset: `destroy()`
1581
+ * removes the toolset's tools, then destroys the view, which releases its listeners and fails
1582
+ * its pending waits, and a construction that throws destroys the view before it rethrows.
1583
+ * `journeys` adds `record`, `save`, `journeys`, `edit`, and `replay` at construction; a replay
1584
+ * runs the steps the document placement executes.
1585
+ *
1586
+ * @param options - The driven document, `own`, the page-tool source, the manager, the bound, the
1587
+ * journey stores, and the emitter hooks
1588
+ * @returns A {@link BrowserToolsetInterface} whose `view` drives the document
1589
+ * @throws Thrown when `document` is not a document attached to a window, with the code
1590
+ * `BROWSER_DOCUMENT`, when it is `globalThis.document` without `own: true`, with the code
1591
+ * `BROWSER_DOCUMENT_OWN`, and when `limit` is not a positive integer.
1592
+ *
1593
+ * @example
1594
+ * ```ts
1595
+ * const toolset = createDocumentToolset({ document: frame.contentDocument, source: bridge })
1596
+ * await toolset.start()
1597
+ * await toolset.tools.execute({ id: '1', name: 'look', arguments: { what: 'form' } })
1598
+ * ```
1599
+ */
1600
+ function createDocumentToolset(options) {
1601
+ const view = createBrowserDOMView({
1602
+ document: options.document,
1603
+ ...options.own === void 0 ? {} : { own: options.own }
1604
+ });
1605
+ try {
1606
+ return new BrowserToolset(view, {
1607
+ ...options.on === void 0 ? {} : { on: options.on },
1608
+ ...options.error === void 0 ? {} : { error: options.error },
1609
+ ...options.tools === void 0 ? {} : { tools: options.tools },
1610
+ ...options.source === void 0 ? {} : { source: options.source },
1611
+ ...options.limit === void 0 ? {} : { limit: options.limit },
1612
+ ...options.journeys === void 0 ? {} : { journeys: options.journeys },
1613
+ release: view.destroy.bind(view)
1614
+ });
1615
+ } catch (error) {
1616
+ view.destroy();
1617
+ throw error;
1618
+ }
1619
+ }
1620
+ /**
1621
+ * Creates a CDP transport over the browser's native `WebSocket`.
1622
+ *
1623
+ * @param options - The CDP WebSocket debugger URL, the opening deadline, and emitter hooks
1624
+ * @returns A {@link CDPTransportInterface} a `CDPClient` sends and receives frames over
1625
+ *
1626
+ * @example
1627
+ * ```ts
1628
+ * const client = createCDPClient({
1629
+ * transport: createSocketCDPTransport({ url: 'ws://127.0.0.1:9222/devtools/browser/abc' }),
1630
+ * })
1631
+ * await client.connect()
1632
+ * ```
1633
+ */
1634
+ function createSocketCDPTransport(options) {
1635
+ return new SocketCDPTransport(options);
1636
+ }
1637
+ //#endregion
1638
+ export { BROWSER_CONTENT_NAMED_ROLES, BROWSER_CONTEXT_TARGETS, BROWSER_DOCUMENT_TIMEOUT_MS, BROWSER_IMPLICIT_ROLES, BROWSER_INTERACTIVE_CONTENT, BROWSER_TYPED_INPUTS, BrowserDOMElement, BrowserDOMElementManager, BrowserDOMView, BrowserDOMWait, SocketCDPTransport, collectBrowserRoots, computeBrowserAlternative, computeBrowserName, computeBrowserRole, computeBrowserText, createBrowserDOMView, createDocumentToolset, createSocketCDPTransport, isBrowserDocument, listenBrowserNavigation, matchesBrowserActivation, matchesBrowserBlock, matchesBrowserHidden, matchesBrowserInvisible, matchesBrowserOmitted, matchesBrowserPopup, readBrowserBlock, readBrowserCapture, readBrowserContent, readBrowserParent, skipBrowserSubtree };
1639
+
1640
+ //# sourceMappingURL=index.js.map