@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.
- package/dist/index.d.mts +9 -103
- package/dist/index.mjs +8 -824
- package/dist/index.mjs.map +1 -1
- package/dist/interactive-browser.d.mts +27 -5
- package/dist/interactive-browser.mjs +530 -69
- package/dist/interactive-browser.mjs.map +1 -1
- package/dist/scheduling-B-OFqoS9.mjs +1189 -0
- package/dist/scheduling-B-OFqoS9.mjs.map +1 -0
- package/dist/scheduling-BJs_kHTx.d.mts +142 -0
- package/dist/scheduling.d.mts +2 -0
- package/dist/scheduling.mjs +2 -0
- package/package.json +11 -10
- package/src/index.ts +1 -0
- package/src/interactive-browser.ts +843 -75
- package/src/layers.ts +1 -1
- package/src/scheduling.ts +676 -0
|
@@ -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: (
|
|
213
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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: "
|
|
710
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
317
711
|
},
|
|
318
712
|
url: () => page.url(),
|
|
319
|
-
readText: (selector, maximumBytes) =>
|
|
320
|
-
|
|
321
|
-
(
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
?
|
|
337
|
-
:
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
829
|
-
|
|
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
|
-
|
|
835
|
-
|
|
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")),
|