@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.
- package/README.md +47 -28
- package/dist/bin/main.js +35 -0
- package/dist/bin/main.js.map +1 -0
- package/dist/src/browser/index.d.ts +841 -0
- package/dist/src/browser/index.js +1640 -0
- package/dist/src/browser/index.js.map +1 -0
- package/dist/src/core/index.cjs +8372 -2494
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +4410 -2110
- package/dist/src/core/index.d.ts +4410 -2110
- package/dist/src/core/index.js +8256 -2473
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +1013 -92
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +288 -39
- package/dist/src/server/index.d.ts +288 -39
- package/dist/src/server/index.js +975 -69
- package/dist/src/server/index.js.map +1 -1
- package/package.json +35 -12
|
@@ -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
|