@effect-agent/platform-cloudflare 0.1.0-beta.110 → 0.1.0-beta.112
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/Alarm.d.mts +34 -8
- package/dist/Alarm.mjs +231 -41
- package/dist/Alarm.mjs.map +1 -1
- package/dist/CloudflareThreadClient.d.mts +49 -42
- package/dist/CloudflareThreadClient.mjs +1 -1
- package/dist/{InteractiveBrowser-CKp6pqmw.d.mts → InteractiveBrowser-BQo7QKaP.d.mts} +64 -7
- package/dist/{InteractiveBrowser-l6J4eP6T.mjs → InteractiveBrowser-CBUFAECo.mjs} +545 -223
- package/dist/InteractiveBrowser-CBUFAECo.mjs.map +1 -0
- package/dist/InteractiveBrowser.d.mts +2 -2
- package/dist/InteractiveBrowser.mjs +2 -2
- package/dist/ProtectedBrowser.d.mts +1 -1
- package/dist/ProtectedBrowser.mjs +1 -1
- package/dist/{ThreadObject-BGla_Obk.mjs → ThreadObject-0k-SVpY_.mjs} +2 -2
- package/dist/ThreadObject-0k-SVpY_.mjs.map +1 -0
- package/dist/{ThreadObject-MSlpVBUD.d.mts → ThreadObject-DVxgrU7c.d.mts} +46 -39
- package/dist/ThreadObject.d.mts +1 -1
- package/dist/ThreadObject.mjs +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +1 -1
- package/src/Alarm.ts +432 -72
- package/src/InteractiveBrowser.ts +1129 -500
- package/src/ThreadObject.ts +2 -2
- package/dist/InteractiveBrowser-l6J4eP6T.mjs.map +0 -1
- package/dist/ThreadObject-BGla_Obk.mjs.map +0 -1
|
@@ -15,13 +15,13 @@ import {
|
|
|
15
15
|
Effect,
|
|
16
16
|
Layer,
|
|
17
17
|
Redacted,
|
|
18
|
-
Ref,
|
|
19
18
|
Schema,
|
|
20
19
|
Semaphore,
|
|
21
20
|
Scope,
|
|
22
21
|
} from "effect";
|
|
23
22
|
import {
|
|
24
23
|
BrowserActionResult,
|
|
24
|
+
BrowserExpectedTargetState,
|
|
25
25
|
BrowserSelectFileRequest,
|
|
26
26
|
BrowserFileSelectionResult,
|
|
27
27
|
BrowserScreenshotRequest,
|
|
@@ -40,7 +40,9 @@ import {
|
|
|
40
40
|
InteractiveBrowserTargetUrl,
|
|
41
41
|
InteractiveBrowserUnsupportedError,
|
|
42
42
|
type BrowserHandle,
|
|
43
|
+
type BrowserExpectedTarget,
|
|
43
44
|
type InteractiveBrowserError,
|
|
45
|
+
type InteractiveBrowserFailureEvidence,
|
|
44
46
|
type InteractiveBrowserNetworkPolicy,
|
|
45
47
|
} from "effect-agent/interactive-browser";
|
|
46
48
|
import { PageScreenshotResult } from "effect-agent/page-screenshot";
|
|
@@ -126,24 +128,21 @@ const ActionObservation = Schema.Struct({
|
|
|
126
128
|
after: Schema.optionalKey(ActionTargetState),
|
|
127
129
|
afterUnavailable: Schema.Boolean,
|
|
128
130
|
network: ActionNetworkState,
|
|
131
|
+
inputMillis: Schema.optionalKey(Schema.Finite),
|
|
132
|
+
observationMillis: Schema.optionalKey(Schema.Finite),
|
|
129
133
|
});
|
|
130
134
|
|
|
131
|
-
const
|
|
135
|
+
export const BrowserRunPageObservation = Schema.fromJsonString(
|
|
132
136
|
Schema.Struct({
|
|
137
|
+
documentId: Schema.NonEmptyString,
|
|
133
138
|
pageText: BoundedRemoteText,
|
|
134
139
|
selectorMatchCount: Schema.Natural,
|
|
135
140
|
controlsTruncated: Schema.Boolean,
|
|
136
141
|
controls: Schema.Array(
|
|
137
142
|
Schema.Struct({
|
|
143
|
+
nodeId: Schema.NonEmptyString,
|
|
138
144
|
selector: BoundedRemoteText,
|
|
139
|
-
|
|
140
|
-
label: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(200))),
|
|
141
|
-
checked: Schema.optionalKey(Schema.Boolean),
|
|
142
|
-
selected: Schema.optionalKey(Schema.Boolean),
|
|
143
|
-
disabled: Schema.optionalKey(Schema.Boolean),
|
|
144
|
-
required: Schema.optionalKey(Schema.Boolean),
|
|
145
|
-
valid: Schema.optionalKey(Schema.Boolean),
|
|
146
|
-
formValid: Schema.optionalKey(Schema.Boolean),
|
|
145
|
+
...BrowserExpectedTargetState.fields,
|
|
147
146
|
}),
|
|
148
147
|
).check(Schema.isMaxLength(MAX_OBSERVED_CONTROLS)),
|
|
149
148
|
}),
|
|
@@ -319,6 +318,7 @@ export interface BrowserRunInteractiveCdpSession {
|
|
|
319
318
|
|
|
320
319
|
/** Narrow page boundary used by deterministic tests; SDK values remain in this package. */
|
|
321
320
|
export interface BrowserRunInteractivePage {
|
|
321
|
+
readonly identity?: () => Promise<BrowserRunPageIdentity>;
|
|
322
322
|
readonly close: () => Promise<void>;
|
|
323
323
|
readonly setBypassServiceWorker: (enabled: boolean) => Promise<void>;
|
|
324
324
|
readonly setRequestInterception: (enabled: boolean) => Promise<void>;
|
|
@@ -332,16 +332,21 @@ export interface BrowserRunInteractivePage {
|
|
|
332
332
|
value: string,
|
|
333
333
|
signal: AbortSignal,
|
|
334
334
|
onDispatch: () => void,
|
|
335
|
+
onComplete?: () => void,
|
|
336
|
+
expectedTarget?: BrowserExpectedTarget,
|
|
335
337
|
) => Promise<unknown>;
|
|
336
338
|
readonly click: (
|
|
337
339
|
selector: string,
|
|
338
340
|
signal: AbortSignal,
|
|
339
341
|
onDispatch: () => void,
|
|
342
|
+
onComplete?: () => void,
|
|
343
|
+
expectedTarget?: BrowserExpectedTarget,
|
|
340
344
|
) => Promise<unknown>;
|
|
341
345
|
readonly selectFile: (
|
|
342
346
|
request: BrowserSelectFileRequest,
|
|
343
347
|
signal: AbortSignal,
|
|
344
348
|
onDispatch: () => void,
|
|
349
|
+
onComplete?: () => void,
|
|
345
350
|
) => Promise<unknown>;
|
|
346
351
|
readonly screenshot: (fullPage: boolean) => Promise<unknown>;
|
|
347
352
|
readonly scroll: (deltaX: number, deltaY: number) => Promise<void>;
|
|
@@ -355,6 +360,13 @@ export interface BrowserRunInteractiveContext {
|
|
|
355
360
|
}
|
|
356
361
|
|
|
357
362
|
export interface BrowserRunInteractiveBrowser {
|
|
363
|
+
readonly detach?: () => Promise<void>;
|
|
364
|
+
readonly reattach?: (
|
|
365
|
+
identity: BrowserRunPageIdentity,
|
|
366
|
+
) => Promise<
|
|
367
|
+
| { readonly context: BrowserRunInteractiveContext; readonly page: BrowserRunInteractivePage }
|
|
368
|
+
| undefined
|
|
369
|
+
>;
|
|
358
370
|
readonly createContext: () => Promise<BrowserRunInteractiveContext>;
|
|
359
371
|
readonly close: () => Promise<void>;
|
|
360
372
|
readonly sessionId: () => unknown;
|
|
@@ -409,13 +421,43 @@ export class BrowserRunInteractiveBinding extends Context.Service<
|
|
|
409
421
|
}
|
|
410
422
|
}
|
|
411
423
|
|
|
424
|
+
/** Private target identity; never include this checkpoint in model-visible records. */
|
|
425
|
+
export class BrowserRunPageIdentity extends Schema.Class<BrowserRunPageIdentity>(
|
|
426
|
+
"BrowserRunPageIdentity",
|
|
427
|
+
)({
|
|
428
|
+
contextId: BrowserRunSessionId,
|
|
429
|
+
targetId: BrowserRunSessionId,
|
|
430
|
+
}) {}
|
|
431
|
+
|
|
432
|
+
/** Host-owned recovery data. Persist with the application's input receipts and ownership fence. */
|
|
433
|
+
export class BrowserRunInteractiveCheckpoint extends Schema.Class<BrowserRunInteractiveCheckpoint>(
|
|
434
|
+
"BrowserRunInteractiveCheckpoint",
|
|
435
|
+
)({
|
|
436
|
+
sessionId: Schema.RedactedFromValue(BrowserRunSessionId),
|
|
437
|
+
page: BrowserRunPageIdentity,
|
|
438
|
+
policy: InteractiveBrowserPolicy,
|
|
439
|
+
startedAt: Schema.Natural,
|
|
440
|
+
consumedActions: Schema.Natural.check(Schema.isLessThanOrEqualTo(1_000)),
|
|
441
|
+
inputState: Schema.Literals(["idle", "running", "unknown"]),
|
|
442
|
+
}) {}
|
|
443
|
+
|
|
444
|
+
export type BrowserRunInputState = "idle" | "running" | "unknown";
|
|
445
|
+
|
|
412
446
|
export interface BrowserRunInteractiveSession {
|
|
413
447
|
readonly handle: BrowserHandle;
|
|
448
|
+
readonly checkpoint: Effect.Effect<BrowserRunInteractiveCheckpoint, InteractiveBrowserError>;
|
|
449
|
+
/** Invalidate this owner and disconnect locally, retaining the exact remote page until its deadline.
|
|
450
|
+
* Checkpoint and durable input receipts must be persisted first. Never grants another owner authority.
|
|
451
|
+
*/
|
|
452
|
+
readonly detach: Effect.Effect<void, InteractiveBrowserError>;
|
|
453
|
+
readonly inputState: Effect.Effect<BrowserRunInputState>;
|
|
454
|
+
/** Wait at most 500 ms for local SDK input. A restart/transport-unknown fence cannot be drained. */
|
|
455
|
+
readonly drainInput: Effect.Effect<BrowserRunInputState>;
|
|
414
456
|
readonly sessionId: Redacted.Redacted<string>;
|
|
415
457
|
/**
|
|
416
458
|
* Resize without charging an agent action or changing emulation modes. Shares
|
|
417
459
|
* the handle's fail-fast lock, page-policy preflight, and elapsed deadline.
|
|
418
|
-
*
|
|
460
|
+
* An interrupted resize fences further input until the SDK settles; no resize is retried.
|
|
419
461
|
* The host must authorize callers. No viewer ownership or durable state is added.
|
|
420
462
|
*/
|
|
421
463
|
readonly resizeViewport: (
|
|
@@ -449,6 +491,14 @@ export class BrowserRunInteractiveHost extends Context.Service<
|
|
|
449
491
|
readonly open: (
|
|
450
492
|
policy: InteractiveBrowserPolicy,
|
|
451
493
|
) => Effect.Effect<BrowserRunInteractiveSession, InteractiveBrowserError, Scope.Scope>;
|
|
494
|
+
/** Requires exclusive host ownership. Never allocates a replacement page or replays an action.
|
|
495
|
+
* pendingInput must include durable receipts written after the checkpoint. Unknown input keeps
|
|
496
|
+
* mutations and human control fenced; reads remain available. Only confirmed close ends that fence.
|
|
497
|
+
*/
|
|
498
|
+
readonly resume: (
|
|
499
|
+
checkpoint: BrowserRunInteractiveCheckpoint,
|
|
500
|
+
options: { readonly pendingInput: boolean },
|
|
501
|
+
) => Effect.Effect<BrowserRunInteractiveSession, InteractiveBrowserError, Scope.Scope>;
|
|
452
502
|
readonly closeSession: (
|
|
453
503
|
sessionId: Redacted.Redacted<string>,
|
|
454
504
|
) => Effect.Effect<void, InteractiveBrowserError>;
|
|
@@ -719,6 +769,366 @@ const disposeActionHandles = async (handles: ReadonlyArray<{ dispose: () => Prom
|
|
|
719
769
|
);
|
|
720
770
|
};
|
|
721
771
|
|
|
772
|
+
/** Runs entirely in the page realm for both observations and the last bound-element guard. */
|
|
773
|
+
const observePage = (
|
|
774
|
+
target: object | undefined,
|
|
775
|
+
requestedSelector: string | undefined,
|
|
776
|
+
maximum: number,
|
|
777
|
+
maximumControls: number,
|
|
778
|
+
input?: {
|
|
779
|
+
readonly mode: "click" | "fill";
|
|
780
|
+
readonly expectedTarget: BrowserExpectedTarget;
|
|
781
|
+
readonly value?: string;
|
|
782
|
+
},
|
|
783
|
+
) => {
|
|
784
|
+
const pageDocument = Reflect.get(globalThis, "document");
|
|
785
|
+
|
|
786
|
+
const matches =
|
|
787
|
+
requestedSelector === undefined
|
|
788
|
+
? undefined
|
|
789
|
+
: Reflect.apply(Reflect.get(pageDocument, "querySelectorAll"), pageDocument, [
|
|
790
|
+
requestedSelector,
|
|
791
|
+
]);
|
|
792
|
+
|
|
793
|
+
const selectorMatchCount =
|
|
794
|
+
matches === undefined || matches === null
|
|
795
|
+
? 1
|
|
796
|
+
: Math.min(10_000, Reflect.get(matches, "length"));
|
|
797
|
+
|
|
798
|
+
const element =
|
|
799
|
+
target ??
|
|
800
|
+
(matches === undefined || matches === null
|
|
801
|
+
? Reflect.get(pageDocument, "body")
|
|
802
|
+
: Reflect.get(matches, 0));
|
|
803
|
+
|
|
804
|
+
if (target !== undefined && Reflect.get(target, "isConnected") !== true)
|
|
805
|
+
return { _tag: "MissingElement" };
|
|
806
|
+
|
|
807
|
+
if (element === null) return { _tag: "MissingElement" };
|
|
808
|
+
if (element === undefined) return { _tag: "MissingElement" };
|
|
809
|
+
const innerText = Reflect.get(element, "innerText");
|
|
810
|
+
const textContent = Reflect.get(element, "textContent");
|
|
811
|
+
|
|
812
|
+
const pageText =
|
|
813
|
+
typeof innerText === "string" ? innerText : typeof textContent === "string" ? textContent : "";
|
|
814
|
+
|
|
815
|
+
const primaryControlSelector =
|
|
816
|
+
'input,select,textarea,label,button,[role="checkbox"],[role="radio"],[role="option"],[role="switch"],[role="tab"],[role="button"]';
|
|
817
|
+
|
|
818
|
+
const optionSelector = "select option";
|
|
819
|
+
const secondaryControlSelector = "a[href]";
|
|
820
|
+
const controlSelector = `${primaryControlSelector},${optionSelector},${secondaryControlSelector}`;
|
|
821
|
+
|
|
822
|
+
const selectorFor = (candidate: object) => {
|
|
823
|
+
const parts: Array<string> = [];
|
|
824
|
+
let current: object | null = candidate;
|
|
825
|
+
|
|
826
|
+
while (current !== null && current !== undefined) {
|
|
827
|
+
const tagName = String(Reflect.get(current, "tagName") ?? "").toLowerCase();
|
|
828
|
+
|
|
829
|
+
if (tagName === "") break;
|
|
830
|
+
const parent: object | null = Reflect.get(current, "parentElement");
|
|
831
|
+
|
|
832
|
+
if (parent === null) {
|
|
833
|
+
parts.push(tagName);
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
let sibling = Reflect.get(current, "previousElementSibling");
|
|
837
|
+
let index = 1;
|
|
838
|
+
|
|
839
|
+
while (sibling !== null && sibling !== undefined) {
|
|
840
|
+
if (String(Reflect.get(sibling, "tagName") ?? "").toLowerCase() === tagName) index++;
|
|
841
|
+
sibling = Reflect.get(sibling, "previousElementSibling");
|
|
842
|
+
}
|
|
843
|
+
parts.push(`${tagName}:nth-of-type(${index})`);
|
|
844
|
+
current = parent;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return parts.reverse().join(" > ");
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
const visible = (candidate: object) => {
|
|
851
|
+
const hidden = Reflect.get(candidate, "hidden");
|
|
852
|
+
|
|
853
|
+
const ariaHidden = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
|
|
854
|
+
"aria-hidden",
|
|
855
|
+
]);
|
|
856
|
+
|
|
857
|
+
const rects = Reflect.apply(Reflect.get(candidate, "getClientRects"), candidate, []);
|
|
858
|
+
|
|
859
|
+
return (
|
|
860
|
+
hidden !== true &&
|
|
861
|
+
ariaHidden !== "true" &&
|
|
862
|
+
typeof rects === "object" &&
|
|
863
|
+
rects !== null &&
|
|
864
|
+
Reflect.get(rects, "length") > 0
|
|
865
|
+
);
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
const candidates: Array<object> = [];
|
|
869
|
+
let controlsTruncated = false;
|
|
870
|
+
|
|
871
|
+
const consider = (candidate: object) => {
|
|
872
|
+
const tagName = String(Reflect.get(candidate, "tagName") ?? "").toLowerCase();
|
|
873
|
+
|
|
874
|
+
// Collapsed native options have no client rects. Their owning
|
|
875
|
+
// select determines visibility; observe text/selection, never value.
|
|
876
|
+
const visibilityTarget =
|
|
877
|
+
tagName === "option"
|
|
878
|
+
? Reflect.apply(Reflect.get(candidate, "closest"), candidate, ["select"])
|
|
879
|
+
: candidate;
|
|
880
|
+
|
|
881
|
+
const actionable = tagName !== "label" || Reflect.get(candidate, "control") !== null;
|
|
882
|
+
|
|
883
|
+
if (
|
|
884
|
+
!actionable ||
|
|
885
|
+
typeof visibilityTarget !== "object" ||
|
|
886
|
+
visibilityTarget === null ||
|
|
887
|
+
!visible(visibilityTarget)
|
|
888
|
+
)
|
|
889
|
+
return;
|
|
890
|
+
candidates.push(candidate);
|
|
891
|
+
if (candidates.length > maximumControls) controlsTruncated = true;
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
const elementMatches = Reflect.get(element, "matches");
|
|
895
|
+
|
|
896
|
+
if (
|
|
897
|
+
typeof elementMatches === "function" &&
|
|
898
|
+
Reflect.apply(elementMatches, element, [controlSelector])
|
|
899
|
+
) {
|
|
900
|
+
consider(element);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const considerSelector = (candidateSelector: string) => {
|
|
904
|
+
const descendants = Reflect.apply(Reflect.get(element, "querySelectorAll"), element, [
|
|
905
|
+
candidateSelector,
|
|
906
|
+
]);
|
|
907
|
+
|
|
908
|
+
if (typeof descendants !== "object" || descendants === null) return;
|
|
909
|
+
const descendantCount = Reflect.get(descendants, "length");
|
|
910
|
+
|
|
911
|
+
for (let index = 0; index < descendantCount && !controlsTruncated; index++) {
|
|
912
|
+
consider(Reflect.get(descendants, index));
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
considerSelector(primaryControlSelector);
|
|
917
|
+
if (!controlsTruncated) considerSelector(optionSelector);
|
|
918
|
+
if (!controlsTruncated) considerSelector(secondaryControlSelector);
|
|
919
|
+
|
|
920
|
+
const identityKey = Symbol.for("@effect-agent/browser-observation");
|
|
921
|
+
let identity = Reflect.get(globalThis, identityKey);
|
|
922
|
+
|
|
923
|
+
if (identity === undefined || identity.document !== pageDocument) {
|
|
924
|
+
identity = {
|
|
925
|
+
document: pageDocument,
|
|
926
|
+
documentId: crypto.getRandomValues(new Uint32Array(4)).join("-"),
|
|
927
|
+
nodes: new WeakMap<object, string>(),
|
|
928
|
+
};
|
|
929
|
+
Reflect.set(globalThis, identityKey, identity);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const controls = candidates.slice(0, maximumControls).map((candidate) => {
|
|
933
|
+
let nodeId = identity.nodes.get(candidate);
|
|
934
|
+
|
|
935
|
+
if (nodeId === undefined) {
|
|
936
|
+
nodeId = crypto.getRandomValues(new Uint32Array(4)).join("-");
|
|
937
|
+
identity.nodes.set(candidate, nodeId);
|
|
938
|
+
}
|
|
939
|
+
const associated = Reflect.get(candidate, "control") ?? candidate;
|
|
940
|
+
const tagName = String(Reflect.get(candidate, "tagName") ?? "").toLowerCase();
|
|
941
|
+
const inputType = String(Reflect.get(associated, "type") ?? "").toLowerCase();
|
|
942
|
+
|
|
943
|
+
const role = String(
|
|
944
|
+
Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, ["role"]) ?? "",
|
|
945
|
+
).toLowerCase();
|
|
946
|
+
|
|
947
|
+
const ariaLabel = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
|
|
948
|
+
"aria-label",
|
|
949
|
+
]);
|
|
950
|
+
|
|
951
|
+
const candidateText =
|
|
952
|
+
tagName === "textarea" || tagName === "input" || tagName === "select"
|
|
953
|
+
? undefined
|
|
954
|
+
: Reflect.get(candidate, tagName === "option" ? "label" : "innerText");
|
|
955
|
+
|
|
956
|
+
const associatedLabels = Reflect.get(associated, "labels");
|
|
957
|
+
|
|
958
|
+
const associatedLabel =
|
|
959
|
+
associatedLabels !== undefined &&
|
|
960
|
+
associatedLabels !== null &&
|
|
961
|
+
Reflect.get(associatedLabels, "length") > 0
|
|
962
|
+
? Reflect.get(Reflect.get(associatedLabels, 0), "innerText")
|
|
963
|
+
: undefined;
|
|
964
|
+
|
|
965
|
+
const label = String(
|
|
966
|
+
typeof ariaLabel === "string" && ariaLabel !== ""
|
|
967
|
+
? ariaLabel
|
|
968
|
+
: typeof candidateText === "string" && candidateText !== ""
|
|
969
|
+
? candidateText
|
|
970
|
+
: (associatedLabel ?? ""),
|
|
971
|
+
)
|
|
972
|
+
.replace(/\s+/g, " ")
|
|
973
|
+
.trim()
|
|
974
|
+
.slice(0, 200);
|
|
975
|
+
|
|
976
|
+
const checked = Reflect.get(associated, "checked");
|
|
977
|
+
|
|
978
|
+
const selected =
|
|
979
|
+
tagName === "select"
|
|
980
|
+
? Reflect.get(associated, "selectedIndex") >= 0
|
|
981
|
+
: Reflect.get(associated, "selected");
|
|
982
|
+
|
|
983
|
+
const disabled = Reflect.get(associated, "disabled");
|
|
984
|
+
const required = Reflect.get(associated, "required");
|
|
985
|
+
const validity = Reflect.get(associated, "validity");
|
|
986
|
+
const form = Reflect.get(associated, "form");
|
|
987
|
+
|
|
988
|
+
const formMatches =
|
|
989
|
+
form === null || form === undefined ? undefined : Reflect.get(form, "matches");
|
|
990
|
+
|
|
991
|
+
const ariaChecked = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
|
|
992
|
+
"aria-checked",
|
|
993
|
+
]);
|
|
994
|
+
|
|
995
|
+
const ariaSelected = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
|
|
996
|
+
"aria-selected",
|
|
997
|
+
]);
|
|
998
|
+
|
|
999
|
+
const ariaDisabled = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
|
|
1000
|
+
"aria-disabled",
|
|
1001
|
+
]);
|
|
1002
|
+
|
|
1003
|
+
return {
|
|
1004
|
+
nodeId,
|
|
1005
|
+
...(inputType === "" ? {} : { inputType: inputType.slice(0, 64) }),
|
|
1006
|
+
selector: selectorFor(candidate),
|
|
1007
|
+
kind: (tagName === "label"
|
|
1008
|
+
? `label:${inputType || "control"}`
|
|
1009
|
+
: tagName === "input"
|
|
1010
|
+
? `input:${inputType || "text"}`
|
|
1011
|
+
: role !== ""
|
|
1012
|
+
? `role:${role}`
|
|
1013
|
+
: tagName
|
|
1014
|
+
).slice(0, 128),
|
|
1015
|
+
...(label === "" ? {} : { label }),
|
|
1016
|
+
...(typeof checked === "boolean"
|
|
1017
|
+
? { checked }
|
|
1018
|
+
: ariaChecked === "true" || ariaChecked === "false"
|
|
1019
|
+
? { checked: ariaChecked === "true" }
|
|
1020
|
+
: {}),
|
|
1021
|
+
...(typeof selected === "boolean"
|
|
1022
|
+
? { selected }
|
|
1023
|
+
: ariaSelected === "true" || ariaSelected === "false"
|
|
1024
|
+
? { selected: ariaSelected === "true" }
|
|
1025
|
+
: {}),
|
|
1026
|
+
...(typeof disabled === "boolean"
|
|
1027
|
+
? { disabled }
|
|
1028
|
+
: ariaDisabled === "true" || ariaDisabled === "false"
|
|
1029
|
+
? { disabled: ariaDisabled === "true" }
|
|
1030
|
+
: {}),
|
|
1031
|
+
...(typeof required === "boolean" ? { required } : {}),
|
|
1032
|
+
...(validity !== undefined && typeof Reflect.get(validity, "valid") === "boolean"
|
|
1033
|
+
? { valid: Reflect.get(validity, "valid") }
|
|
1034
|
+
: {}),
|
|
1035
|
+
...(typeof formMatches === "function"
|
|
1036
|
+
? { formValid: Reflect.apply(formMatches, form, [":valid"]) }
|
|
1037
|
+
: {}),
|
|
1038
|
+
};
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
if (input !== undefined) {
|
|
1042
|
+
const expected = input.expectedTarget;
|
|
1043
|
+
const control = controls.find((candidate) => candidate.nodeId === expected.nodeId);
|
|
1044
|
+
|
|
1045
|
+
const fields = [
|
|
1046
|
+
"kind",
|
|
1047
|
+
"inputType",
|
|
1048
|
+
"label",
|
|
1049
|
+
"checked",
|
|
1050
|
+
"selected",
|
|
1051
|
+
"disabled",
|
|
1052
|
+
"required",
|
|
1053
|
+
"valid",
|
|
1054
|
+
"formValid",
|
|
1055
|
+
] as const;
|
|
1056
|
+
|
|
1057
|
+
if (
|
|
1058
|
+
target === undefined ||
|
|
1059
|
+
identity.documentId !== expected.documentId ||
|
|
1060
|
+
identity.nodes.get(target) !== expected.nodeId ||
|
|
1061
|
+
control === undefined ||
|
|
1062
|
+
(expected.state !== undefined &&
|
|
1063
|
+
fields.some((field) => control[field] !== expected.state?.[field])) ||
|
|
1064
|
+
Reflect.get(target, "isConnected") !== true
|
|
1065
|
+
)
|
|
1066
|
+
return { _tag: "MissingElement" };
|
|
1067
|
+
if (expected.scopeSelector !== undefined) {
|
|
1068
|
+
let roots;
|
|
1069
|
+
|
|
1070
|
+
try {
|
|
1071
|
+
roots = Reflect.apply(Reflect.get(pageDocument, "querySelectorAll"), pageDocument, [
|
|
1072
|
+
expected.scopeSelector,
|
|
1073
|
+
]);
|
|
1074
|
+
} catch {
|
|
1075
|
+
return { _tag: "MissingElement" };
|
|
1076
|
+
}
|
|
1077
|
+
if (typeof roots !== "object" || roots === null) return { _tag: "MissingElement" };
|
|
1078
|
+
const root = Reflect.get(roots, 0);
|
|
1079
|
+
|
|
1080
|
+
if (
|
|
1081
|
+
Reflect.get(roots, "length") !== 1 ||
|
|
1082
|
+
root === undefined ||
|
|
1083
|
+
!Reflect.apply(Reflect.get(root, "contains"), root, [target])
|
|
1084
|
+
)
|
|
1085
|
+
return { _tag: "MissingElement" };
|
|
1086
|
+
}
|
|
1087
|
+
// Validation and dispatch share one page JS task. No coordinate lookup, selector
|
|
1088
|
+
// re-resolution, or asynchronous boundary can select a replacement node.
|
|
1089
|
+
if (input.mode === "click") {
|
|
1090
|
+
const prototype = Reflect.get(Reflect.get(globalThis, "HTMLElement"), "prototype");
|
|
1091
|
+
|
|
1092
|
+
Reflect.apply(Reflect.get(prototype, "click"), target, []);
|
|
1093
|
+
} else {
|
|
1094
|
+
let prototype = Reflect.getPrototypeOf(target);
|
|
1095
|
+
let setValue: ((value: string) => void) | undefined;
|
|
1096
|
+
|
|
1097
|
+
while (prototype !== null) {
|
|
1098
|
+
const setter = Reflect.getOwnPropertyDescriptor(prototype, "value")?.set;
|
|
1099
|
+
|
|
1100
|
+
if (typeof setter === "function") {
|
|
1101
|
+
setValue = setter;
|
|
1102
|
+
break;
|
|
1103
|
+
}
|
|
1104
|
+
prototype = Reflect.getPrototypeOf(prototype);
|
|
1105
|
+
}
|
|
1106
|
+
if (setValue === undefined || input.value === undefined) return { _tag: "MissingElement" };
|
|
1107
|
+
Reflect.apply(setValue, target, [input.value]);
|
|
1108
|
+
const dispatchEvent = Reflect.get(target, "dispatchEvent");
|
|
1109
|
+
|
|
1110
|
+
Reflect.apply(dispatchEvent, target, [new Event("input", { bubbles: true })]);
|
|
1111
|
+
Reflect.apply(dispatchEvent, target, [new Event("change", { bubbles: true })]);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
return { _tag: "Text", text: "" };
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// JSON is the bounded wire representation of this existing text result.
|
|
1118
|
+
// eslint-disable-next-line no-restricted-properties
|
|
1119
|
+
const text = JSON.stringify({
|
|
1120
|
+
documentId: identity.documentId,
|
|
1121
|
+
pageText,
|
|
1122
|
+
selectorMatchCount,
|
|
1123
|
+
controls,
|
|
1124
|
+
controlsTruncated,
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
const observed = new TextEncoder().encode(text).byteLength;
|
|
1128
|
+
|
|
1129
|
+
return observed > maximum ? { _tag: "OverLimit", observed } : { _tag: "Text", text };
|
|
1130
|
+
};
|
|
1131
|
+
|
|
722
1132
|
const runObservedPageAction = async (
|
|
723
1133
|
page: Page,
|
|
724
1134
|
selector: string,
|
|
@@ -726,6 +1136,7 @@ const runObservedPageAction = async (
|
|
|
726
1136
|
onDispatch: () => void,
|
|
727
1137
|
action: (element: NonNullable<Awaited<ReturnType<Page["$"]>>>) => Promise<void>,
|
|
728
1138
|
validate?: (element: NonNullable<Awaited<ReturnType<Page["$"]>>>) => Promise<boolean>,
|
|
1139
|
+
onComplete?: () => void,
|
|
729
1140
|
): Promise<unknown> => {
|
|
730
1141
|
if (signal.aborted) throw new BrowserRunActionUndispatched(0);
|
|
731
1142
|
|
|
@@ -764,13 +1175,20 @@ const runObservedPageAction = async (
|
|
|
764
1175
|
// dispatched, interruption is uncertain, even if the SDK later resolves.
|
|
765
1176
|
if (signal.aborted) throw new BrowserRunActionUndispatched(1);
|
|
766
1177
|
onDispatch();
|
|
1178
|
+
const inputStarted = performance.now();
|
|
1179
|
+
|
|
767
1180
|
await action(matches[0]);
|
|
1181
|
+
const inputFinished = performance.now();
|
|
1182
|
+
|
|
1183
|
+
onComplete?.();
|
|
768
1184
|
tracker.markActionSettled();
|
|
769
1185
|
const network = await tracker.wait();
|
|
770
1186
|
const after = signal.aborted ? undefined : await readActionTargetAfter(page, selector);
|
|
771
1187
|
|
|
772
1188
|
return {
|
|
773
1189
|
before,
|
|
1190
|
+
inputMillis: Math.max(0, inputFinished - inputStarted),
|
|
1191
|
+
observationMillis: Math.max(0, performance.now() - inputFinished),
|
|
774
1192
|
...(after === undefined ? {} : { after }),
|
|
775
1193
|
afterUnavailable: after === undefined,
|
|
776
1194
|
network,
|
|
@@ -782,11 +1200,42 @@ const runObservedPageAction = async (
|
|
|
782
1200
|
}
|
|
783
1201
|
};
|
|
784
1202
|
|
|
1203
|
+
const guardedPageInput = async (
|
|
1204
|
+
element: NonNullable<Awaited<ReturnType<Page["$"]>>>,
|
|
1205
|
+
expectedTarget: BrowserExpectedTarget,
|
|
1206
|
+
mode: "click" | "fill",
|
|
1207
|
+
value?: string,
|
|
1208
|
+
): Promise<void> => {
|
|
1209
|
+
const observed = Schema.decodeUnknownSync(TextObservation)(
|
|
1210
|
+
await element.evaluate(observePage, undefined, 256 * 1024, 1, {
|
|
1211
|
+
mode,
|
|
1212
|
+
expectedTarget,
|
|
1213
|
+
...(value === undefined ? {} : { value }),
|
|
1214
|
+
}),
|
|
1215
|
+
);
|
|
1216
|
+
|
|
1217
|
+
if (observed._tag !== "Text") throw new BrowserRunActionUndispatched(1);
|
|
1218
|
+
};
|
|
1219
|
+
|
|
785
1220
|
const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
|
|
786
1221
|
const prepareFileSelection = makeFileSelection(page);
|
|
787
1222
|
const listeners = new Map<BrowserRunInteractiveRequestListener, (request: HTTPRequest) => void>();
|
|
788
1223
|
|
|
789
1224
|
return {
|
|
1225
|
+
identity: async () => {
|
|
1226
|
+
const cdp = await page.createCDPSession();
|
|
1227
|
+
|
|
1228
|
+
try {
|
|
1229
|
+
const { targetInfo } = await cdp.send("Target.getTargetInfo");
|
|
1230
|
+
|
|
1231
|
+
return Schema.decodeUnknownSync(BrowserRunPageIdentity)({
|
|
1232
|
+
contextId: targetInfo.browserContextId,
|
|
1233
|
+
targetId: targetInfo.targetId,
|
|
1234
|
+
});
|
|
1235
|
+
} finally {
|
|
1236
|
+
await cdp.detach();
|
|
1237
|
+
}
|
|
1238
|
+
},
|
|
790
1239
|
close: async () => {
|
|
791
1240
|
if (!page.browser().isConnected()) return;
|
|
792
1241
|
try {
|
|
@@ -817,307 +1266,68 @@ const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
|
|
|
817
1266
|
url: () => page.url(),
|
|
818
1267
|
readText: async (selector, maximumBytes) => {
|
|
819
1268
|
const observation = Schema.decodeUnknownSync(TextObservation)(
|
|
820
|
-
await page.evaluate(
|
|
821
|
-
(requestedSelector, maximum, maximumControls) => {
|
|
822
|
-
const pageDocument = Reflect.get(globalThis, "document");
|
|
823
|
-
|
|
824
|
-
const matches =
|
|
825
|
-
requestedSelector === undefined
|
|
826
|
-
? undefined
|
|
827
|
-
: Reflect.apply(Reflect.get(pageDocument, "querySelectorAll"), pageDocument, [
|
|
828
|
-
requestedSelector,
|
|
829
|
-
]);
|
|
830
|
-
|
|
831
|
-
const selectorMatchCount =
|
|
832
|
-
matches === undefined || matches === null
|
|
833
|
-
? 1
|
|
834
|
-
: Math.min(10_000, Reflect.get(matches, "length"));
|
|
835
|
-
|
|
836
|
-
const element =
|
|
837
|
-
matches === undefined || matches === null
|
|
838
|
-
? Reflect.get(pageDocument, "body")
|
|
839
|
-
: Reflect.get(matches, 0);
|
|
840
|
-
|
|
841
|
-
if (element === null) return { _tag: "MissingElement" };
|
|
842
|
-
if (element === undefined) return { _tag: "MissingElement" };
|
|
843
|
-
const innerText = Reflect.get(element, "innerText");
|
|
844
|
-
const textContent = Reflect.get(element, "textContent");
|
|
845
|
-
|
|
846
|
-
const pageText =
|
|
847
|
-
typeof innerText === "string"
|
|
848
|
-
? innerText
|
|
849
|
-
: typeof textContent === "string"
|
|
850
|
-
? textContent
|
|
851
|
-
: "";
|
|
852
|
-
|
|
853
|
-
const primaryControlSelector =
|
|
854
|
-
'input,select,textarea,label,button,[role="checkbox"],[role="radio"],[role="option"],[role="switch"],[role="tab"],[role="button"]';
|
|
855
|
-
|
|
856
|
-
const optionSelector = "select option";
|
|
857
|
-
const secondaryControlSelector = "a[href]";
|
|
858
|
-
const controlSelector = `${primaryControlSelector},${optionSelector},${secondaryControlSelector}`;
|
|
859
|
-
|
|
860
|
-
const selectorFor = (candidate: object) => {
|
|
861
|
-
const parts: Array<string> = [];
|
|
862
|
-
let current: object | null = candidate;
|
|
863
|
-
|
|
864
|
-
while (current !== null && current !== undefined) {
|
|
865
|
-
const tagName = String(Reflect.get(current, "tagName") ?? "").toLowerCase();
|
|
866
|
-
|
|
867
|
-
if (tagName === "") break;
|
|
868
|
-
const parent: object | null = Reflect.get(current, "parentElement");
|
|
869
|
-
|
|
870
|
-
if (parent === null) {
|
|
871
|
-
parts.push(tagName);
|
|
872
|
-
break;
|
|
873
|
-
}
|
|
874
|
-
let sibling = Reflect.get(current, "previousElementSibling");
|
|
875
|
-
let index = 1;
|
|
876
|
-
|
|
877
|
-
while (sibling !== null && sibling !== undefined) {
|
|
878
|
-
if (String(Reflect.get(sibling, "tagName") ?? "").toLowerCase() === tagName)
|
|
879
|
-
index++;
|
|
880
|
-
sibling = Reflect.get(sibling, "previousElementSibling");
|
|
881
|
-
}
|
|
882
|
-
parts.push(`${tagName}:nth-of-type(${index})`);
|
|
883
|
-
current = parent;
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
return parts.reverse().join(" > ");
|
|
887
|
-
};
|
|
888
|
-
|
|
889
|
-
const visible = (candidate: object) => {
|
|
890
|
-
const hidden = Reflect.get(candidate, "hidden");
|
|
891
|
-
|
|
892
|
-
const ariaHidden = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
|
|
893
|
-
"aria-hidden",
|
|
894
|
-
]);
|
|
895
|
-
|
|
896
|
-
const rects = Reflect.apply(Reflect.get(candidate, "getClientRects"), candidate, []);
|
|
897
|
-
|
|
898
|
-
return (
|
|
899
|
-
hidden !== true &&
|
|
900
|
-
ariaHidden !== "true" &&
|
|
901
|
-
typeof rects === "object" &&
|
|
902
|
-
rects !== null &&
|
|
903
|
-
Reflect.get(rects, "length") > 0
|
|
904
|
-
);
|
|
905
|
-
};
|
|
906
|
-
|
|
907
|
-
const candidates: Array<object> = [];
|
|
908
|
-
let controlsTruncated = false;
|
|
909
|
-
|
|
910
|
-
const consider = (candidate: object) => {
|
|
911
|
-
const tagName = String(Reflect.get(candidate, "tagName") ?? "").toLowerCase();
|
|
912
|
-
|
|
913
|
-
// Collapsed native options have no client rects. Their owning
|
|
914
|
-
// select determines visibility; observe text/selection, never value.
|
|
915
|
-
const visibilityTarget =
|
|
916
|
-
tagName === "option"
|
|
917
|
-
? Reflect.apply(Reflect.get(candidate, "closest"), candidate, ["select"])
|
|
918
|
-
: candidate;
|
|
919
|
-
|
|
920
|
-
const actionable = tagName !== "label" || Reflect.get(candidate, "control") !== null;
|
|
921
|
-
|
|
922
|
-
if (
|
|
923
|
-
!actionable ||
|
|
924
|
-
typeof visibilityTarget !== "object" ||
|
|
925
|
-
visibilityTarget === null ||
|
|
926
|
-
!visible(visibilityTarget)
|
|
927
|
-
)
|
|
928
|
-
return;
|
|
929
|
-
candidates.push(candidate);
|
|
930
|
-
if (candidates.length > maximumControls) controlsTruncated = true;
|
|
931
|
-
};
|
|
932
|
-
|
|
933
|
-
const elementMatches = Reflect.get(element, "matches");
|
|
934
|
-
|
|
935
|
-
if (
|
|
936
|
-
typeof elementMatches === "function" &&
|
|
937
|
-
Reflect.apply(elementMatches, element, [controlSelector])
|
|
938
|
-
) {
|
|
939
|
-
consider(element);
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
const considerSelector = (candidateSelector: string) => {
|
|
943
|
-
const descendants = Reflect.apply(Reflect.get(element, "querySelectorAll"), element, [
|
|
944
|
-
candidateSelector,
|
|
945
|
-
]);
|
|
946
|
-
|
|
947
|
-
if (typeof descendants !== "object" || descendants === null) return;
|
|
948
|
-
const descendantCount = Reflect.get(descendants, "length");
|
|
949
|
-
|
|
950
|
-
for (let index = 0; index < descendantCount && !controlsTruncated; index++) {
|
|
951
|
-
consider(Reflect.get(descendants, index));
|
|
952
|
-
}
|
|
953
|
-
};
|
|
954
|
-
|
|
955
|
-
considerSelector(primaryControlSelector);
|
|
956
|
-
if (!controlsTruncated) considerSelector(optionSelector);
|
|
957
|
-
if (!controlsTruncated) considerSelector(secondaryControlSelector);
|
|
958
|
-
|
|
959
|
-
const controls = candidates.slice(0, maximumControls).map((candidate) => {
|
|
960
|
-
const associated = Reflect.get(candidate, "control") ?? candidate;
|
|
961
|
-
const tagName = String(Reflect.get(candidate, "tagName") ?? "").toLowerCase();
|
|
962
|
-
const inputType = String(Reflect.get(associated, "type") ?? "").toLowerCase();
|
|
963
|
-
|
|
964
|
-
const role = String(
|
|
965
|
-
Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, ["role"]) ?? "",
|
|
966
|
-
).toLowerCase();
|
|
967
|
-
|
|
968
|
-
const ariaLabel = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
|
|
969
|
-
"aria-label",
|
|
970
|
-
]);
|
|
971
|
-
|
|
972
|
-
const candidateText =
|
|
973
|
-
tagName === "textarea" || tagName === "input" || tagName === "select"
|
|
974
|
-
? undefined
|
|
975
|
-
: Reflect.get(candidate, tagName === "option" ? "label" : "innerText");
|
|
976
|
-
|
|
977
|
-
const associatedLabels = Reflect.get(associated, "labels");
|
|
978
|
-
|
|
979
|
-
const associatedLabel =
|
|
980
|
-
associatedLabels !== undefined &&
|
|
981
|
-
associatedLabels !== null &&
|
|
982
|
-
Reflect.get(associatedLabels, "length") > 0
|
|
983
|
-
? Reflect.get(Reflect.get(associatedLabels, 0), "innerText")
|
|
984
|
-
: undefined;
|
|
985
|
-
|
|
986
|
-
const label = String(
|
|
987
|
-
typeof ariaLabel === "string" && ariaLabel !== ""
|
|
988
|
-
? ariaLabel
|
|
989
|
-
: typeof candidateText === "string" && candidateText !== ""
|
|
990
|
-
? candidateText
|
|
991
|
-
: (associatedLabel ?? ""),
|
|
992
|
-
)
|
|
993
|
-
.replace(/\s+/g, " ")
|
|
994
|
-
.trim()
|
|
995
|
-
.slice(0, 200);
|
|
996
|
-
|
|
997
|
-
const checked = Reflect.get(associated, "checked");
|
|
998
|
-
|
|
999
|
-
const selected =
|
|
1000
|
-
tagName === "select"
|
|
1001
|
-
? Reflect.get(associated, "selectedIndex") >= 0
|
|
1002
|
-
: Reflect.get(associated, "selected");
|
|
1003
|
-
|
|
1004
|
-
const disabled = Reflect.get(associated, "disabled");
|
|
1005
|
-
const required = Reflect.get(associated, "required");
|
|
1006
|
-
const validity = Reflect.get(associated, "validity");
|
|
1007
|
-
const form = Reflect.get(associated, "form");
|
|
1008
|
-
|
|
1009
|
-
const formMatches =
|
|
1010
|
-
form === null || form === undefined ? undefined : Reflect.get(form, "matches");
|
|
1011
|
-
|
|
1012
|
-
const ariaChecked = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
|
|
1013
|
-
"aria-checked",
|
|
1014
|
-
]);
|
|
1015
|
-
|
|
1016
|
-
const ariaSelected = Reflect.apply(
|
|
1017
|
-
Reflect.get(candidate, "getAttribute"),
|
|
1018
|
-
candidate,
|
|
1019
|
-
["aria-selected"],
|
|
1020
|
-
);
|
|
1021
|
-
|
|
1022
|
-
const ariaDisabled = Reflect.apply(
|
|
1023
|
-
Reflect.get(candidate, "getAttribute"),
|
|
1024
|
-
candidate,
|
|
1025
|
-
["aria-disabled"],
|
|
1026
|
-
);
|
|
1027
|
-
|
|
1028
|
-
return {
|
|
1029
|
-
selector: selectorFor(candidate),
|
|
1030
|
-
kind:
|
|
1031
|
-
tagName === "label"
|
|
1032
|
-
? `label:${inputType || "control"}`
|
|
1033
|
-
: tagName === "input"
|
|
1034
|
-
? `input:${inputType || "text"}`
|
|
1035
|
-
: role !== ""
|
|
1036
|
-
? `role:${role}`
|
|
1037
|
-
: tagName,
|
|
1038
|
-
...(label === "" ? {} : { label }),
|
|
1039
|
-
...(typeof checked === "boolean"
|
|
1040
|
-
? { checked }
|
|
1041
|
-
: ariaChecked === "true" || ariaChecked === "false"
|
|
1042
|
-
? { checked: ariaChecked === "true" }
|
|
1043
|
-
: {}),
|
|
1044
|
-
...(typeof selected === "boolean"
|
|
1045
|
-
? { selected }
|
|
1046
|
-
: ariaSelected === "true" || ariaSelected === "false"
|
|
1047
|
-
? { selected: ariaSelected === "true" }
|
|
1048
|
-
: {}),
|
|
1049
|
-
...(typeof disabled === "boolean"
|
|
1050
|
-
? { disabled }
|
|
1051
|
-
: ariaDisabled === "true" || ariaDisabled === "false"
|
|
1052
|
-
? { disabled: ariaDisabled === "true" }
|
|
1053
|
-
: {}),
|
|
1054
|
-
...(typeof required === "boolean" ? { required } : {}),
|
|
1055
|
-
...(validity !== undefined && typeof Reflect.get(validity, "valid") === "boolean"
|
|
1056
|
-
? { valid: Reflect.get(validity, "valid") }
|
|
1057
|
-
: {}),
|
|
1058
|
-
...(typeof formMatches === "function"
|
|
1059
|
-
? { formValid: Reflect.apply(formMatches, form, [":valid"]) }
|
|
1060
|
-
: {}),
|
|
1061
|
-
};
|
|
1062
|
-
});
|
|
1063
|
-
|
|
1064
|
-
// JSON is the bounded wire representation of this existing text result.
|
|
1065
|
-
// eslint-disable-next-line no-restricted-properties
|
|
1066
|
-
const text = JSON.stringify({
|
|
1067
|
-
pageText,
|
|
1068
|
-
selectorMatchCount,
|
|
1069
|
-
controls,
|
|
1070
|
-
controlsTruncated,
|
|
1071
|
-
});
|
|
1072
|
-
|
|
1073
|
-
const observed = new TextEncoder().encode(text).byteLength;
|
|
1074
|
-
|
|
1075
|
-
return observed > maximum ? { _tag: "OverLimit", observed } : { _tag: "Text", text };
|
|
1076
|
-
},
|
|
1077
|
-
selector,
|
|
1078
|
-
maximumBytes,
|
|
1079
|
-
MAX_OBSERVED_CONTROLS,
|
|
1080
|
-
),
|
|
1269
|
+
await page.evaluate(observePage, undefined, selector, maximumBytes, MAX_OBSERVED_CONTROLS),
|
|
1081
1270
|
);
|
|
1082
1271
|
|
|
1083
|
-
if (observation._tag === "Text")
|
|
1272
|
+
if (observation._tag === "Text")
|
|
1273
|
+
Schema.decodeSync(BrowserRunPageObservation)(observation.text);
|
|
1084
1274
|
|
|
1085
1275
|
return observation;
|
|
1086
1276
|
},
|
|
1087
|
-
fill: (selector, value, signal, onDispatch) =>
|
|
1088
|
-
runObservedPageAction(
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1277
|
+
fill: (selector, value, signal, onDispatch, onComplete, expectedTarget) =>
|
|
1278
|
+
runObservedPageAction(
|
|
1279
|
+
page,
|
|
1280
|
+
selector,
|
|
1281
|
+
signal,
|
|
1282
|
+
onDispatch,
|
|
1283
|
+
(element) =>
|
|
1284
|
+
expectedTarget !== undefined
|
|
1285
|
+
? guardedPageInput(element, expectedTarget, "fill", value)
|
|
1286
|
+
: element.evaluate((element, nextValue) => {
|
|
1287
|
+
// Bypass instance setters so React can detect the change when events fire.
|
|
1288
|
+
let prototype = Reflect.getPrototypeOf(element);
|
|
1289
|
+
let setValue: ((value: string) => void) | undefined;
|
|
1290
|
+
|
|
1291
|
+
while (prototype !== null) {
|
|
1292
|
+
const setter = Reflect.getOwnPropertyDescriptor(prototype, "value")?.set;
|
|
1293
|
+
|
|
1294
|
+
if (typeof setter === "function") {
|
|
1295
|
+
setValue = setter;
|
|
1296
|
+
break;
|
|
1297
|
+
}
|
|
1298
|
+
prototype = Reflect.getPrototypeOf(prototype);
|
|
1299
|
+
}
|
|
1300
|
+
if (setValue === undefined) {
|
|
1301
|
+
throw new Error("The selector did not resolve to a fillable field");
|
|
1302
|
+
}
|
|
1303
|
+
const focus = Reflect.get(element, "focus");
|
|
1107
1304
|
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1305
|
+
if (typeof focus === "function") Reflect.apply(focus, element, []);
|
|
1306
|
+
Reflect.apply(setValue, element, [nextValue]);
|
|
1307
|
+
const dispatchEvent = Reflect.get(element, "dispatchEvent");
|
|
1111
1308
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1309
|
+
if (typeof dispatchEvent === "function") {
|
|
1310
|
+
Reflect.apply(dispatchEvent, element, [new Event("input", { bubbles: true })]);
|
|
1311
|
+
Reflect.apply(dispatchEvent, element, [new Event("change", { bubbles: true })]);
|
|
1312
|
+
}
|
|
1313
|
+
}, value),
|
|
1314
|
+
undefined,
|
|
1315
|
+
onComplete,
|
|
1117
1316
|
),
|
|
1118
|
-
click: (selector, signal, onDispatch) =>
|
|
1119
|
-
runObservedPageAction(
|
|
1120
|
-
|
|
1317
|
+
click: (selector, signal, onDispatch, onComplete, expectedTarget) =>
|
|
1318
|
+
runObservedPageAction(
|
|
1319
|
+
page,
|
|
1320
|
+
selector,
|
|
1321
|
+
signal,
|
|
1322
|
+
onDispatch,
|
|
1323
|
+
(element) =>
|
|
1324
|
+
expectedTarget === undefined
|
|
1325
|
+
? element.click()
|
|
1326
|
+
: guardedPageInput(element, expectedTarget, "click"),
|
|
1327
|
+
undefined,
|
|
1328
|
+
onComplete,
|
|
1329
|
+
),
|
|
1330
|
+
selectFile: async (request, signal, onDispatch, onComplete) => {
|
|
1121
1331
|
const selection = await prepareFileSelection(request, signal);
|
|
1122
1332
|
|
|
1123
1333
|
try {
|
|
@@ -1128,6 +1338,7 @@ const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
|
|
|
1128
1338
|
onDispatch,
|
|
1129
1339
|
selection.select,
|
|
1130
1340
|
selection.validate,
|
|
1341
|
+
onComplete,
|
|
1131
1342
|
);
|
|
1132
1343
|
} finally {
|
|
1133
1344
|
await selection.close();
|
|
@@ -1176,6 +1387,26 @@ const makeProductionBrowser = (
|
|
|
1176
1387
|
browser: Browser,
|
|
1177
1388
|
viewport?: BrowserRunViewport,
|
|
1178
1389
|
): BrowserRunInteractiveBrowser => ({
|
|
1390
|
+
detach: () => browser.disconnect(),
|
|
1391
|
+
reattach: async (identity) => {
|
|
1392
|
+
const context = browser
|
|
1393
|
+
.browserContexts()
|
|
1394
|
+
.find((candidate) => candidate.id === identity.contextId);
|
|
1395
|
+
|
|
1396
|
+
if (context === undefined) return undefined;
|
|
1397
|
+
const pages = await context.pages();
|
|
1398
|
+
|
|
1399
|
+
if (pages.length > 64) throw new Error("The browser target inventory exceeds its bound");
|
|
1400
|
+
for (const candidate of pages) {
|
|
1401
|
+
const page = makeProductionPage(candidate);
|
|
1402
|
+
const current = await page.identity?.();
|
|
1403
|
+
|
|
1404
|
+
if (current?.targetId === identity.targetId)
|
|
1405
|
+
return { context: makeProductionContext(context), page };
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
return undefined;
|
|
1409
|
+
},
|
|
1179
1410
|
createContext: async () => makeProductionContext(await browser.createBrowserContext(), viewport),
|
|
1180
1411
|
// Exact-session termination may already have closed the provider transport.
|
|
1181
1412
|
close: async () => {
|
|
@@ -1203,10 +1434,34 @@ const protocolError = (message: string, cause?: unknown): InteractiveBrowserProt
|
|
|
1203
1434
|
...(cause === undefined ? {} : { cause }),
|
|
1204
1435
|
});
|
|
1205
1436
|
|
|
1206
|
-
const
|
|
1437
|
+
const failReportedProtocol = (
|
|
1438
|
+
operation: string,
|
|
1439
|
+
error: InteractiveBrowserProtocolError,
|
|
1440
|
+
evidence = error.evidence,
|
|
1441
|
+
) =>
|
|
1442
|
+
reportBrowserCause(operation, Cause.fail(error)).pipe(
|
|
1443
|
+
Effect.andThen(
|
|
1444
|
+
Effect.fail(
|
|
1445
|
+
reportedBrowserError(
|
|
1446
|
+
InteractiveBrowserProtocolError.make({
|
|
1447
|
+
implementation: browserRunInteractiveImplementation,
|
|
1448
|
+
message: error.message,
|
|
1449
|
+
...(evidence === undefined ? {} : { evidence }),
|
|
1450
|
+
}),
|
|
1451
|
+
),
|
|
1452
|
+
),
|
|
1453
|
+
),
|
|
1454
|
+
);
|
|
1455
|
+
|
|
1456
|
+
const actionError = (
|
|
1457
|
+
operation: BrowserOperation,
|
|
1458
|
+
cause?: unknown,
|
|
1459
|
+
evidence?: InteractiveBrowserFailureEvidence,
|
|
1460
|
+
): InteractiveBrowserActionError =>
|
|
1207
1461
|
InteractiveBrowserActionError.make({
|
|
1208
1462
|
implementation: browserRunInteractiveImplementation,
|
|
1209
1463
|
operation,
|
|
1464
|
+
...(evidence === undefined ? {} : { evidence }),
|
|
1210
1465
|
message: `The interactive browser ${operation} operation failed`,
|
|
1211
1466
|
...(cause === undefined ? {} : { cause }),
|
|
1212
1467
|
});
|
|
@@ -1216,14 +1471,14 @@ const undispatchedActionError = (operation: BrowserOperation): InteractiveBrowse
|
|
|
1216
1471
|
implementation: browserRunInteractiveImplementation,
|
|
1217
1472
|
operation,
|
|
1218
1473
|
message: `The interactive browser ${operation} operation was not dispatched`,
|
|
1474
|
+
evidence: { stage: "preparation", dispatch: "not-dispatched", session: "attached" },
|
|
1219
1475
|
});
|
|
1220
1476
|
|
|
1221
1477
|
/** Recognizes a local pre-dispatch refusal without exposing selector or page content. */
|
|
1222
1478
|
export const isBrowserRunUndispatchedActionError = (error: unknown): boolean =>
|
|
1223
1479
|
Schema.is(InteractiveBrowserActionError)(error) &&
|
|
1224
1480
|
error.implementation.identity === browserRunInteractiveImplementation.identity &&
|
|
1225
|
-
|
|
1226
|
-
error.message === `The interactive browser ${error.operation} operation was not dispatched`;
|
|
1481
|
+
error.evidence?.dispatch === "not-dispatched";
|
|
1227
1482
|
|
|
1228
1483
|
const policyError = (message: string): InteractiveBrowserPolicyDeniedError =>
|
|
1229
1484
|
InteractiveBrowserPolicyDeniedError.make({
|
|
@@ -1231,10 +1486,11 @@ const policyError = (message: string): InteractiveBrowserPolicyDeniedError =>
|
|
|
1231
1486
|
message,
|
|
1232
1487
|
});
|
|
1233
1488
|
|
|
1234
|
-
const expiredError = (): InteractiveBrowserExpiredError =>
|
|
1489
|
+
const expiredError = (session: "lost" | "disconnected" = "lost"): InteractiveBrowserExpiredError =>
|
|
1235
1490
|
InteractiveBrowserExpiredError.make({
|
|
1236
1491
|
implementation: browserRunInteractiveImplementation,
|
|
1237
1492
|
message: "The remote browser is no longer usable",
|
|
1493
|
+
evidence: { stage: "preparation", dispatch: "not-dispatched", session },
|
|
1238
1494
|
});
|
|
1239
1495
|
|
|
1240
1496
|
const causeText = (cause: unknown): string => {
|
|
@@ -1251,6 +1507,11 @@ const isRemoteClosure = (cause: unknown): boolean =>
|
|
|
1251
1507
|
causeText(cause),
|
|
1252
1508
|
);
|
|
1253
1509
|
|
|
1510
|
+
const inputMayStillRun = (cause: unknown): boolean =>
|
|
1511
|
+
isRemoteClosure(cause) ||
|
|
1512
|
+
(cause instanceof Error && cause.name === "TimeoutError") ||
|
|
1513
|
+
(cause instanceof BrowserRunFailure && cause.reason === "timeout");
|
|
1514
|
+
|
|
1254
1515
|
const snapshotPolicy = Effect.fn("BrowserRunInteractive.snapshotPolicy")(function* (
|
|
1255
1516
|
input: InteractiveBrowserPolicy,
|
|
1256
1517
|
): Effect.fn.Return<
|
|
@@ -1380,15 +1641,45 @@ interface HandleState {
|
|
|
1380
1641
|
readonly closed: { value: boolean };
|
|
1381
1642
|
readonly disconnected: { value: boolean };
|
|
1382
1643
|
readonly uncertain: { value: boolean };
|
|
1644
|
+
readonly pendingInputs: Set<Promise<unknown>>;
|
|
1645
|
+
readonly actions: { value: number };
|
|
1383
1646
|
readonly violation: { value: BrowserFailure | undefined };
|
|
1384
1647
|
readonly pendingRequests: Set<Promise<void>>;
|
|
1385
1648
|
}
|
|
1386
1649
|
|
|
1650
|
+
const trackPendingInput = <A>(state: HandleState, pending: Promise<A>): Promise<A> => {
|
|
1651
|
+
state.pendingInputs.add(pending);
|
|
1652
|
+
void pending.then(
|
|
1653
|
+
() => state.pendingInputs.delete(pending),
|
|
1654
|
+
(cause) => {
|
|
1655
|
+
state.pendingInputs.delete(pending);
|
|
1656
|
+
if (state.disconnected.value || inputMayStillRun(cause)) state.uncertain.value = true;
|
|
1657
|
+
if (isRemoteClosure(cause)) state.disconnected.value = true;
|
|
1658
|
+
},
|
|
1659
|
+
);
|
|
1660
|
+
|
|
1661
|
+
return pending;
|
|
1662
|
+
};
|
|
1663
|
+
|
|
1664
|
+
const makeHostInputObserver = Effect.fnUntraced(function* (state: HandleState, operation: string) {
|
|
1665
|
+
const reporterContext = yield* Effect.context<never>();
|
|
1666
|
+
|
|
1667
|
+
return <A>(pending: Promise<A>, signal: AbortSignal): Promise<A> => {
|
|
1668
|
+
void pending.then(undefined, (cause) => {
|
|
1669
|
+
if (signal.aborted)
|
|
1670
|
+
return Effect.runPromiseWith(reporterContext)(
|
|
1671
|
+
reportBrowserCause(operation, Cause.fail(cause)),
|
|
1672
|
+
);
|
|
1673
|
+
});
|
|
1674
|
+
|
|
1675
|
+
return trackPendingInput(state, pending);
|
|
1676
|
+
};
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1387
1679
|
const stateFailure = (state: HandleState): BrowserFailure | undefined => {
|
|
1388
1680
|
if (state.violation.value !== undefined) return state.violation.value;
|
|
1389
|
-
if (state.closed.value
|
|
1390
|
-
|
|
1391
|
-
}
|
|
1681
|
+
if (state.closed.value) return expiredError();
|
|
1682
|
+
if (state.disconnected.value) return expiredError("disconnected");
|
|
1392
1683
|
|
|
1393
1684
|
return undefined;
|
|
1394
1685
|
};
|
|
@@ -1409,6 +1700,7 @@ interface HandleRuntime {
|
|
|
1409
1700
|
effect: Effect.Effect<A, BrowserFailure>,
|
|
1410
1701
|
preflight?: Effect.Effect<void, BrowserFailure>,
|
|
1411
1702
|
consumeAction?: boolean,
|
|
1703
|
+
mutating?: boolean,
|
|
1412
1704
|
) => Effect.Effect<A, BrowserFailure>;
|
|
1413
1705
|
}
|
|
1414
1706
|
|
|
@@ -1528,75 +1820,131 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
|
|
|
1528
1820
|
close: Effect.Effect<void, InteractiveBrowserError>,
|
|
1529
1821
|
): Effect.fn.Return<HandleRuntime> {
|
|
1530
1822
|
const permits = yield* Semaphore.make(1);
|
|
1531
|
-
|
|
1823
|
+
let currentOperation: BrowserOperation | undefined;
|
|
1824
|
+
let generation = 0;
|
|
1532
1825
|
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1826
|
+
let evidence: InteractiveBrowserFailureEvidence = {
|
|
1827
|
+
stage: "preparation",
|
|
1828
|
+
dispatch: "not-dispatched",
|
|
1829
|
+
session: "attached",
|
|
1830
|
+
};
|
|
1831
|
+
|
|
1832
|
+
const reporterContext = yield* Effect.context<never>();
|
|
1833
|
+
|
|
1834
|
+
const remote = <A>(
|
|
1835
|
+
operation: BrowserOperation,
|
|
1836
|
+
evaluate: (signal: AbortSignal) => Promise<A>,
|
|
1837
|
+
observed = false,
|
|
1838
|
+
) =>
|
|
1839
|
+
Effect.suspend(() => {
|
|
1840
|
+
currentOperation = operation;
|
|
1841
|
+
const mutating = operation !== "read-text" && operation !== "screenshot";
|
|
1842
|
+
const currentGeneration = ++generation;
|
|
1843
|
+
|
|
1844
|
+
evidence = {
|
|
1845
|
+
stage: observed ? "preparation" : mutating ? "input" : "observation",
|
|
1846
|
+
dispatch: observed || !mutating ? "not-dispatched" : "running",
|
|
1847
|
+
session: "attached",
|
|
1848
|
+
};
|
|
1849
|
+
|
|
1850
|
+
return Effect.tryPromise({
|
|
1851
|
+
try: (signal) => {
|
|
1852
|
+
const pending = evaluate(signal);
|
|
1853
|
+
|
|
1854
|
+
if (mutating) trackPendingInput(state, pending);
|
|
1855
|
+
void pending.then(
|
|
1856
|
+
() => {
|
|
1857
|
+
if (mutating && generation === currentGeneration)
|
|
1858
|
+
evidence = { ...evidence, stage: "observation", dispatch: "completed" };
|
|
1859
|
+
},
|
|
1860
|
+
(cause) => {
|
|
1861
|
+
if (state.disconnected.value || isRemoteClosure(cause)) {
|
|
1862
|
+
state.disconnected.value = true;
|
|
1863
|
+
if (mutating) state.uncertain.value = true;
|
|
1864
|
+
}
|
|
1865
|
+
if (generation === currentGeneration && evidence.dispatch === "running")
|
|
1866
|
+
evidence = { ...evidence, dispatch: "unknown" };
|
|
1867
|
+
if (signal.aborted && !(cause instanceof BrowserRunActionUndispatched))
|
|
1868
|
+
void Effect.runPromiseWith(reporterContext)(
|
|
1869
|
+
reportBrowserCause("interactive.input.late", Cause.fail(cause)),
|
|
1870
|
+
);
|
|
1871
|
+
},
|
|
1872
|
+
);
|
|
1543
1873
|
|
|
1544
|
-
|
|
1874
|
+
return pending;
|
|
1875
|
+
},
|
|
1876
|
+
catch: (cause) => new BrowserRunRemoteFailure(cause),
|
|
1877
|
+
}).pipe(
|
|
1878
|
+
Effect.catch((failure) => {
|
|
1879
|
+
if (failure.cause instanceof BrowserRunActionUndispatched)
|
|
1545
1880
|
return Effect.logInfo("Browser interactive action was not dispatched").pipe(
|
|
1546
1881
|
Effect.annotateLogs({
|
|
1547
1882
|
"browser.action": operation,
|
|
1548
|
-
"browser.selector_match_count": cause.matchCount,
|
|
1883
|
+
"browser.selector_match_count": failure.cause.matchCount,
|
|
1549
1884
|
}),
|
|
1550
1885
|
Effect.andThen(Effect.fail(undispatchedActionError(operation))),
|
|
1551
1886
|
);
|
|
1552
|
-
|
|
1553
|
-
if (
|
|
1887
|
+
if (mutating && inputMayStillRun(failure.cause)) state.uncertain.value = true;
|
|
1888
|
+
if (isRemoteClosure(failure.cause)) {
|
|
1554
1889
|
state.disconnected.value = true;
|
|
1555
|
-
|
|
1556
|
-
return Effect.fail(expiredError());
|
|
1890
|
+
if (mutating) state.uncertain.value = true;
|
|
1557
1891
|
}
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1892
|
+
evidence = {
|
|
1893
|
+
...evidence,
|
|
1894
|
+
dispatch: evidence.dispatch === "running" ? "unknown" : evidence.dispatch,
|
|
1895
|
+
session: state.disconnected.value ? "disconnected" : "attached",
|
|
1896
|
+
};
|
|
1897
|
+
|
|
1898
|
+
return reportBrowserCause(
|
|
1899
|
+
`interactive.${operation}.${evidence.stage}.${evidence.dispatch}`,
|
|
1900
|
+
Cause.fail(failure.cause),
|
|
1901
|
+
).pipe(
|
|
1902
|
+
Effect.andThen(
|
|
1903
|
+
Effect.fail(reportedBrowserError(actionError(operation, undefined, evidence))),
|
|
1904
|
+
),
|
|
1905
|
+
);
|
|
1906
|
+
}),
|
|
1907
|
+
);
|
|
1908
|
+
});
|
|
1563
1909
|
|
|
1564
1910
|
const observedAction = (
|
|
1565
1911
|
operation: "fill" | "click" | "select-file",
|
|
1566
|
-
evaluate: (
|
|
1912
|
+
evaluate: (
|
|
1913
|
+
signal: AbortSignal,
|
|
1914
|
+
onDispatch: () => void,
|
|
1915
|
+
onComplete: () => void,
|
|
1916
|
+
) => Promise<unknown>,
|
|
1567
1917
|
) =>
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
return remote(operation, (signal) => {
|
|
1573
|
-
pending = evaluate(signal, () => {
|
|
1574
|
-
dispatched = true;
|
|
1575
|
-
});
|
|
1576
|
-
|
|
1577
|
-
return pending;
|
|
1578
|
-
}).pipe(
|
|
1579
|
-
Effect.onInterrupt(() =>
|
|
1580
|
-
Effect.gen(function* () {
|
|
1581
|
-
state.uncertain.value = true;
|
|
1582
|
-
// Retain evidence before Scope teardown, without claiming SDK
|
|
1583
|
-
// cancellation or success. A late completion never makes this replayable.
|
|
1584
|
-
yield* Effect.logWarning("Browser interactive action interrupted").pipe(
|
|
1585
|
-
Effect.annotateLogs({
|
|
1586
|
-
"browser.action": operation,
|
|
1587
|
-
"browser.action_dispatched": dispatched,
|
|
1588
|
-
"browser.action_outcome_unknown": dispatched,
|
|
1589
|
-
}),
|
|
1590
|
-
);
|
|
1591
|
-
const completion = pending;
|
|
1918
|
+
remote(
|
|
1919
|
+
operation,
|
|
1920
|
+
(signal) => {
|
|
1921
|
+
const observedGeneration = generation;
|
|
1592
1922
|
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1923
|
+
return evaluate(
|
|
1924
|
+
signal,
|
|
1925
|
+
() => {
|
|
1926
|
+
if (generation === observedGeneration)
|
|
1927
|
+
evidence = { ...evidence, stage: "input", dispatch: "running" };
|
|
1928
|
+
},
|
|
1929
|
+
() => {
|
|
1930
|
+
if (generation === observedGeneration)
|
|
1931
|
+
evidence = { ...evidence, stage: "observation", dispatch: "completed" };
|
|
1932
|
+
},
|
|
1933
|
+
);
|
|
1934
|
+
},
|
|
1935
|
+
true,
|
|
1936
|
+
).pipe(
|
|
1937
|
+
Effect.onInterrupt(() =>
|
|
1938
|
+
Effect.logWarning("Browser interactive action interrupted").pipe(
|
|
1939
|
+
Effect.annotateLogs({
|
|
1940
|
+
"browser.action": operation,
|
|
1941
|
+
"browser.action_dispatched": evidence.dispatch !== "not-dispatched",
|
|
1942
|
+
"browser.action_outcome_unknown":
|
|
1943
|
+
evidence.dispatch === "running" || evidence.dispatch === "unknown",
|
|
1596
1944
|
}),
|
|
1597
1945
|
),
|
|
1598
|
-
)
|
|
1599
|
-
|
|
1946
|
+
),
|
|
1947
|
+
);
|
|
1600
1948
|
|
|
1601
1949
|
const decodeActionObservation = Effect.fn("BrowserRunInteractive.decodeActionObservation")(
|
|
1602
1950
|
function* (raw: unknown) {
|
|
@@ -1615,6 +1963,8 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
|
|
|
1615
1963
|
Effect.logInfo("Browser interactive action observed").pipe(
|
|
1616
1964
|
Effect.annotateLogs({
|
|
1617
1965
|
"browser.action": operation,
|
|
1966
|
+
"browser.input_millis": observation.inputMillis,
|
|
1967
|
+
"browser.observation_millis": observation.observationMillis,
|
|
1618
1968
|
"browser.selector_match_count": observation.before.matchCount,
|
|
1619
1969
|
...(observation.before.kind === undefined
|
|
1620
1970
|
? {}
|
|
@@ -1671,21 +2021,52 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
|
|
|
1671
2021
|
effect: Effect.Effect<A, BrowserFailure>,
|
|
1672
2022
|
preflight: Effect.Effect<void, BrowserFailure> = Effect.void,
|
|
1673
2023
|
consumeAction = true,
|
|
2024
|
+
mutating = true,
|
|
1674
2025
|
) =>
|
|
1675
2026
|
permits
|
|
1676
2027
|
.withPermitsIfAvailable(1)(
|
|
1677
2028
|
Effect.gen(function* () {
|
|
1678
2029
|
const unavailable = stateFailure(state);
|
|
1679
2030
|
|
|
1680
|
-
if (unavailable !== undefined)
|
|
2031
|
+
if (unavailable !== undefined) {
|
|
2032
|
+
if (unavailable._tag === "InteractiveBrowserProtocolError") {
|
|
2033
|
+
yield* reportBrowserCause("interactive.policy", Cause.fail(unavailable));
|
|
2034
|
+
|
|
2035
|
+
const safe = reportedBrowserError(
|
|
2036
|
+
InteractiveBrowserProtocolError.make({
|
|
2037
|
+
implementation: browserRunInteractiveImplementation,
|
|
2038
|
+
message: unavailable.message,
|
|
2039
|
+
evidence: unavailable.evidence ?? {
|
|
2040
|
+
stage: "preparation",
|
|
2041
|
+
dispatch: "not-dispatched",
|
|
2042
|
+
session: "attached",
|
|
2043
|
+
},
|
|
2044
|
+
}),
|
|
2045
|
+
);
|
|
2046
|
+
|
|
2047
|
+
if (state.violation.value === unavailable) state.violation.value = safe;
|
|
2048
|
+
|
|
2049
|
+
return yield* safe;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
return yield* unavailable;
|
|
2053
|
+
}
|
|
2054
|
+
if (mutating && (state.uncertain.value || state.pendingInputs.size > 0))
|
|
2055
|
+
return yield* InteractiveBrowserBusyError.make({
|
|
2056
|
+
implementation: browserRunInteractiveImplementation,
|
|
2057
|
+
message: "Earlier browser input has not been proven stopped",
|
|
2058
|
+
});
|
|
2059
|
+
currentOperation = undefined;
|
|
2060
|
+
generation++;
|
|
1681
2061
|
yield* preflight;
|
|
1682
2062
|
|
|
1683
2063
|
if (consumeAction) {
|
|
1684
|
-
const admitted =
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
2064
|
+
const admitted = {
|
|
2065
|
+
allowed: state.actions.value < policy.maxActions,
|
|
2066
|
+
observed: state.actions.value + 1,
|
|
2067
|
+
};
|
|
2068
|
+
|
|
2069
|
+
if (admitted.allowed) state.actions.value++;
|
|
1689
2070
|
|
|
1690
2071
|
if (!admitted.allowed) {
|
|
1691
2072
|
return yield* InteractiveBrowserLimitError.make({
|
|
@@ -1699,45 +2080,66 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
|
|
|
1699
2080
|
}
|
|
1700
2081
|
|
|
1701
2082
|
const completed = effect.pipe(
|
|
1702
|
-
Effect.catch((error) => {
|
|
1703
|
-
const failure = stateFailure(state);
|
|
1704
|
-
|
|
1705
|
-
return Effect.fail(failure ?? error);
|
|
1706
|
-
}),
|
|
1707
2083
|
Effect.flatMap((result) =>
|
|
1708
2084
|
awaitPendingRequests(state).pipe(
|
|
1709
|
-
Effect.
|
|
1710
|
-
|
|
2085
|
+
Effect.andThen(
|
|
2086
|
+
Effect.suspend(() => {
|
|
2087
|
+
const failure = stateFailure(state);
|
|
1711
2088
|
|
|
1712
|
-
|
|
1713
|
-
|
|
2089
|
+
return failure === undefined ? Effect.succeed(result) : Effect.fail(failure);
|
|
2090
|
+
}),
|
|
2091
|
+
),
|
|
1714
2092
|
),
|
|
1715
2093
|
),
|
|
1716
2094
|
);
|
|
1717
2095
|
|
|
1718
|
-
return yield* withinDeadline(completed, policy, startedAt
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
2096
|
+
return yield* withinDeadline(completed, policy, startedAt).pipe(
|
|
2097
|
+
Effect.mapError((error) =>
|
|
2098
|
+
currentOperation === undefined
|
|
2099
|
+
? error
|
|
2100
|
+
: error._tag === "InteractiveBrowserLimitError"
|
|
2101
|
+
? InteractiveBrowserLimitError.make({
|
|
2102
|
+
implementation: error.implementation,
|
|
2103
|
+
limit: error.limit,
|
|
2104
|
+
maximum: error.maximum,
|
|
2105
|
+
observed: error.observed,
|
|
2106
|
+
message: error.message,
|
|
2107
|
+
evidence,
|
|
2108
|
+
})
|
|
2109
|
+
: error._tag === "InteractiveBrowserPolicyDeniedError"
|
|
2110
|
+
? InteractiveBrowserPolicyDeniedError.make({
|
|
2111
|
+
implementation: error.implementation,
|
|
2112
|
+
message: error.message,
|
|
2113
|
+
evidence,
|
|
2114
|
+
})
|
|
2115
|
+
: error,
|
|
1725
2116
|
),
|
|
1726
|
-
Effect.
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
2117
|
+
Effect.catchIf(
|
|
2118
|
+
(error) =>
|
|
2119
|
+
currentOperation !== undefined &&
|
|
2120
|
+
error._tag !== "InteractiveBrowserActionError" &&
|
|
2121
|
+
error._tag !== "InteractiveBrowserLimitError" &&
|
|
2122
|
+
error._tag !== "InteractiveBrowserPolicyDeniedError" &&
|
|
2123
|
+
error._tag !== "InteractiveBrowserExpiredError",
|
|
2124
|
+
(error) => {
|
|
2125
|
+
const safe = reportedBrowserError(
|
|
2126
|
+
error._tag === "InteractiveBrowserProtocolError"
|
|
2127
|
+
? InteractiveBrowserProtocolError.make({
|
|
2128
|
+
implementation: browserRunInteractiveImplementation,
|
|
2129
|
+
message: error.message,
|
|
2130
|
+
evidence,
|
|
2131
|
+
})
|
|
2132
|
+
: actionError(currentOperation ?? "read-text", undefined, evidence),
|
|
2133
|
+
);
|
|
1731
2134
|
|
|
1732
|
-
|
|
1733
|
-
Schema.is(InteractiveBrowserActionError)(error) &&
|
|
1734
|
-
!isBrowserRunUndispatchedActionError(error)
|
|
1735
|
-
) {
|
|
1736
|
-
state.uncertain.value = true;
|
|
1737
|
-
}
|
|
2135
|
+
if (state.violation.value === error) state.violation.value = safe;
|
|
1738
2136
|
|
|
1739
|
-
|
|
1740
|
-
|
|
2137
|
+
return reportBrowserCause(
|
|
2138
|
+
`interactive.${currentOperation}.observation`,
|
|
2139
|
+
Cause.fail(error),
|
|
2140
|
+
).pipe(Effect.andThen(Effect.fail(safe)));
|
|
2141
|
+
},
|
|
2142
|
+
),
|
|
1741
2143
|
);
|
|
1742
2144
|
}),
|
|
1743
2145
|
)
|
|
@@ -1780,7 +2182,7 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
|
|
|
1780
2182
|
);
|
|
1781
2183
|
|
|
1782
2184
|
if (observation._tag === "MissingElement") {
|
|
1783
|
-
return yield* actionError("read-text");
|
|
2185
|
+
return yield* actionError("read-text", undefined, evidence);
|
|
1784
2186
|
}
|
|
1785
2187
|
if (observation._tag === "OverLimit") {
|
|
1786
2188
|
return yield* InteractiveBrowserLimitError.make({
|
|
@@ -1811,12 +2213,22 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
|
|
|
1811
2213
|
),
|
|
1812
2214
|
);
|
|
1813
2215
|
}),
|
|
2216
|
+
Effect.void,
|
|
2217
|
+
true,
|
|
2218
|
+
false,
|
|
1814
2219
|
),
|
|
1815
2220
|
fill: (request) =>
|
|
1816
2221
|
run(
|
|
1817
2222
|
Effect.gen(function* () {
|
|
1818
|
-
const observation = yield* observedAction("fill", (signal, onDispatch) =>
|
|
1819
|
-
page.fill(
|
|
2223
|
+
const observation = yield* observedAction("fill", (signal, onDispatch, onComplete) =>
|
|
2224
|
+
page.fill(
|
|
2225
|
+
request.selector,
|
|
2226
|
+
request.value,
|
|
2227
|
+
signal,
|
|
2228
|
+
onDispatch,
|
|
2229
|
+
onComplete,
|
|
2230
|
+
request.expectedTarget,
|
|
2231
|
+
),
|
|
1820
2232
|
).pipe(Effect.flatMap(decodeActionObservation));
|
|
1821
2233
|
|
|
1822
2234
|
yield* logActionObservation("fill", observation);
|
|
@@ -1827,8 +2239,8 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
|
|
|
1827
2239
|
click: (request) =>
|
|
1828
2240
|
run(
|
|
1829
2241
|
Effect.gen(function* () {
|
|
1830
|
-
const observation = yield* observedAction("click", (signal, onDispatch) =>
|
|
1831
|
-
page.click(request.selector, signal, onDispatch),
|
|
2242
|
+
const observation = yield* observedAction("click", (signal, onDispatch, onComplete) =>
|
|
2243
|
+
page.click(request.selector, signal, onDispatch, onComplete, request.expectedTarget),
|
|
1832
2244
|
).pipe(Effect.flatMap(decodeActionObservation));
|
|
1833
2245
|
|
|
1834
2246
|
yield* logActionObservation("click", observation);
|
|
@@ -1842,8 +2254,10 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
|
|
|
1842
2254
|
Effect.flatMap((decoded) =>
|
|
1843
2255
|
run(
|
|
1844
2256
|
Effect.gen(function* () {
|
|
1845
|
-
const observation = yield* observedAction(
|
|
1846
|
-
|
|
2257
|
+
const observation = yield* observedAction(
|
|
2258
|
+
"select-file",
|
|
2259
|
+
(signal, onDispatch, onComplete) =>
|
|
2260
|
+
page.selectFile(decoded, signal, onDispatch, onComplete),
|
|
1847
2261
|
).pipe(Effect.flatMap(decodeActionObservation));
|
|
1848
2262
|
|
|
1849
2263
|
yield* logActionObservation("select-file", observation);
|
|
@@ -1894,6 +2308,8 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
|
|
|
1894
2308
|
);
|
|
1895
2309
|
}),
|
|
1896
2310
|
decodeActionResult(page, policy).pipe(Effect.asVoid),
|
|
2311
|
+
true,
|
|
2312
|
+
false,
|
|
1897
2313
|
),
|
|
1898
2314
|
),
|
|
1899
2315
|
),
|
|
@@ -1968,6 +2384,7 @@ const cdpCommand = <A>(
|
|
|
1968
2384
|
Effect.scoped(
|
|
1969
2385
|
Effect.gen(function* () {
|
|
1970
2386
|
const cleanupContext = yield* Effect.context<never>();
|
|
2387
|
+
const observeInput = yield* makeHostInputObserver(state, "interactive.control.late");
|
|
1971
2388
|
|
|
1972
2389
|
const cdp = yield* Effect.acquireRelease(
|
|
1973
2390
|
Effect.tryPromise({
|
|
@@ -2006,7 +2423,11 @@ const cdpCommand = <A>(
|
|
|
2006
2423
|
);
|
|
2007
2424
|
|
|
2008
2425
|
const raw = yield* Effect.tryPromise({
|
|
2009
|
-
try: () =>
|
|
2426
|
+
try: (signal) => {
|
|
2427
|
+
const pending = cdp.send(command, parameters);
|
|
2428
|
+
|
|
2429
|
+
return command === "Cloudflare.getHandoffState" ? pending : observeInput(pending, signal);
|
|
2430
|
+
},
|
|
2010
2431
|
catch: (cause) =>
|
|
2011
2432
|
state.disconnected.value || isRemoteClosure(cause)
|
|
2012
2433
|
? expiredError()
|
|
@@ -2021,12 +2442,25 @@ const cdpCommand = <A>(
|
|
|
2021
2442
|
),
|
|
2022
2443
|
);
|
|
2023
2444
|
}),
|
|
2445
|
+
).pipe(
|
|
2446
|
+
Effect.catchTag("InteractiveBrowserProtocolError", (error) =>
|
|
2447
|
+
failReportedProtocol("interactive.control", error),
|
|
2448
|
+
),
|
|
2024
2449
|
);
|
|
2025
2450
|
|
|
2026
2451
|
const makeHostService = (
|
|
2027
2452
|
binding: BrowserRunInteractiveBinding["Service"],
|
|
2028
2453
|
): BrowserRunInteractiveHost["Service"] => {
|
|
2029
|
-
const
|
|
2454
|
+
const owners = new Map<string, symbol>();
|
|
2455
|
+
|
|
2456
|
+
const closeSession: BrowserRunInteractiveHost["Service"]["closeSession"] = (sessionId) =>
|
|
2457
|
+
binding.closeSession(sessionId).pipe(
|
|
2458
|
+
Effect.tap(() =>
|
|
2459
|
+
Effect.sync(() => {
|
|
2460
|
+
owners.delete(Redacted.value(sessionId));
|
|
2461
|
+
}),
|
|
2462
|
+
),
|
|
2463
|
+
);
|
|
2030
2464
|
|
|
2031
2465
|
const terminate = Effect.fn("BrowserRunInteractiveHost.terminate")(function* (
|
|
2032
2466
|
sessionId: Redacted.Redacted<string>,
|
|
@@ -2094,10 +2528,12 @@ const makeHostService = (
|
|
|
2094
2528
|
|
|
2095
2529
|
const acquire = Effect.fn("BrowserRunInteractiveHost.acquire")(function* (
|
|
2096
2530
|
policy: InteractiveBrowserPolicy,
|
|
2531
|
+
resume?: BrowserRunInteractiveCheckpoint,
|
|
2532
|
+
pendingInput = false,
|
|
2097
2533
|
): Effect.fn.Return<BrowserRunInteractiveAcquisition, InteractiveBrowserError, Scope.Scope> {
|
|
2098
2534
|
const scope = yield* Scope.Scope;
|
|
2099
2535
|
const fixedPolicy = yield* snapshotPolicy(policy);
|
|
2100
|
-
const startedAt = yield*
|
|
2536
|
+
const startedAt = resume?.startedAt ?? (yield* Clock.currentTimeMillis);
|
|
2101
2537
|
|
|
2102
2538
|
// Late SDK replies retain this pass's clock and private reporting scope.
|
|
2103
2539
|
const runCleanup = <A, E>(effect: Effect.Effect<A, E>) =>
|
|
@@ -2122,20 +2558,23 @@ const makeHostService = (
|
|
|
2122
2558
|
const state: HandleState = {
|
|
2123
2559
|
closed: { value: false },
|
|
2124
2560
|
disconnected: { value: false },
|
|
2125
|
-
uncertain: { value:
|
|
2561
|
+
uncertain: { value: pendingInput || (resume !== undefined && resume.inputState !== "idle") },
|
|
2562
|
+
pendingInputs: new Set(),
|
|
2563
|
+
actions: { value: resume?.consumedActions ?? 0 },
|
|
2126
2564
|
violation: { value: undefined },
|
|
2127
2565
|
pendingRequests: new Set(),
|
|
2128
2566
|
};
|
|
2129
2567
|
|
|
2130
2568
|
const lifecycle = {
|
|
2131
2569
|
managedTeardownInstalled: false,
|
|
2570
|
+
retained: resume !== undefined,
|
|
2132
2571
|
};
|
|
2133
2572
|
|
|
2134
2573
|
const closers: Array<CloseEntry> = [];
|
|
2135
2574
|
|
|
2136
2575
|
const releaseBeforeManaged = (entry: CloseEntry): Effect.Effect<void> =>
|
|
2137
2576
|
Effect.suspend(() =>
|
|
2138
|
-
lifecycle.managedTeardownInstalled
|
|
2577
|
+
lifecycle.managedTeardownInstalled || lifecycle.retained
|
|
2139
2578
|
? Effect.void
|
|
2140
2579
|
: entry.close.pipe(
|
|
2141
2580
|
Effect.catchCause((cause) =>
|
|
@@ -2148,34 +2587,36 @@ const makeHostService = (
|
|
|
2148
2587
|
|
|
2149
2588
|
const sessionIdValue = yield* Effect.acquireRelease(
|
|
2150
2589
|
withinDeadline(
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2590
|
+
resume === undefined
|
|
2591
|
+
? Effect.tryPromise({
|
|
2592
|
+
try: (signal) =>
|
|
2593
|
+
observeLate(
|
|
2594
|
+
signal,
|
|
2595
|
+
closeLateAcquisition(
|
|
2596
|
+
signal,
|
|
2597
|
+
() => binding.acquire(keepAliveMillis(fixedPolicy)),
|
|
2598
|
+
(id) =>
|
|
2599
|
+
runCleanup(
|
|
2600
|
+
Schema.decodeUnknownEffect(BrowserRunSessionId)(id).pipe(
|
|
2601
|
+
Effect.flatMap((value) => terminate(Redacted.make(value), [])),
|
|
2602
|
+
),
|
|
2603
|
+
),
|
|
2163
2604
|
),
|
|
2605
|
+
),
|
|
2606
|
+
catch: (cause) =>
|
|
2607
|
+
isCapacityRefusal(cause)
|
|
2608
|
+
? InteractiveBrowserCapacityError.make({
|
|
2609
|
+
implementation: browserRunInteractiveImplementation,
|
|
2610
|
+
message: "Browser Run has no capacity for a new browser session",
|
|
2611
|
+
})
|
|
2612
|
+
: protocolError("Acquiring the Browser Run session failed", cause),
|
|
2613
|
+
}).pipe(
|
|
2614
|
+
Effect.flatMap(Schema.decodeUnknownEffect(BrowserRunSessionId)),
|
|
2615
|
+
Effect.catchTag("SchemaError", () =>
|
|
2616
|
+
protocolError("The Browser Run session identity was malformed"),
|
|
2164
2617
|
),
|
|
2165
|
-
)
|
|
2166
|
-
|
|
2167
|
-
isCapacityRefusal(cause)
|
|
2168
|
-
? InteractiveBrowserCapacityError.make({
|
|
2169
|
-
implementation: browserRunInteractiveImplementation,
|
|
2170
|
-
message: "Browser Run has no capacity for a new browser session",
|
|
2171
|
-
})
|
|
2172
|
-
: protocolError("Acquiring the Browser Run session failed", cause),
|
|
2173
|
-
}).pipe(
|
|
2174
|
-
Effect.flatMap(Schema.decodeUnknownEffect(BrowserRunSessionId)),
|
|
2175
|
-
Effect.catchTag("SchemaError", () =>
|
|
2176
|
-
protocolError("The Browser Run session identity was malformed"),
|
|
2177
|
-
),
|
|
2178
|
-
),
|
|
2618
|
+
)
|
|
2619
|
+
: Effect.succeed(Redacted.value(resume.sessionId)),
|
|
2179
2620
|
fixedPolicy,
|
|
2180
2621
|
startedAt,
|
|
2181
2622
|
),
|
|
@@ -2184,7 +2625,7 @@ const makeHostService = (
|
|
|
2184
2625
|
state.closed.value = true;
|
|
2185
2626
|
state.disconnected.value = true;
|
|
2186
2627
|
|
|
2187
|
-
return lifecycle.managedTeardownInstalled
|
|
2628
|
+
return lifecycle.managedTeardownInstalled || lifecycle.retained
|
|
2188
2629
|
? Effect.void
|
|
2189
2630
|
: terminate(Redacted.make(id), []).pipe(
|
|
2190
2631
|
Effect.catchCause((cause) =>
|
|
@@ -2197,12 +2638,33 @@ const makeHostService = (
|
|
|
2197
2638
|
{ interruptible: true },
|
|
2198
2639
|
);
|
|
2199
2640
|
|
|
2641
|
+
const owner = Symbol();
|
|
2642
|
+
|
|
2643
|
+
const releaseOwner = () => {
|
|
2644
|
+
if (owners.get(sessionIdValue) === owner) owners.delete(sessionIdValue);
|
|
2645
|
+
};
|
|
2646
|
+
|
|
2200
2647
|
const connect = yield* Effect.cached(
|
|
2201
2648
|
Effect.gen(function* (): Effect.fn.Return<
|
|
2202
2649
|
BrowserRunInteractiveSession,
|
|
2203
2650
|
InteractiveBrowserError,
|
|
2204
2651
|
Scope.Scope
|
|
2205
2652
|
> {
|
|
2653
|
+
if (owners.has(sessionIdValue))
|
|
2654
|
+
return yield* InteractiveBrowserBusyError.make({
|
|
2655
|
+
implementation: browserRunInteractiveImplementation,
|
|
2656
|
+
message: "The previous local browser owner must detach before resuming",
|
|
2657
|
+
});
|
|
2658
|
+
yield* Effect.acquireRelease(
|
|
2659
|
+
Effect.sync(() => {
|
|
2660
|
+
owners.set(sessionIdValue, owner);
|
|
2661
|
+
}),
|
|
2662
|
+
() =>
|
|
2663
|
+
Effect.sync(() => {
|
|
2664
|
+
if (!lifecycle.managedTeardownInstalled) releaseOwner();
|
|
2665
|
+
}),
|
|
2666
|
+
);
|
|
2667
|
+
|
|
2206
2668
|
const browser = yield* Effect.acquireRelease(
|
|
2207
2669
|
withinDeadline(
|
|
2208
2670
|
Effect.tryPromise({
|
|
@@ -2212,22 +2674,60 @@ const makeHostService = (
|
|
|
2212
2674
|
closeLateAcquisition(
|
|
2213
2675
|
signal,
|
|
2214
2676
|
() => binding.connect(sessionIdValue),
|
|
2215
|
-
(acquired) =>
|
|
2677
|
+
(acquired) =>
|
|
2678
|
+
runCleanup(
|
|
2679
|
+
resume === undefined
|
|
2680
|
+
? closeAcquired(acquired)
|
|
2681
|
+
: closeEntry(
|
|
2682
|
+
acquired.detach ?? (async () => {}),
|
|
2683
|
+
"Detaching the browser failed",
|
|
2684
|
+
).close,
|
|
2685
|
+
),
|
|
2216
2686
|
),
|
|
2217
2687
|
),
|
|
2218
2688
|
catch: (cause) =>
|
|
2219
|
-
|
|
2220
|
-
|
|
2689
|
+
resume !== undefined &&
|
|
2690
|
+
cause instanceof BrowserRunFailure &&
|
|
2691
|
+
(cause.status === 404 || cause.status === 410)
|
|
2692
|
+
? expiredError()
|
|
2693
|
+
: protocolError("Connecting to the Browser Run session failed", cause),
|
|
2694
|
+
}).pipe(
|
|
2695
|
+
Effect.timeoutOrElse({
|
|
2696
|
+
duration: "10 seconds",
|
|
2697
|
+
orElse: () =>
|
|
2698
|
+
Effect.fail(protocolError("Connecting to the Browser Run session timed out")),
|
|
2699
|
+
}),
|
|
2700
|
+
),
|
|
2221
2701
|
fixedPolicy,
|
|
2222
2702
|
startedAt,
|
|
2223
2703
|
),
|
|
2224
2704
|
(acquired) =>
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2705
|
+
lifecycle.managedTeardownInstalled
|
|
2706
|
+
? Effect.void
|
|
2707
|
+
: lifecycle.retained
|
|
2708
|
+
? closeEntry(
|
|
2709
|
+
acquired.detach ?? (async () => {}),
|
|
2710
|
+
"Detaching the browser failed",
|
|
2711
|
+
).close.pipe(
|
|
2712
|
+
Effect.tap(() => Effect.sync(releaseOwner)),
|
|
2713
|
+
Effect.catchCause((cause) => reportBrowserCause("interactive.detach", cause)),
|
|
2714
|
+
)
|
|
2715
|
+
: releaseBeforeManaged(
|
|
2716
|
+
closeEntry(acquired.close, "Closing the local browser connection failed"),
|
|
2717
|
+
),
|
|
2228
2718
|
{ interruptible: true },
|
|
2229
2719
|
);
|
|
2230
2720
|
|
|
2721
|
+
if (resume !== undefined) {
|
|
2722
|
+
const actualSession = yield* Effect.try({
|
|
2723
|
+
try: browser.sessionId,
|
|
2724
|
+
catch: (cause) =>
|
|
2725
|
+
protocolError("Reading the reattached browser identity failed", cause),
|
|
2726
|
+
});
|
|
2727
|
+
|
|
2728
|
+
if (actualSession !== sessionIdValue)
|
|
2729
|
+
return yield* protocolError("The reattached browser session identity did not match");
|
|
2730
|
+
}
|
|
2231
2731
|
closers.push(closeEntry(browser.close, "Closing the interactive browser failed"));
|
|
2232
2732
|
|
|
2233
2733
|
const disconnected = () => {
|
|
@@ -2266,67 +2766,97 @@ const makeHostService = (
|
|
|
2266
2766
|
return yield* expiredError();
|
|
2267
2767
|
}
|
|
2268
2768
|
|
|
2269
|
-
const
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2769
|
+
const reattached =
|
|
2770
|
+
resume === undefined
|
|
2771
|
+
? undefined
|
|
2772
|
+
: yield* withinDeadline(
|
|
2773
|
+
Effect.tryPromise({
|
|
2774
|
+
try: async () => {
|
|
2775
|
+
if (browser.reattach === undefined || browser.detach === undefined)
|
|
2776
|
+
throw new Error("The browser binding does not support exact reattachment");
|
|
2777
|
+
|
|
2778
|
+
return await browser.reattach(resume.page);
|
|
2779
|
+
},
|
|
2780
|
+
catch: (cause) =>
|
|
2781
|
+
protocolError("Reattaching the exact browser page failed", cause),
|
|
2782
|
+
}).pipe(
|
|
2783
|
+
Effect.timeoutOrElse({
|
|
2784
|
+
duration: "10 seconds",
|
|
2785
|
+
orElse: () =>
|
|
2786
|
+
Effect.fail(protocolError("Reattaching the exact browser page timed out")),
|
|
2787
|
+
}),
|
|
2788
|
+
),
|
|
2789
|
+
fixedPolicy,
|
|
2790
|
+
startedAt,
|
|
2791
|
+
);
|
|
2792
|
+
|
|
2793
|
+
if (resume !== undefined && reattached === undefined) return yield* expiredError();
|
|
2794
|
+
|
|
2795
|
+
const context =
|
|
2796
|
+
reattached?.context ??
|
|
2797
|
+
(yield* Effect.acquireRelease(
|
|
2798
|
+
withinDeadline(
|
|
2799
|
+
Effect.tryPromise({
|
|
2800
|
+
try: (signal) =>
|
|
2801
|
+
observeLate(
|
|
2802
|
+
signal,
|
|
2803
|
+
closeLateAcquisition(signal, browser.createContext, (acquired) =>
|
|
2804
|
+
runCleanup(
|
|
2805
|
+
Effect.tryPromise({
|
|
2806
|
+
try: () => acquired.close(),
|
|
2807
|
+
catch: (cause) => actionError("close", cause),
|
|
2808
|
+
}),
|
|
2809
|
+
),
|
|
2281
2810
|
),
|
|
2282
2811
|
),
|
|
2283
|
-
)
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
startedAt,
|
|
2291
|
-
),
|
|
2292
|
-
(acquired) =>
|
|
2293
|
-
releaseBeforeManaged(
|
|
2294
|
-
closeEntry(acquired.close, "Closing the interactive browser context failed"),
|
|
2812
|
+
catch: (cause) =>
|
|
2813
|
+
state.disconnected.value || isRemoteClosure(cause)
|
|
2814
|
+
? expiredError()
|
|
2815
|
+
: protocolError("Creating the browser context failed", cause),
|
|
2816
|
+
}),
|
|
2817
|
+
fixedPolicy,
|
|
2818
|
+
startedAt,
|
|
2295
2819
|
),
|
|
2296
|
-
|
|
2297
|
-
|
|
2820
|
+
(acquired) =>
|
|
2821
|
+
releaseBeforeManaged(
|
|
2822
|
+
closeEntry(acquired.close, "Closing the interactive browser context failed"),
|
|
2823
|
+
),
|
|
2824
|
+
{ interruptible: true },
|
|
2825
|
+
));
|
|
2298
2826
|
|
|
2299
2827
|
closers.push(closeEntry(context.close, "Closing the interactive browser context failed"));
|
|
2300
2828
|
|
|
2301
|
-
const page =
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2829
|
+
const page =
|
|
2830
|
+
reattached?.page ??
|
|
2831
|
+
(yield* Effect.acquireRelease(
|
|
2832
|
+
withinDeadline(
|
|
2833
|
+
Effect.tryPromise({
|
|
2834
|
+
try: (signal) =>
|
|
2835
|
+
observeLate(
|
|
2836
|
+
signal,
|
|
2837
|
+
closeLateAcquisition(signal, context.newPage, (acquired) =>
|
|
2838
|
+
runCleanup(
|
|
2839
|
+
Effect.tryPromise({
|
|
2840
|
+
try: () => acquired.close(),
|
|
2841
|
+
catch: (cause) => actionError("close", cause),
|
|
2842
|
+
}),
|
|
2843
|
+
),
|
|
2313
2844
|
),
|
|
2314
2845
|
),
|
|
2315
|
-
)
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
startedAt,
|
|
2323
|
-
),
|
|
2324
|
-
(acquired) =>
|
|
2325
|
-
releaseBeforeManaged(
|
|
2326
|
-
closeEntry(acquired.close, "Closing the interactive browser page failed"),
|
|
2846
|
+
catch: (cause) =>
|
|
2847
|
+
state.disconnected.value || isRemoteClosure(cause)
|
|
2848
|
+
? expiredError()
|
|
2849
|
+
: protocolError("Creating the browser page failed", cause),
|
|
2850
|
+
}),
|
|
2851
|
+
fixedPolicy,
|
|
2852
|
+
startedAt,
|
|
2327
2853
|
),
|
|
2328
|
-
|
|
2329
|
-
|
|
2854
|
+
(acquired) =>
|
|
2855
|
+
releaseBeforeManaged(
|
|
2856
|
+
closeEntry(acquired.close, "Closing the interactive browser page failed"),
|
|
2857
|
+
),
|
|
2858
|
+
{ interruptible: true },
|
|
2859
|
+
));
|
|
2330
2860
|
|
|
2331
2861
|
closers.push(closeEntry(page.close, "Closing the interactive browser page failed"));
|
|
2332
2862
|
|
|
@@ -2376,6 +2906,34 @@ const makeHostService = (
|
|
|
2376
2906
|
|
|
2377
2907
|
if (setupFailure !== undefined) return yield* setupFailure;
|
|
2378
2908
|
|
|
2909
|
+
const detach = yield* Effect.cached(
|
|
2910
|
+
Effect.uninterruptible(
|
|
2911
|
+
Effect.gen(function* () {
|
|
2912
|
+
if (browser.detach === undefined)
|
|
2913
|
+
return yield* protocolError("The browser binding does not support detachment");
|
|
2914
|
+
lifecycle.retained = true;
|
|
2915
|
+
state.closed.value = true;
|
|
2916
|
+
state.disconnected.value = true;
|
|
2917
|
+
if (state.pendingInputs.size > 0) state.uncertain.value = true;
|
|
2918
|
+
yield* closeEntry(async () => {
|
|
2919
|
+
page.offRequest(requestListener);
|
|
2920
|
+
browser.offDisconnected(disconnected);
|
|
2921
|
+
await browser.detach?.();
|
|
2922
|
+
}, "Detaching the browser transport failed").close;
|
|
2923
|
+
releaseOwner();
|
|
2924
|
+
}).pipe(
|
|
2925
|
+
Effect.catchTag("InteractiveBrowserActionError", (error) =>
|
|
2926
|
+
reportBrowserCause("interactive.detach", Cause.fail(error)).pipe(
|
|
2927
|
+
Effect.andThen(Effect.fail(reportedBrowserError(actionError("close")))),
|
|
2928
|
+
),
|
|
2929
|
+
),
|
|
2930
|
+
Effect.catchTag("InteractiveBrowserProtocolError", (error) =>
|
|
2931
|
+
failReportedProtocol("interactive.detach", error),
|
|
2932
|
+
),
|
|
2933
|
+
),
|
|
2934
|
+
),
|
|
2935
|
+
);
|
|
2936
|
+
|
|
2379
2937
|
const teardown = yield* Effect.uninterruptible(
|
|
2380
2938
|
Effect.gen(function* () {
|
|
2381
2939
|
const cached = yield* Effect.cached(
|
|
@@ -2386,13 +2944,14 @@ const makeHostService = (
|
|
|
2386
2944
|
);
|
|
2387
2945
|
|
|
2388
2946
|
lifecycle.managedTeardownInstalled = true;
|
|
2947
|
+
lifecycle.retained = false;
|
|
2389
2948
|
yield* Effect.addFinalizer(() =>
|
|
2390
2949
|
Effect.uninterruptible(
|
|
2391
2950
|
Effect.sync(() => {
|
|
2392
2951
|
state.closed.value = true;
|
|
2393
2952
|
state.disconnected.value = true;
|
|
2394
2953
|
}).pipe(
|
|
2395
|
-
Effect.andThen(cached),
|
|
2954
|
+
Effect.andThen(Effect.suspend(() => (lifecycle.retained ? detach : cached))),
|
|
2396
2955
|
Effect.catchCause((cause) =>
|
|
2397
2956
|
reportBrowserCause("interactive.close", cause).pipe(
|
|
2398
2957
|
Effect.andThen(
|
|
@@ -2409,10 +2968,13 @@ const makeHostService = (
|
|
|
2409
2968
|
);
|
|
2410
2969
|
|
|
2411
2970
|
const close: Effect.Effect<void, InteractiveBrowserError> = Effect.uninterruptible(
|
|
2412
|
-
Effect.
|
|
2971
|
+
Effect.suspend(() => {
|
|
2972
|
+
if (lifecycle.retained || owners.get(sessionIdValue) !== owner) return Effect.void;
|
|
2413
2973
|
state.closed.value = true;
|
|
2414
2974
|
state.disconnected.value = true;
|
|
2415
|
-
|
|
2975
|
+
|
|
2976
|
+
return teardown;
|
|
2977
|
+
}),
|
|
2416
2978
|
);
|
|
2417
2979
|
|
|
2418
2980
|
const runtime = yield* makeHandle(page, fixedPolicy, startedAt, state, close);
|
|
@@ -2429,15 +2991,61 @@ const makeHostService = (
|
|
|
2429
2991
|
),
|
|
2430
2992
|
);
|
|
2431
2993
|
|
|
2994
|
+
const inputState = Effect.sync((): BrowserRunInputState =>
|
|
2995
|
+
state.uncertain.value ? "unknown" : state.pendingInputs.size > 0 ? "running" : "idle",
|
|
2996
|
+
);
|
|
2997
|
+
|
|
2998
|
+
const pageIdentity = yield* Effect.cached(
|
|
2999
|
+
Effect.tryPromise({
|
|
3000
|
+
try: async () => {
|
|
3001
|
+
if (page.identity === undefined)
|
|
3002
|
+
throw new Error("The browser binding does not expose page identity");
|
|
3003
|
+
|
|
3004
|
+
return await page.identity();
|
|
3005
|
+
},
|
|
3006
|
+
catch: (cause) =>
|
|
3007
|
+
protocolError("Reading the exact browser page identity failed", cause),
|
|
3008
|
+
}).pipe(
|
|
3009
|
+
Effect.catchTag("InteractiveBrowserProtocolError", (error) =>
|
|
3010
|
+
failReportedProtocol("interactive.checkpoint", error),
|
|
3011
|
+
),
|
|
3012
|
+
),
|
|
3013
|
+
);
|
|
3014
|
+
|
|
3015
|
+
const observeResize = yield* makeHostInputObserver(state, "interactive.resize.late");
|
|
3016
|
+
|
|
2432
3017
|
return {
|
|
2433
3018
|
handle: runtime.handle,
|
|
3019
|
+
checkpoint: withinDeadline(
|
|
3020
|
+
Effect.gen(function* () {
|
|
3021
|
+
const identity = resume?.page ?? (yield* pageIdentity);
|
|
3022
|
+
|
|
3023
|
+
return BrowserRunInteractiveCheckpoint.make({
|
|
3024
|
+
sessionId: Redacted.make(sessionIdValue),
|
|
3025
|
+
page: identity,
|
|
3026
|
+
policy: InteractiveBrowserPolicy.make(fixedPolicy),
|
|
3027
|
+
startedAt,
|
|
3028
|
+
consumedActions: state.actions.value,
|
|
3029
|
+
inputState: yield* inputState,
|
|
3030
|
+
});
|
|
3031
|
+
}),
|
|
3032
|
+
fixedPolicy,
|
|
3033
|
+
startedAt,
|
|
3034
|
+
),
|
|
3035
|
+
detach,
|
|
3036
|
+
inputState,
|
|
3037
|
+
drainInput: Effect.suspend(() =>
|
|
3038
|
+
Effect.promise(() =>
|
|
3039
|
+
boundedBestEffort(Promise.allSettled(state.pendingInputs), 500),
|
|
3040
|
+
).pipe(Effect.andThen(inputState)),
|
|
3041
|
+
),
|
|
2434
3042
|
sessionId: Redacted.make(sessionIdValue),
|
|
2435
3043
|
resizeViewport: (viewport) =>
|
|
2436
3044
|
decodeViewport(viewport).pipe(
|
|
2437
3045
|
Effect.flatMap((decoded) =>
|
|
2438
3046
|
runtime.run(
|
|
2439
3047
|
Effect.tryPromise({
|
|
2440
|
-
try: () => page.setViewport(decoded),
|
|
3048
|
+
try: (signal) => observeResize(page.setViewport(decoded), signal),
|
|
2441
3049
|
catch: (cause) => {
|
|
2442
3050
|
if (state.disconnected.value || isRemoteClosure(cause)) {
|
|
2443
3051
|
state.disconnected.value = true;
|
|
@@ -2447,7 +3055,11 @@ const makeHostService = (
|
|
|
2447
3055
|
|
|
2448
3056
|
return protocolError("Resizing the browser viewport failed", cause);
|
|
2449
3057
|
},
|
|
2450
|
-
})
|
|
3058
|
+
}).pipe(
|
|
3059
|
+
Effect.catchTag("InteractiveBrowserProtocolError", (error) =>
|
|
3060
|
+
failReportedProtocol("interactive.resize", error),
|
|
3061
|
+
),
|
|
3062
|
+
),
|
|
2451
3063
|
currentPagePreflight,
|
|
2452
3064
|
false,
|
|
2453
3065
|
),
|
|
@@ -2479,6 +3091,8 @@ const makeHostService = (
|
|
|
2479
3091
|
currentPagePreflight.pipe(
|
|
2480
3092
|
Effect.andThen(requestFitsSession(decoded.expiresInMs)),
|
|
2481
3093
|
),
|
|
3094
|
+
true,
|
|
3095
|
+
true,
|
|
2482
3096
|
),
|
|
2483
3097
|
),
|
|
2484
3098
|
),
|
|
@@ -2535,6 +3149,8 @@ const makeHostService = (
|
|
|
2535
3149
|
),
|
|
2536
3150
|
),
|
|
2537
3151
|
currentPagePreflight,
|
|
3152
|
+
true,
|
|
3153
|
+
false,
|
|
2538
3154
|
),
|
|
2539
3155
|
close,
|
|
2540
3156
|
};
|
|
@@ -2550,6 +3166,19 @@ const makeHostService = (
|
|
|
2550
3166
|
return BrowserRunInteractiveHost.of({
|
|
2551
3167
|
acquire,
|
|
2552
3168
|
open: (policy) => acquire(policy).pipe(Effect.flatMap((acquired) => acquired.connect)),
|
|
3169
|
+
resume: (checkpoint, options) =>
|
|
3170
|
+
Schema.decodeEffect(Schema.toType(BrowserRunInteractiveCheckpoint))(checkpoint).pipe(
|
|
3171
|
+
Effect.mapError(() => policyError("The browser checkpoint is malformed")),
|
|
3172
|
+
Effect.flatMap((decoded) => acquire(decoded.policy, decoded, options.pendingInput)),
|
|
3173
|
+
Effect.flatMap((acquired) => acquired.connect),
|
|
3174
|
+
Effect.catchTag("InteractiveBrowserProtocolError", (error) =>
|
|
3175
|
+
failReportedProtocol("interactive.resume", error, {
|
|
3176
|
+
stage: "preparation",
|
|
3177
|
+
dispatch: "not-dispatched",
|
|
3178
|
+
session: "disconnected",
|
|
3179
|
+
}),
|
|
3180
|
+
),
|
|
3181
|
+
),
|
|
2553
3182
|
closeSession,
|
|
2554
3183
|
cleanupSemantics: "confirmed-terminal",
|
|
2555
3184
|
});
|