all repos — sz-chrome-remote-cdp @ master

pi-browser-harness, patched to drive a Chrome on another host over CDP, with castle_cdp_search web search

src/domains/element-call.ts (view raw)

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 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 ?? {}) };
};