aboutsummaryrefslogtreecommitdiff
path: root/src/domains/element-call.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/domains/element-call.ts')
-rw-r--r--src/domains/element-call.ts117
1 files changed, 117 insertions, 0 deletions
diff --git a/src/domains/element-call.ts b/src/domains/element-call.ts
new file mode 100644
index 0000000..0292e6f
--- /dev/null
+++ b/src/domains/element-call.ts
@@ -0,0 +1,117 @@
+import type { BrowserClient } from "../client";
+import { safeJs } from "../util/js-template";
+import { type Result, err, ok } from "../util/result";
+import type { ToolErr } from "../util/tool";
+import { cdpCall, evalJs } from "./cdp-call";
+import { resolveRefToObjectId } from "./ref-resolve";
+
+export type PageResult = {
+ readonly ok: boolean;
+ readonly reason?: string;
+ readonly kind?: string;
+ readonly value?: unknown;
+ readonly text?: string;
+ readonly checked?: boolean;
+ readonly changed?: boolean;
+ readonly tag?: string;
+ readonly options?: ReadonlyArray<{ value: string; text: string }>;
+};
+
+const isOption = (v: unknown): v is { readonly value: string; readonly text: string } =>
+ typeof v === "object" && v !== null
+ && "value" in v && typeof v.value === "string"
+ && "text" in v && typeof v.text === "string";
+
+export const isPageResult = (v: unknown): v is PageResult => {
+ if (typeof v !== "object" || v === null) return false;
+ if (!("ok" in v) || typeof v.ok !== "boolean") return false;
+ if ("reason" in v && typeof v.reason !== "string") return false;
+ if ("kind" in v && typeof v.kind !== "string") return false;
+ if ("text" in v && typeof v.text !== "string") return false;
+ if ("checked" in v && typeof v.checked !== "boolean") return false;
+ if ("changed" in v && typeof v.changed !== "boolean") return false;
+ if ("tag" in v && typeof v.tag !== "string") return false;
+ if ("options" in v && !(Array.isArray(v.options) && v.options.every(isOption))) return false;
+ return true;
+};
+
+export const runOnElement = async (
+ client: BrowserClient,
+ opts: {
+ ref?: string | undefined;
+ selector?: string | undefined;
+ fnBody: string;
+ arg: unknown;
+ notFound?: unknown;
+ },
+): Promise<Result<unknown, ToolErr>> => {
+ if (opts.ref !== undefined) {
+ const objectId = await resolveRefToObjectId(client, opts.ref);
+ if (!objectId.success) return objectId;
+ const r = await cdpCall(client, "Runtime.callFunctionOn", {
+ objectId: objectId.data,
+ functionDeclaration: `function (arg) { ${opts.fnBody} }`,
+ arguments: [{ value: opts.arg }],
+ returnByValue: true,
+ });
+ if (!r.success) return r;
+ if (r.data.exceptionDetails !== undefined) {
+ return err({
+ kind: "cdp_error",
+ message: `page function threw: ${r.data.exceptionDetails.text}`,
+ details: { ref: opts.ref },
+ });
+ }
+ return ok(r.data.result.value);
+ }
+ if (opts.selector === undefined) {
+ return err({ kind: "invalid_state", message: "Provide either `ref` or `selector`." });
+ }
+ // fnBody is harness-authored, not user input; only the selector and value are interpolated, and those go through safeJs.
+ const prelude = safeJs`
+ (() => {
+ const el = document.querySelector(${opts.selector});
+ if (!el) return ${opts.notFound ?? { status: "not_found" }};
+ const __arg = ${opts.arg};
+ return (function (arg) {`;
+ const expr = `${prelude} ${opts.fnBody} }).call(el, __arg); })()`;
+ const r = await evalJs(client, expr);
+ if (!r.success) return r;
+ return ok(r.data);
+};
+
+export const resolveAndCall = async (
+ client: BrowserClient,
+ ref: string,
+ functionDeclaration: string,
+ args: ReadonlyArray<unknown>,
+): Promise<Result<PageResult, ToolErr>> => {
+ const objectId = await resolveRefToObjectId(client, ref);
+ if (!objectId.success) return objectId;
+ const handle = objectId.data;
+ try {
+ const called = await cdpCall(client, "Runtime.callFunctionOn", {
+ objectId: handle,
+ functionDeclaration,
+ arguments: args.map((v) => ({ value: v })),
+ returnByValue: true,
+ awaitPromise: true,
+ });
+ if (!called.success) return err({ ...called.error, details: { ref } });
+ if (called.data.exceptionDetails !== undefined) {
+ return err({ kind: "cdp_error", message: `page function threw: ${JSON.stringify(called.data.exceptionDetails)}`, details: { ref } });
+ }
+ const value = called.data.result.value;
+ if (!isPageResult(value)) {
+ return err({ kind: "internal", message: "page function returned no result object", details: { ref } });
+ }
+ return ok(value);
+ } finally {
+ await cdpCall(client, "Runtime.releaseObject", { objectId: handle });
+ }
+};
+
+export const detailsOf = (ref: string, res: PageResult, extra?: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> => {
+ const { ok: _ok, ...rest } = res;
+ return { ref, ...rest, ...(extra ?? {}) };
+};