@yielded/agent-platform-cloudflare 0.1.0-beta.166 → 0.1.0-beta.168

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.
Files changed (97) hide show
  1. package/dist/{Alarm-B0ODr1NE.mjs → Alarm-DxODgAZZ.mjs} +259 -159
  2. package/dist/Alarm-DxODgAZZ.mjs.map +1 -0
  3. package/dist/Alarm.d.mts +27 -36
  4. package/dist/Alarm.mjs +2 -2
  5. package/dist/BrowserCredentials-C5IWfi7f.d.mts +91 -0
  6. package/dist/BrowserCredentials.d.mts +2 -91
  7. package/dist/BrowserRestCapture.mjs +3 -3
  8. package/dist/BrowserRestCapture.mjs.map +1 -1
  9. package/dist/BrowserRestCrawl.mjs +6 -6
  10. package/dist/BrowserRestCrawl.mjs.map +1 -1
  11. package/dist/{BrowserSession-RfIzxGCH.mjs → BrowserSession-BfNmyayH.mjs} +5 -5
  12. package/dist/BrowserSession-BfNmyayH.mjs.map +1 -0
  13. package/dist/{BrowserSession-B969Xg-2.d.mts → BrowserSession-Bx0V1-6_.d.mts} +6 -3
  14. package/dist/BrowserSession.d.mts +1 -1
  15. package/dist/BrowserSession.mjs +2 -2
  16. package/dist/BrowserUse-AstK0jKX.mjs +1467 -0
  17. package/dist/BrowserUse-AstK0jKX.mjs.map +1 -0
  18. package/dist/BrowserUse.d.mts +83 -0
  19. package/dist/BrowserUse.mjs +2 -0
  20. package/dist/CloudflareBindings.d.mts +8 -1
  21. package/dist/CloudflareBindings.mjs +2 -2
  22. package/dist/CloudflareBindings.mjs.map +1 -1
  23. package/dist/{CloudflareBrowser-5an6m-Qb.mjs → CloudflareBrowser-Vt8qTdYW.mjs} +11 -13
  24. package/dist/CloudflareBrowser-Vt8qTdYW.mjs.map +1 -0
  25. package/dist/CloudflareBrowser.mjs +1 -1
  26. package/dist/CloudflareCodeMode.mjs +1 -1
  27. package/dist/CloudflareCodeMode.mjs.map +1 -1
  28. package/dist/{CloudflareConfig-qDrRLofL.d.mts → CloudflareConfig-C9MvPbX8.d.mts} +9 -11
  29. package/dist/CloudflareConfig.d.mts +1 -1
  30. package/dist/CloudflareConfig.mjs +9 -12
  31. package/dist/CloudflareConfig.mjs.map +1 -1
  32. package/dist/CloudflareMemory.d.mts +3 -25
  33. package/dist/CloudflareMemory.mjs +33 -31
  34. package/dist/CloudflareMemory.mjs.map +1 -1
  35. package/dist/CloudflareScheduling.mjs +8 -15
  36. package/dist/CloudflareScheduling.mjs.map +1 -1
  37. package/dist/CloudflareSubscriptions.mjs +9 -16
  38. package/dist/CloudflareSubscriptions.mjs.map +1 -1
  39. package/dist/CloudflareThreadClient-CZm0TvUe.mjs +588 -0
  40. package/dist/CloudflareThreadClient-CZm0TvUe.mjs.map +1 -0
  41. package/dist/CloudflareThreadClient-QsVzLGGZ.d.mts +2033 -0
  42. package/dist/CloudflareThreadClient.d.mts +2 -1796
  43. package/dist/CloudflareThreadClient.mjs +2 -382
  44. package/dist/{InteractiveBrowser-BbG3kGA9.mjs → InteractiveBrowser-LdKzQeLR.mjs} +18 -19
  45. package/dist/InteractiveBrowser-LdKzQeLR.mjs.map +1 -0
  46. package/dist/InteractiveBrowser.mjs +1 -1
  47. package/dist/{ThreadObject-BcfXbzSO.d.mts → ThreadObject-7s5XnlH-.d.mts} +103 -79
  48. package/dist/{ThreadObject-DHjrnlX4.mjs → ThreadObject-C0vrcDrw.mjs} +265 -66
  49. package/dist/ThreadObject-C0vrcDrw.mjs.map +1 -0
  50. package/dist/ThreadObject.d.mts +2 -2
  51. package/dist/ThreadObject.mjs +2 -3
  52. package/dist/WakeScheduler.d.mts +6 -5
  53. package/dist/WakeScheduler.mjs +15 -11
  54. package/dist/WakeScheduler.mjs.map +1 -1
  55. package/dist/index.d.mts +7 -6
  56. package/dist/index.mjs +7 -6
  57. package/dist/page-function-CmUCcyNP.mjs +11 -0
  58. package/dist/page-function-CmUCcyNP.mjs.map +1 -0
  59. package/dist/{prepared-admission-DMX5p9dC.mjs → prepared-admission-3UUq0zth.mjs} +3 -3
  60. package/dist/{prepared-admission-DMX5p9dC.mjs.map → prepared-admission-3UUq0zth.mjs.map} +1 -1
  61. package/package.json +1 -1
  62. package/src/Alarm.ts +510 -279
  63. package/src/BrowserRestCapture.ts +2 -2
  64. package/src/BrowserRestCrawl.ts +10 -14
  65. package/src/BrowserSession.ts +10 -2
  66. package/src/BrowserUse.ts +1541 -0
  67. package/src/CloudflareBindings.ts +13 -4
  68. package/src/CloudflareCodeMode.ts +1 -1
  69. package/src/CloudflareConfig.ts +9 -12
  70. package/src/CloudflareMemory.ts +21 -28
  71. package/src/CloudflareScheduling.ts +7 -15
  72. package/src/CloudflareSubscriptions.ts +11 -20
  73. package/src/CloudflareThreadClient.ts +289 -9
  74. package/src/InteractiveBrowser.ts +325 -323
  75. package/src/ThreadObject.ts +172 -52
  76. package/src/WakeScheduler.ts +29 -18
  77. package/src/index.ts +1 -0
  78. package/src/internal/browser-dom.ts +845 -0
  79. package/src/internal/browser-file-selection.ts +8 -1
  80. package/src/internal/browser-jev-dom.ts +344 -0
  81. package/src/internal/browser-quick-action.ts +7 -8
  82. package/src/internal/browser-session-lifecycle.ts +2 -5
  83. package/src/internal/crypto.ts +41 -0
  84. package/src/internal/due-queue.ts +5 -69
  85. package/src/internal/layers.ts +100 -17
  86. package/src/internal/live-text-protocol.ts +51 -0
  87. package/src/internal/live-text.ts +256 -0
  88. package/src/internal/page-function.ts +12 -0
  89. package/src/internal/prepared-admission.ts +1 -1
  90. package/src/internal/progress-wait.ts +19 -24
  91. package/src/internal/transport.ts +1 -1
  92. package/dist/Alarm-B0ODr1NE.mjs.map +0 -1
  93. package/dist/BrowserSession-RfIzxGCH.mjs.map +0 -1
  94. package/dist/CloudflareBrowser-5an6m-Qb.mjs.map +0 -1
  95. package/dist/CloudflareThreadClient.mjs.map +0 -1
  96. package/dist/InteractiveBrowser-BbG3kGA9.mjs.map +0 -1
  97. package/dist/ThreadObject-DHjrnlX4.mjs.map +0 -1
@@ -0,0 +1,845 @@
1
+ /// <reference lib="dom" />
2
+
3
+ import type { Control } from "@yielded/agent/browser-use";
4
+
5
+ import { pageFunction } from "./page-function.ts";
6
+
7
+ /** Executor-owned input settling, matching jev-ultrafast's frame/option readiness predicate. */
8
+ export const settleInputDom = pageFunction(
9
+ (ref: string | undefined) =>
10
+ new Promise<void>((resolve) => {
11
+ const field: Element | undefined =
12
+ ref === undefined
13
+ ? undefined
14
+ : Reflect.get(globalThis, "@effect-agent/native-browser")?.get(ref);
15
+
16
+ const autocomplete = field?.getAttribute("role") === "combobox";
17
+ let frames = 0;
18
+ let stopped = false;
19
+ let animation = 0;
20
+
21
+ const finish = () => {
22
+ if (stopped) return;
23
+ stopped = true;
24
+ clearTimeout(deadline);
25
+ cancelAnimationFrame(animation);
26
+ resolve();
27
+ };
28
+
29
+ const deadline = setTimeout(finish, autocomplete ? 200 : 50);
30
+
31
+ const ready = () => {
32
+ if (stopped) return;
33
+
34
+ const ids = (field?.getAttribute("aria-controls") || field?.getAttribute("aria-owns") || "")
35
+ .split(/\s+/)
36
+ .filter(Boolean);
37
+
38
+ const roots = ids.length ? ids.map((id) => document.getElementById(id)) : [document];
39
+
40
+ const options = roots.flatMap((root) => [
41
+ ...(root?.querySelectorAll('[role="option"]') ?? []),
42
+ ]);
43
+
44
+ if (
45
+ ++frames >= 2 &&
46
+ (!autocomplete ||
47
+ options.some((element) => {
48
+ const rect = element.getBoundingClientRect();
49
+
50
+ return (
51
+ rect.width &&
52
+ rect.height &&
53
+ rect.bottom > 0 &&
54
+ rect.top < innerHeight &&
55
+ element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
56
+ );
57
+ }))
58
+ )
59
+ finish();
60
+ else animation = requestAnimationFrame(ready);
61
+ };
62
+
63
+ animation = requestAnimationFrame(ready);
64
+ }),
65
+ );
66
+
67
+ /** Read-only executor settling: 100 ms without a DOM mutation, bounded by one second. */
68
+ export const settleDom = pageFunction(
69
+ () =>
70
+ new Promise<boolean>((resolve) => {
71
+ let quiet: ReturnType<typeof setTimeout>;
72
+
73
+ const observer = new MutationObserver(() => {
74
+ clearTimeout(quiet);
75
+ quiet = setTimeout(() => finish(true), 100);
76
+ });
77
+
78
+ const finish = (settled: boolean) => {
79
+ observer.disconnect();
80
+ clearTimeout(quiet);
81
+ clearTimeout(deadline);
82
+ resolve(settled);
83
+ };
84
+
85
+ const deadline = setTimeout(() => finish(false), 1_000);
86
+
87
+ observer.observe(document, {
88
+ subtree: true,
89
+ childList: true,
90
+ attributes: true,
91
+ characterData: true,
92
+ });
93
+ quiet = setTimeout(() => finish(true), 100);
94
+ }),
95
+ );
96
+
97
+ /** Executed in the isolated page realm; the page cannot forge the reference registry. */
98
+ export const inspectDom = pageFunction(
99
+ (
100
+ selector: string | undefined,
101
+ prefix: string,
102
+ maximum: number,
103
+ optionFilter: string | undefined,
104
+ viewportOnly = false,
105
+ ) => {
106
+ const registry = new Map<string, Element>();
107
+
108
+ const nameChecks = new Map<
109
+ string,
110
+ { name: string; originalName: string; verify: () => boolean }
111
+ >();
112
+
113
+ Reflect.set(globalThis, "@effect-agent/native-browser", registry);
114
+ Reflect.set(globalThis, "@effect-agent/native-browser-name-checks", nameChecks);
115
+ const roots: Array<Document | ShadowRoot> = [document];
116
+ const visited = new Set<Node>();
117
+ let discoveredElements = 0;
118
+ const candidates: Array<HTMLElement> = [];
119
+ let scanned = 0;
120
+ let truncated = false;
121
+ let text = "";
122
+
123
+ const controlSelector =
124
+ 'button,a[href],input,textarea,select,label,[contenteditable="true"],[role="button"],[role="link"],[role="checkbox"],[role="radio"],[role="switch"],[role="tab"],[role="option"],[role="combobox"],[role="textbox"],[role="menuitem"],[role="scrollbar"]';
125
+
126
+ const filter: NodeFilter = {
127
+ acceptNode(node) {
128
+ if (!(node instanceof Element)) return NodeFilter.FILTER_ACCEPT;
129
+ const style = getComputedStyle(node);
130
+
131
+ return node.matches('script,style,template,option,optgroup,[inert],[aria-hidden="true"]') ||
132
+ style.display === "none" ||
133
+ style.opacity === "0" ||
134
+ style.contentVisibility === "hidden"
135
+ ? NodeFilter.FILTER_REJECT
136
+ : NodeFilter.FILTER_ACCEPT;
137
+ },
138
+ };
139
+
140
+ for (let index = 0; index < roots.length && !truncated; index++) {
141
+ const root = roots[index];
142
+
143
+ if (root === undefined) break;
144
+
145
+ // Narrow inspection starts at matching roots, so unrelated page content
146
+ // cannot exhaust its budget. Shadow-host discovery has a separate bound.
147
+ if (selector !== undefined) {
148
+ const discovery = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, filter);
149
+ let node = discovery.nextNode();
150
+
151
+ while (node !== null) {
152
+ if (++discoveredElements > 10_000) {
153
+ truncated = true;
154
+ break;
155
+ }
156
+ if (node instanceof Element && node.shadowRoot !== null) roots.push(node.shadowRoot);
157
+ node = discovery.nextNode();
158
+ }
159
+ }
160
+ const matches = selector === undefined ? [root] : Array.from(root.querySelectorAll(selector));
161
+ const scopes = matches.slice(0, 256);
162
+ const nodes: Array<Element> = [];
163
+
164
+ truncated ||= matches.length > scopes.length;
165
+ for (const scope of scopes) {
166
+ if (scope instanceof Element && filter.acceptNode(scope) === NodeFilter.FILTER_REJECT)
167
+ continue;
168
+
169
+ const walker = document.createTreeWalker(
170
+ scope,
171
+ NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
172
+ filter,
173
+ );
174
+
175
+ let node: Node | null = scope instanceof Element ? scope : walker.nextNode();
176
+
177
+ while (node !== null) {
178
+ if (visited.has(node)) {
179
+ node = walker.nextNode();
180
+ continue;
181
+ }
182
+ visited.add(node);
183
+ if (++scanned > 10_000) {
184
+ truncated = true;
185
+ break;
186
+ }
187
+ if (node instanceof Element) {
188
+ nodes.push(node);
189
+ if (selector === undefined && node.shadowRoot !== null) roots.push(node.shadowRoot);
190
+ } else if (node instanceof Text) {
191
+ const parent = node.parentElement;
192
+
193
+ // Select options have their own bounded lookup; collapsed option text
194
+ // must not bury the current form's values and validation messages.
195
+ if (
196
+ parent instanceof HTMLElement &&
197
+ parent.closest('select,script,style,[inert],[aria-hidden="true"]') === null &&
198
+ parent.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
199
+ ) {
200
+ const value = node.textContent?.replace(/\s+/g, " ").trim() ?? "";
201
+
202
+ if (value.length > 0) {
203
+ const room = Math.max(0, 12_000 - text.length);
204
+
205
+ text += `${value.slice(0, room)}\n`;
206
+ truncated ||= value.length >= room;
207
+ }
208
+ }
209
+ }
210
+ node = walker.nextNode();
211
+ }
212
+ if (scanned > 10_000) break;
213
+ }
214
+ for (const node of nodes) {
215
+ if (selector !== undefined && !node.matches(selector) && node.closest(selector) === null)
216
+ continue;
217
+ if (!(node instanceof HTMLElement)) continue;
218
+
219
+ const scrollable =
220
+ node.scrollHeight > node.clientHeight &&
221
+ ["auto", "scroll"].includes(getComputedStyle(node).overflowY);
222
+
223
+ if (!node.matches(controlSelector) && !scrollable) continue;
224
+ if (
225
+ !node.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) ||
226
+ node.closest('[inert],[aria-hidden="true"]') !== null
227
+ )
228
+ continue;
229
+ const modal = document.querySelector("dialog:modal");
230
+
231
+ if (modal !== null && !modal.contains(node) && node.getRootNode() === document) continue;
232
+ candidates.push(node);
233
+ }
234
+ }
235
+
236
+ let retained = candidates;
237
+
238
+ // A popup falls back to off-screen controls only when none of its controls
239
+ // are in view. Do not restore an entire off-screen calendar beside visible days.
240
+ if (viewportOnly || candidates.length > maximum) {
241
+ const visible: Array<HTMLElement> = [];
242
+ const popupControls = new Map<Element, Array<HTMLElement>>();
243
+ const visiblePopups = new Set<Element>();
244
+ const offscreen: Array<HTMLElement> = [];
245
+
246
+ const popupSelector =
247
+ 'dialog[open],[role="dialog"],[role="listbox"],[role="menu"],:popover-open';
248
+
249
+ for (const node of candidates) {
250
+ const rect = node.getBoundingClientRect();
251
+ const x = rect.x + rect.width / 2;
252
+ const y = rect.y + rect.height / 2;
253
+
254
+ const inView = x >= 0 && x < innerWidth && y >= 0 && y < innerHeight;
255
+
256
+ if (viewportOnly) {
257
+ let popup = node.closest(popupSelector);
258
+
259
+ if (popup?.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) {
260
+ if (!inView) {
261
+ const group = popupControls.get(popup) ?? [];
262
+
263
+ group.push(node);
264
+ popupControls.set(popup, group);
265
+ continue;
266
+ }
267
+ while (popup !== null) {
268
+ visiblePopups.add(popup);
269
+ popup = popup.parentElement?.closest(popupSelector) ?? null;
270
+ }
271
+ }
272
+ }
273
+ (inView ? visible : offscreen).push(node);
274
+ }
275
+
276
+ const preferred = [
277
+ ...visible,
278
+ ...[...popupControls].flatMap(([popup, nodes]) => (visiblePopups.has(popup) ? [] : nodes)),
279
+ ];
280
+
281
+ retained = (
282
+ viewportOnly ? (preferred.length > 0 ? preferred : offscreen) : [...visible, ...offscreen]
283
+ ).slice(0, maximum);
284
+ truncated ||= candidates.length > retained.length;
285
+ }
286
+
287
+ const readName = (node: HTMLElement) => {
288
+ const associated = node instanceof HTMLLabelElement ? (node.control ?? node) : node;
289
+
290
+ const nativeField =
291
+ associated instanceof HTMLInputElement ||
292
+ associated instanceof HTMLTextAreaElement ||
293
+ associated instanceof HTMLSelectElement;
294
+
295
+ const labels = nativeField ? associated.labels : null;
296
+ const root = node.getRootNode();
297
+
298
+ const labelledBy = (node.getAttribute("aria-labelledby") ?? "")
299
+ .split(/\s+/)
300
+ .map((id) =>
301
+ root instanceof Document || root instanceof ShadowRoot
302
+ ? (root.getElementById(id)?.textContent ?? "")
303
+ : "",
304
+ )
305
+ .join(" ");
306
+
307
+ return (
308
+ node.getAttribute("aria-label") ||
309
+ labelledBy.trim() ||
310
+ (labels === null
311
+ ? ""
312
+ : Array.from(labels)
313
+ .map((label) => label.textContent)
314
+ .join(" ")) ||
315
+ (nativeField
316
+ ? node.getAttribute("placeholder")
317
+ : node instanceof HTMLElement
318
+ ? node.innerText
319
+ : "") ||
320
+ ""
321
+ )
322
+ .replace(/\s+/g, " ")
323
+ .trim()
324
+ .slice(0, 300);
325
+ };
326
+
327
+ const controls = retained.map((node, index) => {
328
+ const ref = `${prefix}-${index}`;
329
+ const tag = node.tagName.toLowerCase();
330
+ const associated = node instanceof HTMLLabelElement ? (node.control ?? node) : node;
331
+ const type = associated instanceof HTMLInputElement ? associated.type : "";
332
+
333
+ const nativeField =
334
+ associated instanceof HTMLInputElement ||
335
+ associated instanceof HTMLTextAreaElement ||
336
+ associated instanceof HTMLSelectElement;
337
+
338
+ const role = node.getAttribute("role");
339
+
340
+ const kind =
341
+ tag === "label" &&
342
+ associated instanceof HTMLInputElement &&
343
+ ["checkbox", "radio"].includes(type)
344
+ ? type
345
+ : (role ?? (type === "checkbox" || type === "radio" ? type : tag === "a" ? "link" : tag));
346
+
347
+ const name = readName(node);
348
+
349
+ const allOptions = node instanceof HTMLSelectElement ? Array.from(node.options) : [];
350
+
351
+ const matchingOptions = allOptions.flatMap((option, index) =>
352
+ option.selected ||
353
+ optionFilter === undefined ||
354
+ `${option.label}\n${option.value}`.toLowerCase().includes(optionFilter.toLowerCase())
355
+ ? [{ option, index }]
356
+ : [],
357
+ );
358
+
359
+ // Keep selected state even when it falls beyond the bounded lookup window.
360
+ const selectedOptions = matchingOptions.filter(({ option }) => option.selected);
361
+
362
+ const visibleOptions = [
363
+ ...selectedOptions.slice(0, 256),
364
+ ...matchingOptions
365
+ .filter(({ option }) => !option.selected)
366
+ .slice(0, Math.max(0, 256 - selectedOptions.length)),
367
+ ];
368
+
369
+ if (matchingOptions.length > visibleOptions.length) truncated = true;
370
+
371
+ const attributes = Object.fromEntries(
372
+ [
373
+ "id",
374
+ "name",
375
+ "type",
376
+ "role",
377
+ "tabindex",
378
+ "placeholder",
379
+ "aria-label",
380
+ "aria-labelledby",
381
+ "aria-expanded",
382
+ "aria-selected",
383
+ "aria-checked",
384
+ "aria-controls",
385
+ "href",
386
+ ].flatMap((key) => {
387
+ const value = node.getAttribute(key);
388
+
389
+ return value === null ? [] : [[key, value.slice(0, 1_024)]];
390
+ }),
391
+ );
392
+
393
+ registry.set(ref, node);
394
+ nameChecks.set(ref, {
395
+ name,
396
+ originalName: name,
397
+ verify: () => readName(node) === name,
398
+ });
399
+ const pointerEvents = getComputedStyle(node).pointerEvents?.slice(0, 64);
400
+
401
+ return {
402
+ ref,
403
+ kind,
404
+ name,
405
+ attributes,
406
+ ...(pointerEvents ? { pointerEvents } : {}),
407
+ value:
408
+ nativeField && type !== "password"
409
+ ? associated.value.slice(0, 4_096)
410
+ : node.isContentEditable
411
+ ? node.innerText.slice(0, 4_096)
412
+ : "",
413
+ options: visibleOptions.map(({ option }) => option.value.slice(0, 4_096)),
414
+ ...(node instanceof HTMLSelectElement
415
+ ? {
416
+ optionCount: allOptions.length,
417
+ optionDetails: visibleOptions.map(({ option, index }) => ({
418
+ index,
419
+ value: option.value.slice(0, 4096),
420
+ label: option.label.slice(0, 300),
421
+ disabled:
422
+ option.disabled ||
423
+ (option.parentElement instanceof HTMLOptGroupElement &&
424
+ option.parentElement.disabled),
425
+ selected: option.selected,
426
+ })),
427
+ }
428
+ : {}),
429
+ disabled:
430
+ associated.matches(":disabled") || associated.getAttribute("aria-disabled") === "true",
431
+ editable:
432
+ (node instanceof HTMLInputElement &&
433
+ !node.readOnly &&
434
+ !["password", "file", "checkbox", "radio", "hidden", "submit", "button"].includes(
435
+ node.type,
436
+ )) ||
437
+ (node instanceof HTMLTextAreaElement && !node.readOnly) ||
438
+ (node.isContentEditable && node.getAttribute("aria-readonly") !== "true"),
439
+ ...(associated instanceof HTMLInputElement && (type === "checkbox" || type === "radio")
440
+ ? { checked: associated.checked }
441
+ : {}),
442
+ };
443
+ });
444
+
445
+ const names = new Map<string, number>();
446
+
447
+ for (const control of controls) names.set(control.name, (names.get(control.name) ?? 0) + 1);
448
+
449
+ const explicitLabel = (node: Element) => {
450
+ const root = node.getRootNode();
451
+
452
+ return (
453
+ node.getAttribute("aria-label") ||
454
+ (node.getAttribute("aria-labelledby") ?? "")
455
+ .split(/\s+/)
456
+ .map((id) =>
457
+ root instanceof Document || root instanceof ShadowRoot
458
+ ? (root.getElementById(id)?.textContent ?? "")
459
+ : "",
460
+ )
461
+ .join(" ")
462
+ )
463
+ .replace(/\s+/g, " ")
464
+ .trim();
465
+ };
466
+
467
+ for (const [index, control] of controls.entries()) {
468
+ const node = retained[index];
469
+
470
+ if (node === undefined || control.name.length === 0 || (names.get(control.name) ?? 0) < 2)
471
+ continue;
472
+ let branch: Element = node;
473
+
474
+ for (let scope = node.parentElement; scope !== null; scope = scope.parentElement) {
475
+ let source: Element | undefined = explicitLabel(scope) ? scope : undefined;
476
+ let read = () => (source === undefined ? "" : explicitLabel(source));
477
+
478
+ if (source === undefined) {
479
+ for (
480
+ let previous = branch.previousElementSibling;
481
+ previous !== null;
482
+ previous = previous.previousElementSibling
483
+ ) {
484
+ if (
485
+ !(previous instanceof HTMLElement) ||
486
+ !previous.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) ||
487
+ previous.closest('[inert],[aria-hidden="true"]') !== null ||
488
+ previous.matches(controlSelector) ||
489
+ previous.querySelector(controlSelector) !== null ||
490
+ (previous.childElementCount > 0 &&
491
+ !previous.matches('h1,h2,h3,h4,h5,h6,caption,figcaption,legend,[role="heading"]'))
492
+ )
493
+ continue;
494
+ const caption = previous.innerText.replace(/\s+/g, " ").trim();
495
+
496
+ // Short static leaf text also serves as a caption in unlabelled containers.
497
+ if (caption.length === 0 || caption.length > 120) continue;
498
+ source = previous;
499
+ const captionElement = previous;
500
+
501
+ read = () => captionElement.innerText.replace(/\s+/g, " ").trim();
502
+ break;
503
+ }
504
+ }
505
+ const context = read();
506
+
507
+ if (source !== undefined && context.length > 0) {
508
+ const originalName = control.name;
509
+ const container = scope;
510
+ const caption = source;
511
+
512
+ control.name = `${originalName} (${context})`.slice(0, 300);
513
+ // Keep raw-name and contextual identity checks in the isolated realm.
514
+ // Presentation must not weaken the existing native dispatch guard.
515
+ nameChecks.set(control.ref, {
516
+ name: control.name,
517
+ originalName,
518
+ verify: () =>
519
+ readName(node) === originalName &&
520
+ container.isConnected &&
521
+ container.contains(node) &&
522
+ container.contains(caption) &&
523
+ (caption === container ||
524
+ (caption.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0) &&
525
+ read() === context,
526
+ });
527
+ break;
528
+ }
529
+ branch = scope;
530
+ }
531
+ }
532
+
533
+ return {
534
+ text: text.slice(0, 12_000),
535
+ controls,
536
+ readyState: document.readyState,
537
+ truncated: truncated || text.length > 12_000,
538
+ };
539
+ },
540
+ );
541
+
542
+ /** Current state after standard DOM scrolling. Pointer actions also require a hit test;
543
+ * keyboard actions verify native focus at dispatch instead. Never dispatches input.
544
+ */
545
+ export const checkDom = pageFunction(
546
+ (target: Element | string, expected: typeof Control.Type, scroll: boolean, pointer: boolean) => {
547
+ // A ref is looked up in this realm's registry, saving a handle round trip each way.
548
+ const node: unknown =
549
+ typeof target === "string"
550
+ ? Reflect.get(globalThis, "@effect-agent/native-browser")?.get(target)
551
+ : target;
552
+
553
+ if (!(node instanceof HTMLElement) || !node.isConnected) return false;
554
+
555
+ const nameCheck:
556
+ | {
557
+ name: string;
558
+ originalName: string;
559
+ kind?: string;
560
+ viewport?: boolean;
561
+ verify: () => boolean;
562
+ }
563
+ | undefined = Reflect.get(globalThis, "@effect-agent/native-browser-name-checks")?.get(
564
+ expected.ref,
565
+ );
566
+
567
+ if (nameCheck !== undefined && (nameCheck.name !== expected.name || !nameCheck.verify()))
568
+ return false;
569
+ // Jev refs require their centers to remain in view. Do not move a visible
570
+ // form or popup just to prepare input; the same native hit test still applies.
571
+ if (scroll && !nameCheck?.viewport)
572
+ node.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
573
+ const associated = node instanceof HTMLLabelElement ? (node.control ?? node) : node;
574
+ const type = associated instanceof HTMLInputElement ? associated.type : "";
575
+
576
+ if (
577
+ associated instanceof HTMLSelectElement &&
578
+ expected.optionDetails !== undefined &&
579
+ ((expected.optionCount === undefined
580
+ ? expected.optionDetails.length
581
+ : expected.optionCount) !== associated.options.length ||
582
+ expected.optionDetails.some((option, index) => {
583
+ const current = associated.options.item(option.index ?? index);
584
+
585
+ return (
586
+ current === null ||
587
+ current.value !== option.value ||
588
+ current.label.slice(0, 300) !== option.label ||
589
+ current.selected !== option.selected ||
590
+ (current.disabled ||
591
+ (current.parentElement instanceof HTMLOptGroupElement &&
592
+ current.parentElement.disabled)) !== option.disabled
593
+ );
594
+ }))
595
+ )
596
+ return false;
597
+ if (node.isContentEditable && node.innerText.slice(0, 4_096) !== expected.value) return false;
598
+
599
+ const kind =
600
+ nameCheck?.kind ??
601
+ (node instanceof HTMLLabelElement &&
602
+ associated instanceof HTMLInputElement &&
603
+ ["checkbox", "radio"].includes(type)
604
+ ? type
605
+ : (node.getAttribute("role") ??
606
+ (type === "checkbox" || type === "radio"
607
+ ? type
608
+ : node.tagName === "A"
609
+ ? "link"
610
+ : node.tagName.toLowerCase())));
611
+
612
+ if (
613
+ kind !== expected.kind ||
614
+ associated.matches(":disabled") ||
615
+ associated.getAttribute("aria-disabled") === "true" ||
616
+ node.closest('[inert],[aria-hidden="true"]') !== null ||
617
+ !node.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
618
+ )
619
+ return false;
620
+ if (
621
+ (associated instanceof HTMLInputElement ||
622
+ associated instanceof HTMLTextAreaElement ||
623
+ associated instanceof HTMLSelectElement) &&
624
+ type !== "password" &&
625
+ associated.value.slice(0, 4_096) !== expected.value
626
+ )
627
+ return false;
628
+ if (
629
+ !(
630
+ associated instanceof HTMLInputElement ||
631
+ associated instanceof HTMLTextAreaElement ||
632
+ associated instanceof HTMLSelectElement
633
+ ) &&
634
+ !node.isContentEditable &&
635
+ !node.hasAttribute("aria-label") &&
636
+ !node.hasAttribute("aria-labelledby") &&
637
+ node.innerText.replace(/\s+/g, " ").trim().slice(0, 300) !==
638
+ (nameCheck?.originalName ?? expected.name)
639
+ )
640
+ return false;
641
+ if (
642
+ expected.checked !== undefined &&
643
+ (!(associated instanceof HTMLInputElement) || associated.checked !== expected.checked)
644
+ )
645
+ return false;
646
+ if (
647
+ Object.entries(expected.attributes ?? {}).some(
648
+ ([key, value]) => (node.getAttribute(key) ?? "").slice(0, 1_024) !== value,
649
+ )
650
+ )
651
+ return false;
652
+ const ancestors = new Set<Node>();
653
+ let ancestor: Node | null = node;
654
+
655
+ while (ancestor !== null) {
656
+ ancestors.add(ancestor);
657
+ ancestor = ancestor instanceof ShadowRoot ? ancestor.host : ancestor.parentNode;
658
+ }
659
+
660
+ const modalSelector =
661
+ 'dialog:modal,[role="dialog"][aria-modal="true"],[role="alertdialog"][aria-modal="true"]';
662
+
663
+ const roots = new Set([document, node.getRootNode()]);
664
+
665
+ for (const root of roots) {
666
+ if (!(root instanceof Document || root instanceof ShadowRoot)) continue;
667
+ for (const modal of root.querySelectorAll(modalSelector)) {
668
+ if (
669
+ !(modal instanceof HTMLElement) ||
670
+ ancestors.has(modal) ||
671
+ !modal.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
672
+ )
673
+ continue;
674
+ const box = modal.getBoundingClientRect();
675
+
676
+ // An ARIA modal without an on-screen box, such as a collapsed cart drawer, covers
677
+ // nothing; the hit test below still refuses anything an open overlay obstructs.
678
+ if (
679
+ modal.matches("dialog:modal") ||
680
+ (box.width > 0 &&
681
+ box.height > 0 &&
682
+ box.right > 0 &&
683
+ box.bottom > 0 &&
684
+ box.left < innerWidth &&
685
+ box.top < innerHeight)
686
+ )
687
+ return false;
688
+ }
689
+ }
690
+ const rect = node.getBoundingClientRect();
691
+
692
+ const x =
693
+ Math.max(0, rect.left) + (Math.min(innerWidth, rect.right) - Math.max(0, rect.left)) / 2;
694
+
695
+ const y =
696
+ Math.max(0, rect.top) + (Math.min(innerHeight, rect.bottom) - Math.max(0, rect.top)) / 2;
697
+
698
+ let hit = document.elementFromPoint(x, y);
699
+
700
+ while (hit?.shadowRoot !== null && hit?.shadowRoot !== undefined) {
701
+ const next = hit.shadowRoot.elementFromPoint(x, y);
702
+
703
+ if (next === null || next === hit) break;
704
+ hit = next;
705
+ }
706
+
707
+ // Keyboard-only overlays can sit above sibling card content. Require that
708
+ // containing element to remain unobstructed, then verify native focus at dispatch.
709
+ // This never permits a pointer click through the overlay.
710
+ return rect.width > 0 &&
711
+ rect.height > 0 &&
712
+ node.isConnected &&
713
+ (hit === node ||
714
+ (hit !== null &&
715
+ (node.contains(hit) ||
716
+ (!pointer &&
717
+ node.tabIndex >= 0 &&
718
+ getComputedStyle(node).pointerEvents === "none" &&
719
+ node.parentElement?.contains(hit)))))
720
+ ? { x, y }
721
+ : false;
722
+ },
723
+ );
724
+
725
+ /** Translate a checked child point and refuse input through hidden/covered frame owners. */
726
+ export const checkFrameDom = pageFunction(
727
+ async (node: Element, point: { x: number; y: number }, scroll: boolean) => {
728
+ if (
729
+ !(node instanceof HTMLElement) ||
730
+ !node.isConnected ||
731
+ node.closest('[inert],[aria-hidden="true"]') !== null ||
732
+ !node.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
733
+ )
734
+ return false;
735
+ if (scroll) {
736
+ node.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
737
+ // Input into an out-of-process frame is routed by the compositor's last frame, not by
738
+ // live layout. Let a frame with the new scroll position render before input follows.
739
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
740
+ }
741
+ const style = getComputedStyle(node);
742
+
743
+ // The bounding rect already includes translations. Anything that scales, rotates, skews or
744
+ // projects the frame needs a native geometry adapter; never guess.
745
+ let ancestor: Element | null = node;
746
+
747
+ while (ancestor !== null) {
748
+ const current = getComputedStyle(ancestor);
749
+
750
+ const matrix = new DOMMatrixReadOnly(
751
+ current.transform === "none" ? undefined : current.transform,
752
+ );
753
+
754
+ if (
755
+ !(
756
+ matrix.m11 === 1 &&
757
+ matrix.m12 === 0 &&
758
+ matrix.m13 === 0 &&
759
+ matrix.m14 === 0 &&
760
+ matrix.m21 === 0 &&
761
+ matrix.m22 === 1 &&
762
+ matrix.m23 === 0 &&
763
+ matrix.m24 === 0 &&
764
+ matrix.m31 === 0 &&
765
+ matrix.m32 === 0 &&
766
+ matrix.m33 === 1 &&
767
+ matrix.m34 === 0 &&
768
+ matrix.m44 === 1
769
+ ) ||
770
+ current.perspective !== "none" ||
771
+ // Individual scale, rotate and motion-path properties apply outside `transform`.
772
+ current.scale !== "none" ||
773
+ current.rotate !== "none" ||
774
+ current.offsetPath !== "none" ||
775
+ (current.zoom !== "1" && current.zoom !== "normal")
776
+ )
777
+ return false;
778
+ ancestor = ancestor.parentElement;
779
+ }
780
+ const rect = node.getBoundingClientRect();
781
+
782
+ const x =
783
+ rect.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft) + point.x;
784
+
785
+ const y = rect.top + parseFloat(style.borderTopWidth) + parseFloat(style.paddingTop) + point.y;
786
+ const hit = document.elementFromPoint(x, y);
787
+
788
+ return hit === node ? { x, y } : false;
789
+ },
790
+ );
791
+
792
+ export const waitDom = pageFunction((selector: string, state: string, text: string | undefined) => {
793
+ const roots: Array<Document | ShadowRoot> = [document];
794
+ const expectedText = text?.replace(/\s+/g, " ").trim();
795
+ let scanned = 0;
796
+
797
+ for (let index = 0; index < roots.length; index++) {
798
+ const root = roots[index];
799
+
800
+ if (root === undefined) break;
801
+
802
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
803
+ acceptNode(node) {
804
+ if (!(node instanceof Element)) return NodeFilter.FILTER_ACCEPT;
805
+ const style = getComputedStyle(node);
806
+
807
+ return node.matches('script,style,template,option,optgroup,[inert],[aria-hidden="true"]') ||
808
+ style.display === "none" ||
809
+ style.opacity === "0" ||
810
+ style.contentVisibility === "hidden"
811
+ ? NodeFilter.FILTER_REJECT
812
+ : NodeFilter.FILTER_ACCEPT;
813
+ },
814
+ });
815
+
816
+ let node = walker.nextNode();
817
+
818
+ while (node !== null) {
819
+ if (++scanned > 10_000) return false;
820
+ if (node instanceof Element) {
821
+ if (node.shadowRoot !== null) roots.push(node.shadowRoot);
822
+ if (
823
+ node instanceof HTMLElement &&
824
+ node.matches(selector) &&
825
+ node.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) &&
826
+ (expectedText === undefined ||
827
+ node.innerText.replace(/\s+/g, " ").trim().includes(expectedText))
828
+ ) {
829
+ if (state === "hidden") return false;
830
+ if (
831
+ state === "visible" ||
832
+ (state === "enabled" &&
833
+ !node.matches(":disabled") &&
834
+ node.getAttribute("aria-disabled") !== "true") ||
835
+ (state === "text" && text !== undefined)
836
+ )
837
+ return true;
838
+ }
839
+ }
840
+ node = walker.nextNode();
841
+ }
842
+ }
843
+
844
+ return state === "hidden";
845
+ });