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/page.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
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
import { Type } from "typebox";
import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { evalJs } from "./cdp-call";
import { safeJs } from "../util/js-template";

const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
  new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(signal.reason ?? new Error("aborted"));
      return;
    }
    const onAbort = (): void => {
      clearTimeout(timer);
      reject(signal?.reason ?? new Error("aborted"));
    };
    const timer = setTimeout(() => {
      signal?.removeEventListener("abort", onAbort);
      resolve();
    }, ms);
    signal?.addEventListener("abort", onAbort, { once: true });
  });

export const pageInfoTool = defineBrowserTool({
  name: "browser_page_info",
  label: "Browser Page Info",
  description:
    "Get current page state: URL, title, viewport size, scroll position, page dimensions. If a JS dialog is open, returns dialog info instead.",
  promptSnippet: "Get current page URL, title, viewport, and scroll position",
  promptGuidelines: [
    "If browser_page_info returns a dialog, call browser_handle_dialog before any other browser actions: dialogs freeze the page's JS thread.",
  ],
  parameters: Type.Object({}),
  concurrency: "parallel",
  async handler(_args, { client }): Promise<Result<ToolOk, ToolErr>> {
    const r = await client.pageInfo();
    if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
    if ("dialog" in r.data) {
      const d = r.data.dialog;
      return ok({
        text: `⚠️  DIALOG OPEN: ${d.type}\nMessage: ${d.message}${d.defaultPrompt ? `\nDefault: ${d.defaultPrompt}` : ""}\n\nUse browser_handle_dialog to accept or dismiss.`,
        details: { dialog: d },
      });
    }
    const i = r.data;
    return ok({
      text: `URL: ${i.url}\nTitle: ${i.title}\nViewport: ${i.width}x${i.height}\nScroll: (${i.scrollX}, ${i.scrollY})\nPage size: ${i.pageWidth}x${i.pageHeight}`,
      details: { ...i },
    });
  },
});

const WaitArgs = Type.Object({
  seconds: Type.Number({ minimum: 0, maximum: 60, description: "Seconds to wait" }),
});

export const waitTool = defineBrowserTool({
  name: "browser_wait",
  label: "Browser Wait",
  description: "Wait N seconds before continuing.",
  promptSnippet: "Wait N seconds",
  promptGuidelines: ["Use sparingly — prefer browser_wait_for_load when waiting for a page load."],
  parameters: WaitArgs,
  concurrency: "parallel",
  async handler(args, { signal }): Promise<Result<ToolOk, ToolErr>> {
    try {
      await sleep(Math.round(args.seconds * 1000), signal);
      return ok({ text: `Waited ${args.seconds}s` });
    } catch (e) {
      return err({ kind: "internal", message: e instanceof Error ? e.message : String(e) });
    }
  },
});

const WaitForLoadArgs = Type.Object({
  timeout: Type.Optional(Type.Number({ default: 15, minimum: 1, maximum: 120, description: "Max seconds to wait. Default: 15." })),
});

export const waitForLoadTool = defineBrowserTool({
  name: "browser_wait_for_load",
  label: "Browser Wait For Load",
  description:
    "Poll until the current page reports document.readyState === 'complete'. Returns a typed timeout error if the deadline elapses.",
  promptSnippet: "Wait for the page to finish loading",
  promptGuidelines: [
    "Call after browser_navigate / browser_open_urls before extracting data.",
  ],
  parameters: WaitForLoadArgs,
  concurrency: "serialized",
  async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
    const timeoutMs = (args.timeout ?? 15) * 1000;
    const start = Date.now();
    while (Date.now() - start < timeoutMs) {
      if (signal?.aborted) return err({ kind: "internal", message: "aborted" });
      const r = await evalJs(client, safeJs`document.readyState`);
      if (r.success && r.data === "complete") {
        const ms = Date.now() - start;
        return ok({ text: `Page loaded in ${Math.round(ms / 100) / 10}s`, details: { ms } });
      }
      await new Promise((res) => setTimeout(res, 50));
    }
    return err({ kind: "timeout", message: `Page did not finish loading in ${args.timeout ?? 15}s` });
  },
});

const WaitForArgs = Type.Object({
  selector: Type.Optional(Type.String({ description: "CSS selector to wait for (or wait to disappear with gone:true)" })),
  text: Type.Optional(Type.String({ description: "Wait until this text appears anywhere in document.body" })),
  gone: Type.Optional(Type.Boolean({ default: false, description: "If true with a selector, wait until it is absent instead of present" })),
  timeout: Type.Optional(Type.Number({ default: 5, minimum: 1, maximum: 60, description: "Max seconds to wait. Default: 5." })),
});

export const waitForTool = defineBrowserTool({
  name: "browser_wait_for",
  label: "Browser Wait For",
  description:
    "Poll until a condition holds: a selector appears (or disappears with gone:true), or text appears in the page. Returns a typed timeout error if the deadline elapses. Use to wait for SPA content to render before interacting.",
  promptSnippet: "Wait for an element or text to appear (or an element to disappear)",
  promptGuidelines: [
    "Use before browser_fill / browser_click on dynamic pages so you act after the element renders.",
  ],
  parameters: WaitForArgs,
  concurrency: "parallel",
  async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
    if (args.selector === undefined && args.text === undefined) {
      return err({ kind: "invalid_state", message: "Provide a selector or text to wait for." });
    }
    const gone = args.gone ?? false;
    const timeoutMs = (args.timeout ?? 5) * 1000;
    const expr =
      args.selector !== undefined
        ? safeJs`(() => { const found = !!document.querySelector(${args.selector}); return ${gone} ? !found : found; })()`
        : safeJs`(() => { const t = document.body ? document.body.innerText : ""; return t.includes(${args.text}); })()`;
    const start = Date.now();
    while (Date.now() - start < timeoutMs) {
      if (signal?.aborted) return err({ kind: "internal", message: "aborted" });
      const r = await evalJs(client, expr);
      if (r.success && r.data === true) {
        const ms = Date.now() - start;
        const what = args.selector !== undefined ? `${args.selector}${gone ? " gone" : ""}` : `text "${args.text}"`;
        return ok({ text: `Condition met (${what}) in ${Math.round(ms / 100) / 10}s`, details: { ms } });
      }
      await sleep(50, signal);
    }
    const what = args.selector !== undefined ? `${args.selector}${gone ? " to disappear" : ""}` : `text "${args.text}"`;
    return err({ kind: "timeout", message: `Timed out after ${args.timeout ?? 5}s waiting for ${what}` });
  },
});