@effect-agent/platform-cloudflare 0.1.0-beta.36 → 0.1.0-beta.37

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.
@@ -58,6 +58,10 @@ const MAX_HANDOFF_TIMEOUT_MILLIS = 30 * 60_000;
58
58
  const MAX_HOST_TEXT_LENGTH = 8 * 1024;
59
59
  const CLEANUP_STEP_TIMEOUT_MILLIS = 10_000;
60
60
  const CLOSE_SESSION_TIMEOUT_MILLIS = 10_000;
61
+ const ACTION_NETWORK_QUIET_MILLIS = 200;
62
+ const ACTION_NETWORK_SETTLE_MILLIS = 2_000;
63
+ const ACTION_POST_STATE_MILLIS = 250;
64
+ const MAX_OBSERVED_CONTROLS = 64;
61
65
  const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0));
62
66
  const BoundedHostText = Schema.String.check(
63
67
  Schema.isMinLength(1),
@@ -69,6 +73,55 @@ const TextObservation = Schema.Union([
69
73
  Schema.Struct({ _tag: Schema.Literal("MissingElement") }),
70
74
  Schema.Struct({ _tag: Schema.Literal("OverLimit"), observed: Schema.Natural }),
71
75
  ]);
76
+ const ActionTargetState = Schema.Struct({
77
+ matchCount: Schema.Natural,
78
+ invalidSelector: Schema.optionalKey(Schema.Boolean),
79
+ kind: Schema.optionalKey(
80
+ Schema.Literals(["button", "checkbox", "radio", "select", "text", "link", "other"]),
81
+ ),
82
+ checked: Schema.optionalKey(Schema.Boolean),
83
+ selected: Schema.optionalKey(Schema.Boolean),
84
+ disabled: Schema.optionalKey(Schema.Boolean),
85
+ required: Schema.optionalKey(Schema.Boolean),
86
+ valid: Schema.optionalKey(Schema.Boolean),
87
+ formValid: Schema.optionalKey(Schema.Boolean),
88
+ });
89
+ const ActionNetworkState = Schema.Struct({
90
+ total: Schema.Natural,
91
+ status2xx: Schema.Natural,
92
+ status3xx: Schema.Natural,
93
+ status4xx: Schema.Natural,
94
+ status5xx: Schema.Natural,
95
+ failed: Schema.Natural,
96
+ pending: Schema.Natural,
97
+ settleTimedOut: Schema.Boolean,
98
+ });
99
+ const ActionObservation = Schema.Struct({
100
+ before: ActionTargetState,
101
+ after: Schema.optionalKey(ActionTargetState),
102
+ afterUnavailable: Schema.Boolean,
103
+ network: ActionNetworkState,
104
+ });
105
+ const PageObservation = Schema.fromJsonString(
106
+ Schema.Struct({
107
+ pageText: BoundedRemoteText,
108
+ selectorMatchCount: Schema.Natural,
109
+ controlsTruncated: Schema.Boolean,
110
+ controls: Schema.Array(
111
+ Schema.Struct({
112
+ selector: BoundedRemoteText,
113
+ kind: BoundedRemoteText,
114
+ label: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(200))),
115
+ checked: Schema.optionalKey(Schema.Boolean),
116
+ selected: Schema.optionalKey(Schema.Boolean),
117
+ disabled: Schema.optionalKey(Schema.Boolean),
118
+ required: Schema.optionalKey(Schema.Boolean),
119
+ valid: Schema.optionalKey(Schema.Boolean),
120
+ formValid: Schema.optionalKey(Schema.Boolean),
121
+ }),
122
+ ).check(Schema.isMaxLength(MAX_OBSERVED_CONTROLS)),
123
+ }),
124
+ );
72
125
  const PngBytes = Schema.Uint8Array.check(
73
126
  Schema.isMaxLength(MAX_SCREENSHOT_BYTES),
74
127
  Schema.makeFilter(
@@ -132,6 +185,38 @@ const HandoffStateObservation = Schema.Union([
132
185
  }),
133
186
  ]);
134
187
 
188
+ /**
189
+ * Host presentation state in CSS pixels. Dimensions are integers in 1..2048,
190
+ * density is finite in 1..2 (default 1), and neither scaled dimension may exceed
191
+ * 2048 pixels. Mobile, touch, and orientation emulation are not supported.
192
+ */
193
+ export class BrowserRunViewport extends Schema.Class<BrowserRunViewport>("BrowserRunViewport")(
194
+ Schema.Struct({
195
+ width: PositiveInt.check(Schema.isLessThanOrEqualTo(2_048)),
196
+ height: PositiveInt.check(Schema.isLessThanOrEqualTo(2_048)),
197
+ deviceScaleFactor: Schema.optionalKey(
198
+ Schema.Finite.check(Schema.isBetween({ minimum: 1, maximum: 2 })),
199
+ ),
200
+ }).check(
201
+ Schema.makeFilter(
202
+ (viewport) =>
203
+ Math.max(viewport.width, viewport.height) * (viewport.deviceScaleFactor ?? 1) <= 2_048,
204
+ { title: "a viewport with scaled dimensions no larger than 2048 pixels" },
205
+ ),
206
+ ),
207
+ ) {}
208
+
209
+ const decodeViewport = (input: BrowserRunViewport) =>
210
+ Schema.decodeUnknownEffect(BrowserRunViewport)(input, { onExcessProperty: "error" }).pipe(
211
+ Effect.mapError(() => policyError("The browser viewport is malformed")),
212
+ // Copy only presentation fields, including for Schema class instances.
213
+ Effect.map((viewport) => ({
214
+ width: viewport.width,
215
+ height: viewport.height,
216
+ deviceScaleFactor: viewport.deviceScaleFactor ?? 1,
217
+ })),
218
+ );
219
+
135
220
  /** Host-only request for a redacted Cloudflare Live View URL. */
136
221
  export class BrowserRunLiveViewRequest extends Schema.Class<BrowserRunLiveViewRequest>(
137
222
  "BrowserRunLiveViewRequest",
@@ -209,10 +294,20 @@ export interface BrowserRunInteractivePage {
209
294
  readonly goto: (url: string) => Promise<void>;
210
295
  readonly url: () => unknown;
211
296
  readonly readText: (selector: string | undefined, maximumBytes: number) => Promise<unknown>;
212
- readonly fill: (selector: string, value: string) => Promise<void>;
213
- readonly click: (selector: string) => Promise<void>;
297
+ readonly fill: (
298
+ selector: string,
299
+ value: string,
300
+ signal: AbortSignal,
301
+ onDispatch: () => void,
302
+ ) => Promise<unknown>;
303
+ readonly click: (
304
+ selector: string,
305
+ signal: AbortSignal,
306
+ onDispatch: () => void,
307
+ ) => Promise<unknown>;
214
308
  readonly screenshot: (fullPage: boolean) => Promise<unknown>;
215
309
  readonly scroll: (deltaX: number, deltaY: number) => Promise<void>;
310
+ readonly setViewport: (viewport: BrowserRunViewport) => Promise<void>;
216
311
  readonly createCdpSession: () => Promise<BrowserRunInteractiveCdpSession>;
217
312
  }
218
313
 
@@ -240,23 +335,40 @@ export class BrowserRunInteractiveBinding extends Context.Service<
240
335
  >()("@effect-agent/platform-cloudflare/BrowserRunInteractiveBinding") {
241
336
  static layer(options: {
242
337
  readonly browser: BrowserRun;
243
- }): Layer.Layer<BrowserRunInteractiveBinding> {
244
- return Layer.succeed(BrowserRunInteractiveBinding)({
245
- launch: async (keepAliveMillis) =>
246
- makeProductionBrowser(
247
- await puppeteer.launch(options.browser, {
248
- keep_alive: keepAliveMillis,
249
- }),
250
- ),
251
- connect: async (sessionId) =>
252
- makeProductionBrowser(await puppeteer.connect(options.browser, sessionId)),
253
- });
338
+ readonly viewport?: BrowserRunViewport;
339
+ }): Layer.Layer<BrowserRunInteractiveBinding, InteractiveBrowserPolicyDeniedError> {
340
+ return Layer.effect(BrowserRunInteractiveBinding)(
341
+ Effect.gen(function* () {
342
+ const viewport =
343
+ options.viewport === undefined ? undefined : yield* decodeViewport(options.viewport);
344
+ return {
345
+ launch: async (keepAliveMillis: number) =>
346
+ makeProductionBrowser(
347
+ await puppeteer.launch(options.browser, {
348
+ keep_alive: keepAliveMillis,
349
+ ...(viewport === undefined ? {} : { defaultViewport: { ...viewport } }),
350
+ }),
351
+ ),
352
+ connect: async (sessionId: string) =>
353
+ makeProductionBrowser(await puppeteer.connect(options.browser, sessionId)),
354
+ };
355
+ }),
356
+ );
254
357
  }
255
358
  }
256
359
 
257
360
  export interface BrowserRunInteractiveSession {
258
361
  readonly handle: BrowserHandle;
259
362
  readonly sessionId: Redacted.Redacted<string>;
363
+ /**
364
+ * Resize without charging an agent action or changing emulation modes. Shares
365
+ * the handle's fail-fast lock, page-policy preflight, and elapsed deadline.
366
+ * A timeout or interruption leaves the session unusable; no resize is retried.
367
+ * The host must authorize callers. No viewer ownership or durable state is added.
368
+ */
369
+ readonly resizeViewport: (
370
+ viewport: BrowserRunViewport,
371
+ ) => Effect.Effect<void, InteractiveBrowserError>;
260
372
  readonly getLiveView: (
261
373
  request: BrowserRunLiveViewRequest,
262
374
  ) => Effect.Effect<BrowserRunLiveViewResult, InteractiveBrowserError>;
@@ -294,6 +406,288 @@ const makeProductionCdpSession = (session: CDPSession): BrowserRunInteractiveCdp
294
406
  detach: () => session.detach(),
295
407
  });
296
408
 
409
+ class BrowserRunActionUndispatched extends Error {
410
+ readonly matchCount: number;
411
+
412
+ constructor(matchCount: number) {
413
+ super("The browser action selector did not resolve to exactly one element");
414
+ this.matchCount = matchCount;
415
+ }
416
+ }
417
+
418
+ const readActionTarget = (page: Page, selector: string): Promise<unknown> =>
419
+ page.evaluate((requestedSelector) => {
420
+ const pageDocument = Reflect.get(globalThis, "document");
421
+ let matches;
422
+ try {
423
+ matches = Reflect.apply(Reflect.get(pageDocument, "querySelectorAll"), pageDocument, [
424
+ requestedSelector,
425
+ ]);
426
+ } catch (cause) {
427
+ if (cause instanceof Error && cause.name === "SyntaxError") {
428
+ return { matchCount: 0, invalidSelector: true };
429
+ }
430
+ throw cause;
431
+ }
432
+ if (typeof matches !== "object" || matches === null) throw new Error("Invalid query result");
433
+ const matchCount = Math.min(10_000, Reflect.get(matches, "length"));
434
+ const element = Reflect.get(matches, 0);
435
+ if (element === undefined) return { matchCount };
436
+
437
+ const associated = Reflect.get(element, "control") ?? element;
438
+ const tagName = String(Reflect.get(associated, "tagName") ?? "").toLowerCase();
439
+ const inputType = String(Reflect.get(associated, "type") ?? "").toLowerCase();
440
+ const role = String(
441
+ Reflect.apply(Reflect.get(element, "getAttribute"), element, ["role"]) ?? "",
442
+ ).toLowerCase();
443
+ const kind =
444
+ tagName === "button" || role === "button"
445
+ ? "button"
446
+ : inputType === "checkbox" || role === "checkbox" || role === "switch"
447
+ ? "checkbox"
448
+ : inputType === "radio" || role === "radio"
449
+ ? "radio"
450
+ : tagName === "select"
451
+ ? "select"
452
+ : tagName === "input" || tagName === "textarea"
453
+ ? "text"
454
+ : tagName === "a"
455
+ ? "link"
456
+ : "other";
457
+ const checked = Reflect.get(associated, "checked");
458
+ const selected =
459
+ tagName === "select"
460
+ ? Reflect.get(associated, "selectedIndex") >= 0
461
+ : Reflect.get(associated, "selected");
462
+ const disabled = Reflect.get(associated, "disabled");
463
+ const required = Reflect.get(associated, "required");
464
+ const validity = Reflect.get(associated, "validity");
465
+ const form = Reflect.get(associated, "form");
466
+ const formMatches =
467
+ form === null || form === undefined ? undefined : Reflect.get(form, "matches");
468
+ const ariaChecked = Reflect.apply(Reflect.get(element, "getAttribute"), element, [
469
+ "aria-checked",
470
+ ]);
471
+ const ariaDisabled = Reflect.apply(Reflect.get(element, "getAttribute"), element, [
472
+ "aria-disabled",
473
+ ]);
474
+ const ariaSelected = Reflect.apply(Reflect.get(element, "getAttribute"), element, [
475
+ "aria-selected",
476
+ ]);
477
+
478
+ return {
479
+ matchCount,
480
+ kind,
481
+ ...(typeof checked === "boolean"
482
+ ? { checked }
483
+ : ariaChecked === "true" || ariaChecked === "false"
484
+ ? { checked: ariaChecked === "true" }
485
+ : {}),
486
+ ...(typeof selected === "boolean"
487
+ ? { selected }
488
+ : ariaSelected === "true" || ariaSelected === "false"
489
+ ? { selected: ariaSelected === "true" }
490
+ : {}),
491
+ disabled: typeof disabled === "boolean" ? disabled : ariaDisabled === "true",
492
+ ...(typeof required === "boolean" ? { required } : {}),
493
+ ...(validity !== undefined && typeof Reflect.get(validity, "valid") === "boolean"
494
+ ? { valid: Reflect.get(validity, "valid") }
495
+ : {}),
496
+ ...(typeof formMatches === "function"
497
+ ? { formValid: Reflect.apply(formMatches, form, [":valid"]) }
498
+ : {}),
499
+ };
500
+ }, selector);
501
+
502
+ // SDK promises cannot be cancelled. Clear our timer and observe late rejections;
503
+ // the owning browser Scope is responsible for terminating the remote session.
504
+ const boundedBestEffort = async <A>(
505
+ promise: Promise<A>,
506
+ millis: number,
507
+ ): Promise<A | undefined> => {
508
+ let timer: ReturnType<typeof setTimeout> | number | undefined;
509
+ try {
510
+ return await Promise.race([
511
+ promise.catch(() => undefined),
512
+ new Promise<undefined>((resolve) => {
513
+ timer = setTimeout(resolve, millis);
514
+ }),
515
+ ]);
516
+ } finally {
517
+ clearTimeout(timer);
518
+ }
519
+ };
520
+
521
+ const readActionTargetAfter = (page: Page, selector: string) =>
522
+ boundedBestEffort(
523
+ readActionTarget(page, selector).then(Schema.decodeUnknownSync(ActionTargetState)),
524
+ ACTION_POST_STATE_MILLIS,
525
+ );
526
+
527
+ const makeActionRequestTracker = (page: Page, signal: AbortSignal) => {
528
+ const pending = new Set<HTTPRequest>();
529
+ let total = 0;
530
+ let status2xx = 0;
531
+ let status3xx = 0;
532
+ let status4xx = 0;
533
+ let status5xx = 0;
534
+ let failed = 0;
535
+ let lastChange = performance.now();
536
+ let closed = false;
537
+ let wake: (() => void) | undefined;
538
+ let timer: ReturnType<typeof setTimeout> | number | undefined;
539
+
540
+ const relevant = (request: HTTPRequest) => {
541
+ const resourceType = request.resourceType();
542
+ return resourceType === "fetch" || resourceType === "xhr";
543
+ };
544
+ const onRequest = (request: HTTPRequest) => {
545
+ if (!relevant(request)) return;
546
+ pending.add(request);
547
+ total++;
548
+ lastChange = performance.now();
549
+ };
550
+ const onFinished = (request: HTTPRequest) => {
551
+ if (!pending.delete(request)) return;
552
+ let status: number | undefined;
553
+ try {
554
+ status = request.response()?.status();
555
+ } catch {
556
+ // Provider response details remain intentionally unavailable to diagnostics.
557
+ }
558
+ if (status !== undefined) {
559
+ if (status >= 200 && status < 300) status2xx++;
560
+ else if (status >= 300 && status < 400) status3xx++;
561
+ else if (status >= 400 && status < 500) status4xx++;
562
+ else if (status >= 500 && status < 600) status5xx++;
563
+ }
564
+ lastChange = performance.now();
565
+ };
566
+ const onFailed = (request: HTTPRequest) => {
567
+ if (!pending.delete(request)) return;
568
+ failed++;
569
+ lastChange = performance.now();
570
+ };
571
+ const close = () => {
572
+ if (closed) return;
573
+ closed = true;
574
+ page.off("request", onRequest);
575
+ page.off("requestfinished", onFinished);
576
+ page.off("requestfailed", onFailed);
577
+ signal.removeEventListener("abort", close);
578
+ clearTimeout(timer);
579
+ wake?.();
580
+ };
581
+
582
+ try {
583
+ page.on("request", onRequest);
584
+ page.on("requestfinished", onFinished);
585
+ page.on("requestfailed", onFailed);
586
+ signal.addEventListener("abort", close, { once: true });
587
+ if (signal.aborted) close();
588
+ } catch (cause) {
589
+ close();
590
+ throw cause;
591
+ }
592
+
593
+ const wait = async () => {
594
+ const startedAt = performance.now();
595
+ let settleTimedOut = false;
596
+ while (!closed) {
597
+ const now = performance.now();
598
+ if (pending.size === 0 && now - lastChange >= ACTION_NETWORK_QUIET_MILLIS) break;
599
+ if (now - startedAt >= ACTION_NETWORK_SETTLE_MILLIS) {
600
+ settleTimedOut = true;
601
+ break;
602
+ }
603
+ await new Promise<void>((resolve) => {
604
+ wake = resolve;
605
+ timer = setTimeout(() => resolve(), 50);
606
+ });
607
+ wake = undefined;
608
+ }
609
+ return {
610
+ total,
611
+ status2xx,
612
+ status3xx,
613
+ status4xx,
614
+ status5xx,
615
+ failed,
616
+ pending: pending.size,
617
+ settleTimedOut,
618
+ };
619
+ };
620
+
621
+ return {
622
+ wait,
623
+ close,
624
+ markActionSettled: () => {
625
+ lastChange = performance.now();
626
+ },
627
+ };
628
+ };
629
+
630
+ const disposeActionHandles = async (handles: ReadonlyArray<{ dispose: () => Promise<void> }>) => {
631
+ await boundedBestEffort(
632
+ Promise.allSettled(handles.map(async (handle) => handle.dispose())),
633
+ ACTION_POST_STATE_MILLIS,
634
+ );
635
+ };
636
+
637
+ const runObservedPageAction = async (
638
+ page: Page,
639
+ selector: string,
640
+ signal: AbortSignal,
641
+ onDispatch: () => void,
642
+ action: (element: NonNullable<Awaited<ReturnType<Page["$"]>>>) => Promise<void>,
643
+ ): Promise<unknown> => {
644
+ if (signal.aborted) throw new BrowserRunActionUndispatched(0);
645
+ const before = Schema.decodeUnknownSync(ActionTargetState)(
646
+ await readActionTarget(page, selector),
647
+ );
648
+ if (before.matchCount !== 1 || before.invalidSelector === true) {
649
+ throw new BrowserRunActionUndispatched(before.matchCount);
650
+ }
651
+ const matches = await page.$$(selector);
652
+ if (matches.length !== 1 || matches[0] === undefined) {
653
+ await disposeActionHandles(matches);
654
+ throw new BrowserRunActionUndispatched(Math.min(10_000, matches.length));
655
+ }
656
+ if (signal.aborted) {
657
+ await disposeActionHandles(matches);
658
+ throw new BrowserRunActionUndispatched(1);
659
+ }
660
+
661
+ let tracker: ReturnType<typeof makeActionRequestTracker> | undefined;
662
+ let disposing: Promise<void> | undefined;
663
+ const dispose = () => (disposing ??= disposeActionHandles(matches));
664
+ const onAbort = () => {
665
+ void dispose();
666
+ };
667
+ signal.addEventListener("abort", onAbort, { once: true });
668
+ try {
669
+ tracker = makeActionRequestTracker(page, signal);
670
+ // No await between the final cancellation fence and SDK dispatch. Once
671
+ // dispatched, interruption is uncertain, even if the SDK later resolves.
672
+ if (signal.aborted) throw new BrowserRunActionUndispatched(1);
673
+ onDispatch();
674
+ await action(matches[0]);
675
+ tracker.markActionSettled();
676
+ const network = await tracker.wait();
677
+ const after = signal.aborted ? undefined : await readActionTargetAfter(page, selector);
678
+ return {
679
+ before,
680
+ ...(after === undefined ? {} : { after }),
681
+ afterUnavailable: after === undefined,
682
+ network,
683
+ };
684
+ } finally {
685
+ tracker?.close();
686
+ signal.removeEventListener("abort", onAbort);
687
+ await dispose();
688
+ }
689
+ };
690
+
297
691
  const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
298
692
  const listeners = new Map<BrowserRunInteractiveRequestListener, (request: HTTPRequest) => void>();
299
693
  return {
@@ -313,38 +707,234 @@ const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
313
707
  }
314
708
  },
315
709
  goto: async (url) => {
316
- await page.goto(url, { waitUntil: "networkidle0", timeout: 0 });
710
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
317
711
  },
318
712
  url: () => page.url(),
319
- readText: (selector, maximumBytes) =>
320
- page.evaluate(
321
- (requestedSelector, maximum) => {
322
- const pageDocument = Reflect.get(globalThis, "document");
323
- const element =
324
- requestedSelector === undefined
325
- ? Reflect.get(pageDocument, "body")
326
- : Reflect.apply(Reflect.get(pageDocument, "querySelector"), pageDocument, [
327
- requestedSelector,
328
- ]);
329
- if (element === null) return { _tag: "MissingElement" };
330
- const innerText = Reflect.get(element, "innerText");
331
- const textContent = Reflect.get(element, "textContent");
332
- const text =
333
- typeof innerText === "string"
334
- ? innerText
335
- : typeof textContent === "string"
336
- ? textContent
337
- : "";
338
- const observed = new TextEncoder().encode(text).byteLength;
339
- return observed > maximum ? { _tag: "OverLimit", observed } : { _tag: "Text", text };
340
- },
341
- selector,
342
- maximumBytes,
343
- ),
344
- fill: async (selector, value) => {
345
- await page.$eval(
346
- selector,
347
- (element, nextValue) => {
713
+ readText: async (selector, maximumBytes) => {
714
+ const observation = Schema.decodeUnknownSync(TextObservation)(
715
+ await page.evaluate(
716
+ (requestedSelector, maximum, maximumControls) => {
717
+ const pageDocument = Reflect.get(globalThis, "document");
718
+ const matches =
719
+ requestedSelector === undefined
720
+ ? undefined
721
+ : Reflect.apply(Reflect.get(pageDocument, "querySelectorAll"), pageDocument, [
722
+ requestedSelector,
723
+ ]);
724
+ const selectorMatchCount =
725
+ matches === undefined || matches === null
726
+ ? 1
727
+ : Math.min(10_000, Reflect.get(matches, "length"));
728
+ const element =
729
+ matches === undefined || matches === null
730
+ ? Reflect.get(pageDocument, "body")
731
+ : Reflect.get(matches, 0);
732
+ if (element === null) return { _tag: "MissingElement" };
733
+ if (element === undefined) return { _tag: "MissingElement" };
734
+ const innerText = Reflect.get(element, "innerText");
735
+ const textContent = Reflect.get(element, "textContent");
736
+ const pageText =
737
+ typeof innerText === "string"
738
+ ? innerText
739
+ : typeof textContent === "string"
740
+ ? textContent
741
+ : "";
742
+ const primaryControlSelector =
743
+ 'input,select,textarea,label,button,[role="checkbox"],[role="radio"],[role="option"],[role="switch"],[role="tab"],[role="button"]';
744
+ const optionSelector = "select option";
745
+ const secondaryControlSelector = "a[href]";
746
+ const controlSelector = `${primaryControlSelector},${optionSelector},${secondaryControlSelector}`;
747
+ const selectorFor = (candidate: object) => {
748
+ const parts: Array<string> = [];
749
+ let current: object | null = candidate;
750
+ while (current !== null && current !== undefined) {
751
+ const tagName = String(Reflect.get(current, "tagName") ?? "").toLowerCase();
752
+ if (tagName === "") break;
753
+ const parent: object | null = Reflect.get(current, "parentElement");
754
+ if (parent === null) {
755
+ parts.push(tagName);
756
+ break;
757
+ }
758
+ let sibling = Reflect.get(current, "previousElementSibling");
759
+ let index = 1;
760
+ while (sibling !== null && sibling !== undefined) {
761
+ if (String(Reflect.get(sibling, "tagName") ?? "").toLowerCase() === tagName)
762
+ index++;
763
+ sibling = Reflect.get(sibling, "previousElementSibling");
764
+ }
765
+ parts.push(`${tagName}:nth-of-type(${index})`);
766
+ current = parent;
767
+ }
768
+ return parts.reverse().join(" > ");
769
+ };
770
+ const visible = (candidate: object) => {
771
+ const hidden = Reflect.get(candidate, "hidden");
772
+ const ariaHidden = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
773
+ "aria-hidden",
774
+ ]);
775
+ const rects = Reflect.apply(Reflect.get(candidate, "getClientRects"), candidate, []);
776
+ return (
777
+ hidden !== true &&
778
+ ariaHidden !== "true" &&
779
+ typeof rects === "object" &&
780
+ rects !== null &&
781
+ Reflect.get(rects, "length") > 0
782
+ );
783
+ };
784
+ const candidates: Array<object> = [];
785
+ let controlsTruncated = false;
786
+ const consider = (candidate: object) => {
787
+ const tagName = String(Reflect.get(candidate, "tagName") ?? "").toLowerCase();
788
+ // Collapsed native options have no client rects. Their owning
789
+ // select determines visibility; observe text/selection, never value.
790
+ const visibilityTarget =
791
+ tagName === "option"
792
+ ? Reflect.apply(Reflect.get(candidate, "closest"), candidate, ["select"])
793
+ : candidate;
794
+ const actionable = tagName !== "label" || Reflect.get(candidate, "control") !== null;
795
+ if (
796
+ !actionable ||
797
+ typeof visibilityTarget !== "object" ||
798
+ visibilityTarget === null ||
799
+ !visible(visibilityTarget)
800
+ )
801
+ return;
802
+ candidates.push(candidate);
803
+ if (candidates.length > maximumControls) controlsTruncated = true;
804
+ };
805
+ const elementMatches = Reflect.get(element, "matches");
806
+ if (
807
+ typeof elementMatches === "function" &&
808
+ Reflect.apply(elementMatches, element, [controlSelector])
809
+ ) {
810
+ consider(element);
811
+ }
812
+ const considerSelector = (candidateSelector: string) => {
813
+ const descendants = Reflect.apply(Reflect.get(element, "querySelectorAll"), element, [
814
+ candidateSelector,
815
+ ]);
816
+ if (typeof descendants !== "object" || descendants === null) return;
817
+ const descendantCount = Reflect.get(descendants, "length");
818
+ for (let index = 0; index < descendantCount && !controlsTruncated; index++) {
819
+ consider(Reflect.get(descendants, index));
820
+ }
821
+ };
822
+ considerSelector(primaryControlSelector);
823
+ if (!controlsTruncated) considerSelector(optionSelector);
824
+ if (!controlsTruncated) considerSelector(secondaryControlSelector);
825
+ const controls = candidates.slice(0, maximumControls).map((candidate) => {
826
+ const associated = Reflect.get(candidate, "control") ?? candidate;
827
+ const tagName = String(Reflect.get(candidate, "tagName") ?? "").toLowerCase();
828
+ const inputType = String(Reflect.get(associated, "type") ?? "").toLowerCase();
829
+ const role = String(
830
+ Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, ["role"]) ?? "",
831
+ ).toLowerCase();
832
+ const ariaLabel = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
833
+ "aria-label",
834
+ ]);
835
+ const candidateText =
836
+ tagName === "textarea" || tagName === "input" || tagName === "select"
837
+ ? undefined
838
+ : Reflect.get(candidate, tagName === "option" ? "label" : "innerText");
839
+ const associatedLabels = Reflect.get(associated, "labels");
840
+ const associatedLabel =
841
+ associatedLabels !== undefined &&
842
+ associatedLabels !== null &&
843
+ Reflect.get(associatedLabels, "length") > 0
844
+ ? Reflect.get(Reflect.get(associatedLabels, 0), "innerText")
845
+ : undefined;
846
+ const label = String(
847
+ typeof ariaLabel === "string" && ariaLabel !== ""
848
+ ? ariaLabel
849
+ : typeof candidateText === "string" && candidateText !== ""
850
+ ? candidateText
851
+ : (associatedLabel ?? ""),
852
+ )
853
+ .replace(/\s+/g, " ")
854
+ .trim()
855
+ .slice(0, 200);
856
+ const checked = Reflect.get(associated, "checked");
857
+ const selected =
858
+ tagName === "select"
859
+ ? Reflect.get(associated, "selectedIndex") >= 0
860
+ : Reflect.get(associated, "selected");
861
+ const disabled = Reflect.get(associated, "disabled");
862
+ const required = Reflect.get(associated, "required");
863
+ const validity = Reflect.get(associated, "validity");
864
+ const form = Reflect.get(associated, "form");
865
+ const formMatches =
866
+ form === null || form === undefined ? undefined : Reflect.get(form, "matches");
867
+ const ariaChecked = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
868
+ "aria-checked",
869
+ ]);
870
+ const ariaSelected = Reflect.apply(
871
+ Reflect.get(candidate, "getAttribute"),
872
+ candidate,
873
+ ["aria-selected"],
874
+ );
875
+ const ariaDisabled = Reflect.apply(
876
+ Reflect.get(candidate, "getAttribute"),
877
+ candidate,
878
+ ["aria-disabled"],
879
+ );
880
+
881
+ return {
882
+ selector: selectorFor(candidate),
883
+ kind:
884
+ tagName === "label"
885
+ ? `label:${inputType || "control"}`
886
+ : tagName === "input"
887
+ ? `input:${inputType || "text"}`
888
+ : role !== ""
889
+ ? `role:${role}`
890
+ : tagName,
891
+ ...(label === "" ? {} : { label }),
892
+ ...(typeof checked === "boolean"
893
+ ? { checked }
894
+ : ariaChecked === "true" || ariaChecked === "false"
895
+ ? { checked: ariaChecked === "true" }
896
+ : {}),
897
+ ...(typeof selected === "boolean"
898
+ ? { selected }
899
+ : ariaSelected === "true" || ariaSelected === "false"
900
+ ? { selected: ariaSelected === "true" }
901
+ : {}),
902
+ ...(typeof disabled === "boolean"
903
+ ? { disabled }
904
+ : ariaDisabled === "true" || ariaDisabled === "false"
905
+ ? { disabled: ariaDisabled === "true" }
906
+ : {}),
907
+ ...(typeof required === "boolean" ? { required } : {}),
908
+ ...(validity !== undefined && typeof Reflect.get(validity, "valid") === "boolean"
909
+ ? { valid: Reflect.get(validity, "valid") }
910
+ : {}),
911
+ ...(typeof formMatches === "function"
912
+ ? { formValid: Reflect.apply(formMatches, form, [":valid"]) }
913
+ : {}),
914
+ };
915
+ });
916
+ // JSON is the bounded wire representation of this existing text result.
917
+ // eslint-disable-next-line no-restricted-properties
918
+ const text = JSON.stringify({
919
+ pageText,
920
+ selectorMatchCount,
921
+ controls,
922
+ controlsTruncated,
923
+ });
924
+ const observed = new TextEncoder().encode(text).byteLength;
925
+ return observed > maximum ? { _tag: "OverLimit", observed } : { _tag: "Text", text };
926
+ },
927
+ selector,
928
+ maximumBytes,
929
+ MAX_OBSERVED_CONTROLS,
930
+ ),
931
+ );
932
+ if (observation._tag === "Text") Schema.decodeUnknownSync(PageObservation)(observation.text);
933
+ return observation;
934
+ },
935
+ fill: (selector, value, signal, onDispatch) =>
936
+ runObservedPageAction(page, selector, signal, onDispatch, (element) =>
937
+ element.evaluate((element, nextValue) => {
348
938
  // Bypass instance setters so React can detect the change when events fire.
349
939
  let prototype = Reflect.getPrototypeOf(element);
350
940
  let setValue: ((value: string) => void) | undefined;
@@ -367,11 +957,10 @@ const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
367
957
  Reflect.apply(dispatchEvent, element, [new Event("input", { bubbles: true })]);
368
958
  Reflect.apply(dispatchEvent, element, [new Event("change", { bubbles: true })]);
369
959
  }
370
- },
371
- value,
372
- );
373
- },
374
- click: (selector) => page.click(selector),
960
+ }, value),
961
+ ),
962
+ click: (selector, signal, onDispatch) =>
963
+ runObservedPageAction(page, selector, signal, onDispatch, (element) => element.click()),
375
964
  // Puppeteer materializes the complete image before returning. The adapter
376
965
  // validates the 8 MiB Schema ceiling and pass limit immediately afterward.
377
966
  screenshot: (fullPage) => page.screenshot({ type: "png", fullPage }),
@@ -385,6 +974,7 @@ const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
385
974
  deltaY,
386
975
  ),
387
976
  createCdpSession: async () => makeProductionCdpSession(await page.createCDPSession()),
977
+ setViewport: (viewport) => page.setViewport(viewport),
388
978
  };
389
979
  };
390
980
 
@@ -421,6 +1011,20 @@ const actionError = (operation: BrowserOperation, cause?: unknown): InteractiveB
421
1011
  ...(cause === undefined ? {} : { cause }),
422
1012
  });
423
1013
 
1014
+ const undispatchedActionError = (operation: BrowserOperation): InteractiveBrowserActionError =>
1015
+ InteractiveBrowserActionError.make({
1016
+ implementation: browserRunInteractiveImplementation,
1017
+ operation,
1018
+ message: `The interactive browser ${operation} operation was not dispatched`,
1019
+ });
1020
+
1021
+ /** Recognizes a local pre-dispatch refusal without exposing selector or page content. */
1022
+ export const isBrowserRunUndispatchedActionError = (error: unknown): boolean =>
1023
+ Schema.is(InteractiveBrowserActionError)(error) &&
1024
+ error.implementation.identity === browserRunInteractiveImplementation.identity &&
1025
+ (error.operation === "click" || error.operation === "fill") &&
1026
+ error.message === `The interactive browser ${error.operation} operation was not dispatched`;
1027
+
424
1028
  const policyError = (message: string): InteractiveBrowserPolicyDeniedError =>
425
1029
  InteractiveBrowserPolicyDeniedError.make({
426
1030
  implementation: browserRunInteractiveImplementation,
@@ -590,9 +1194,18 @@ interface HandleRuntime {
590
1194
  readonly run: <A>(
591
1195
  effect: Effect.Effect<A, BrowserFailure>,
592
1196
  preflight?: Effect.Effect<void, BrowserFailure>,
1197
+ consumeAction?: boolean,
593
1198
  ) => Effect.Effect<A, BrowserFailure>;
594
1199
  }
595
1200
 
1201
+ class BrowserRunRemoteFailure {
1202
+ readonly cause: unknown;
1203
+
1204
+ constructor(cause: unknown) {
1205
+ this.cause = cause;
1206
+ }
1207
+ }
1208
+
596
1209
  const awaitPendingRequests = (state: HandleState): Effect.Effect<void> =>
597
1210
  Effect.suspend(() => {
598
1211
  const pending = [...state.pendingRequests];
@@ -685,21 +1298,141 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
685
1298
  const permits = yield* Semaphore.make(1);
686
1299
  const actions = yield* Ref.make(0);
687
1300
 
688
- const remote = <A>(operation: BrowserOperation, evaluate: () => Promise<A>) =>
1301
+ const remote = <A>(operation: BrowserOperation, evaluate: (signal: AbortSignal) => Promise<A>) =>
689
1302
  Effect.tryPromise({
690
1303
  try: evaluate,
691
- catch: (cause) => {
692
- if (state.disconnected.value || isRemoteClosure(cause)) {
693
- state.disconnected.value = true;
694
- return expiredError();
695
- }
696
- return actionError(operation, cause);
697
- },
1304
+ catch: (cause) => new BrowserRunRemoteFailure(cause),
1305
+ }).pipe(
1306
+ Effect.catch(
1307
+ (
1308
+ failure,
1309
+ ): Effect.Effect<never, InteractiveBrowserActionError | InteractiveBrowserExpiredError> => {
1310
+ const cause = failure.cause;
1311
+ if (cause instanceof BrowserRunActionUndispatched) {
1312
+ return Effect.logInfo("Browser interactive action was not dispatched").pipe(
1313
+ Effect.annotateLogs({
1314
+ "browser.action": operation,
1315
+ "browser.selector_match_count": cause.matchCount,
1316
+ }),
1317
+ Effect.andThen(Effect.fail(undispatchedActionError(operation))),
1318
+ );
1319
+ }
1320
+ if (state.disconnected.value || isRemoteClosure(cause)) {
1321
+ state.disconnected.value = true;
1322
+ return Effect.fail(expiredError());
1323
+ }
1324
+ return Effect.fail(actionError(operation, cause));
1325
+ },
1326
+ ),
1327
+ );
1328
+
1329
+ const observedAction = (
1330
+ operation: "fill" | "click",
1331
+ evaluate: (signal: AbortSignal, onDispatch: () => void) => Promise<unknown>,
1332
+ ) =>
1333
+ Effect.suspend(() => {
1334
+ let dispatched = false;
1335
+ let pending: Promise<unknown> | undefined;
1336
+ return remote(operation, (signal) => {
1337
+ pending = evaluate(signal, () => {
1338
+ dispatched = true;
1339
+ });
1340
+ return pending;
1341
+ }).pipe(
1342
+ Effect.onInterrupt(() =>
1343
+ Effect.gen(function* () {
1344
+ state.uncertain.value = true;
1345
+ // Retain evidence before Scope teardown, without claiming SDK
1346
+ // cancellation or success. A late completion never makes this replayable.
1347
+ yield* Effect.logWarning("Browser interactive action interrupted").pipe(
1348
+ Effect.annotateLogs({
1349
+ "browser.action": operation,
1350
+ "browser.action_dispatched": dispatched,
1351
+ "browser.action_outcome_unknown": dispatched,
1352
+ }),
1353
+ );
1354
+ const completion = pending;
1355
+ if (completion !== undefined) {
1356
+ yield* Effect.promise(() => boundedBestEffort(completion, 500));
1357
+ }
1358
+ }),
1359
+ ),
1360
+ );
698
1361
  });
699
1362
 
1363
+ const decodeActionObservation = Effect.fn("BrowserRunInteractive.decodeActionObservation")(
1364
+ function* (raw: unknown) {
1365
+ return yield* Schema.decodeUnknownEffect(ActionObservation)(raw).pipe(
1366
+ Effect.mapError((cause) =>
1367
+ protocolError("The browser returned a malformed action observation", cause),
1368
+ ),
1369
+ );
1370
+ },
1371
+ );
1372
+
1373
+ const logActionObservation = (
1374
+ operation: "fill" | "click",
1375
+ observation: typeof ActionObservation.Type,
1376
+ ) =>
1377
+ Effect.logInfo("Browser interactive action observed").pipe(
1378
+ Effect.annotateLogs({
1379
+ "browser.action": operation,
1380
+ "browser.selector_match_count": observation.before.matchCount,
1381
+ ...(observation.before.kind === undefined
1382
+ ? {}
1383
+ : { "browser.target_kind": observation.before.kind }),
1384
+ ...(observation.before.checked === undefined
1385
+ ? {}
1386
+ : { "browser.target_checked_before": observation.before.checked }),
1387
+ ...(observation.after?.checked === undefined
1388
+ ? {}
1389
+ : { "browser.target_checked_after": observation.after.checked }),
1390
+ ...(observation.before.selected === undefined
1391
+ ? {}
1392
+ : { "browser.target_selected_before": observation.before.selected }),
1393
+ ...(observation.after?.selected === undefined
1394
+ ? {}
1395
+ : { "browser.target_selected_after": observation.after.selected }),
1396
+ ...(observation.before.disabled === undefined
1397
+ ? {}
1398
+ : { "browser.target_disabled_before": observation.before.disabled }),
1399
+ ...(observation.after?.disabled === undefined
1400
+ ? {}
1401
+ : { "browser.target_disabled_after": observation.after.disabled }),
1402
+ ...(observation.before.required === undefined
1403
+ ? {}
1404
+ : { "browser.target_required_before": observation.before.required }),
1405
+ ...(observation.after?.required === undefined
1406
+ ? {}
1407
+ : { "browser.target_required_after": observation.after.required }),
1408
+ ...(observation.before.valid === undefined
1409
+ ? {}
1410
+ : { "browser.target_valid_before": observation.before.valid }),
1411
+ ...(observation.after?.valid === undefined
1412
+ ? {}
1413
+ : { "browser.target_valid_after": observation.after.valid }),
1414
+ ...(observation.before.formValid === undefined
1415
+ ? {}
1416
+ : { "browser.form_valid_before": observation.before.formValid }),
1417
+ ...(observation.after?.formValid === undefined
1418
+ ? {}
1419
+ : { "browser.form_valid_after": observation.after.formValid }),
1420
+ "browser.target_after_unavailable": observation.afterUnavailable,
1421
+ "browser.fetch_xhr_total": observation.network.total,
1422
+ "browser.fetch_xhr_2xx": observation.network.status2xx,
1423
+ "browser.fetch_xhr_3xx": observation.network.status3xx,
1424
+ "browser.fetch_xhr_4xx": observation.network.status4xx,
1425
+ "browser.fetch_xhr_5xx": observation.network.status5xx,
1426
+ "browser.fetch_xhr_failed": observation.network.failed,
1427
+ "browser.fetch_xhr_pending": observation.network.pending,
1428
+ "browser.network_settle_timed_out": observation.network.settleTimedOut,
1429
+ }),
1430
+ );
1431
+
700
1432
  const run = <A>(
701
1433
  effect: Effect.Effect<A, BrowserFailure>,
702
1434
  preflight: Effect.Effect<void, BrowserFailure> = Effect.void,
1435
+ consumeAction = true,
703
1436
  ) =>
704
1437
  permits
705
1438
  .withPermitsIfAvailable(1)(
@@ -708,19 +1441,21 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
708
1441
  if (unavailable !== undefined) return yield* unavailable;
709
1442
  yield* preflight;
710
1443
 
711
- const admitted = yield* Ref.modify(actions, (count) =>
712
- count >= policy.maxActions
713
- ? [{ allowed: false, observed: count + 1 }, count]
714
- : [{ allowed: true, observed: count + 1 }, count + 1],
715
- );
716
- if (!admitted.allowed) {
717
- return yield* InteractiveBrowserLimitError.make({
718
- implementation: browserRunInteractiveImplementation,
719
- limit: "actions",
720
- maximum: policy.maxActions,
721
- observed: admitted.observed,
722
- message: "The browser action limit was reached",
723
- });
1444
+ if (consumeAction) {
1445
+ const admitted = yield* Ref.modify(actions, (count) =>
1446
+ count >= policy.maxActions
1447
+ ? [{ allowed: false, observed: count + 1 }, count]
1448
+ : [{ allowed: true, observed: count + 1 }, count + 1],
1449
+ );
1450
+ if (!admitted.allowed) {
1451
+ return yield* InteractiveBrowserLimitError.make({
1452
+ implementation: browserRunInteractiveImplementation,
1453
+ limit: "actions",
1454
+ maximum: policy.maxActions,
1455
+ observed: admitted.observed,
1456
+ message: "The browser action limit was reached",
1457
+ });
1458
+ }
724
1459
  }
725
1460
 
726
1461
  const completed = effect.pipe(
@@ -750,6 +1485,12 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
750
1485
  return Effect.fail(error);
751
1486
  }
752
1487
  const failure = stateFailure(state);
1488
+ if (
1489
+ Schema.is(InteractiveBrowserActionError)(error) &&
1490
+ !isBrowserRunUndispatchedActionError(error)
1491
+ ) {
1492
+ state.uncertain.value = true;
1493
+ }
753
1494
  return Effect.fail(failure ?? error);
754
1495
  }),
755
1496
  );
@@ -825,15 +1566,23 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
825
1566
  ),
826
1567
  fill: (request) =>
827
1568
  run(
828
- remote("fill", () => page.fill(request.selector, request.value)).pipe(
829
- Effect.andThen(decodeActionResult(page, policy)),
830
- ),
1569
+ Effect.gen(function* () {
1570
+ const observation = yield* observedAction("fill", (signal, onDispatch) =>
1571
+ page.fill(request.selector, request.value, signal, onDispatch),
1572
+ ).pipe(Effect.flatMap(decodeActionObservation));
1573
+ yield* logActionObservation("fill", observation);
1574
+ return yield* decodeActionResult(page, policy);
1575
+ }),
831
1576
  ),
832
1577
  click: (request) =>
833
1578
  run(
834
- remote("click", () => page.click(request.selector)).pipe(
835
- Effect.andThen(decodeActionResult(page, policy)),
836
- ),
1579
+ Effect.gen(function* () {
1580
+ const observation = yield* observedAction("click", (signal, onDispatch) =>
1581
+ page.click(request.selector, signal, onDispatch),
1582
+ ).pipe(Effect.flatMap(decodeActionObservation));
1583
+ yield* logActionObservation("click", observation);
1584
+ return yield* decodeActionResult(page, policy);
1585
+ }),
837
1586
  ),
838
1587
  screenshot: (request) =>
839
1588
  Schema.decodeUnknownEffect(BrowserScreenshotRequest)(request).pipe(
@@ -1204,6 +1953,25 @@ const makeHostService = (
1204
1953
  return {
1205
1954
  handle: runtime.handle,
1206
1955
  sessionId: Redacted.make(sessionIdValue),
1956
+ resizeViewport: (viewport) =>
1957
+ decodeViewport(viewport).pipe(
1958
+ Effect.flatMap((decoded) =>
1959
+ runtime.run(
1960
+ Effect.tryPromise({
1961
+ try: () => page.setViewport(decoded),
1962
+ catch: (cause) => {
1963
+ if (state.disconnected.value || isRemoteClosure(cause)) {
1964
+ state.disconnected.value = true;
1965
+ return expiredError();
1966
+ }
1967
+ return protocolError("Resizing the browser viewport failed", cause);
1968
+ },
1969
+ }),
1970
+ currentPagePreflight,
1971
+ false,
1972
+ ),
1973
+ ),
1974
+ ),
1207
1975
  getLiveView: (request) =>
1208
1976
  Schema.decodeUnknownEffect(BrowserRunLiveViewRequest)(request).pipe(
1209
1977
  Effect.mapError(() => policyError("The Live View request is malformed")),