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}` });
},
});
|