@orkestrel/browser 0.0.29 → 0.0.31

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.
@@ -1,7 +1,7 @@
1
1
  import { attempt, isInteger, isJSONValue, isObject, isString } from "@orkestrel/contract";
2
- import { BROWSER_DEFAULT_TIMEOUT_MS, BROWSER_INTERACTIVE_ROLES, BROWSER_OUTLINE_LIMIT, BROWSER_REFERENCE_PREFIX, BROWSER_RESULT_LIMIT, BROWSER_WAIT_EVENTS, BrowserError, BrowserTransition, createBrowserReading, describeBrowserRefusal, filterBrowserOutline, normalizeBrowserName, parseBrowserReference, renderBrowserElement, renderBrowserOutline, validateBrowserTimeout } from "../core/index.js";
2
+ import { BROWSER_DEFAULT_TIMEOUT_MS, BROWSER_INTERACTIVE_ROLES, BROWSER_OUTLINE_LIMIT, BROWSER_REFERENCE_PREFIX, BROWSER_RESULT_LIMIT, BROWSER_WAIT_EVENTS, BrowserError, BrowserTransition, createBrowserContent, describeBrowserRefusal, filterBrowserOutline, normalizeBrowserName, parseBrowserReference, renderBrowserElement, renderBrowserOutline, validateBrowserTimeout } from "../core/index.js";
3
3
  import { Emitter } from "@orkestrel/emitter";
4
- //#region src/browser/constants.ts
4
+ // src/browser/constants.ts
5
5
  /**
6
6
  * Sets the default `wait` deadline for DOM waits in a browser document toolset, `5_000`
7
7
  * milliseconds.
@@ -180,8 +180,87 @@ var BROWSER_TYPED_INPUTS = Object.freeze(/* @__PURE__ */ new Set([
180
180
  * `controls`, `input` other than `hidden`, `label`, `select`, and `textarea`.
181
181
  */
182
182
  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]";
183
- //#endregion
184
- //#region src/browser/helpers.ts
183
+ // src/browser/BrowserAncestors.ts
184
+ /** Traverses ancestors lazily while retaining the previously yielded node. */
185
+ var BrowserAncestors = class extends Iterator {
186
+ #parent;
187
+ #current;
188
+ #previous;
189
+ #started = false;
190
+ constructor(node, parent) {
191
+ super();
192
+ this.#current = node;
193
+ this.#previous = node;
194
+ this.#parent = parent;
195
+ }
196
+ next() {
197
+ if (this.#started && this.#current !== null) {
198
+ this.#previous = this.#current;
199
+ this.#current = this.#parent(this.#current);
200
+ }
201
+ this.#started = true;
202
+ return this.#current === null ? {
203
+ done: true,
204
+ value: void 0
205
+ } : {
206
+ done: false,
207
+ value: this.#current
208
+ };
209
+ }
210
+ get previous() {
211
+ return this.#previous;
212
+ }
213
+ };
214
+ // src/browser/BrowserCaptureSiblings.ts
215
+ /** Traverses paired live and inert siblings while preserving pointers across pruning. */
216
+ var BrowserCaptureSiblings = class extends Iterator {
217
+ #element;
218
+ #counterpart;
219
+ #child;
220
+ #mirror;
221
+ #previous;
222
+ constructor(live, twin) {
223
+ super();
224
+ this.#element = live.lastElementChild;
225
+ this.#counterpart = twin.lastElementChild;
226
+ this.#child = live.lastChild;
227
+ this.#mirror = twin.lastChild;
228
+ }
229
+ next() {
230
+ if (this.#previous !== void 0) {
231
+ if (this.#child?.nodeType === 1) {
232
+ this.#element = this.#previous.element;
233
+ this.#counterpart = this.#previous.counterpart;
234
+ }
235
+ this.#child = this.#previous.child;
236
+ this.#mirror = this.#previous.mirror;
237
+ }
238
+ if (this.#child === null || this.#mirror === null) {
239
+ this.#previous = void 0;
240
+ this.#child = null;
241
+ return {
242
+ done: true,
243
+ value: void 0
244
+ };
245
+ }
246
+ this.#previous = {
247
+ child: this.#child.previousSibling,
248
+ mirror: this.#mirror.previousSibling,
249
+ element: this.#element?.previousElementSibling ?? null,
250
+ counterpart: this.#counterpart?.previousElementSibling ?? null
251
+ };
252
+ return {
253
+ done: false,
254
+ value: {
255
+ child: this.#child,
256
+ mirror: this.#mirror,
257
+ element: this.#element,
258
+ counterpart: this.#counterpart
259
+ }
260
+ };
261
+ }
262
+ };
263
+ // src/browser/helpers.ts
185
264
  /**
186
265
  * Narrows a value to a `Document` attached to a window.
187
266
  *
@@ -198,8 +277,8 @@ function isBrowserDocument(value) {
198
277
  * @remarks
199
278
  * The measure is the length of `JSON.stringify({ url, title, html })`, the form the CDP capture
200
279
  * guard compares with `BROWSER_RESULT_LIMIT`, so a character JSON escapes counts as its escape
201
- * sequence. A reading a caller builds with `createBrowserReading` passes through no limit.
202
- * {@link BrowserReadingInput} defines the inert capture, lowering, redaction, and limits.
280
+ * sequence. A reading a caller builds with `createBrowserContent` passes through no limit.
281
+ * {@link BrowserContentInput} defines the inert capture, lowering, redaction, and limits.
203
282
  *
204
283
  * @param node - The root whose rendered markup is captured; its owner document supplies `URL`
205
284
  * and `title`
@@ -217,15 +296,19 @@ function readBrowserCapture(node) {
217
296
  const view = document.defaultView;
218
297
  const rendered = view !== null && node.isConnected;
219
298
  const inert = document.implementation.createHTMLDocument("");
220
- let root = inert.importNode(node, true);
299
+ const root = inert.importNode(node, true);
300
+ const output = inert.createDocumentFragment();
301
+ output.append(root);
221
302
  const pending = [{
222
303
  live: node,
223
304
  twin: root,
224
305
  visited: false
225
306
  }];
226
307
  if (rendered) {
227
- let child = node;
228
- for (let parent = node; parent?.ownerDocument === document; parent = readBrowserParent(parent)) {
308
+ const ancestors = new BrowserAncestors(node, readBrowserParent);
309
+ for (const parent of ancestors) {
310
+ if (parent.ownerDocument !== document) break;
311
+ const child = ancestors.previous ?? node;
229
312
  const style = view.getComputedStyle(parent);
230
313
  const outside = parent !== node && (style.contentVisibility === "hidden" || [
231
314
  "canvas",
@@ -249,11 +332,10 @@ function readBrowserCapture(node) {
249
332
  ].includes(parent.localName) || parent.localName === "details" && !parent.hasAttribute("open") && child !== parent.querySelector(":scope > summary"));
250
333
  const unassigned = child.parentElement?.shadowRoot !== null && child.parentElement?.shadowRoot !== void 0 && child.assignedSlot === null;
251
334
  if (style.display === "none" || outside || unassigned) {
252
- root = void 0;
335
+ root.remove();
253
336
  pending.length = 0;
254
337
  break;
255
338
  }
256
- child = parent;
257
339
  }
258
340
  }
259
341
  while (pending.length > 0) {
@@ -279,14 +361,12 @@ function readBrowserCapture(node) {
279
361
  "math"
280
362
  ].includes(tag) || tag === "input" && ["password", "hidden"].includes(input)) {
281
363
  twin.remove();
282
- if (twin === root) root = void 0;
283
364
  continue;
284
365
  }
285
366
  const style = entry.style ?? (rendered ? view.getComputedStyle(live) : void 0);
286
367
  const invisible = style?.visibility === "hidden" || style?.visibility === "collapse";
287
368
  if (style?.display === "none" || invisible && !live.hasChildNodes()) {
288
369
  twin.remove();
289
- if (twin === root) root = void 0;
290
370
  continue;
291
371
  }
292
372
  if (!visited) {
@@ -313,35 +393,13 @@ function readBrowserCapture(node) {
313
393
  const summary = tag === "details" && !live.hasAttribute("open") ? live.querySelector(":scope > summary") : void 0;
314
394
  const conditional = tag === "switch" && live.namespaceURI === "http://www.w3.org/2000/svg";
315
395
  const branch = rendered && conditional ? Array.from(live.children).find((candidate) => candidate.getClientRects().length > 0) ?? live.firstElementChild : void 0;
316
- let element = live.lastElementChild;
317
- let counterpart = twin.lastElementChild;
318
- let child = live.lastChild;
319
- let mirror = twin.lastChild;
320
- while (child !== null && mirror !== null) {
321
- const previous = child.previousSibling;
322
- const previousMirror = mirror.previousSibling;
323
- const previousElement = element?.previousElementSibling ?? null;
324
- const previousCounterpart = counterpart?.previousElementSibling ?? null;
325
- let omitted = rendered && (summary !== void 0 && child !== summary || conditional && child !== branch);
326
- if (rendered && live.shadowRoot !== null) {
327
- const slot = child instanceof view.Element || child instanceof view.Text ? child.assignedSlot : null;
328
- if (slot === null) omitted = true;
329
- else for (let parent = slot; parent !== null && parent !== live; parent = readBrowserParent(parent)) if (view.getComputedStyle(parent).display === "none") omitted = true;
330
- }
331
- if (omitted || child.nodeType === 3 && invisible) mirror.parentNode?.removeChild(mirror);
332
- else if (child.nodeType === 1) {
333
- if (element !== null && counterpart !== null) pending.push({
334
- live: element,
335
- twin: counterpart,
336
- visited: false
337
- });
338
- }
339
- if (child.nodeType === 1) {
340
- element = previousElement;
341
- counterpart = previousCounterpart;
342
- }
343
- child = previous;
344
- mirror = previousMirror;
396
+ for (const { child, mirror, element, counterpart } of new BrowserCaptureSiblings(live, twin)) if (readBrowserCaptureOmission(live, child, view, rendered, rendered && (summary !== void 0 && child !== summary || conditional && child !== branch)) || child.nodeType === 3 && invisible) mirror.parentNode?.removeChild(mirror);
397
+ else if (child.nodeType === 1) {
398
+ if (element !== null && counterpart !== null) pending.push({
399
+ live: element,
400
+ twin: counterpart,
401
+ visited: false
402
+ });
345
403
  }
346
404
  continue;
347
405
  }
@@ -354,82 +412,10 @@ function readBrowserCapture(node) {
354
412
  twin.prepend(inert.createElement("br"));
355
413
  twin.append(inert.createElement("br"));
356
414
  }
357
- let fragments;
358
- let lines;
359
- let container = false;
360
- if (live instanceof view.HTMLInputElement) {
361
- lines = [];
362
- if ([
363
- "text",
364
- "email",
365
- "search",
366
- "tel",
367
- "url",
368
- "number"
369
- ].includes(live.type)) {
370
- if (live.type !== "number" || !live.validity.badInput) {
371
- const placeholder = view.getComputedStyle(live, "::placeholder");
372
- lines = [live.value || (live.matches(":placeholder-shown") && placeholder.visibility === "visible" && placeholder.opacity !== "0" && placeholder.color !== "rgba(0, 0, 0, 0)" ? live.placeholder : "")];
373
- }
374
- } else if ([
375
- "submit",
376
- "reset",
377
- "button"
378
- ].includes(live.type)) lines = [live.value];
379
- else if (live.type === "file" && live.files?.length === 1) lines = Array.from(live.files ?? [], (file) => file.name);
380
- else if (live.type === "image" && live.getAttribute("aria-hidden") !== "true") lines = [live.getAttribute("aria-label")?.trim() || live.alt];
381
- } else if (live instanceof view.HTMLTextAreaElement) {
382
- const placeholder = view.getComputedStyle(live, "::placeholder");
383
- lines = (live.value || (live.matches(":placeholder-shown") && placeholder.visibility === "visible" && placeholder.opacity !== "0" && placeholder.color !== "rgba(0, 0, 0, 0)" ? live.placeholder : "")).split(/\r?\n/);
384
- } else if (live instanceof view.HTMLSelectElement || live instanceof view.HTMLOptionElement || live instanceof view.HTMLOptGroupElement) {
385
- lines = [];
386
- const select = live instanceof view.HTMLSelectElement ? live : live.closest("select");
387
- if (select !== null) {
388
- if (!select.multiple && select.size <= 1) {
389
- const option = select.selectedOptions[0];
390
- if (option !== void 0 && (live === select || live === option || live.contains(option))) lines.push(option.label);
391
- } else {
392
- const box = select.getBoundingClientRect();
393
- const top = box.top + select.clientTop;
394
- const bottom = top + select.clientHeight;
395
- const rows = live === select ? select.querySelectorAll("option,optgroup") : [live, ...live.querySelectorAll("option")];
396
- for (const row of rows) {
397
- const rowStyle = view.getComputedStyle(row);
398
- const groupStyle = row.parentElement instanceof view.HTMLOptGroupElement ? view.getComputedStyle(row.parentElement) : void 0;
399
- if (rowStyle.display === "none" || rowStyle.visibility !== "visible" || groupStyle?.display === "none") continue;
400
- const rect = row.getBoundingClientRect();
401
- if ((row instanceof view.HTMLOptGroupElement ? Array.from(row.querySelectorAll("option")).find((option) => option.getClientRects().length > 0)?.getBoundingClientRect().top ?? rect.bottom : rect.bottom) > top && rect.top < bottom && rect.right > box.left + select.clientLeft && rect.left < box.left + select.clientLeft + select.clientWidth && (row instanceof view.HTMLOptionElement || row instanceof view.HTMLOptGroupElement)) lines.push(row.label);
402
- }
403
- }
404
- }
405
- } else if (tag === "svg") {
406
- if (twin.parentElement?.closest("svg, foreignObject")?.localName === "svg") continue;
407
- lines = [];
408
- fragments = [];
409
- for (const content of twin.querySelectorAll("text, foreignObject")) {
410
- if (content.parentElement?.closest("svg, foreignObject")?.localName === "foreignObject") continue;
411
- if (content.localName === "text" && content.parentElement?.closest("text")?.closest("svg") === content.closest("svg")) continue;
412
- if (content.localName === "foreignObject") fragments.push(...content.childNodes);
413
- else if (content.textContent) fragments.push(inert.createTextNode(content.textContent));
414
- }
415
- if (fragments.length === 0) fragments = void 0;
416
- if (!invisible && fragments === void 0 && live.getAttribute("aria-hidden") !== "true") lines.push(live.getAttribute("aria-label")?.trim() || live.querySelector(":scope > title")?.textContent || "");
417
- } else if ([
418
- "form",
419
- "dialog",
420
- "button"
421
- ].includes(tag)) {
422
- container = true;
423
- if (live instanceof view.HTMLButtonElement && !invisible && live.innerText.trim() === "") {
424
- const name = live.getAttribute("aria-label")?.trim();
425
- if (name) {
426
- lines = [name];
427
- container = false;
428
- }
429
- }
430
- }
431
- if (lines === void 0 && !container) continue;
432
- if (invisible && !container && tag !== "svg") lines = [];
415
+ const value = readBrowserCaptureValue(live, twin, view, inert, invisible, tag);
416
+ if (value === void 0) continue;
417
+ const { fragments, container } = value;
418
+ const lines = invisible && !container && tag !== "svg" ? [] : value.lines;
433
419
  const block = !style?.display.startsWith("inline") && style?.display !== "contents";
434
420
  const carrier = inert.createElement(block ? "div" : "span");
435
421
  if (container) carrier.append(...twin.childNodes);
@@ -443,10 +429,9 @@ function readBrowserCapture(node) {
443
429
  carrier.append(inert.createElement("br"));
444
430
  }
445
431
  twin.replaceWith(carrier);
446
- if (twin === root) root = carrier;
447
432
  }
448
- for (const input of root?.querySelectorAll("input[type=\"password\" i],input[type=\"hidden\" i]") ?? []) input.remove();
449
- capture.html = root?.outerHTML ?? "";
433
+ for (const input of output.firstElementChild?.querySelectorAll("input[type=\"password\" i],input[type=\"hidden\" i]") ?? []) input.remove();
434
+ capture.html = output.firstElementChild?.outerHTML ?? "";
450
435
  const length = JSON.stringify(capture).length;
451
436
  if (length > BROWSER_RESULT_LIMIT) throw new BrowserError("RESULT_LIMIT", "Document capture exceeds BROWSER_RESULT_LIMIT", {
452
437
  length,
@@ -605,16 +590,17 @@ function computeBrowserText(root, context) {
605
590
  function readBrowserContent(root, context) {
606
591
  const view = root.ownerDocument?.defaultView ?? null;
607
592
  const shadow = view !== null && root instanceof view.Element ? root.shadowRoot : null;
608
- const children = shadow !== null ? Array.from(shadow.childNodes) : view !== null && root instanceof view.HTMLSlotElement ? root.assignedNodes({ flatten: true }) : Array.from(root.childNodes);
609
- let text = "";
610
- let previous = false;
611
- for (const child of children) {
593
+ return (shadow !== null ? Array.from(shadow.childNodes) : view !== null && root instanceof view.HTMLSlotElement ? root.assignedNodes({ flatten: true }) : Array.from(root.childNodes)).reduce((state, child) => {
612
594
  const piece = computeBrowserAlternative(child, context);
613
595
  const block = view !== null && child instanceof view.Element && matchesBrowserBlock(child);
614
- if (piece !== "") text += text !== "" && (block || previous) ? ` ${piece}` : piece;
615
- previous = block;
616
- }
617
- return text;
596
+ return {
597
+ text: piece === "" ? state.text : state.text + (state.text !== "" && (block || state.previous) ? ` ${piece}` : piece),
598
+ previous: block
599
+ };
600
+ }, {
601
+ text: "",
602
+ previous: false
603
+ }).text;
618
604
  }
619
605
  /**
620
606
  * Computes the text one node contributes to an accessible name: its own text alternative, or its
@@ -734,7 +720,7 @@ function matchesBrowserInvisible(element) {
734
720
  * ```
735
721
  */
736
722
  function matchesBrowserOmitted(element) {
737
- for (let current = element; current !== null; current = readBrowserParent(current)) if (matchesBrowserHidden(current)) return true;
723
+ for (const current of new BrowserAncestors(element, readBrowserParent)) if (matchesBrowserHidden(current)) return true;
738
724
  return false;
739
725
  }
740
726
  /**
@@ -793,7 +779,9 @@ function matchesBrowserBlock(element) {
793
779
  function collectBrowserRoots(root) {
794
780
  const roots = [root.getRootNode()];
795
781
  const queue = [root];
796
- for (let current = queue.shift(); current !== void 0; current = queue.shift()) {
782
+ while (queue.length > 0) {
783
+ const current = queue.shift();
784
+ if (current === void 0) continue;
797
785
  const view = (isBrowserDocument(current) ? current : current.ownerDocument)?.defaultView ?? null;
798
786
  const own = view !== null && current instanceof view.Element ? [current] : [];
799
787
  for (const element of [...own, ...current.querySelectorAll("*")]) {
@@ -842,20 +830,15 @@ function matchesBrowserPopup(element) {
842
830
  const type = Reflect.get(element, "type");
843
831
  const submitter = tag === "button" && type === "submit" || tag === "input" && (type === "submit" || type === "image");
844
832
  const form = tag === "form" ? element : Reflect.get(element, "form");
845
- let target;
846
- if ((tag === "a" || tag === "area") && element.hasAttribute("href")) target = element.getAttribute("target") ?? base;
847
- else if (view !== null && form instanceof view.HTMLFormElement && (tag === "form" || submitter)) target = (submitter ? element.getAttribute("formtarget") : null) ?? form.getAttribute("target") ?? base;
833
+ const target = (tag === "a" || tag === "area") && element.hasAttribute("href") ? element.getAttribute("target") ?? base : view !== null && form instanceof view.HTMLFormElement && (tag === "form" || submitter) ? (submitter ? element.getAttribute("formtarget") : null) ?? form.getAttribute("target") ?? base : void 0;
848
834
  if (target === void 0) return false;
849
835
  const keyword = target.trim().toLowerCase();
850
836
  if (BROWSER_CONTEXT_TARGETS.has(keyword)) return false;
851
837
  if (keyword === "_blank") return true;
852
838
  const selector = `iframe[name="${CSS.escape(target)}"], frame[name="${CSS.escape(target)}"]`;
853
- let current = view;
854
- while (current !== null) {
855
- const frame = current;
839
+ for (const frame of new BrowserAncestors(view, (entry) => entry.parent === entry ? null : entry.parent)) {
856
840
  const found = attempt(() => frame.name === target || frame.document.querySelector(selector) !== null);
857
841
  if (found.success && found.value) return false;
858
- current = frame.parent === frame ? null : frame.parent;
859
842
  }
860
843
  return true;
861
844
  }
@@ -881,7 +864,10 @@ function matchesBrowserPopup(element) {
881
864
  * ```
882
865
  */
883
866
  function matchesBrowserActivation(target, label) {
884
- 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;
867
+ for (const current of new BrowserAncestors(target, (entry) => entry.parentElement)) {
868
+ if (current === label) break;
869
+ 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;
870
+ }
885
871
  return true;
886
872
  }
887
873
  /**
@@ -898,12 +884,11 @@ function matchesBrowserActivation(target, label) {
898
884
  * ```
899
885
  */
900
886
  function skipBrowserSubtree(walker) {
901
- let node = walker.nextSibling();
902
- while (node === null) {
887
+ while (true) {
888
+ const node = walker.nextSibling();
889
+ if (node !== null) return node;
903
890
  if (walker.parentNode() === null) return null;
904
- node = walker.nextSibling();
905
891
  }
906
- return node;
907
892
  }
908
893
  /**
909
894
  * Reads the nearest block container of an element, the unit that separates runs of text.
@@ -923,10 +908,9 @@ function skipBrowserSubtree(walker) {
923
908
  * ```
924
909
  */
925
910
  function readBrowserBlock(element, root) {
926
- let current = element;
927
- while (current !== null && current !== root) {
911
+ for (const current of new BrowserAncestors(element, (entry) => entry.parentElement)) {
912
+ if (current === root) break;
928
913
  if (matchesBrowserBlock(current)) return current;
929
- current = current.parentElement;
930
914
  }
931
915
  return root;
932
916
  }
@@ -1011,8 +995,100 @@ function readBrowserStates(element, role) {
1011
995
  }
1012
996
  return states;
1013
997
  }
1014
- //#endregion
1015
- //#region src/browser/elements/BrowserDOMElement.ts
998
+ /** Derives rendered form values and SVG fragments for an inert capture. */
999
+ function readBrowserCaptureValue(live, twin, view, inert, invisible, tag) {
1000
+ if (live instanceof view.HTMLInputElement) {
1001
+ if ([
1002
+ "text",
1003
+ "email",
1004
+ "search",
1005
+ "tel",
1006
+ "url",
1007
+ "number"
1008
+ ].includes(live.type)) {
1009
+ if (live.type !== "number" || !live.validity.badInput) {
1010
+ const placeholder = view.getComputedStyle(live, "::placeholder");
1011
+ return { lines: [live.value || (live.matches(":placeholder-shown") && placeholder.visibility === "visible" && placeholder.opacity !== "0" && placeholder.color !== "rgba(0, 0, 0, 0)" ? live.placeholder : "")] };
1012
+ }
1013
+ } else if ([
1014
+ "submit",
1015
+ "reset",
1016
+ "button"
1017
+ ].includes(live.type)) return { lines: [live.value] };
1018
+ else if (live.type === "file" && live.files?.length === 1) return { lines: Array.from(live.files ?? [], (file) => file.name) };
1019
+ else if (live.type === "image" && live.getAttribute("aria-hidden") !== "true") return { lines: [live.getAttribute("aria-label")?.trim() || live.alt] };
1020
+ return { lines: [] };
1021
+ } else if (live instanceof view.HTMLTextAreaElement) {
1022
+ const placeholder = view.getComputedStyle(live, "::placeholder");
1023
+ return { lines: (live.value || (live.matches(":placeholder-shown") && placeholder.visibility === "visible" && placeholder.opacity !== "0" && placeholder.color !== "rgba(0, 0, 0, 0)" ? live.placeholder : "")).split(/\r?\n/) };
1024
+ } else if (live instanceof view.HTMLSelectElement || live instanceof view.HTMLOptionElement || live instanceof view.HTMLOptGroupElement) {
1025
+ const lines = [];
1026
+ const select = live instanceof view.HTMLSelectElement ? live : live.closest("select");
1027
+ if (select !== null) {
1028
+ if (!select.multiple && select.size <= 1) {
1029
+ const option = select.selectedOptions[0];
1030
+ if (option !== void 0 && (live === select || live === option || live.contains(option))) lines.push(option.label);
1031
+ } else {
1032
+ const box = select.getBoundingClientRect();
1033
+ const top = box.top + select.clientTop;
1034
+ const bottom = top + select.clientHeight;
1035
+ const rows = live === select ? select.querySelectorAll("option,optgroup") : [live, ...live.querySelectorAll("option")];
1036
+ for (const row of rows) {
1037
+ const rowStyle = view.getComputedStyle(row);
1038
+ const groupStyle = row.parentElement instanceof view.HTMLOptGroupElement ? view.getComputedStyle(row.parentElement) : void 0;
1039
+ if (rowStyle.display === "none" || rowStyle.visibility !== "visible" || groupStyle?.display === "none") continue;
1040
+ const rect = row.getBoundingClientRect();
1041
+ if ((row instanceof view.HTMLOptGroupElement ? Array.from(row.querySelectorAll("option")).find((option) => option.getClientRects().length > 0)?.getBoundingClientRect().top ?? rect.bottom : rect.bottom) > top && rect.top < bottom && rect.right > box.left + select.clientLeft && rect.left < box.left + select.clientLeft + select.clientWidth && (row instanceof view.HTMLOptionElement || row instanceof view.HTMLOptGroupElement)) lines.push(row.label);
1042
+ }
1043
+ }
1044
+ }
1045
+ return { lines };
1046
+ } else if (tag === "svg") {
1047
+ if (twin.parentElement?.closest("svg, foreignObject")?.localName === "svg") return void 0;
1048
+ const lines = [];
1049
+ const fragments = [];
1050
+ for (const content of twin.querySelectorAll("text, foreignObject")) {
1051
+ if (content.parentElement?.closest("svg, foreignObject")?.localName === "foreignObject") continue;
1052
+ if (content.localName === "text" && content.parentElement?.closest("text")?.closest("svg") === content.closest("svg")) continue;
1053
+ if (content.localName === "foreignObject") fragments.push(...content.childNodes);
1054
+ else if (content.textContent) fragments.push(inert.createTextNode(content.textContent));
1055
+ }
1056
+ if (!invisible && fragments.length === 0 && live.getAttribute("aria-hidden") !== "true") lines.push(live.getAttribute("aria-label")?.trim() || live.querySelector(":scope > title")?.textContent || "");
1057
+ return {
1058
+ lines,
1059
+ ...fragments.length === 0 ? {} : { fragments }
1060
+ };
1061
+ } else if ([
1062
+ "form",
1063
+ "dialog",
1064
+ "button"
1065
+ ].includes(tag)) {
1066
+ if (live instanceof view.HTMLButtonElement && !invisible && live.innerText.trim() === "") {
1067
+ const name = live.getAttribute("aria-label")?.trim();
1068
+ if (name) return { lines: [name] };
1069
+ }
1070
+ return { container: true };
1071
+ }
1072
+ }
1073
+ /** Checks shadow-slot ancestors for capture omission without skipping style reads. */
1074
+ function readBrowserCaptureOmission(live, child, view, rendered, excluded) {
1075
+ if (!rendered || live.shadowRoot === null || view === null) return excluded;
1076
+ const slot = child instanceof view.Element || child instanceof view.Text ? child.assignedSlot : null;
1077
+ if (slot === null) return true;
1078
+ return new BrowserAncestors(slot, (entry) => entry === live ? null : readBrowserParent(entry)).reduce((omitted, parent) => parent === live ? omitted : view.getComputedStyle(parent).display === "none" || omitted, excluded);
1079
+ }
1080
+ // src/browser/BrowserDOMSubmission.ts
1081
+ /** Observes whether one form submission dispatches its submit event. */
1082
+ var BrowserDOMSubmission = class {
1083
+ #submitted = false;
1084
+ get submitted() {
1085
+ return this.#submitted;
1086
+ }
1087
+ handleEvent() {
1088
+ this.#submitted = true;
1089
+ }
1090
+ };
1091
+ // src/browser/elements/BrowserDOMElement.ts
1016
1092
  /**
1017
1093
  * Drives a referenced element of a DOM document without trusted input.
1018
1094
  *
@@ -1122,7 +1198,7 @@ var BrowserDOMElement = class {
1122
1198
  }
1123
1199
  async read(options) {
1124
1200
  const [node] = this.#current(options);
1125
- return createBrowserReading({
1201
+ return createBrowserContent({
1126
1202
  ...readBrowserCapture(node),
1127
1203
  navigation: this.#input.navigation
1128
1204
  });
@@ -1144,17 +1220,15 @@ var BrowserDOMElement = class {
1144
1220
  reference: this.reference,
1145
1221
  reason: "UNTRUSTED"
1146
1222
  });
1147
- let submitted = false;
1223
+ const submission = new BrowserDOMSubmission();
1148
1224
  const release = new AbortController();
1149
- owner.addEventListener("submit", () => {
1150
- submitted = true;
1151
- }, { signal: release.signal });
1225
+ owner.addEventListener("submit", submission, { signal: release.signal });
1152
1226
  try {
1153
1227
  owner.requestSubmit(submitter ?? null);
1154
1228
  } finally {
1155
1229
  release.abort();
1156
1230
  }
1157
- if (submitted) return;
1231
+ if (submission.submitted) return;
1158
1232
  const invalid = Array.from(owner.elements).find((control) => control.localName !== "fieldset" && control.matches(":invalid"));
1159
1233
  if (invalid === void 0) throw new BrowserError("DOCUMENT_SUBMIT", "did not submit", { reference: this.reference });
1160
1234
  const field = computeBrowserName(invalid) || (invalid.getAttribute("name") ?? invalid.localName);
@@ -1200,8 +1274,118 @@ var BrowserDOMElement = class {
1200
1274
  });
1201
1275
  }
1202
1276
  };
1203
- //#endregion
1204
- //#region src/browser/BrowserDOMWait.ts
1277
+ // src/browser/BrowserDOMOutline.ts
1278
+ /** Captures one flat-tree subtree while retaining its text and visibility state. */
1279
+ var BrowserDOMOutline = class {
1280
+ #input;
1281
+ #text = "";
1282
+ #block;
1283
+ #silenced;
1284
+ #node = null;
1285
+ constructor(input) {
1286
+ this.#input = input;
1287
+ }
1288
+ capture() {
1289
+ const { root, rows, muted } = this.#input;
1290
+ this.#text = "";
1291
+ this.#block = void 0;
1292
+ this.#silenced = void 0;
1293
+ const document = root.ownerDocument;
1294
+ const view = document.defaultView;
1295
+ if (view === null) return;
1296
+ if (root instanceof view.Element && matchesBrowserHidden(root)) return;
1297
+ this.#input.watch(document);
1298
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, (node) => node instanceof view.Element && matchesBrowserHidden(node) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT);
1299
+ this.#node = root instanceof view.Element ? root : walker.nextNode();
1300
+ while (this.#node !== null) {
1301
+ if (this.#silenced !== void 0 && !this.#silenced.contains(this.#node)) this.#silenced = void 0;
1302
+ const quiet = muted || this.#silenced !== void 0;
1303
+ if (!(this.#node instanceof view.Element)) {
1304
+ const parent = this.#node.parentElement;
1305
+ const data = this.#node.textContent ?? "";
1306
+ const blank = data.trim() === "";
1307
+ if (!quiet && (!blank || this.#text !== "") && (parent === null || !matchesBrowserInvisible(parent))) {
1308
+ const container = readBrowserBlock(parent, root);
1309
+ if (!blank) {
1310
+ if (this.#block !== void 0 && this.#block !== container) this.#text = this.#flush(rows, this.#text, this.#block);
1311
+ this.#text += data;
1312
+ this.#block = container;
1313
+ } else if (container === this.#block) this.#text += data;
1314
+ }
1315
+ this.#node = walker.nextNode();
1316
+ continue;
1317
+ }
1318
+ const invisible = matchesBrowserInvisible(this.#node);
1319
+ if (this.#node.localName === "br") {
1320
+ if (!quiet && this.#text !== "") this.#text += "\n";
1321
+ this.#node = walker.nextNode();
1322
+ continue;
1323
+ }
1324
+ if (this.#node instanceof view.HTMLIFrameElement) {
1325
+ this.#text = this.#flush(rows, this.#text, this.#block);
1326
+ const child = this.#node.contentDocument;
1327
+ if (invisible) {
1328
+ this.#node = skipBrowserSubtree(walker);
1329
+ continue;
1330
+ }
1331
+ if (child === null) this.#input.text(rows, `iframe ${JSON.stringify(computeBrowserName(this.#node))} (cross-origin, not readable)`);
1332
+ else this.#input.walk(child.documentElement, rows, quiet);
1333
+ this.#node = skipBrowserSubtree(walker);
1334
+ continue;
1335
+ }
1336
+ if (this.#node instanceof view.HTMLSlotElement) {
1337
+ this.#text = this.#flush(rows, this.#text, this.#block);
1338
+ for (const assigned of this.#node.assignedNodes({ flatten: true })) if (assigned instanceof view.Element) this.#input.walk(assigned, rows, quiet);
1339
+ else if (!quiet) this.#flush(rows, assigned.textContent ?? "");
1340
+ this.#node = skipBrowserSubtree(walker);
1341
+ continue;
1342
+ }
1343
+ const tool = this.#node.localName === "form" ? this.#node.getAttribute("toolname") ?? "" : "";
1344
+ const role = tool === "" ? computeBrowserRole(this.#node) : "form";
1345
+ if (!invisible && role !== void 0 && (BROWSER_INTERACTIVE_ROLES.has(role) || [
1346
+ "heading",
1347
+ "image",
1348
+ "img",
1349
+ "listitem",
1350
+ "row",
1351
+ "cell",
1352
+ "gridcell",
1353
+ "columnheader",
1354
+ "rowheader"
1355
+ ].includes(role) || tool !== "")) {
1356
+ this.#text = this.#flush(rows, this.#text, this.#block);
1357
+ const row = this.#input.row(this.#node, role, view, String(rows.length));
1358
+ rows.push(row);
1359
+ this.#input.positions.set(this.#node, row.id);
1360
+ if (this.#silenced === void 0 && BROWSER_CONTENT_NAMED_ROLES.has(role) && ![
1361
+ "row",
1362
+ "cell",
1363
+ "gridcell",
1364
+ "columnheader",
1365
+ "rowheader"
1366
+ ].includes(role)) this.#silenced = this.#node;
1367
+ if (this.#node.localName === "textarea") {
1368
+ this.#node = skipBrowserSubtree(walker);
1369
+ continue;
1370
+ }
1371
+ }
1372
+ const shadow = this.#node.shadowRoot;
1373
+ if (shadow !== null) {
1374
+ this.#text = this.#flush(rows, this.#text, this.#block);
1375
+ this.#input.walk(shadow, rows, muted || this.#silenced !== void 0);
1376
+ this.#node = skipBrowserSubtree(walker);
1377
+ continue;
1378
+ }
1379
+ this.#node = walker.nextNode();
1380
+ }
1381
+ this.#flush(rows, this.#text, this.#block);
1382
+ }
1383
+ #flush(rows, text, parent) {
1384
+ if (normalizeBrowserName(text) !== "") this.#input.text(rows, text, parent);
1385
+ return "";
1386
+ }
1387
+ };
1388
+ // src/browser/BrowserDOMWait.ts
1205
1389
  /**
1206
1390
  * Parks one condition on DOM mutations and finished transitions and animations until it holds, with one deadline and coalesced wake tasks.
1207
1391
  *
@@ -1367,8 +1551,7 @@ var BrowserDOMWait = class {
1367
1551
  this.#observers.clear();
1368
1552
  }
1369
1553
  };
1370
- //#endregion
1371
- //#region src/browser/elements/BrowserDOMElementManager.ts
1554
+ // src/browser/elements/BrowserDOMElementManager.ts
1372
1555
  /**
1373
1556
  * Outlines a DOM document from its elements and binds stable references to the interactive ones.
1374
1557
  *
@@ -1497,98 +1680,16 @@ var BrowserDOMElementManager = class {
1497
1680
  return collectBrowserRoots(this.#root(this.#input.document(), within));
1498
1681
  }
1499
1682
  #walk(root, rows, muted) {
1500
- const document = root.ownerDocument;
1501
- const view = document.defaultView;
1502
- if (view === null) return;
1503
- if (root instanceof view.Element && matchesBrowserHidden(root)) return;
1504
- this.#watch(document);
1505
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, (node) => node instanceof view.Element && matchesBrowserHidden(node) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT);
1506
- let text = "";
1507
- let block;
1508
- let silenced;
1509
- let node = root instanceof view.Element ? root : walker.nextNode();
1510
- while (node !== null) {
1511
- if (silenced !== void 0 && !silenced.contains(node)) silenced = void 0;
1512
- const quiet = muted || silenced !== void 0;
1513
- if (!(node instanceof view.Element)) {
1514
- const parent = node.parentElement;
1515
- const data = node.textContent ?? "";
1516
- const blank = data.trim() === "";
1517
- if (!quiet && (!blank || text !== "") && (parent === null || !matchesBrowserInvisible(parent))) {
1518
- const container = readBrowserBlock(parent, root);
1519
- if (!blank) {
1520
- if (block !== void 0 && block !== container) text = this.#flush(rows, text, block);
1521
- text += data;
1522
- block = container;
1523
- } else if (container === block) text += data;
1524
- }
1525
- node = walker.nextNode();
1526
- continue;
1527
- }
1528
- const invisible = matchesBrowserInvisible(node);
1529
- if (node.localName === "br") {
1530
- if (!quiet && text !== "") text += "\n";
1531
- node = walker.nextNode();
1532
- continue;
1533
- }
1534
- if (node instanceof view.HTMLIFrameElement) {
1535
- text = this.#flush(rows, text, block);
1536
- const child = node.contentDocument;
1537
- if (invisible) {
1538
- node = skipBrowserSubtree(walker);
1539
- continue;
1540
- }
1541
- if (child === null) this.#text(rows, `iframe ${JSON.stringify(computeBrowserName(node))} (cross-origin, not readable)`);
1542
- else this.#walk(child.documentElement, rows, quiet);
1543
- node = skipBrowserSubtree(walker);
1544
- continue;
1545
- }
1546
- if (node instanceof view.HTMLSlotElement) {
1547
- text = this.#flush(rows, text, block);
1548
- for (const assigned of node.assignedNodes({ flatten: true })) if (assigned instanceof view.Element) this.#walk(assigned, rows, quiet);
1549
- else if (!quiet) this.#flush(rows, assigned.textContent ?? "");
1550
- node = skipBrowserSubtree(walker);
1551
- continue;
1552
- }
1553
- const tool = node.localName === "form" ? node.getAttribute("toolname") ?? "" : "";
1554
- const role = tool === "" ? computeBrowserRole(node) : "form";
1555
- if (!invisible && role !== void 0 && (BROWSER_INTERACTIVE_ROLES.has(role) || [
1556
- "heading",
1557
- "image",
1558
- "img",
1559
- "listitem",
1560
- "row",
1561
- "cell",
1562
- "gridcell",
1563
- "columnheader",
1564
- "rowheader"
1565
- ].includes(role) || tool !== "")) {
1566
- text = this.#flush(rows, text, block);
1567
- const row = this.#row(node, role, view, String(rows.length));
1568
- rows.push(row);
1569
- this.#positions.set(node, row.id);
1570
- if (silenced === void 0 && BROWSER_CONTENT_NAMED_ROLES.has(role) && ![
1571
- "row",
1572
- "cell",
1573
- "gridcell",
1574
- "columnheader",
1575
- "rowheader"
1576
- ].includes(role)) silenced = node;
1577
- if (node.localName === "textarea") {
1578
- node = skipBrowserSubtree(walker);
1579
- continue;
1580
- }
1581
- }
1582
- const shadow = node.shadowRoot;
1583
- if (shadow !== null) {
1584
- text = this.#flush(rows, text, block);
1585
- this.#walk(shadow, rows, muted || silenced !== void 0);
1586
- node = skipBrowserSubtree(walker);
1587
- continue;
1588
- }
1589
- node = walker.nextNode();
1590
- }
1591
- this.#flush(rows, text, block);
1683
+ new BrowserDOMOutline({
1684
+ root,
1685
+ rows,
1686
+ muted,
1687
+ positions: this.#positions,
1688
+ watch: this.#watch.bind(this),
1689
+ walk: this.#walk.bind(this),
1690
+ text: this.#text.bind(this),
1691
+ row: this.#row.bind(this)
1692
+ }).capture();
1592
1693
  }
1593
1694
  #watch(document) {
1594
1695
  const view = document.defaultView;
@@ -1610,10 +1711,6 @@ var BrowserDOMElementManager = class {
1610
1711
  const target = document.deref();
1611
1712
  return target === void 0 ? -1 : this.#input.navigation() + (this.#epochs.get(target) ?? 0);
1612
1713
  }
1613
- #flush(rows, text, parent) {
1614
- if (normalizeBrowserName(text) !== "") this.#text(rows, text, parent);
1615
- return "";
1616
- }
1617
1714
  #text(rows, text, parent) {
1618
1715
  rows.push({
1619
1716
  id: String(rows.length),
@@ -1639,11 +1736,7 @@ var BrowserDOMElementManager = class {
1639
1736
  const checked = input !== void 0 && (input.type === "checkbox" || input.type === "radio") ? input.checked : element.getAttribute("aria-checked") === "true";
1640
1737
  const root = element.getRootNode();
1641
1738
  const focused = (root instanceof view.Document || root instanceof view.ShadowRoot) && root.activeElement === element;
1642
- let value;
1643
- if (element instanceof view.HTMLSelectElement) value = Array.from(element.selectedOptions, (option) => option.label).join(", ");
1644
- else if (element instanceof view.HTMLTextAreaElement) value = element.value;
1645
- else if (input?.type === "password") value = "•".repeat(input.value.length);
1646
- else if (input !== void 0 && input.type !== "password" && (BROWSER_TYPED_INPUTS.has(input.type) || input.type === "range")) value = input.value;
1739
+ const value = element instanceof view.HTMLSelectElement ? Array.from(element.selectedOptions, (option) => option.label).join(", ") : element instanceof view.HTMLTextAreaElement ? element.value : input?.type === "password" ? "•".repeat(input.value.length) : input === void 0 ? void 0 : input.type === "password" ? void 0 : BROWSER_TYPED_INPUTS.has(input.type) || input.type === "range" ? input.value : void 0;
1647
1740
  return {
1648
1741
  id: reference ?? id,
1649
1742
  parent: this.#parent(element.parentNode),
@@ -1670,10 +1763,9 @@ var BrowserDOMElementManager = class {
1670
1763
  };
1671
1764
  }
1672
1765
  #parent(node) {
1673
- while (node !== null) {
1674
- const id = this.#positions.get(node);
1766
+ for (const current of new BrowserAncestors(node, (entry) => entry.parentNode)) {
1767
+ const id = this.#positions.get(current);
1675
1768
  if (id !== void 0) return id;
1676
- node = node.parentNode;
1677
1769
  }
1678
1770
  }
1679
1771
  #bind(element, role, name) {
@@ -1715,8 +1807,7 @@ var BrowserDOMElementManager = class {
1715
1807
  return this.#descriptions.get(node) ?? captured;
1716
1808
  }
1717
1809
  };
1718
- //#endregion
1719
- //#region src/browser/BrowserDOMView.ts
1810
+ // src/browser/BrowserDOMView.ts
1720
1811
  /**
1721
1812
  * Reads and drives a DOM document from a realm that can reach it, without trusted input.
1722
1813
  *
@@ -1781,7 +1872,7 @@ var BrowserDOMView = class {
1781
1872
  async read(options) {
1782
1873
  this.#release.signal.throwIfAborted();
1783
1874
  options?.signal?.throwIfAborted();
1784
- return createBrowserReading({
1875
+ return createBrowserContent({
1785
1876
  ...readBrowserCapture(this.#current().documentElement),
1786
1877
  navigation: this.#navigation.bind(this)
1787
1878
  });
@@ -1832,8 +1923,7 @@ var BrowserDOMView = class {
1832
1923
  listenBrowserNavigation(this.#window, this.#navigateHandler, this.#listeners.signal);
1833
1924
  }
1834
1925
  };
1835
- //#endregion
1836
- //#region src/browser/transports/SocketCDPTransport.ts
1926
+ // src/browser/transports/SocketCDPTransport.ts
1837
1927
  /**
1838
1928
  * Provides a raw CDP text transport over the browser's native `WebSocket`.
1839
1929
  *
@@ -1961,8 +2051,7 @@ var SocketCDPTransport = class {
1961
2051
  return new BrowserError("CONNECTION", `Socket CDP connection to ${this.#url} ${reason}`, { url: this.#url });
1962
2052
  }
1963
2053
  };
1964
- //#endregion
1965
- //#region src/browser/factories.ts
2054
+ // src/browser/factories.ts
1966
2055
  /**
1967
2056
  * Creates a view that reads and drives a DOM document without trusted input.
1968
2057
  *
@@ -2000,7 +2089,6 @@ function createBrowserDOMView(options) {
2000
2089
  function createSocketCDPTransport(options) {
2001
2090
  return new SocketCDPTransport(options);
2002
2091
  }
2003
- //#endregion
2004
- export { BROWSER_CONTENT_NAMED_ROLES, BROWSER_CONTEXT_TARGETS, BROWSER_DOCUMENT_TIMEOUT_MS, BROWSER_EXPANDED_ROLES, BROWSER_IMPLICIT_ROLES, BROWSER_INTERACTIVE_CONTENT, BROWSER_SELECTED_ROLES, BROWSER_TYPED_INPUTS, BrowserDOMView, BrowserDOMWait, SocketCDPTransport, collectBrowserRoots, computeBrowserAlternative, computeBrowserName, computeBrowserRole, computeBrowserText, createBrowserDOMView, createSocketCDPTransport, isBrowserDocument, listenBrowserNavigation, matchesBrowserActivation, matchesBrowserBlock, matchesBrowserHidden, matchesBrowserInvisible, matchesBrowserOmitted, matchesBrowserPopup, readBrowserBlock, readBrowserCapture, readBrowserContent, readBrowserParent, readBrowserStates, readBrowserToken, skipBrowserSubtree };
2092
+ export { BROWSER_CONTENT_NAMED_ROLES, BROWSER_CONTEXT_TARGETS, BROWSER_DOCUMENT_TIMEOUT_MS, BROWSER_EXPANDED_ROLES, BROWSER_IMPLICIT_ROLES, BROWSER_INTERACTIVE_CONTENT, BROWSER_SELECTED_ROLES, BROWSER_TYPED_INPUTS, BrowserAncestors, BrowserCaptureSiblings, BrowserDOMOutline, BrowserDOMSubmission, BrowserDOMView, BrowserDOMWait, SocketCDPTransport, collectBrowserRoots, computeBrowserAlternative, computeBrowserName, computeBrowserRole, computeBrowserText, createBrowserDOMView, createSocketCDPTransport, isBrowserDocument, listenBrowserNavigation, matchesBrowserActivation, matchesBrowserBlock, matchesBrowserHidden, matchesBrowserInvisible, matchesBrowserOmitted, matchesBrowserPopup, readBrowserBlock, readBrowserCapture, readBrowserCaptureOmission, readBrowserCaptureValue, readBrowserContent, readBrowserParent, readBrowserStates, readBrowserToken, skipBrowserSubtree };
2005
2093
 
2006
2094
  //# sourceMappingURL=index.js.map