@effect-agent/platform-cloudflare 0.1.0-beta.36 → 0.1.0-beta.38
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 +14 -108
- package/dist/index.mjs +8 -824
- package/dist/index.mjs.map +1 -1
- package/dist/interactive-browser-BRo-Bfvb.d.mts +144 -0
- package/dist/interactive-browser-Cy5q-ldl.mjs +1154 -0
- package/dist/interactive-browser-Cy5q-ldl.mjs.map +1 -0
- package/dist/interactive-browser.d.mts +2 -102
- package/dist/interactive-browser.mjs +2 -618
- 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/browser-session-lifecycle.ts +142 -0
- package/src/index.ts +1 -0
- package/src/interactive-browser.ts +921 -142
- package/src/layers.ts +1 -1
- package/src/scheduling.ts +676 -0
- package/dist/interactive-browser.mjs.map +0 -1
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
} from "@effect-agent/sandbox";
|
|
33
33
|
import {
|
|
34
34
|
Context,
|
|
35
|
+
Clock,
|
|
35
36
|
Duration,
|
|
36
37
|
Effect,
|
|
37
38
|
Layer,
|
|
@@ -43,6 +44,9 @@ import {
|
|
|
43
44
|
type Scope,
|
|
44
45
|
} from "effect";
|
|
45
46
|
|
|
47
|
+
import { BrowserRunSessionLifecycle } from "./browser-session-lifecycle.ts";
|
|
48
|
+
export { BrowserRunCleanupError, BrowserRunSessionLifecycle } from "./browser-session-lifecycle.ts";
|
|
49
|
+
|
|
46
50
|
export const browserRunInteractiveImplementation = SandboxImplementation.make({
|
|
47
51
|
isolation: "isolated",
|
|
48
52
|
identity: "cloudflare-browser-run-interactive",
|
|
@@ -57,7 +61,10 @@ const MAX_LIVE_VIEW_EXPIRY_MILLIS = 60 * 60_000;
|
|
|
57
61
|
const MAX_HANDOFF_TIMEOUT_MILLIS = 30 * 60_000;
|
|
58
62
|
const MAX_HOST_TEXT_LENGTH = 8 * 1024;
|
|
59
63
|
const CLEANUP_STEP_TIMEOUT_MILLIS = 10_000;
|
|
60
|
-
const
|
|
64
|
+
const ACTION_NETWORK_QUIET_MILLIS = 200;
|
|
65
|
+
const ACTION_NETWORK_SETTLE_MILLIS = 2_000;
|
|
66
|
+
const ACTION_POST_STATE_MILLIS = 250;
|
|
67
|
+
const MAX_OBSERVED_CONTROLS = 64;
|
|
61
68
|
const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0));
|
|
62
69
|
const BoundedHostText = Schema.String.check(
|
|
63
70
|
Schema.isMinLength(1),
|
|
@@ -69,6 +76,55 @@ const TextObservation = Schema.Union([
|
|
|
69
76
|
Schema.Struct({ _tag: Schema.Literal("MissingElement") }),
|
|
70
77
|
Schema.Struct({ _tag: Schema.Literal("OverLimit"), observed: Schema.Natural }),
|
|
71
78
|
]);
|
|
79
|
+
const ActionTargetState = Schema.Struct({
|
|
80
|
+
matchCount: Schema.Natural,
|
|
81
|
+
invalidSelector: Schema.optionalKey(Schema.Boolean),
|
|
82
|
+
kind: Schema.optionalKey(
|
|
83
|
+
Schema.Literals(["button", "checkbox", "radio", "select", "text", "link", "other"]),
|
|
84
|
+
),
|
|
85
|
+
checked: Schema.optionalKey(Schema.Boolean),
|
|
86
|
+
selected: Schema.optionalKey(Schema.Boolean),
|
|
87
|
+
disabled: Schema.optionalKey(Schema.Boolean),
|
|
88
|
+
required: Schema.optionalKey(Schema.Boolean),
|
|
89
|
+
valid: Schema.optionalKey(Schema.Boolean),
|
|
90
|
+
formValid: Schema.optionalKey(Schema.Boolean),
|
|
91
|
+
});
|
|
92
|
+
const ActionNetworkState = Schema.Struct({
|
|
93
|
+
total: Schema.Natural,
|
|
94
|
+
status2xx: Schema.Natural,
|
|
95
|
+
status3xx: Schema.Natural,
|
|
96
|
+
status4xx: Schema.Natural,
|
|
97
|
+
status5xx: Schema.Natural,
|
|
98
|
+
failed: Schema.Natural,
|
|
99
|
+
pending: Schema.Natural,
|
|
100
|
+
settleTimedOut: Schema.Boolean,
|
|
101
|
+
});
|
|
102
|
+
const ActionObservation = Schema.Struct({
|
|
103
|
+
before: ActionTargetState,
|
|
104
|
+
after: Schema.optionalKey(ActionTargetState),
|
|
105
|
+
afterUnavailable: Schema.Boolean,
|
|
106
|
+
network: ActionNetworkState,
|
|
107
|
+
});
|
|
108
|
+
const PageObservation = Schema.fromJsonString(
|
|
109
|
+
Schema.Struct({
|
|
110
|
+
pageText: BoundedRemoteText,
|
|
111
|
+
selectorMatchCount: Schema.Natural,
|
|
112
|
+
controlsTruncated: Schema.Boolean,
|
|
113
|
+
controls: Schema.Array(
|
|
114
|
+
Schema.Struct({
|
|
115
|
+
selector: BoundedRemoteText,
|
|
116
|
+
kind: BoundedRemoteText,
|
|
117
|
+
label: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(200))),
|
|
118
|
+
checked: Schema.optionalKey(Schema.Boolean),
|
|
119
|
+
selected: Schema.optionalKey(Schema.Boolean),
|
|
120
|
+
disabled: Schema.optionalKey(Schema.Boolean),
|
|
121
|
+
required: Schema.optionalKey(Schema.Boolean),
|
|
122
|
+
valid: Schema.optionalKey(Schema.Boolean),
|
|
123
|
+
formValid: Schema.optionalKey(Schema.Boolean),
|
|
124
|
+
}),
|
|
125
|
+
).check(Schema.isMaxLength(MAX_OBSERVED_CONTROLS)),
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
72
128
|
const PngBytes = Schema.Uint8Array.check(
|
|
73
129
|
Schema.isMaxLength(MAX_SCREENSHOT_BYTES),
|
|
74
130
|
Schema.makeFilter(
|
|
@@ -132,6 +188,38 @@ const HandoffStateObservation = Schema.Union([
|
|
|
132
188
|
}),
|
|
133
189
|
]);
|
|
134
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Host presentation state in CSS pixels. Dimensions are integers in 1..2048,
|
|
193
|
+
* density is finite in 1..2 (default 1), and neither scaled dimension may exceed
|
|
194
|
+
* 2048 pixels. Mobile, touch, and orientation emulation are not supported.
|
|
195
|
+
*/
|
|
196
|
+
export class BrowserRunViewport extends Schema.Class<BrowserRunViewport>("BrowserRunViewport")(
|
|
197
|
+
Schema.Struct({
|
|
198
|
+
width: PositiveInt.check(Schema.isLessThanOrEqualTo(2_048)),
|
|
199
|
+
height: PositiveInt.check(Schema.isLessThanOrEqualTo(2_048)),
|
|
200
|
+
deviceScaleFactor: Schema.optionalKey(
|
|
201
|
+
Schema.Finite.check(Schema.isBetween({ minimum: 1, maximum: 2 })),
|
|
202
|
+
),
|
|
203
|
+
}).check(
|
|
204
|
+
Schema.makeFilter(
|
|
205
|
+
(viewport) =>
|
|
206
|
+
Math.max(viewport.width, viewport.height) * (viewport.deviceScaleFactor ?? 1) <= 2_048,
|
|
207
|
+
{ title: "a viewport with scaled dimensions no larger than 2048 pixels" },
|
|
208
|
+
),
|
|
209
|
+
),
|
|
210
|
+
) {}
|
|
211
|
+
|
|
212
|
+
const decodeViewport = (input: BrowserRunViewport) =>
|
|
213
|
+
Schema.decodeUnknownEffect(BrowserRunViewport)(input, { onExcessProperty: "error" }).pipe(
|
|
214
|
+
Effect.mapError(() => policyError("The browser viewport is malformed")),
|
|
215
|
+
// Copy only presentation fields, including for Schema class instances.
|
|
216
|
+
Effect.map((viewport) => ({
|
|
217
|
+
width: viewport.width,
|
|
218
|
+
height: viewport.height,
|
|
219
|
+
deviceScaleFactor: viewport.deviceScaleFactor ?? 1,
|
|
220
|
+
})),
|
|
221
|
+
);
|
|
222
|
+
|
|
135
223
|
/** Host-only request for a redacted Cloudflare Live View URL. */
|
|
136
224
|
export class BrowserRunLiveViewRequest extends Schema.Class<BrowserRunLiveViewRequest>(
|
|
137
225
|
"BrowserRunLiveViewRequest",
|
|
@@ -209,10 +297,20 @@ export interface BrowserRunInteractivePage {
|
|
|
209
297
|
readonly goto: (url: string) => Promise<void>;
|
|
210
298
|
readonly url: () => unknown;
|
|
211
299
|
readonly readText: (selector: string | undefined, maximumBytes: number) => Promise<unknown>;
|
|
212
|
-
readonly fill: (
|
|
213
|
-
|
|
300
|
+
readonly fill: (
|
|
301
|
+
selector: string,
|
|
302
|
+
value: string,
|
|
303
|
+
signal: AbortSignal,
|
|
304
|
+
onDispatch: () => void,
|
|
305
|
+
) => Promise<unknown>;
|
|
306
|
+
readonly click: (
|
|
307
|
+
selector: string,
|
|
308
|
+
signal: AbortSignal,
|
|
309
|
+
onDispatch: () => void,
|
|
310
|
+
) => Promise<unknown>;
|
|
214
311
|
readonly screenshot: (fullPage: boolean) => Promise<unknown>;
|
|
215
312
|
readonly scroll: (deltaX: number, deltaY: number) => Promise<void>;
|
|
313
|
+
readonly setViewport: (viewport: BrowserRunViewport) => Promise<void>;
|
|
216
314
|
readonly createCdpSession: () => Promise<BrowserRunInteractiveCdpSession>;
|
|
217
315
|
}
|
|
218
316
|
|
|
@@ -235,28 +333,55 @@ export class BrowserRunInteractiveBinding extends Context.Service<
|
|
|
235
333
|
BrowserRunInteractiveBinding,
|
|
236
334
|
{
|
|
237
335
|
readonly launch: (keepAliveMillis: number) => Promise<BrowserRunInteractiveBrowser>;
|
|
238
|
-
|
|
336
|
+
/** Success proves whole-browser termination or exact-session absence. */
|
|
337
|
+
readonly closeSession: (
|
|
338
|
+
sessionId: Redacted.Redacted<string>,
|
|
339
|
+
) => Effect.Effect<void, InteractiveBrowserError>;
|
|
239
340
|
}
|
|
240
341
|
>()("@effect-agent/platform-cloudflare/BrowserRunInteractiveBinding") {
|
|
241
342
|
static layer(options: {
|
|
242
343
|
readonly browser: BrowserRun;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
344
|
+
readonly viewport?: BrowserRunViewport;
|
|
345
|
+
}): Layer.Layer<
|
|
346
|
+
BrowserRunInteractiveBinding,
|
|
347
|
+
InteractiveBrowserPolicyDeniedError,
|
|
348
|
+
BrowserRunSessionLifecycle
|
|
349
|
+
> {
|
|
350
|
+
return Layer.effect(BrowserRunInteractiveBinding)(
|
|
351
|
+
Effect.gen(function* () {
|
|
352
|
+
const lifecycle = yield* BrowserRunSessionLifecycle;
|
|
353
|
+
const viewport =
|
|
354
|
+
options.viewport === undefined ? undefined : yield* decodeViewport(options.viewport);
|
|
355
|
+
return {
|
|
356
|
+
launch: async (keepAliveMillis: number) =>
|
|
357
|
+
makeProductionBrowser(
|
|
358
|
+
await puppeteer.launch(options.browser, {
|
|
359
|
+
keep_alive: keepAliveMillis,
|
|
360
|
+
...(viewport === undefined ? {} : { defaultViewport: { ...viewport } }),
|
|
361
|
+
}),
|
|
362
|
+
),
|
|
363
|
+
closeSession: (sessionId: Redacted.Redacted<string>) =>
|
|
364
|
+
lifecycle
|
|
365
|
+
.close(sessionId)
|
|
366
|
+
.pipe(Effect.mapError((cause) => actionError("close", cause))),
|
|
367
|
+
};
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
254
370
|
}
|
|
255
371
|
}
|
|
256
372
|
|
|
257
373
|
export interface BrowserRunInteractiveSession {
|
|
258
374
|
readonly handle: BrowserHandle;
|
|
259
375
|
readonly sessionId: Redacted.Redacted<string>;
|
|
376
|
+
/**
|
|
377
|
+
* Resize without charging an agent action or changing emulation modes. Shares
|
|
378
|
+
* the handle's fail-fast lock, page-policy preflight, and elapsed deadline.
|
|
379
|
+
* A timeout or interruption leaves the session unusable; no resize is retried.
|
|
380
|
+
* The host must authorize callers. No viewer ownership or durable state is added.
|
|
381
|
+
*/
|
|
382
|
+
readonly resizeViewport: (
|
|
383
|
+
viewport: BrowserRunViewport,
|
|
384
|
+
) => Effect.Effect<void, InteractiveBrowserError>;
|
|
260
385
|
readonly getLiveView: (
|
|
261
386
|
request: BrowserRunLiveViewRequest,
|
|
262
387
|
) => Effect.Effect<BrowserRunLiveViewResult, InteractiveBrowserError>;
|
|
@@ -271,6 +396,7 @@ export interface BrowserRunInteractiveSession {
|
|
|
271
396
|
export class BrowserRunInteractiveHost extends Context.Service<
|
|
272
397
|
BrowserRunInteractiveHost,
|
|
273
398
|
{
|
|
399
|
+
readonly cleanupSemantics?: "confirmed-terminal";
|
|
274
400
|
readonly open: (
|
|
275
401
|
policy: InteractiveBrowserPolicy,
|
|
276
402
|
) => Effect.Effect<BrowserRunInteractiveSession, InteractiveBrowserError, Scope.Scope>;
|
|
@@ -294,6 +420,288 @@ const makeProductionCdpSession = (session: CDPSession): BrowserRunInteractiveCdp
|
|
|
294
420
|
detach: () => session.detach(),
|
|
295
421
|
});
|
|
296
422
|
|
|
423
|
+
class BrowserRunActionUndispatched extends Error {
|
|
424
|
+
readonly matchCount: number;
|
|
425
|
+
|
|
426
|
+
constructor(matchCount: number) {
|
|
427
|
+
super("The browser action selector did not resolve to exactly one element");
|
|
428
|
+
this.matchCount = matchCount;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const readActionTarget = (page: Page, selector: string): Promise<unknown> =>
|
|
433
|
+
page.evaluate((requestedSelector) => {
|
|
434
|
+
const pageDocument = Reflect.get(globalThis, "document");
|
|
435
|
+
let matches;
|
|
436
|
+
try {
|
|
437
|
+
matches = Reflect.apply(Reflect.get(pageDocument, "querySelectorAll"), pageDocument, [
|
|
438
|
+
requestedSelector,
|
|
439
|
+
]);
|
|
440
|
+
} catch (cause) {
|
|
441
|
+
if (cause instanceof Error && cause.name === "SyntaxError") {
|
|
442
|
+
return { matchCount: 0, invalidSelector: true };
|
|
443
|
+
}
|
|
444
|
+
throw cause;
|
|
445
|
+
}
|
|
446
|
+
if (typeof matches !== "object" || matches === null) throw new Error("Invalid query result");
|
|
447
|
+
const matchCount = Math.min(10_000, Reflect.get(matches, "length"));
|
|
448
|
+
const element = Reflect.get(matches, 0);
|
|
449
|
+
if (element === undefined) return { matchCount };
|
|
450
|
+
|
|
451
|
+
const associated = Reflect.get(element, "control") ?? element;
|
|
452
|
+
const tagName = String(Reflect.get(associated, "tagName") ?? "").toLowerCase();
|
|
453
|
+
const inputType = String(Reflect.get(associated, "type") ?? "").toLowerCase();
|
|
454
|
+
const role = String(
|
|
455
|
+
Reflect.apply(Reflect.get(element, "getAttribute"), element, ["role"]) ?? "",
|
|
456
|
+
).toLowerCase();
|
|
457
|
+
const kind =
|
|
458
|
+
tagName === "button" || role === "button"
|
|
459
|
+
? "button"
|
|
460
|
+
: inputType === "checkbox" || role === "checkbox" || role === "switch"
|
|
461
|
+
? "checkbox"
|
|
462
|
+
: inputType === "radio" || role === "radio"
|
|
463
|
+
? "radio"
|
|
464
|
+
: tagName === "select"
|
|
465
|
+
? "select"
|
|
466
|
+
: tagName === "input" || tagName === "textarea"
|
|
467
|
+
? "text"
|
|
468
|
+
: tagName === "a"
|
|
469
|
+
? "link"
|
|
470
|
+
: "other";
|
|
471
|
+
const checked = Reflect.get(associated, "checked");
|
|
472
|
+
const selected =
|
|
473
|
+
tagName === "select"
|
|
474
|
+
? Reflect.get(associated, "selectedIndex") >= 0
|
|
475
|
+
: Reflect.get(associated, "selected");
|
|
476
|
+
const disabled = Reflect.get(associated, "disabled");
|
|
477
|
+
const required = Reflect.get(associated, "required");
|
|
478
|
+
const validity = Reflect.get(associated, "validity");
|
|
479
|
+
const form = Reflect.get(associated, "form");
|
|
480
|
+
const formMatches =
|
|
481
|
+
form === null || form === undefined ? undefined : Reflect.get(form, "matches");
|
|
482
|
+
const ariaChecked = Reflect.apply(Reflect.get(element, "getAttribute"), element, [
|
|
483
|
+
"aria-checked",
|
|
484
|
+
]);
|
|
485
|
+
const ariaDisabled = Reflect.apply(Reflect.get(element, "getAttribute"), element, [
|
|
486
|
+
"aria-disabled",
|
|
487
|
+
]);
|
|
488
|
+
const ariaSelected = Reflect.apply(Reflect.get(element, "getAttribute"), element, [
|
|
489
|
+
"aria-selected",
|
|
490
|
+
]);
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
matchCount,
|
|
494
|
+
kind,
|
|
495
|
+
...(typeof checked === "boolean"
|
|
496
|
+
? { checked }
|
|
497
|
+
: ariaChecked === "true" || ariaChecked === "false"
|
|
498
|
+
? { checked: ariaChecked === "true" }
|
|
499
|
+
: {}),
|
|
500
|
+
...(typeof selected === "boolean"
|
|
501
|
+
? { selected }
|
|
502
|
+
: ariaSelected === "true" || ariaSelected === "false"
|
|
503
|
+
? { selected: ariaSelected === "true" }
|
|
504
|
+
: {}),
|
|
505
|
+
disabled: typeof disabled === "boolean" ? disabled : ariaDisabled === "true",
|
|
506
|
+
...(typeof required === "boolean" ? { required } : {}),
|
|
507
|
+
...(validity !== undefined && typeof Reflect.get(validity, "valid") === "boolean"
|
|
508
|
+
? { valid: Reflect.get(validity, "valid") }
|
|
509
|
+
: {}),
|
|
510
|
+
...(typeof formMatches === "function"
|
|
511
|
+
? { formValid: Reflect.apply(formMatches, form, [":valid"]) }
|
|
512
|
+
: {}),
|
|
513
|
+
};
|
|
514
|
+
}, selector);
|
|
515
|
+
|
|
516
|
+
// SDK promises cannot be cancelled. Clear our timer and observe late rejections;
|
|
517
|
+
// the owning browser Scope is responsible for terminating the remote session.
|
|
518
|
+
const boundedBestEffort = async <A>(
|
|
519
|
+
promise: Promise<A>,
|
|
520
|
+
millis: number,
|
|
521
|
+
): Promise<A | undefined> => {
|
|
522
|
+
let timer: ReturnType<typeof setTimeout> | number | undefined;
|
|
523
|
+
try {
|
|
524
|
+
return await Promise.race([
|
|
525
|
+
promise.catch(() => undefined),
|
|
526
|
+
new Promise<undefined>((resolve) => {
|
|
527
|
+
timer = setTimeout(resolve, millis);
|
|
528
|
+
}),
|
|
529
|
+
]);
|
|
530
|
+
} finally {
|
|
531
|
+
clearTimeout(timer);
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const readActionTargetAfter = (page: Page, selector: string) =>
|
|
536
|
+
boundedBestEffort(
|
|
537
|
+
readActionTarget(page, selector).then(Schema.decodeUnknownSync(ActionTargetState)),
|
|
538
|
+
ACTION_POST_STATE_MILLIS,
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
const makeActionRequestTracker = (page: Page, signal: AbortSignal) => {
|
|
542
|
+
const pending = new Set<HTTPRequest>();
|
|
543
|
+
let total = 0;
|
|
544
|
+
let status2xx = 0;
|
|
545
|
+
let status3xx = 0;
|
|
546
|
+
let status4xx = 0;
|
|
547
|
+
let status5xx = 0;
|
|
548
|
+
let failed = 0;
|
|
549
|
+
let lastChange = performance.now();
|
|
550
|
+
let closed = false;
|
|
551
|
+
let wake: (() => void) | undefined;
|
|
552
|
+
let timer: ReturnType<typeof setTimeout> | number | undefined;
|
|
553
|
+
|
|
554
|
+
const relevant = (request: HTTPRequest) => {
|
|
555
|
+
const resourceType = request.resourceType();
|
|
556
|
+
return resourceType === "fetch" || resourceType === "xhr";
|
|
557
|
+
};
|
|
558
|
+
const onRequest = (request: HTTPRequest) => {
|
|
559
|
+
if (!relevant(request)) return;
|
|
560
|
+
pending.add(request);
|
|
561
|
+
total++;
|
|
562
|
+
lastChange = performance.now();
|
|
563
|
+
};
|
|
564
|
+
const onFinished = (request: HTTPRequest) => {
|
|
565
|
+
if (!pending.delete(request)) return;
|
|
566
|
+
let status: number | undefined;
|
|
567
|
+
try {
|
|
568
|
+
status = request.response()?.status();
|
|
569
|
+
} catch {
|
|
570
|
+
// Provider response details remain intentionally unavailable to diagnostics.
|
|
571
|
+
}
|
|
572
|
+
if (status !== undefined) {
|
|
573
|
+
if (status >= 200 && status < 300) status2xx++;
|
|
574
|
+
else if (status >= 300 && status < 400) status3xx++;
|
|
575
|
+
else if (status >= 400 && status < 500) status4xx++;
|
|
576
|
+
else if (status >= 500 && status < 600) status5xx++;
|
|
577
|
+
}
|
|
578
|
+
lastChange = performance.now();
|
|
579
|
+
};
|
|
580
|
+
const onFailed = (request: HTTPRequest) => {
|
|
581
|
+
if (!pending.delete(request)) return;
|
|
582
|
+
failed++;
|
|
583
|
+
lastChange = performance.now();
|
|
584
|
+
};
|
|
585
|
+
const close = () => {
|
|
586
|
+
if (closed) return;
|
|
587
|
+
closed = true;
|
|
588
|
+
page.off("request", onRequest);
|
|
589
|
+
page.off("requestfinished", onFinished);
|
|
590
|
+
page.off("requestfailed", onFailed);
|
|
591
|
+
signal.removeEventListener("abort", close);
|
|
592
|
+
clearTimeout(timer);
|
|
593
|
+
wake?.();
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
try {
|
|
597
|
+
page.on("request", onRequest);
|
|
598
|
+
page.on("requestfinished", onFinished);
|
|
599
|
+
page.on("requestfailed", onFailed);
|
|
600
|
+
signal.addEventListener("abort", close, { once: true });
|
|
601
|
+
if (signal.aborted) close();
|
|
602
|
+
} catch (cause) {
|
|
603
|
+
close();
|
|
604
|
+
throw cause;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const wait = async () => {
|
|
608
|
+
const startedAt = performance.now();
|
|
609
|
+
let settleTimedOut = false;
|
|
610
|
+
while (!closed) {
|
|
611
|
+
const now = performance.now();
|
|
612
|
+
if (pending.size === 0 && now - lastChange >= ACTION_NETWORK_QUIET_MILLIS) break;
|
|
613
|
+
if (now - startedAt >= ACTION_NETWORK_SETTLE_MILLIS) {
|
|
614
|
+
settleTimedOut = true;
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
await new Promise<void>((resolve) => {
|
|
618
|
+
wake = resolve;
|
|
619
|
+
timer = setTimeout(() => resolve(), 50);
|
|
620
|
+
});
|
|
621
|
+
wake = undefined;
|
|
622
|
+
}
|
|
623
|
+
return {
|
|
624
|
+
total,
|
|
625
|
+
status2xx,
|
|
626
|
+
status3xx,
|
|
627
|
+
status4xx,
|
|
628
|
+
status5xx,
|
|
629
|
+
failed,
|
|
630
|
+
pending: pending.size,
|
|
631
|
+
settleTimedOut,
|
|
632
|
+
};
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
return {
|
|
636
|
+
wait,
|
|
637
|
+
close,
|
|
638
|
+
markActionSettled: () => {
|
|
639
|
+
lastChange = performance.now();
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
const disposeActionHandles = async (handles: ReadonlyArray<{ dispose: () => Promise<void> }>) => {
|
|
645
|
+
await boundedBestEffort(
|
|
646
|
+
Promise.allSettled(handles.map(async (handle) => handle.dispose())),
|
|
647
|
+
ACTION_POST_STATE_MILLIS,
|
|
648
|
+
);
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
const runObservedPageAction = async (
|
|
652
|
+
page: Page,
|
|
653
|
+
selector: string,
|
|
654
|
+
signal: AbortSignal,
|
|
655
|
+
onDispatch: () => void,
|
|
656
|
+
action: (element: NonNullable<Awaited<ReturnType<Page["$"]>>>) => Promise<void>,
|
|
657
|
+
): Promise<unknown> => {
|
|
658
|
+
if (signal.aborted) throw new BrowserRunActionUndispatched(0);
|
|
659
|
+
const before = Schema.decodeUnknownSync(ActionTargetState)(
|
|
660
|
+
await readActionTarget(page, selector),
|
|
661
|
+
);
|
|
662
|
+
if (before.matchCount !== 1 || before.invalidSelector === true) {
|
|
663
|
+
throw new BrowserRunActionUndispatched(before.matchCount);
|
|
664
|
+
}
|
|
665
|
+
const matches = await page.$$(selector);
|
|
666
|
+
if (matches.length !== 1 || matches[0] === undefined) {
|
|
667
|
+
await disposeActionHandles(matches);
|
|
668
|
+
throw new BrowserRunActionUndispatched(Math.min(10_000, matches.length));
|
|
669
|
+
}
|
|
670
|
+
if (signal.aborted) {
|
|
671
|
+
await disposeActionHandles(matches);
|
|
672
|
+
throw new BrowserRunActionUndispatched(1);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
let tracker: ReturnType<typeof makeActionRequestTracker> | undefined;
|
|
676
|
+
let disposing: Promise<void> | undefined;
|
|
677
|
+
const dispose = () => (disposing ??= disposeActionHandles(matches));
|
|
678
|
+
const onAbort = () => {
|
|
679
|
+
void dispose();
|
|
680
|
+
};
|
|
681
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
682
|
+
try {
|
|
683
|
+
tracker = makeActionRequestTracker(page, signal);
|
|
684
|
+
// No await between the final cancellation fence and SDK dispatch. Once
|
|
685
|
+
// dispatched, interruption is uncertain, even if the SDK later resolves.
|
|
686
|
+
if (signal.aborted) throw new BrowserRunActionUndispatched(1);
|
|
687
|
+
onDispatch();
|
|
688
|
+
await action(matches[0]);
|
|
689
|
+
tracker.markActionSettled();
|
|
690
|
+
const network = await tracker.wait();
|
|
691
|
+
const after = signal.aborted ? undefined : await readActionTargetAfter(page, selector);
|
|
692
|
+
return {
|
|
693
|
+
before,
|
|
694
|
+
...(after === undefined ? {} : { after }),
|
|
695
|
+
afterUnavailable: after === undefined,
|
|
696
|
+
network,
|
|
697
|
+
};
|
|
698
|
+
} finally {
|
|
699
|
+
tracker?.close();
|
|
700
|
+
signal.removeEventListener("abort", onAbort);
|
|
701
|
+
await dispose();
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
|
|
297
705
|
const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
|
|
298
706
|
const listeners = new Map<BrowserRunInteractiveRequestListener, (request: HTTPRequest) => void>();
|
|
299
707
|
return {
|
|
@@ -313,38 +721,234 @@ const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
|
|
|
313
721
|
}
|
|
314
722
|
},
|
|
315
723
|
goto: async (url) => {
|
|
316
|
-
await page.goto(url, { waitUntil: "
|
|
724
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
317
725
|
},
|
|
318
726
|
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
|
-
|
|
727
|
+
readText: async (selector, maximumBytes) => {
|
|
728
|
+
const observation = Schema.decodeUnknownSync(TextObservation)(
|
|
729
|
+
await page.evaluate(
|
|
730
|
+
(requestedSelector, maximum, maximumControls) => {
|
|
731
|
+
const pageDocument = Reflect.get(globalThis, "document");
|
|
732
|
+
const matches =
|
|
733
|
+
requestedSelector === undefined
|
|
734
|
+
? undefined
|
|
735
|
+
: Reflect.apply(Reflect.get(pageDocument, "querySelectorAll"), pageDocument, [
|
|
736
|
+
requestedSelector,
|
|
737
|
+
]);
|
|
738
|
+
const selectorMatchCount =
|
|
739
|
+
matches === undefined || matches === null
|
|
740
|
+
? 1
|
|
741
|
+
: Math.min(10_000, Reflect.get(matches, "length"));
|
|
742
|
+
const element =
|
|
743
|
+
matches === undefined || matches === null
|
|
744
|
+
? Reflect.get(pageDocument, "body")
|
|
745
|
+
: Reflect.get(matches, 0);
|
|
746
|
+
if (element === null) return { _tag: "MissingElement" };
|
|
747
|
+
if (element === undefined) return { _tag: "MissingElement" };
|
|
748
|
+
const innerText = Reflect.get(element, "innerText");
|
|
749
|
+
const textContent = Reflect.get(element, "textContent");
|
|
750
|
+
const pageText =
|
|
751
|
+
typeof innerText === "string"
|
|
752
|
+
? innerText
|
|
753
|
+
: typeof textContent === "string"
|
|
754
|
+
? textContent
|
|
755
|
+
: "";
|
|
756
|
+
const primaryControlSelector =
|
|
757
|
+
'input,select,textarea,label,button,[role="checkbox"],[role="radio"],[role="option"],[role="switch"],[role="tab"],[role="button"]';
|
|
758
|
+
const optionSelector = "select option";
|
|
759
|
+
const secondaryControlSelector = "a[href]";
|
|
760
|
+
const controlSelector = `${primaryControlSelector},${optionSelector},${secondaryControlSelector}`;
|
|
761
|
+
const selectorFor = (candidate: object) => {
|
|
762
|
+
const parts: Array<string> = [];
|
|
763
|
+
let current: object | null = candidate;
|
|
764
|
+
while (current !== null && current !== undefined) {
|
|
765
|
+
const tagName = String(Reflect.get(current, "tagName") ?? "").toLowerCase();
|
|
766
|
+
if (tagName === "") break;
|
|
767
|
+
const parent: object | null = Reflect.get(current, "parentElement");
|
|
768
|
+
if (parent === null) {
|
|
769
|
+
parts.push(tagName);
|
|
770
|
+
break;
|
|
771
|
+
}
|
|
772
|
+
let sibling = Reflect.get(current, "previousElementSibling");
|
|
773
|
+
let index = 1;
|
|
774
|
+
while (sibling !== null && sibling !== undefined) {
|
|
775
|
+
if (String(Reflect.get(sibling, "tagName") ?? "").toLowerCase() === tagName)
|
|
776
|
+
index++;
|
|
777
|
+
sibling = Reflect.get(sibling, "previousElementSibling");
|
|
778
|
+
}
|
|
779
|
+
parts.push(`${tagName}:nth-of-type(${index})`);
|
|
780
|
+
current = parent;
|
|
781
|
+
}
|
|
782
|
+
return parts.reverse().join(" > ");
|
|
783
|
+
};
|
|
784
|
+
const visible = (candidate: object) => {
|
|
785
|
+
const hidden = Reflect.get(candidate, "hidden");
|
|
786
|
+
const ariaHidden = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
|
|
787
|
+
"aria-hidden",
|
|
788
|
+
]);
|
|
789
|
+
const rects = Reflect.apply(Reflect.get(candidate, "getClientRects"), candidate, []);
|
|
790
|
+
return (
|
|
791
|
+
hidden !== true &&
|
|
792
|
+
ariaHidden !== "true" &&
|
|
793
|
+
typeof rects === "object" &&
|
|
794
|
+
rects !== null &&
|
|
795
|
+
Reflect.get(rects, "length") > 0
|
|
796
|
+
);
|
|
797
|
+
};
|
|
798
|
+
const candidates: Array<object> = [];
|
|
799
|
+
let controlsTruncated = false;
|
|
800
|
+
const consider = (candidate: object) => {
|
|
801
|
+
const tagName = String(Reflect.get(candidate, "tagName") ?? "").toLowerCase();
|
|
802
|
+
// Collapsed native options have no client rects. Their owning
|
|
803
|
+
// select determines visibility; observe text/selection, never value.
|
|
804
|
+
const visibilityTarget =
|
|
805
|
+
tagName === "option"
|
|
806
|
+
? Reflect.apply(Reflect.get(candidate, "closest"), candidate, ["select"])
|
|
807
|
+
: candidate;
|
|
808
|
+
const actionable = tagName !== "label" || Reflect.get(candidate, "control") !== null;
|
|
809
|
+
if (
|
|
810
|
+
!actionable ||
|
|
811
|
+
typeof visibilityTarget !== "object" ||
|
|
812
|
+
visibilityTarget === null ||
|
|
813
|
+
!visible(visibilityTarget)
|
|
814
|
+
)
|
|
815
|
+
return;
|
|
816
|
+
candidates.push(candidate);
|
|
817
|
+
if (candidates.length > maximumControls) controlsTruncated = true;
|
|
818
|
+
};
|
|
819
|
+
const elementMatches = Reflect.get(element, "matches");
|
|
820
|
+
if (
|
|
821
|
+
typeof elementMatches === "function" &&
|
|
822
|
+
Reflect.apply(elementMatches, element, [controlSelector])
|
|
823
|
+
) {
|
|
824
|
+
consider(element);
|
|
825
|
+
}
|
|
826
|
+
const considerSelector = (candidateSelector: string) => {
|
|
827
|
+
const descendants = Reflect.apply(Reflect.get(element, "querySelectorAll"), element, [
|
|
828
|
+
candidateSelector,
|
|
829
|
+
]);
|
|
830
|
+
if (typeof descendants !== "object" || descendants === null) return;
|
|
831
|
+
const descendantCount = Reflect.get(descendants, "length");
|
|
832
|
+
for (let index = 0; index < descendantCount && !controlsTruncated; index++) {
|
|
833
|
+
consider(Reflect.get(descendants, index));
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
considerSelector(primaryControlSelector);
|
|
837
|
+
if (!controlsTruncated) considerSelector(optionSelector);
|
|
838
|
+
if (!controlsTruncated) considerSelector(secondaryControlSelector);
|
|
839
|
+
const controls = candidates.slice(0, maximumControls).map((candidate) => {
|
|
840
|
+
const associated = Reflect.get(candidate, "control") ?? candidate;
|
|
841
|
+
const tagName = String(Reflect.get(candidate, "tagName") ?? "").toLowerCase();
|
|
842
|
+
const inputType = String(Reflect.get(associated, "type") ?? "").toLowerCase();
|
|
843
|
+
const role = String(
|
|
844
|
+
Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, ["role"]) ?? "",
|
|
845
|
+
).toLowerCase();
|
|
846
|
+
const ariaLabel = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
|
|
847
|
+
"aria-label",
|
|
848
|
+
]);
|
|
849
|
+
const candidateText =
|
|
850
|
+
tagName === "textarea" || tagName === "input" || tagName === "select"
|
|
851
|
+
? undefined
|
|
852
|
+
: Reflect.get(candidate, tagName === "option" ? "label" : "innerText");
|
|
853
|
+
const associatedLabels = Reflect.get(associated, "labels");
|
|
854
|
+
const associatedLabel =
|
|
855
|
+
associatedLabels !== undefined &&
|
|
856
|
+
associatedLabels !== null &&
|
|
857
|
+
Reflect.get(associatedLabels, "length") > 0
|
|
858
|
+
? Reflect.get(Reflect.get(associatedLabels, 0), "innerText")
|
|
859
|
+
: undefined;
|
|
860
|
+
const label = String(
|
|
861
|
+
typeof ariaLabel === "string" && ariaLabel !== ""
|
|
862
|
+
? ariaLabel
|
|
863
|
+
: typeof candidateText === "string" && candidateText !== ""
|
|
864
|
+
? candidateText
|
|
865
|
+
: (associatedLabel ?? ""),
|
|
866
|
+
)
|
|
867
|
+
.replace(/\s+/g, " ")
|
|
868
|
+
.trim()
|
|
869
|
+
.slice(0, 200);
|
|
870
|
+
const checked = Reflect.get(associated, "checked");
|
|
871
|
+
const selected =
|
|
872
|
+
tagName === "select"
|
|
873
|
+
? Reflect.get(associated, "selectedIndex") >= 0
|
|
874
|
+
: Reflect.get(associated, "selected");
|
|
875
|
+
const disabled = Reflect.get(associated, "disabled");
|
|
876
|
+
const required = Reflect.get(associated, "required");
|
|
877
|
+
const validity = Reflect.get(associated, "validity");
|
|
878
|
+
const form = Reflect.get(associated, "form");
|
|
879
|
+
const formMatches =
|
|
880
|
+
form === null || form === undefined ? undefined : Reflect.get(form, "matches");
|
|
881
|
+
const ariaChecked = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
|
|
882
|
+
"aria-checked",
|
|
883
|
+
]);
|
|
884
|
+
const ariaSelected = Reflect.apply(
|
|
885
|
+
Reflect.get(candidate, "getAttribute"),
|
|
886
|
+
candidate,
|
|
887
|
+
["aria-selected"],
|
|
888
|
+
);
|
|
889
|
+
const ariaDisabled = Reflect.apply(
|
|
890
|
+
Reflect.get(candidate, "getAttribute"),
|
|
891
|
+
candidate,
|
|
892
|
+
["aria-disabled"],
|
|
893
|
+
);
|
|
894
|
+
|
|
895
|
+
return {
|
|
896
|
+
selector: selectorFor(candidate),
|
|
897
|
+
kind:
|
|
898
|
+
tagName === "label"
|
|
899
|
+
? `label:${inputType || "control"}`
|
|
900
|
+
: tagName === "input"
|
|
901
|
+
? `input:${inputType || "text"}`
|
|
902
|
+
: role !== ""
|
|
903
|
+
? `role:${role}`
|
|
904
|
+
: tagName,
|
|
905
|
+
...(label === "" ? {} : { label }),
|
|
906
|
+
...(typeof checked === "boolean"
|
|
907
|
+
? { checked }
|
|
908
|
+
: ariaChecked === "true" || ariaChecked === "false"
|
|
909
|
+
? { checked: ariaChecked === "true" }
|
|
910
|
+
: {}),
|
|
911
|
+
...(typeof selected === "boolean"
|
|
912
|
+
? { selected }
|
|
913
|
+
: ariaSelected === "true" || ariaSelected === "false"
|
|
914
|
+
? { selected: ariaSelected === "true" }
|
|
915
|
+
: {}),
|
|
916
|
+
...(typeof disabled === "boolean"
|
|
917
|
+
? { disabled }
|
|
918
|
+
: ariaDisabled === "true" || ariaDisabled === "false"
|
|
919
|
+
? { disabled: ariaDisabled === "true" }
|
|
920
|
+
: {}),
|
|
921
|
+
...(typeof required === "boolean" ? { required } : {}),
|
|
922
|
+
...(validity !== undefined && typeof Reflect.get(validity, "valid") === "boolean"
|
|
923
|
+
? { valid: Reflect.get(validity, "valid") }
|
|
924
|
+
: {}),
|
|
925
|
+
...(typeof formMatches === "function"
|
|
926
|
+
? { formValid: Reflect.apply(formMatches, form, [":valid"]) }
|
|
927
|
+
: {}),
|
|
928
|
+
};
|
|
929
|
+
});
|
|
930
|
+
// JSON is the bounded wire representation of this existing text result.
|
|
931
|
+
// eslint-disable-next-line no-restricted-properties
|
|
932
|
+
const text = JSON.stringify({
|
|
933
|
+
pageText,
|
|
934
|
+
selectorMatchCount,
|
|
935
|
+
controls,
|
|
936
|
+
controlsTruncated,
|
|
937
|
+
});
|
|
938
|
+
const observed = new TextEncoder().encode(text).byteLength;
|
|
939
|
+
return observed > maximum ? { _tag: "OverLimit", observed } : { _tag: "Text", text };
|
|
940
|
+
},
|
|
941
|
+
selector,
|
|
942
|
+
maximumBytes,
|
|
943
|
+
MAX_OBSERVED_CONTROLS,
|
|
944
|
+
),
|
|
945
|
+
);
|
|
946
|
+
if (observation._tag === "Text") Schema.decodeUnknownSync(PageObservation)(observation.text);
|
|
947
|
+
return observation;
|
|
948
|
+
},
|
|
949
|
+
fill: (selector, value, signal, onDispatch) =>
|
|
950
|
+
runObservedPageAction(page, selector, signal, onDispatch, (element) =>
|
|
951
|
+
element.evaluate((element, nextValue) => {
|
|
348
952
|
// Bypass instance setters so React can detect the change when events fire.
|
|
349
953
|
let prototype = Reflect.getPrototypeOf(element);
|
|
350
954
|
let setValue: ((value: string) => void) | undefined;
|
|
@@ -367,11 +971,10 @@ const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
|
|
|
367
971
|
Reflect.apply(dispatchEvent, element, [new Event("input", { bubbles: true })]);
|
|
368
972
|
Reflect.apply(dispatchEvent, element, [new Event("change", { bubbles: true })]);
|
|
369
973
|
}
|
|
370
|
-
},
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
click: (selector) => page.click(selector),
|
|
974
|
+
}, value),
|
|
975
|
+
),
|
|
976
|
+
click: (selector, signal, onDispatch) =>
|
|
977
|
+
runObservedPageAction(page, selector, signal, onDispatch, (element) => element.click()),
|
|
375
978
|
// Puppeteer materializes the complete image before returning. The adapter
|
|
376
979
|
// validates the 8 MiB Schema ceiling and pass limit immediately afterward.
|
|
377
980
|
screenshot: (fullPage) => page.screenshot({ type: "png", fullPage }),
|
|
@@ -385,6 +988,7 @@ const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
|
|
|
385
988
|
deltaY,
|
|
386
989
|
),
|
|
387
990
|
createCdpSession: async () => makeProductionCdpSession(await page.createCDPSession()),
|
|
991
|
+
setViewport: (viewport) => page.setViewport(viewport),
|
|
388
992
|
};
|
|
389
993
|
};
|
|
390
994
|
|
|
@@ -421,6 +1025,20 @@ const actionError = (operation: BrowserOperation, cause?: unknown): InteractiveB
|
|
|
421
1025
|
...(cause === undefined ? {} : { cause }),
|
|
422
1026
|
});
|
|
423
1027
|
|
|
1028
|
+
const undispatchedActionError = (operation: BrowserOperation): InteractiveBrowserActionError =>
|
|
1029
|
+
InteractiveBrowserActionError.make({
|
|
1030
|
+
implementation: browserRunInteractiveImplementation,
|
|
1031
|
+
operation,
|
|
1032
|
+
message: `The interactive browser ${operation} operation was not dispatched`,
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
/** Recognizes a local pre-dispatch refusal without exposing selector or page content. */
|
|
1036
|
+
export const isBrowserRunUndispatchedActionError = (error: unknown): boolean =>
|
|
1037
|
+
Schema.is(InteractiveBrowserActionError)(error) &&
|
|
1038
|
+
error.implementation.identity === browserRunInteractiveImplementation.identity &&
|
|
1039
|
+
(error.operation === "click" || error.operation === "fill") &&
|
|
1040
|
+
error.message === `The interactive browser ${error.operation} operation was not dispatched`;
|
|
1041
|
+
|
|
424
1042
|
const policyError = (message: string): InteractiveBrowserPolicyDeniedError =>
|
|
425
1043
|
InteractiveBrowserPolicyDeniedError.make({
|
|
426
1044
|
implementation: browserRunInteractiveImplementation,
|
|
@@ -590,9 +1208,18 @@ interface HandleRuntime {
|
|
|
590
1208
|
readonly run: <A>(
|
|
591
1209
|
effect: Effect.Effect<A, BrowserFailure>,
|
|
592
1210
|
preflight?: Effect.Effect<void, BrowserFailure>,
|
|
1211
|
+
consumeAction?: boolean,
|
|
593
1212
|
) => Effect.Effect<A, BrowserFailure>;
|
|
594
1213
|
}
|
|
595
1214
|
|
|
1215
|
+
class BrowserRunRemoteFailure {
|
|
1216
|
+
readonly cause: unknown;
|
|
1217
|
+
|
|
1218
|
+
constructor(cause: unknown) {
|
|
1219
|
+
this.cause = cause;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
596
1223
|
const awaitPendingRequests = (state: HandleState): Effect.Effect<void> =>
|
|
597
1224
|
Effect.suspend(() => {
|
|
598
1225
|
const pending = [...state.pendingRequests];
|
|
@@ -685,21 +1312,141 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
|
|
|
685
1312
|
const permits = yield* Semaphore.make(1);
|
|
686
1313
|
const actions = yield* Ref.make(0);
|
|
687
1314
|
|
|
688
|
-
const remote = <A>(operation: BrowserOperation, evaluate: () => Promise<A>) =>
|
|
1315
|
+
const remote = <A>(operation: BrowserOperation, evaluate: (signal: AbortSignal) => Promise<A>) =>
|
|
689
1316
|
Effect.tryPromise({
|
|
690
1317
|
try: evaluate,
|
|
691
|
-
catch: (cause) =>
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
1318
|
+
catch: (cause) => new BrowserRunRemoteFailure(cause),
|
|
1319
|
+
}).pipe(
|
|
1320
|
+
Effect.catch(
|
|
1321
|
+
(
|
|
1322
|
+
failure,
|
|
1323
|
+
): Effect.Effect<never, InteractiveBrowserActionError | InteractiveBrowserExpiredError> => {
|
|
1324
|
+
const cause = failure.cause;
|
|
1325
|
+
if (cause instanceof BrowserRunActionUndispatched) {
|
|
1326
|
+
return Effect.logInfo("Browser interactive action was not dispatched").pipe(
|
|
1327
|
+
Effect.annotateLogs({
|
|
1328
|
+
"browser.action": operation,
|
|
1329
|
+
"browser.selector_match_count": cause.matchCount,
|
|
1330
|
+
}),
|
|
1331
|
+
Effect.andThen(Effect.fail(undispatchedActionError(operation))),
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
if (state.disconnected.value || isRemoteClosure(cause)) {
|
|
1335
|
+
state.disconnected.value = true;
|
|
1336
|
+
return Effect.fail(expiredError());
|
|
1337
|
+
}
|
|
1338
|
+
return Effect.fail(actionError(operation, cause));
|
|
1339
|
+
},
|
|
1340
|
+
),
|
|
1341
|
+
);
|
|
1342
|
+
|
|
1343
|
+
const observedAction = (
|
|
1344
|
+
operation: "fill" | "click",
|
|
1345
|
+
evaluate: (signal: AbortSignal, onDispatch: () => void) => Promise<unknown>,
|
|
1346
|
+
) =>
|
|
1347
|
+
Effect.suspend(() => {
|
|
1348
|
+
let dispatched = false;
|
|
1349
|
+
let pending: Promise<unknown> | undefined;
|
|
1350
|
+
return remote(operation, (signal) => {
|
|
1351
|
+
pending = evaluate(signal, () => {
|
|
1352
|
+
dispatched = true;
|
|
1353
|
+
});
|
|
1354
|
+
return pending;
|
|
1355
|
+
}).pipe(
|
|
1356
|
+
Effect.onInterrupt(() =>
|
|
1357
|
+
Effect.gen(function* () {
|
|
1358
|
+
state.uncertain.value = true;
|
|
1359
|
+
// Retain evidence before Scope teardown, without claiming SDK
|
|
1360
|
+
// cancellation or success. A late completion never makes this replayable.
|
|
1361
|
+
yield* Effect.logWarning("Browser interactive action interrupted").pipe(
|
|
1362
|
+
Effect.annotateLogs({
|
|
1363
|
+
"browser.action": operation,
|
|
1364
|
+
"browser.action_dispatched": dispatched,
|
|
1365
|
+
"browser.action_outcome_unknown": dispatched,
|
|
1366
|
+
}),
|
|
1367
|
+
);
|
|
1368
|
+
const completion = pending;
|
|
1369
|
+
if (completion !== undefined) {
|
|
1370
|
+
yield* Effect.promise(() => boundedBestEffort(completion, 500));
|
|
1371
|
+
}
|
|
1372
|
+
}),
|
|
1373
|
+
),
|
|
1374
|
+
);
|
|
698
1375
|
});
|
|
699
1376
|
|
|
1377
|
+
const decodeActionObservation = Effect.fn("BrowserRunInteractive.decodeActionObservation")(
|
|
1378
|
+
function* (raw: unknown) {
|
|
1379
|
+
return yield* Schema.decodeUnknownEffect(ActionObservation)(raw).pipe(
|
|
1380
|
+
Effect.mapError((cause) =>
|
|
1381
|
+
protocolError("The browser returned a malformed action observation", cause),
|
|
1382
|
+
),
|
|
1383
|
+
);
|
|
1384
|
+
},
|
|
1385
|
+
);
|
|
1386
|
+
|
|
1387
|
+
const logActionObservation = (
|
|
1388
|
+
operation: "fill" | "click",
|
|
1389
|
+
observation: typeof ActionObservation.Type,
|
|
1390
|
+
) =>
|
|
1391
|
+
Effect.logInfo("Browser interactive action observed").pipe(
|
|
1392
|
+
Effect.annotateLogs({
|
|
1393
|
+
"browser.action": operation,
|
|
1394
|
+
"browser.selector_match_count": observation.before.matchCount,
|
|
1395
|
+
...(observation.before.kind === undefined
|
|
1396
|
+
? {}
|
|
1397
|
+
: { "browser.target_kind": observation.before.kind }),
|
|
1398
|
+
...(observation.before.checked === undefined
|
|
1399
|
+
? {}
|
|
1400
|
+
: { "browser.target_checked_before": observation.before.checked }),
|
|
1401
|
+
...(observation.after?.checked === undefined
|
|
1402
|
+
? {}
|
|
1403
|
+
: { "browser.target_checked_after": observation.after.checked }),
|
|
1404
|
+
...(observation.before.selected === undefined
|
|
1405
|
+
? {}
|
|
1406
|
+
: { "browser.target_selected_before": observation.before.selected }),
|
|
1407
|
+
...(observation.after?.selected === undefined
|
|
1408
|
+
? {}
|
|
1409
|
+
: { "browser.target_selected_after": observation.after.selected }),
|
|
1410
|
+
...(observation.before.disabled === undefined
|
|
1411
|
+
? {}
|
|
1412
|
+
: { "browser.target_disabled_before": observation.before.disabled }),
|
|
1413
|
+
...(observation.after?.disabled === undefined
|
|
1414
|
+
? {}
|
|
1415
|
+
: { "browser.target_disabled_after": observation.after.disabled }),
|
|
1416
|
+
...(observation.before.required === undefined
|
|
1417
|
+
? {}
|
|
1418
|
+
: { "browser.target_required_before": observation.before.required }),
|
|
1419
|
+
...(observation.after?.required === undefined
|
|
1420
|
+
? {}
|
|
1421
|
+
: { "browser.target_required_after": observation.after.required }),
|
|
1422
|
+
...(observation.before.valid === undefined
|
|
1423
|
+
? {}
|
|
1424
|
+
: { "browser.target_valid_before": observation.before.valid }),
|
|
1425
|
+
...(observation.after?.valid === undefined
|
|
1426
|
+
? {}
|
|
1427
|
+
: { "browser.target_valid_after": observation.after.valid }),
|
|
1428
|
+
...(observation.before.formValid === undefined
|
|
1429
|
+
? {}
|
|
1430
|
+
: { "browser.form_valid_before": observation.before.formValid }),
|
|
1431
|
+
...(observation.after?.formValid === undefined
|
|
1432
|
+
? {}
|
|
1433
|
+
: { "browser.form_valid_after": observation.after.formValid }),
|
|
1434
|
+
"browser.target_after_unavailable": observation.afterUnavailable,
|
|
1435
|
+
"browser.fetch_xhr_total": observation.network.total,
|
|
1436
|
+
"browser.fetch_xhr_2xx": observation.network.status2xx,
|
|
1437
|
+
"browser.fetch_xhr_3xx": observation.network.status3xx,
|
|
1438
|
+
"browser.fetch_xhr_4xx": observation.network.status4xx,
|
|
1439
|
+
"browser.fetch_xhr_5xx": observation.network.status5xx,
|
|
1440
|
+
"browser.fetch_xhr_failed": observation.network.failed,
|
|
1441
|
+
"browser.fetch_xhr_pending": observation.network.pending,
|
|
1442
|
+
"browser.network_settle_timed_out": observation.network.settleTimedOut,
|
|
1443
|
+
}),
|
|
1444
|
+
);
|
|
1445
|
+
|
|
700
1446
|
const run = <A>(
|
|
701
1447
|
effect: Effect.Effect<A, BrowserFailure>,
|
|
702
1448
|
preflight: Effect.Effect<void, BrowserFailure> = Effect.void,
|
|
1449
|
+
consumeAction = true,
|
|
703
1450
|
) =>
|
|
704
1451
|
permits
|
|
705
1452
|
.withPermitsIfAvailable(1)(
|
|
@@ -708,19 +1455,21 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
|
|
|
708
1455
|
if (unavailable !== undefined) return yield* unavailable;
|
|
709
1456
|
yield* preflight;
|
|
710
1457
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
1458
|
+
if (consumeAction) {
|
|
1459
|
+
const admitted = yield* Ref.modify(actions, (count) =>
|
|
1460
|
+
count >= policy.maxActions
|
|
1461
|
+
? [{ allowed: false, observed: count + 1 }, count]
|
|
1462
|
+
: [{ allowed: true, observed: count + 1 }, count + 1],
|
|
1463
|
+
);
|
|
1464
|
+
if (!admitted.allowed) {
|
|
1465
|
+
return yield* InteractiveBrowserLimitError.make({
|
|
1466
|
+
implementation: browserRunInteractiveImplementation,
|
|
1467
|
+
limit: "actions",
|
|
1468
|
+
maximum: policy.maxActions,
|
|
1469
|
+
observed: admitted.observed,
|
|
1470
|
+
message: "The browser action limit was reached",
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
724
1473
|
}
|
|
725
1474
|
|
|
726
1475
|
const completed = effect.pipe(
|
|
@@ -750,6 +1499,12 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
|
|
|
750
1499
|
return Effect.fail(error);
|
|
751
1500
|
}
|
|
752
1501
|
const failure = stateFailure(state);
|
|
1502
|
+
if (
|
|
1503
|
+
Schema.is(InteractiveBrowserActionError)(error) &&
|
|
1504
|
+
!isBrowserRunUndispatchedActionError(error)
|
|
1505
|
+
) {
|
|
1506
|
+
state.uncertain.value = true;
|
|
1507
|
+
}
|
|
753
1508
|
return Effect.fail(failure ?? error);
|
|
754
1509
|
}),
|
|
755
1510
|
);
|
|
@@ -825,15 +1580,23 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
|
|
|
825
1580
|
),
|
|
826
1581
|
fill: (request) =>
|
|
827
1582
|
run(
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1583
|
+
Effect.gen(function* () {
|
|
1584
|
+
const observation = yield* observedAction("fill", (signal, onDispatch) =>
|
|
1585
|
+
page.fill(request.selector, request.value, signal, onDispatch),
|
|
1586
|
+
).pipe(Effect.flatMap(decodeActionObservation));
|
|
1587
|
+
yield* logActionObservation("fill", observation);
|
|
1588
|
+
return yield* decodeActionResult(page, policy);
|
|
1589
|
+
}),
|
|
831
1590
|
),
|
|
832
1591
|
click: (request) =>
|
|
833
1592
|
run(
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1593
|
+
Effect.gen(function* () {
|
|
1594
|
+
const observation = yield* observedAction("click", (signal, onDispatch) =>
|
|
1595
|
+
page.click(request.selector, signal, onDispatch),
|
|
1596
|
+
).pipe(Effect.flatMap(decodeActionObservation));
|
|
1597
|
+
yield* logActionObservation("click", observation);
|
|
1598
|
+
return yield* decodeActionResult(page, policy);
|
|
1599
|
+
}),
|
|
837
1600
|
),
|
|
838
1601
|
screenshot: (request) =>
|
|
839
1602
|
Schema.decodeUnknownEffect(BrowserScreenshotRequest)(request).pipe(
|
|
@@ -970,6 +1733,55 @@ const cdpCommand = <A>(
|
|
|
970
1733
|
const makeHostService = (
|
|
971
1734
|
binding: BrowserRunInteractiveBinding["Service"],
|
|
972
1735
|
): BrowserRunInteractiveHost["Service"] => {
|
|
1736
|
+
const closeSession = binding.closeSession;
|
|
1737
|
+
const terminate = Effect.fn("BrowserRunInteractiveHost.terminate")(function* (
|
|
1738
|
+
sessionId: Redacted.Redacted<string>,
|
|
1739
|
+
entries: ReadonlyArray<CloseEntry>,
|
|
1740
|
+
) {
|
|
1741
|
+
const deadline = (yield* Clock.currentTimeMillis) + 10_000;
|
|
1742
|
+
yield* closeSession(sessionId).pipe(
|
|
1743
|
+
Effect.interruptible,
|
|
1744
|
+
Effect.timeoutOrElse({
|
|
1745
|
+
duration: "10 seconds",
|
|
1746
|
+
orElse: () => Effect.fail(actionError("close")),
|
|
1747
|
+
}),
|
|
1748
|
+
);
|
|
1749
|
+
const remaining = deadline - (yield* Clock.currentTimeMillis);
|
|
1750
|
+
if (remaining <= 0)
|
|
1751
|
+
return yield* Effect.logWarning(
|
|
1752
|
+
"Local browser teardown skipped after confirmed termination deadline",
|
|
1753
|
+
);
|
|
1754
|
+
// Remote termination is authoritative. Local cleanup must not veto it or extend the deadline.
|
|
1755
|
+
yield* runTeardown(entries).pipe(
|
|
1756
|
+
Effect.flatMap((failures) =>
|
|
1757
|
+
Effect.forEach(failures, (failure) => Effect.logWarning(failure.warning), {
|
|
1758
|
+
discard: true,
|
|
1759
|
+
}),
|
|
1760
|
+
),
|
|
1761
|
+
Effect.interruptible,
|
|
1762
|
+
Effect.timeout(`${remaining} millis`),
|
|
1763
|
+
Effect.catchCause(() =>
|
|
1764
|
+
Effect.logWarning("Local browser teardown incomplete after confirmed termination"),
|
|
1765
|
+
),
|
|
1766
|
+
);
|
|
1767
|
+
});
|
|
1768
|
+
const closeAcquired = Effect.fn("BrowserRunInteractiveHost.closeAcquired")(function* (
|
|
1769
|
+
browser: BrowserRunInteractiveBrowser,
|
|
1770
|
+
) {
|
|
1771
|
+
const sessionId = yield* Effect.try({
|
|
1772
|
+
try: browser.sessionId,
|
|
1773
|
+
catch: () => actionError("close"),
|
|
1774
|
+
}).pipe(
|
|
1775
|
+
Effect.flatMap(Schema.decodeUnknownEffect(BrowserRunSessionId)),
|
|
1776
|
+
Effect.mapError(() => actionError("close")),
|
|
1777
|
+
Effect.onError(() =>
|
|
1778
|
+
closeWithWarning(browser.close, "Closing an unidentified browser failed"),
|
|
1779
|
+
),
|
|
1780
|
+
);
|
|
1781
|
+
yield* terminate(Redacted.make(sessionId), [
|
|
1782
|
+
closeEntry(browser.close, "Closing the local browser connection failed"),
|
|
1783
|
+
]);
|
|
1784
|
+
});
|
|
973
1785
|
const open = Effect.fn("BrowserRunInteractiveHost.open")(function* (
|
|
974
1786
|
policy: InteractiveBrowserPolicy,
|
|
975
1787
|
): Effect.fn.Return<BrowserRunInteractiveSession, InteractiveBrowserError, Scope.Scope> {
|
|
@@ -984,7 +1796,6 @@ const makeHostService = (
|
|
|
984
1796
|
};
|
|
985
1797
|
const lifecycle = {
|
|
986
1798
|
managedTeardownInstalled: false,
|
|
987
|
-
explicitCloseInvoked: false,
|
|
988
1799
|
};
|
|
989
1800
|
const closers: Array<CloseEntry> = [];
|
|
990
1801
|
const releaseBeforeManaged = (entry: CloseEntry): Effect.Effect<void> =>
|
|
@@ -1001,7 +1812,7 @@ const makeHostService = (
|
|
|
1001
1812
|
closeLateAcquisition(
|
|
1002
1813
|
signal,
|
|
1003
1814
|
() => binding.launch(keepAliveMillis(fixedPolicy)),
|
|
1004
|
-
(acquired) =>
|
|
1815
|
+
(acquired) => Effect.runPromise(closeAcquired(acquired)),
|
|
1005
1816
|
),
|
|
1006
1817
|
catch: (cause) =>
|
|
1007
1818
|
isCapacityRefusal(cause)
|
|
@@ -1016,9 +1827,11 @@ const makeHostService = (
|
|
|
1016
1827
|
),
|
|
1017
1828
|
(acquired) => {
|
|
1018
1829
|
state.disconnected.value = true;
|
|
1019
|
-
return
|
|
1020
|
-
|
|
1021
|
-
|
|
1830
|
+
return lifecycle.managedTeardownInstalled
|
|
1831
|
+
? Effect.void
|
|
1832
|
+
: closeAcquired(acquired).pipe(
|
|
1833
|
+
Effect.catch(() => Effect.logWarning("Whole-browser cleanup remains unconfirmed")),
|
|
1834
|
+
);
|
|
1022
1835
|
},
|
|
1023
1836
|
{ interruptible: true },
|
|
1024
1837
|
);
|
|
@@ -1152,7 +1965,7 @@ const makeHostService = (
|
|
|
1152
1965
|
|
|
1153
1966
|
const teardown = yield* Effect.uninterruptible(
|
|
1154
1967
|
Effect.gen(function* () {
|
|
1155
|
-
const cached = yield* Effect.cached(
|
|
1968
|
+
const cached = yield* Effect.cached(terminate(Redacted.make(sessionIdValue), closers));
|
|
1156
1969
|
lifecycle.managedTeardownInstalled = true;
|
|
1157
1970
|
yield* Effect.addFinalizer(() =>
|
|
1158
1971
|
Effect.uninterruptible(
|
|
@@ -1161,13 +1974,7 @@ const makeHostService = (
|
|
|
1161
1974
|
state.disconnected.value = true;
|
|
1162
1975
|
}).pipe(
|
|
1163
1976
|
Effect.andThen(cached),
|
|
1164
|
-
Effect.
|
|
1165
|
-
lifecycle.explicitCloseInvoked
|
|
1166
|
-
? Effect.void
|
|
1167
|
-
: Effect.forEach(failures, (failure) => Effect.logWarning(failure.warning)).pipe(
|
|
1168
|
-
Effect.asVoid,
|
|
1169
|
-
),
|
|
1170
|
-
),
|
|
1977
|
+
Effect.catch(() => Effect.logWarning("Whole-browser cleanup remains unconfirmed")),
|
|
1171
1978
|
),
|
|
1172
1979
|
),
|
|
1173
1980
|
);
|
|
@@ -1177,15 +1984,9 @@ const makeHostService = (
|
|
|
1177
1984
|
|
|
1178
1985
|
const close: Effect.Effect<void, InteractiveBrowserError> = Effect.uninterruptible(
|
|
1179
1986
|
Effect.sync(() => {
|
|
1180
|
-
lifecycle.explicitCloseInvoked = true;
|
|
1181
1987
|
state.closed.value = true;
|
|
1182
1988
|
state.disconnected.value = true;
|
|
1183
|
-
}).pipe(
|
|
1184
|
-
Effect.andThen(teardown),
|
|
1185
|
-
Effect.flatMap((failures) =>
|
|
1186
|
-
failures[0] === undefined ? Effect.void : Effect.fail(failures[0].error),
|
|
1187
|
-
),
|
|
1188
|
-
),
|
|
1989
|
+
}).pipe(Effect.andThen(teardown)),
|
|
1189
1990
|
);
|
|
1190
1991
|
|
|
1191
1992
|
const runtime = yield* makeHandle(page, fixedPolicy, startedAt, state, close);
|
|
@@ -1204,6 +2005,25 @@ const makeHostService = (
|
|
|
1204
2005
|
return {
|
|
1205
2006
|
handle: runtime.handle,
|
|
1206
2007
|
sessionId: Redacted.make(sessionIdValue),
|
|
2008
|
+
resizeViewport: (viewport) =>
|
|
2009
|
+
decodeViewport(viewport).pipe(
|
|
2010
|
+
Effect.flatMap((decoded) =>
|
|
2011
|
+
runtime.run(
|
|
2012
|
+
Effect.tryPromise({
|
|
2013
|
+
try: () => page.setViewport(decoded),
|
|
2014
|
+
catch: (cause) => {
|
|
2015
|
+
if (state.disconnected.value || isRemoteClosure(cause)) {
|
|
2016
|
+
state.disconnected.value = true;
|
|
2017
|
+
return expiredError();
|
|
2018
|
+
}
|
|
2019
|
+
return protocolError("Resizing the browser viewport failed", cause);
|
|
2020
|
+
},
|
|
2021
|
+
}),
|
|
2022
|
+
currentPagePreflight,
|
|
2023
|
+
false,
|
|
2024
|
+
),
|
|
2025
|
+
),
|
|
2026
|
+
),
|
|
1207
2027
|
getLiveView: (request) =>
|
|
1208
2028
|
Schema.decodeUnknownEffect(BrowserRunLiveViewRequest)(request).pipe(
|
|
1209
2029
|
Effect.mapError(() => policyError("The Live View request is malformed")),
|
|
@@ -1289,52 +2109,11 @@ const makeHostService = (
|
|
|
1289
2109
|
};
|
|
1290
2110
|
});
|
|
1291
2111
|
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
sessionId,
|
|
1297
|
-
).pipe(
|
|
1298
|
-
Effect.mapError(() => policyError("The Browser Run cleanup session identity is malformed")),
|
|
1299
|
-
);
|
|
1300
|
-
return yield* Effect.scoped(
|
|
1301
|
-
Effect.gen(function* () {
|
|
1302
|
-
const closeAttempted = { value: false };
|
|
1303
|
-
const browser = yield* Effect.acquireRelease(
|
|
1304
|
-
Effect.tryPromise({
|
|
1305
|
-
try: (signal) =>
|
|
1306
|
-
closeLateAcquisition(
|
|
1307
|
-
signal,
|
|
1308
|
-
() => binding.connect(Redacted.value(decoded)),
|
|
1309
|
-
(acquired) => acquired.close(),
|
|
1310
|
-
),
|
|
1311
|
-
catch: (cause) => actionError("close", cause),
|
|
1312
|
-
}),
|
|
1313
|
-
(acquired) =>
|
|
1314
|
-
closeAttempted.value
|
|
1315
|
-
? Effect.void
|
|
1316
|
-
: closeWithWarning(acquired.close, "Closing the leaked Browser Run session failed"),
|
|
1317
|
-
{ interruptible: true },
|
|
1318
|
-
);
|
|
1319
|
-
return yield* Effect.tryPromise({
|
|
1320
|
-
try: () => {
|
|
1321
|
-
// Mark and start are synchronous so interruption cannot suppress the
|
|
1322
|
-
// Scope fallback before the one remote close attempt begins.
|
|
1323
|
-
closeAttempted.value = true;
|
|
1324
|
-
return browser.close();
|
|
1325
|
-
},
|
|
1326
|
-
catch: (cause) => actionError("close", cause),
|
|
1327
|
-
});
|
|
1328
|
-
}),
|
|
1329
|
-
).pipe(
|
|
1330
|
-
Effect.timeoutOrElse({
|
|
1331
|
-
duration: Duration.millis(CLOSE_SESSION_TIMEOUT_MILLIS),
|
|
1332
|
-
orElse: () => Effect.fail(actionError("close")),
|
|
1333
|
-
}),
|
|
1334
|
-
);
|
|
2112
|
+
return BrowserRunInteractiveHost.of({
|
|
2113
|
+
open,
|
|
2114
|
+
closeSession,
|
|
2115
|
+
cleanupSemantics: "confirmed-terminal",
|
|
1335
2116
|
});
|
|
1336
|
-
|
|
1337
|
-
return BrowserRunInteractiveHost.of({ open, closeSession });
|
|
1338
2117
|
};
|
|
1339
2118
|
|
|
1340
2119
|
/** Cloudflare host controls and private session identity for one scoped Browser Run pass. */
|