diff --git a/src/browser/pw-tools-core.activity.ts b/src/browser/pw-tools-core.activity.ts new file mode 100644 index 000000000..fb2484972 --- /dev/null +++ b/src/browser/pw-tools-core.activity.ts @@ -0,0 +1,64 @@ +import type { + BrowserConsoleMessage, + BrowserNetworkRequest, + BrowserPageError, +} from "./pw-session.js"; +import { ensurePageState, getPageForTargetId } from "./pw-session.js"; + +export async function getPageErrorsViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + clear?: boolean; +}): Promise<{ errors: BrowserPageError[] }> { + const page = await getPageForTargetId(opts); + const state = ensurePageState(page); + const errors = [...state.errors]; + if (opts.clear) state.errors = []; + return { errors }; +} + +export async function getNetworkRequestsViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + filter?: string; + clear?: boolean; +}): Promise<{ requests: BrowserNetworkRequest[] }> { + const page = await getPageForTargetId(opts); + const state = ensurePageState(page); + const raw = [...state.requests]; + const filter = typeof opts.filter === "string" ? opts.filter.trim() : ""; + const requests = filter ? raw.filter((r) => r.url.includes(filter)) : raw; + if (opts.clear) { + state.requests = []; + state.requestIds = new WeakMap(); + } + return { requests }; +} + +function consolePriority(level: string) { + switch (level) { + case "error": + return 3; + case "warning": + return 2; + case "info": + case "log": + return 1; + case "debug": + return 0; + default: + return 1; + } +} + +export async function getConsoleMessagesViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + level?: string; +}): Promise { + const page = await getPageForTargetId(opts); + const state = ensurePageState(page); + if (!opts.level) return [...state.console]; + const min = consolePriority(opts.level); + return state.console.filter((msg) => consolePriority(msg.type) >= min); +} diff --git a/src/browser/pw-tools-core.part-4.test.ts b/src/browser/pw-tools-core.clamps-timeoutms-scrollintoview.test.ts similarity index 100% rename from src/browser/pw-tools-core.part-4.test.ts rename to src/browser/pw-tools-core.clamps-timeoutms-scrollintoview.test.ts diff --git a/src/browser/pw-tools-core.downloads.ts b/src/browser/pw-tools-core.downloads.ts new file mode 100644 index 000000000..659d8d743 --- /dev/null +++ b/src/browser/pw-tools-core.downloads.ts @@ -0,0 +1,234 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { Page } from "playwright-core"; + +import { + ensurePageState, + getPageForTargetId, + refLocator, +} from "./pw-session.js"; +import { + bumpDialogArmId, + bumpDownloadArmId, + bumpUploadArmId, + normalizeTimeoutMs, + requireRef, + toAIFriendlyError, +} from "./pw-tools-core.shared.js"; + +function buildTempDownloadPath(fileName: string): string { + const id = crypto.randomUUID(); + const safeName = fileName.trim() ? fileName.trim() : "download.bin"; + return path.join("/tmp/clawdbot/downloads", `${id}-${safeName}`); +} + +function createPageDownloadWaiter(page: Page, timeoutMs: number) { + let done = false; + let timer: NodeJS.Timeout | undefined; + let handler: ((download: unknown) => void) | undefined; + + const cleanup = () => { + if (timer) clearTimeout(timer); + timer = undefined; + if (handler) { + page.off("download", handler as never); + handler = undefined; + } + }; + + const promise = new Promise((resolve, reject) => { + handler = (download: unknown) => { + if (done) return; + done = true; + cleanup(); + resolve(download); + }; + + page.on("download", handler as never); + timer = setTimeout(() => { + if (done) return; + done = true; + cleanup(); + reject(new Error("Timeout waiting for download")); + }, timeoutMs); + }); + + return { + promise, + cancel: () => { + if (done) return; + done = true; + cleanup(); + }, + }; +} + +export async function armFileUploadViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + paths?: string[]; + timeoutMs?: number; +}): Promise { + const page = await getPageForTargetId(opts); + const state = ensurePageState(page); + const timeout = Math.max(500, Math.min(120_000, opts.timeoutMs ?? 120_000)); + + state.armIdUpload = bumpUploadArmId(); + const armId = state.armIdUpload; + + void page + .waitForEvent("filechooser", { timeout }) + .then(async (fileChooser) => { + if (state.armIdUpload !== armId) return; + if (!opts.paths?.length) { + // Playwright removed `FileChooser.cancel()`; best-effort close the chooser instead. + try { + await page.keyboard.press("Escape"); + } catch { + // Best-effort. + } + return; + } + await fileChooser.setFiles(opts.paths); + try { + const input = + typeof fileChooser.element === "function" + ? await Promise.resolve(fileChooser.element()) + : null; + if (input) { + await input.evaluate((el) => { + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + }); + } + } catch { + // Best-effort for sites that don't react to setFiles alone. + } + }) + .catch(() => { + // Ignore timeouts; the chooser may never appear. + }); +} + +export async function armDialogViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + accept: boolean; + promptText?: string; + timeoutMs?: number; +}): Promise { + const page = await getPageForTargetId(opts); + const state = ensurePageState(page); + const timeout = normalizeTimeoutMs(opts.timeoutMs, 120_000); + + state.armIdDialog = bumpDialogArmId(); + const armId = state.armIdDialog; + + void page + .waitForEvent("dialog", { timeout }) + .then(async (dialog) => { + if (state.armIdDialog !== armId) return; + if (opts.accept) await dialog.accept(opts.promptText); + else await dialog.dismiss(); + }) + .catch(() => { + // Ignore timeouts; the dialog may never appear. + }); +} + +export async function waitForDownloadViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + path?: string; + timeoutMs?: number; +}): Promise<{ + url: string; + suggestedFilename: string; + path: string; +}> { + const page = await getPageForTargetId(opts); + const state = ensurePageState(page); + const timeout = normalizeTimeoutMs(opts.timeoutMs, 120_000); + + state.armIdDownload = bumpDownloadArmId(); + const armId = state.armIdDownload; + + const waiter = createPageDownloadWaiter(page, timeout); + try { + const download = (await waiter.promise) as { + url?: () => string; + suggestedFilename?: () => string; + saveAs?: (outPath: string) => Promise; + }; + if (state.armIdDownload !== armId) { + throw new Error("Download was superseded by another waiter"); + } + const suggested = download.suggestedFilename?.() || "download.bin"; + const outPath = opts.path?.trim() || buildTempDownloadPath(suggested); + await fs.mkdir(path.dirname(outPath), { recursive: true }); + await download.saveAs?.(outPath); + return { + url: download.url?.() || "", + suggestedFilename: suggested, + path: path.resolve(outPath), + }; + } catch (err) { + waiter.cancel(); + throw err; + } +} + +export async function downloadViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + ref: string; + path: string; + timeoutMs?: number; +}): Promise<{ + url: string; + suggestedFilename: string; + path: string; +}> { + const page = await getPageForTargetId(opts); + const state = ensurePageState(page); + const timeout = normalizeTimeoutMs(opts.timeoutMs, 120_000); + + const ref = requireRef(opts.ref); + const outPath = String(opts.path ?? "").trim(); + if (!outPath) throw new Error("path is required"); + + state.armIdDownload = bumpDownloadArmId(); + const armId = state.armIdDownload; + + const waiter = createPageDownloadWaiter(page, timeout); + try { + const locator = refLocator(page, ref); + try { + await locator.click({ timeout }); + } catch (err) { + throw toAIFriendlyError(err, ref); + } + + const download = (await waiter.promise) as { + url?: () => string; + suggestedFilename?: () => string; + saveAs?: (outPath: string) => Promise; + }; + if (state.armIdDownload !== armId) { + throw new Error("Download was superseded by another waiter"); + } + const suggested = download.suggestedFilename?.() || "download.bin"; + await fs.mkdir(path.dirname(outPath), { recursive: true }); + await download.saveAs?.(outPath); + return { + url: download.url?.() || "", + suggestedFilename: suggested, + path: path.resolve(outPath), + }; + } catch (err) { + waiter.cancel(); + throw err; + } +} diff --git a/src/browser/pw-tools-core.interactions.ts b/src/browser/pw-tools-core.interactions.ts new file mode 100644 index 000000000..89a574b19 --- /dev/null +++ b/src/browser/pw-tools-core.interactions.ts @@ -0,0 +1,409 @@ +import type { BrowserFormField } from "./client-actions-core.js"; +import { + ensurePageState, + getPageForTargetId, + refLocator, +} from "./pw-session.js"; +import { + normalizeTimeoutMs, + requireRef, + toAIFriendlyError, +} from "./pw-tools-core.shared.js"; + +export async function highlightViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + ref: string; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + const ref = requireRef(opts.ref); + try { + await refLocator(page, ref).highlight(); + } catch (err) { + throw toAIFriendlyError(err, ref); + } +} + +export async function clickViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + ref: string; + doubleClick?: boolean; + button?: "left" | "right" | "middle"; + modifiers?: Array<"Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift">; + timeoutMs?: number; +}): Promise { + const page = await getPageForTargetId({ + cdpUrl: opts.cdpUrl, + targetId: opts.targetId, + }); + ensurePageState(page); + const ref = requireRef(opts.ref); + const locator = refLocator(page, ref); + const timeout = Math.max( + 500, + Math.min(60_000, Math.floor(opts.timeoutMs ?? 8000)), + ); + try { + if (opts.doubleClick) { + await locator.dblclick({ + timeout, + button: opts.button, + modifiers: opts.modifiers, + }); + } else { + await locator.click({ + timeout, + button: opts.button, + modifiers: opts.modifiers, + }); + } + } catch (err) { + throw toAIFriendlyError(err, ref); + } +} + +export async function hoverViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + ref: string; + timeoutMs?: number; +}): Promise { + const ref = requireRef(opts.ref); + const page = await getPageForTargetId(opts); + ensurePageState(page); + try { + await refLocator(page, ref).hover({ + timeout: Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)), + }); + } catch (err) { + throw toAIFriendlyError(err, ref); + } +} + +export async function dragViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + startRef: string; + endRef: string; + timeoutMs?: number; +}): Promise { + const startRef = requireRef(opts.startRef); + const endRef = requireRef(opts.endRef); + if (!startRef || !endRef) throw new Error("startRef and endRef are required"); + const page = await getPageForTargetId(opts); + ensurePageState(page); + try { + await refLocator(page, startRef).dragTo(refLocator(page, endRef), { + timeout: Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)), + }); + } catch (err) { + throw toAIFriendlyError(err, `${startRef} -> ${endRef}`); + } +} + +export async function selectOptionViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + ref: string; + values: string[]; + timeoutMs?: number; +}): Promise { + const ref = requireRef(opts.ref); + if (!opts.values?.length) throw new Error("values are required"); + const page = await getPageForTargetId(opts); + ensurePageState(page); + try { + await refLocator(page, ref).selectOption(opts.values, { + timeout: Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)), + }); + } catch (err) { + throw toAIFriendlyError(err, ref); + } +} + +export async function pressKeyViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + key: string; + delayMs?: number; +}): Promise { + const key = String(opts.key ?? "").trim(); + if (!key) throw new Error("key is required"); + const page = await getPageForTargetId(opts); + ensurePageState(page); + await page.keyboard.press(key, { + delay: Math.max(0, Math.floor(opts.delayMs ?? 0)), + }); +} + +export async function typeViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + ref: string; + text: string; + submit?: boolean; + slowly?: boolean; + timeoutMs?: number; +}): Promise { + const text = String(opts.text ?? ""); + const page = await getPageForTargetId(opts); + ensurePageState(page); + const ref = requireRef(opts.ref); + const locator = refLocator(page, ref); + const timeout = Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)); + try { + if (opts.slowly) { + await locator.click({ timeout }); + await locator.type(text, { timeout, delay: 75 }); + } else { + await locator.fill(text, { timeout }); + } + if (opts.submit) { + await locator.press("Enter", { timeout }); + } + } catch (err) { + throw toAIFriendlyError(err, ref); + } +} + +export async function fillFormViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + fields: BrowserFormField[]; + timeoutMs?: number; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + const timeout = Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)); + for (const field of opts.fields) { + const ref = field.ref.trim(); + const type = field.type.trim(); + const rawValue = field.value; + const value = + typeof rawValue === "string" + ? rawValue + : typeof rawValue === "number" || typeof rawValue === "boolean" + ? String(rawValue) + : ""; + if (!ref || !type) continue; + const locator = refLocator(page, ref); + if (type === "checkbox" || type === "radio") { + const checked = + rawValue === true || + rawValue === 1 || + rawValue === "1" || + rawValue === "true"; + try { + await locator.setChecked(checked, { timeout }); + } catch (err) { + throw toAIFriendlyError(err, ref); + } + continue; + } + try { + await locator.fill(value, { timeout }); + } catch (err) { + throw toAIFriendlyError(err, ref); + } + } +} + +export async function evaluateViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + fn: string; + ref?: string; +}): Promise { + const fnText = String(opts.fn ?? "").trim(); + if (!fnText) throw new Error("function is required"); + const page = await getPageForTargetId(opts); + ensurePageState(page); + if (opts.ref) { + const locator = refLocator(page, opts.ref); + // Use Function constructor at runtime to avoid esbuild adding __name helper + // which doesn't exist in the browser context + // eslint-disable-next-line @typescript-eslint/no-implied-eval -- required for browser-context eval + const elementEvaluator = new Function( + "el", + "fnBody", + ` + "use strict"; + try { + var candidate = eval("(" + fnBody + ")"); + return typeof candidate === "function" ? candidate(el) : candidate; + } catch (err) { + throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err))); + } + `, + ) as (el: Element, fnBody: string) => unknown; + return await locator.evaluate(elementEvaluator, fnText); + } + // Use Function constructor at runtime to avoid esbuild adding __name helper + // which doesn't exist in the browser context + // eslint-disable-next-line @typescript-eslint/no-implied-eval -- required for browser-context eval + const browserEvaluator = new Function( + "fnBody", + ` + "use strict"; + try { + var candidate = eval("(" + fnBody + ")"); + return typeof candidate === "function" ? candidate() : candidate; + } catch (err) { + throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err))); + } + `, + ) as (fnBody: string) => unknown; + return await page.evaluate(browserEvaluator, fnText); +} + +export async function scrollIntoViewViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + ref: string; + timeoutMs?: number; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000); + + const ref = requireRef(opts.ref); + const locator = refLocator(page, ref); + try { + await locator.scrollIntoViewIfNeeded({ timeout }); + } catch (err) { + throw toAIFriendlyError(err, ref); + } +} + +export async function waitForViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + timeMs?: number; + text?: string; + textGone?: string; + selector?: string; + url?: string; + loadState?: "load" | "domcontentloaded" | "networkidle"; + fn?: string; + timeoutMs?: number; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000); + + if (typeof opts.timeMs === "number" && Number.isFinite(opts.timeMs)) { + await page.waitForTimeout(Math.max(0, opts.timeMs)); + } + if (opts.text) { + await page.getByText(opts.text).first().waitFor({ + state: "visible", + timeout, + }); + } + if (opts.textGone) { + await page.getByText(opts.textGone).first().waitFor({ + state: "hidden", + timeout, + }); + } + if (opts.selector) { + const selector = String(opts.selector).trim(); + if (selector) { + await page + .locator(selector) + .first() + .waitFor({ state: "visible", timeout }); + } + } + if (opts.url) { + const url = String(opts.url).trim(); + if (url) { + await page.waitForURL(url, { timeout }); + } + } + if (opts.loadState) { + await page.waitForLoadState(opts.loadState, { timeout }); + } + if (opts.fn) { + const fn = String(opts.fn).trim(); + if (fn) { + await page.waitForFunction(fn, { timeout }); + } + } +} + +export async function takeScreenshotViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + ref?: string; + element?: string; + fullPage?: boolean; + type?: "png" | "jpeg"; +}): Promise<{ buffer: Buffer }> { + const page = await getPageForTargetId(opts); + ensurePageState(page); + const type = opts.type ?? "png"; + if (opts.ref) { + if (opts.fullPage) + throw new Error("fullPage is not supported for element screenshots"); + const locator = refLocator(page, opts.ref); + const buffer = await locator.screenshot({ type }); + return { buffer }; + } + if (opts.element) { + if (opts.fullPage) + throw new Error("fullPage is not supported for element screenshots"); + const locator = page.locator(opts.element).first(); + const buffer = await locator.screenshot({ type }); + return { buffer }; + } + const buffer = await page.screenshot({ + type, + fullPage: Boolean(opts.fullPage), + }); + return { buffer }; +} + +export async function setInputFilesViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + inputRef?: string; + element?: string; + paths: string[]; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + if (!opts.paths.length) throw new Error("paths are required"); + const inputRef = + typeof opts.inputRef === "string" ? opts.inputRef.trim() : ""; + const element = typeof opts.element === "string" ? opts.element.trim() : ""; + if (inputRef && element) { + throw new Error("inputRef and element are mutually exclusive"); + } + if (!inputRef && !element) { + throw new Error("inputRef or element is required"); + } + + const locator = inputRef + ? refLocator(page, inputRef) + : page.locator(element).first(); + + try { + await locator.setInputFiles(opts.paths); + } catch (err) { + throw toAIFriendlyError(err, inputRef || element); + } + try { + const handle = await locator.elementHandle(); + if (handle) { + await handle.evaluate((el) => { + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + }); + } + } catch { + // Best-effort for sites that don't react to setInputFiles alone. + } +} diff --git a/src/browser/pw-tools-core.part-2.test.ts b/src/browser/pw-tools-core.last-file-chooser-arm-wins.test.ts similarity index 100% rename from src/browser/pw-tools-core.part-2.test.ts rename to src/browser/pw-tools-core.last-file-chooser-arm-wins.test.ts diff --git a/src/browser/pw-tools-core.responses.ts b/src/browser/pw-tools-core.responses.ts new file mode 100644 index 000000000..2f3aa4617 --- /dev/null +++ b/src/browser/pw-tools-core.responses.ts @@ -0,0 +1,111 @@ +import { ensurePageState, getPageForTargetId } from "./pw-session.js"; +import { normalizeTimeoutMs } from "./pw-tools-core.shared.js"; + +function matchUrlPattern(pattern: string, url: string): boolean { + const p = pattern.trim(); + if (!p) return false; + if (p === url) return true; + if (p.includes("*")) { + const escaped = p.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); + const regex = new RegExp( + `^${escaped.replace(/\*\*/g, ".*").replace(/\*/g, ".*")}$`, + ); + return regex.test(url); + } + return url.includes(p); +} + +export async function responseBodyViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + url: string; + timeoutMs?: number; + maxChars?: number; +}): Promise<{ + url: string; + status?: number; + headers?: Record; + body: string; + truncated?: boolean; +}> { + const pattern = String(opts.url ?? "").trim(); + if (!pattern) throw new Error("url is required"); + const maxChars = + typeof opts.maxChars === "number" && Number.isFinite(opts.maxChars) + ? Math.max(1, Math.min(5_000_000, Math.floor(opts.maxChars))) + : 200_000; + const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000); + + const page = await getPageForTargetId(opts); + ensurePageState(page); + + const promise = new Promise((resolve, reject) => { + let done = false; + let timer: NodeJS.Timeout | undefined; + let handler: ((resp: unknown) => void) | undefined; + + const cleanup = () => { + if (timer) clearTimeout(timer); + timer = undefined; + if (handler) page.off("response", handler as never); + }; + + handler = (resp: unknown) => { + if (done) return; + const r = resp as { url?: () => string }; + const u = r.url?.() || ""; + if (!matchUrlPattern(pattern, u)) return; + done = true; + cleanup(); + resolve(resp); + }; + + page.on("response", handler as never); + timer = setTimeout(() => { + if (done) return; + done = true; + cleanup(); + reject( + new Error( + `Response not found for url pattern "${pattern}". Run 'clawdbot browser requests' to inspect recent network activity.`, + ), + ); + }, timeout); + }); + + const resp = (await promise) as { + url?: () => string; + status?: () => number; + headers?: () => Record; + body?: () => Promise; + text?: () => Promise; + }; + + const url = resp.url?.() || ""; + const status = resp.status?.(); + const headers = resp.headers?.(); + + let bodyText = ""; + try { + if (typeof resp.text === "function") { + bodyText = await resp.text(); + } else if (typeof resp.body === "function") { + const buf = await resp.body(); + bodyText = new TextDecoder("utf-8").decode(buf); + } + } catch (err) { + throw new Error( + `Failed to read response body for "${url}": ${String(err)}`, + ); + } + + const trimmed = + bodyText.length > maxChars ? bodyText.slice(0, maxChars) : bodyText; + return { + url, + status, + headers, + body: trimmed, + truncated: bodyText.length > maxChars ? true : undefined, + }; +} diff --git a/src/browser/pw-tools-core.part-1.test.ts b/src/browser/pw-tools-core.screenshots-element-selector.test.ts similarity index 100% rename from src/browser/pw-tools-core.part-1.test.ts rename to src/browser/pw-tools-core.screenshots-element-selector.test.ts diff --git a/src/browser/pw-tools-core.shared.ts b/src/browser/pw-tools-core.shared.ts new file mode 100644 index 000000000..3f2888d40 --- /dev/null +++ b/src/browser/pw-tools-core.shared.ts @@ -0,0 +1,71 @@ +import { parseRoleRef } from "./pw-role-snapshot.js"; + +let nextUploadArmId = 0; +let nextDialogArmId = 0; +let nextDownloadArmId = 0; + +export function bumpUploadArmId(): number { + nextUploadArmId += 1; + return nextUploadArmId; +} + +export function bumpDialogArmId(): number { + nextDialogArmId += 1; + return nextDialogArmId; +} + +export function bumpDownloadArmId(): number { + nextDownloadArmId += 1; + return nextDownloadArmId; +} + +export function requireRef(value: unknown): string { + const raw = typeof value === "string" ? value.trim() : ""; + const roleRef = raw ? parseRoleRef(raw) : null; + const ref = roleRef ?? (raw.startsWith("@") ? raw.slice(1) : raw); + if (!ref) throw new Error("ref is required"); + return ref; +} + +export function normalizeTimeoutMs( + timeoutMs: number | undefined, + fallback: number, +) { + return Math.max(500, Math.min(120_000, timeoutMs ?? fallback)); +} + +export function toAIFriendlyError(error: unknown, selector: string): Error { + const message = error instanceof Error ? error.message : String(error); + + if (message.includes("strict mode violation")) { + const countMatch = message.match(/resolved to (\d+) elements/); + const count = countMatch ? countMatch[1] : "multiple"; + return new Error( + `Selector "${selector}" matched ${count} elements. ` + + `Run a new snapshot to get updated refs, or use a different ref.`, + ); + } + + if ( + (message.includes("Timeout") || message.includes("waiting for")) && + (message.includes("to be visible") || message.includes("not visible")) + ) { + return new Error( + `Element "${selector}" not found or not visible. ` + + `Run a new snapshot to see current page elements.`, + ); + } + + if ( + message.includes("intercepts pointer events") || + message.includes("not visible") || + message.includes("not receive pointer events") + ) { + return new Error( + `Element "${selector}" is not interactable (hidden or covered). ` + + `Try scrolling it into view, closing overlays, or re-snapshotting.`, + ); + } + + return error instanceof Error ? error : new Error(message); +} diff --git a/src/browser/pw-tools-core.snapshot.ts b/src/browser/pw-tools-core.snapshot.ts new file mode 100644 index 000000000..26a84261a --- /dev/null +++ b/src/browser/pw-tools-core.snapshot.ts @@ -0,0 +1,141 @@ +import type { Page } from "playwright-core"; + +import { + buildRoleSnapshotFromAriaSnapshot, + getRoleSnapshotStats, + type RoleSnapshotOptions, +} from "./pw-role-snapshot.js"; +import { + ensurePageState, + getPageForTargetId, + type WithSnapshotForAI, +} from "./pw-session.js"; + +export async function snapshotAiViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + timeoutMs?: number; + maxChars?: number; +}): Promise<{ snapshot: string; truncated?: boolean }> { + const page = await getPageForTargetId({ + cdpUrl: opts.cdpUrl, + targetId: opts.targetId, + }); + ensurePageState(page); + + const maybe = page as unknown as WithSnapshotForAI; + if (!maybe._snapshotForAI) { + throw new Error( + "Playwright _snapshotForAI is not available. Upgrade playwright-core.", + ); + } + + const result = await maybe._snapshotForAI({ + timeout: Math.max( + 500, + Math.min(60_000, Math.floor(opts.timeoutMs ?? 5000)), + ), + track: "response", + }); + let snapshot = String(result?.full ?? ""); + const maxChars = opts.maxChars; + const limit = + typeof maxChars === "number" && Number.isFinite(maxChars) && maxChars > 0 + ? Math.floor(maxChars) + : undefined; + if (limit && snapshot.length > limit) { + snapshot = `${snapshot.slice(0, limit)}\n\n[...TRUNCATED - page too large]`; + return { snapshot, truncated: true }; + } + return { snapshot }; +} + +export async function snapshotRoleViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + selector?: string; + frameSelector?: string; + options?: RoleSnapshotOptions; +}): Promise<{ + snapshot: string; + refs: Record; + stats: { lines: number; chars: number; refs: number; interactive: number }; +}> { + const page = await getPageForTargetId({ + cdpUrl: opts.cdpUrl, + targetId: opts.targetId, + }); + const state = ensurePageState(page); + + const frameSelector = opts.frameSelector?.trim() || ""; + const selector = opts.selector?.trim() || ""; + const locator = frameSelector + ? selector + ? page.frameLocator(frameSelector).locator(selector) + : page.frameLocator(frameSelector).locator(":root") + : selector + ? page.locator(selector) + : page.locator(":root"); + + const ariaSnapshot = await locator.ariaSnapshot(); + const built = buildRoleSnapshotFromAriaSnapshot( + String(ariaSnapshot ?? ""), + opts.options, + ); + state.roleRefs = built.refs; + state.roleRefsFrameSelector = frameSelector || undefined; + return { + snapshot: built.snapshot, + refs: built.refs, + stats: getRoleSnapshotStats(built.snapshot, built.refs), + }; +} + +export async function navigateViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + url: string; + timeoutMs?: number; +}): Promise<{ url: string }> { + const url = String(opts.url ?? "").trim(); + if (!url) throw new Error("url is required"); + const page = await getPageForTargetId(opts); + ensurePageState(page); + await page.goto(url, { + timeout: Math.max(1000, Math.min(120_000, opts.timeoutMs ?? 20_000)), + }); + return { url: page.url() }; +} + +export async function resizeViewportViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + width: number; + height: number; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + await page.setViewportSize({ + width: Math.max(1, Math.floor(opts.width)), + height: Math.max(1, Math.floor(opts.height)), + }); +} + +export async function closePageViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + await page.close(); +} + +export async function pdfViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; +}): Promise<{ buffer: Buffer }> { + const page = await getPageForTargetId(opts); + ensurePageState(page); + const buffer = await (page as Page).pdf({ printBackground: true }); + return { buffer }; +} diff --git a/src/browser/pw-tools-core.state.ts b/src/browser/pw-tools-core.state.ts new file mode 100644 index 000000000..9c545c710 --- /dev/null +++ b/src/browser/pw-tools-core.state.ts @@ -0,0 +1,204 @@ +import type { CDPSession, Page } from "playwright-core"; +import { devices as playwrightDevices } from "playwright-core"; + +import { ensurePageState, getPageForTargetId } from "./pw-session.js"; + +async function withCdpSession( + page: Page, + fn: (session: CDPSession) => Promise, +): Promise { + const session = await page.context().newCDPSession(page); + try { + return await fn(session); + } finally { + await session.detach().catch(() => {}); + } +} + +export async function setOfflineViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + offline: boolean; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + await page.context().setOffline(Boolean(opts.offline)); +} + +export async function setExtraHTTPHeadersViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + headers: Record; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + await page.context().setExtraHTTPHeaders(opts.headers); +} + +export async function setHttpCredentialsViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + username?: string; + password?: string; + clear?: boolean; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + if (opts.clear) { + await page.context().setHTTPCredentials(null); + return; + } + const username = String(opts.username ?? ""); + const password = String(opts.password ?? ""); + if (!username) throw new Error("username is required (or set clear=true)"); + await page.context().setHTTPCredentials({ username, password }); +} + +export async function setGeolocationViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + latitude?: number; + longitude?: number; + accuracy?: number; + origin?: string; + clear?: boolean; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + const context = page.context(); + if (opts.clear) { + await context.setGeolocation(null); + await context.clearPermissions().catch(() => {}); + return; + } + if (typeof opts.latitude !== "number" || typeof opts.longitude !== "number") { + throw new Error("latitude and longitude are required (or set clear=true)"); + } + await context.setGeolocation({ + latitude: opts.latitude, + longitude: opts.longitude, + accuracy: typeof opts.accuracy === "number" ? opts.accuracy : undefined, + }); + const origin = + opts.origin?.trim() || + (() => { + try { + return new URL(page.url()).origin; + } catch { + return ""; + } + })(); + if (origin) { + await context.grantPermissions(["geolocation"], { origin }).catch(() => {}); + } +} + +export async function emulateMediaViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + colorScheme: "dark" | "light" | "no-preference" | null; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + await page.emulateMedia({ colorScheme: opts.colorScheme }); +} + +export async function setLocaleViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + locale: string; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + const locale = String(opts.locale ?? "").trim(); + if (!locale) throw new Error("locale is required"); + await withCdpSession(page, async (session) => { + try { + await session.send("Emulation.setLocaleOverride", { locale }); + } catch (err) { + if ( + String(err).includes("Another locale override is already in effect") + ) { + return; + } + throw err; + } + }); +} + +export async function setTimezoneViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + timezoneId: string; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + const timezoneId = String(opts.timezoneId ?? "").trim(); + if (!timezoneId) throw new Error("timezoneId is required"); + await withCdpSession(page, async (session) => { + try { + await session.send("Emulation.setTimezoneOverride", { timezoneId }); + } catch (err) { + const msg = String(err); + if (msg.includes("Timezone override is already in effect")) return; + if (msg.includes("Invalid timezone")) + throw new Error(`Invalid timezone ID: ${timezoneId}`); + throw err; + } + }); +} + +export async function setDeviceViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + name: string; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + const name = String(opts.name ?? "").trim(); + if (!name) throw new Error("device name is required"); + const descriptor = (playwrightDevices as Record)[name] as + | { + userAgent?: string; + viewport?: { width: number; height: number }; + deviceScaleFactor?: number; + isMobile?: boolean; + hasTouch?: boolean; + locale?: string; + } + | undefined; + if (!descriptor) { + throw new Error(`Unknown device "${name}".`); + } + + if (descriptor.viewport) { + await page.setViewportSize({ + width: descriptor.viewport.width, + height: descriptor.viewport.height, + }); + } + + await withCdpSession(page, async (session) => { + if (descriptor.userAgent || descriptor.locale) { + await session.send("Emulation.setUserAgentOverride", { + userAgent: descriptor.userAgent ?? "", + acceptLanguage: descriptor.locale ?? undefined, + }); + } + if (descriptor.viewport) { + await session.send("Emulation.setDeviceMetricsOverride", { + mobile: Boolean(descriptor.isMobile), + width: descriptor.viewport.width, + height: descriptor.viewport.height, + deviceScaleFactor: descriptor.deviceScaleFactor ?? 1, + screenWidth: descriptor.viewport.width, + screenHeight: descriptor.viewport.height, + }); + } + if (descriptor.hasTouch) { + await session.send("Emulation.setTouchEmulationEnabled", { + enabled: true, + }); + } + }); +} diff --git a/src/browser/pw-tools-core.storage.ts b/src/browser/pw-tools-core.storage.ts new file mode 100644 index 000000000..df138406b --- /dev/null +++ b/src/browser/pw-tools-core.storage.ts @@ -0,0 +1,125 @@ +import { ensurePageState, getPageForTargetId } from "./pw-session.js"; + +export async function cookiesGetViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; +}): Promise<{ cookies: unknown[] }> { + const page = await getPageForTargetId(opts); + ensurePageState(page); + const cookies = await page.context().cookies(); + return { cookies }; +} + +export async function cookiesSetViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + cookie: { + name: string; + value: string; + url?: string; + domain?: string; + path?: string; + expires?: number; + httpOnly?: boolean; + secure?: boolean; + sameSite?: "Lax" | "None" | "Strict"; + }; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + const cookie = opts.cookie; + if (!cookie.name || cookie.value === undefined) { + throw new Error("cookie name and value are required"); + } + const hasUrl = typeof cookie.url === "string" && cookie.url.trim(); + const hasDomainPath = + typeof cookie.domain === "string" && + cookie.domain.trim() && + typeof cookie.path === "string" && + cookie.path.trim(); + if (!hasUrl && !hasDomainPath) { + throw new Error("cookie requires url, or domain+path"); + } + await page.context().addCookies([cookie]); +} + +export async function cookiesClearViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + await page.context().clearCookies(); +} + +type StorageKind = "local" | "session"; + +export async function storageGetViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + kind: StorageKind; + key?: string; +}): Promise<{ values: Record }> { + const page = await getPageForTargetId(opts); + ensurePageState(page); + const kind = opts.kind; + const key = typeof opts.key === "string" ? opts.key : undefined; + const values = await page.evaluate( + ({ kind: kind2, key: key2 }) => { + const store = + kind2 === "session" ? window.sessionStorage : window.localStorage; + if (key2) { + const value = store.getItem(key2); + return value === null ? {} : { [key2]: value }; + } + const out: Record = {}; + for (let i = 0; i < store.length; i += 1) { + const k = store.key(i); + if (!k) continue; + const v = store.getItem(k); + if (v !== null) out[k] = v; + } + return out; + }, + { kind, key }, + ); + return { values: values ?? {} }; +} + +export async function storageSetViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + kind: StorageKind; + key: string; + value: string; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + const key = String(opts.key ?? ""); + if (!key) throw new Error("key is required"); + await page.evaluate( + ({ kind, key: k, value }) => { + const store = + kind === "session" ? window.sessionStorage : window.localStorage; + store.setItem(k, value); + }, + { kind: opts.kind, key, value: String(opts.value ?? "") }, + ); +} + +export async function storageClearViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + kind: StorageKind; +}): Promise { + const page = await getPageForTargetId(opts); + ensurePageState(page); + await page.evaluate( + ({ kind }) => { + const store = + kind === "session" ? window.sessionStorage : window.localStorage; + store.clear(); + }, + { kind: opts.kind }, + ); +} diff --git a/src/browser/pw-tools-core.trace.ts b/src/browser/pw-tools-core.trace.ts new file mode 100644 index 000000000..5475f9b57 --- /dev/null +++ b/src/browser/pw-tools-core.trace.ts @@ -0,0 +1,39 @@ +import { ensureContextState, getPageForTargetId } from "./pw-session.js"; + +export async function traceStartViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + screenshots?: boolean; + snapshots?: boolean; + sources?: boolean; +}): Promise { + const page = await getPageForTargetId(opts); + const context = page.context(); + const ctxState = ensureContextState(context); + if (ctxState.traceActive) { + throw new Error( + "Trace already running. Stop the current trace before starting a new one.", + ); + } + await context.tracing.start({ + screenshots: opts.screenshots ?? true, + snapshots: opts.snapshots ?? true, + sources: opts.sources ?? false, + }); + ctxState.traceActive = true; +} + +export async function traceStopViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + path: string; +}): Promise { + const page = await getPageForTargetId(opts); + const context = page.context(); + const ctxState = ensureContextState(context); + if (!ctxState.traceActive) { + throw new Error("No active trace. Start a trace before stopping it."); + } + await context.tracing.stop({ path: opts.path }); + ctxState.traceActive = false; +} diff --git a/src/browser/pw-tools-core.ts b/src/browser/pw-tools-core.ts index a699de1cb..55a596280 100644 --- a/src/browser/pw-tools-core.ts +++ b/src/browser/pw-tools-core.ts @@ -1,1346 +1,8 @@ -import crypto from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; - -import type { CDPSession, Page } from "playwright-core"; -import { devices as playwrightDevices } from "playwright-core"; -import type { BrowserFormField } from "./client-actions-core.js"; -import { - buildRoleSnapshotFromAriaSnapshot, - getRoleSnapshotStats, - parseRoleRef, - type RoleSnapshotOptions, -} from "./pw-role-snapshot.js"; -import { - type BrowserConsoleMessage, - type BrowserNetworkRequest, - type BrowserPageError, - ensureContextState, - ensurePageState, - getPageForTargetId, - refLocator, - type WithSnapshotForAI, -} from "./pw-session.js"; - -let nextUploadArmId = 0; -let nextDialogArmId = 0; -let nextDownloadArmId = 0; - -function requireRef(value: unknown): string { - const raw = typeof value === "string" ? value.trim() : ""; - const roleRef = raw ? parseRoleRef(raw) : null; - const ref = roleRef ?? (raw.startsWith("@") ? raw.slice(1) : raw); - if (!ref) throw new Error("ref is required"); - return ref; -} - -function buildTempDownloadPath(fileName: string): string { - const id = crypto.randomUUID(); - const safeName = fileName.trim() ? fileName.trim() : "download.bin"; - return path.join("/tmp/clawdbot/downloads", `${id}-${safeName}`); -} - -function normalizeTimeoutMs(timeoutMs: number | undefined, fallback: number) { - return Math.max(500, Math.min(120_000, timeoutMs ?? fallback)); -} - -function createPageDownloadWaiter(page: Page, timeoutMs: number) { - let done = false; - let timer: NodeJS.Timeout | undefined; - let handler: ((download: unknown) => void) | undefined; - - const cleanup = () => { - if (timer) clearTimeout(timer); - timer = undefined; - if (handler) { - page.off("download", handler as never); - handler = undefined; - } - }; - - const promise = new Promise((resolve, reject) => { - handler = (download: unknown) => { - if (done) return; - done = true; - cleanup(); - resolve(download); - }; - - page.on("download", handler as never); - timer = setTimeout(() => { - if (done) return; - done = true; - cleanup(); - reject(new Error("Timeout waiting for download")); - }, timeoutMs); - }); - - return { - promise, - cancel: () => { - if (done) return; - done = true; - cleanup(); - }, - }; -} - -function matchUrlPattern(pattern: string, url: string): boolean { - const p = pattern.trim(); - if (!p) return false; - if (p === url) return true; - if (p.includes("*")) { - const escaped = p.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); - const regex = new RegExp( - `^${escaped.replace(/\*\*/g, ".*").replace(/\*/g, ".*")}$`, - ); - return regex.test(url); - } - return url.includes(p); -} - -function toAIFriendlyError(error: unknown, selector: string): Error { - const message = error instanceof Error ? error.message : String(error); - - if (message.includes("strict mode violation")) { - const countMatch = message.match(/resolved to (\d+) elements/); - const count = countMatch ? countMatch[1] : "multiple"; - return new Error( - `Selector "${selector}" matched ${count} elements. ` + - `Run a new snapshot to get updated refs, or use a different ref.`, - ); - } - - if ( - (message.includes("Timeout") || message.includes("waiting for")) && - (message.includes("to be visible") || message.includes("not visible")) - ) { - return new Error( - `Element "${selector}" not found or not visible. ` + - `Run a new snapshot to see current page elements.`, - ); - } - - if ( - message.includes("intercepts pointer events") || - message.includes("not visible") || - message.includes("not receive pointer events") - ) { - return new Error( - `Element "${selector}" is not interactable (hidden or covered). ` + - `Try scrolling it into view, closing overlays, or re-snapshotting.`, - ); - } - - return error instanceof Error ? error : new Error(message); -} - -export async function snapshotAiViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - timeoutMs?: number; - maxChars?: number; -}): Promise<{ snapshot: string; truncated?: boolean }> { - const page = await getPageForTargetId({ - cdpUrl: opts.cdpUrl, - targetId: opts.targetId, - }); - ensurePageState(page); - - const maybe = page as unknown as WithSnapshotForAI; - if (!maybe._snapshotForAI) { - throw new Error( - "Playwright _snapshotForAI is not available. Upgrade playwright-core.", - ); - } - - const result = await maybe._snapshotForAI({ - timeout: Math.max( - 500, - Math.min(60_000, Math.floor(opts.timeoutMs ?? 5000)), - ), - track: "response", - }); - let snapshot = String(result?.full ?? ""); - const maxChars = opts.maxChars; - const limit = - typeof maxChars === "number" && Number.isFinite(maxChars) && maxChars > 0 - ? Math.floor(maxChars) - : undefined; - if (limit && snapshot.length > limit) { - snapshot = `${snapshot.slice(0, limit)}\n\n[...TRUNCATED - page too large]`; - return { snapshot, truncated: true }; - } - return { snapshot }; -} - -export async function snapshotRoleViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - selector?: string; - frameSelector?: string; - options?: RoleSnapshotOptions; -}): Promise<{ - snapshot: string; - refs: Record; - stats: { lines: number; chars: number; refs: number; interactive: number }; -}> { - const page = await getPageForTargetId({ - cdpUrl: opts.cdpUrl, - targetId: opts.targetId, - }); - const state = ensurePageState(page); - - const frameSelector = opts.frameSelector?.trim() || ""; - const selector = opts.selector?.trim() || ""; - const locator = frameSelector - ? selector - ? page.frameLocator(frameSelector).locator(selector) - : page.frameLocator(frameSelector).locator(":root") - : selector - ? page.locator(selector) - : page.locator(":root"); - - const ariaSnapshot = await locator.ariaSnapshot(); - const built = buildRoleSnapshotFromAriaSnapshot( - String(ariaSnapshot ?? ""), - opts.options, - ); - state.roleRefs = built.refs; - state.roleRefsFrameSelector = frameSelector || undefined; - return { - snapshot: built.snapshot, - refs: built.refs, - stats: getRoleSnapshotStats(built.snapshot, built.refs), - }; -} - -export async function getPageErrorsViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - clear?: boolean; -}): Promise<{ errors: BrowserPageError[] }> { - const page = await getPageForTargetId(opts); - const state = ensurePageState(page); - const errors = [...state.errors]; - if (opts.clear) state.errors = []; - return { errors }; -} - -export async function getNetworkRequestsViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - filter?: string; - clear?: boolean; -}): Promise<{ requests: BrowserNetworkRequest[] }> { - const page = await getPageForTargetId(opts); - const state = ensurePageState(page); - const raw = [...state.requests]; - const filter = typeof opts.filter === "string" ? opts.filter.trim() : ""; - const requests = filter ? raw.filter((r) => r.url.includes(filter)) : raw; - if (opts.clear) { - state.requests = []; - state.requestIds = new WeakMap(); - } - return { requests }; -} - -export async function highlightViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - ref: string; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const ref = requireRef(opts.ref); - try { - await refLocator(page, ref).highlight(); - } catch (err) { - throw toAIFriendlyError(err, ref); - } -} - -export async function traceStartViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - screenshots?: boolean; - snapshots?: boolean; - sources?: boolean; -}): Promise { - const page = await getPageForTargetId(opts); - const context = page.context(); - const ctxState = ensureContextState(context); - if (ctxState.traceActive) { - throw new Error( - "Trace already running. Stop the current trace before starting a new one.", - ); - } - await context.tracing.start({ - screenshots: opts.screenshots ?? true, - snapshots: opts.snapshots ?? true, - sources: opts.sources ?? false, - }); - ctxState.traceActive = true; -} - -export async function traceStopViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - path: string; -}): Promise { - const page = await getPageForTargetId(opts); - const context = page.context(); - const ctxState = ensureContextState(context); - if (!ctxState.traceActive) { - throw new Error("No active trace. Start a trace before stopping it."); - } - await context.tracing.stop({ path: opts.path }); - ctxState.traceActive = false; -} - -export async function clickViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - ref: string; - doubleClick?: boolean; - button?: "left" | "right" | "middle"; - modifiers?: Array<"Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift">; - timeoutMs?: number; -}): Promise { - const page = await getPageForTargetId({ - cdpUrl: opts.cdpUrl, - targetId: opts.targetId, - }); - ensurePageState(page); - const ref = requireRef(opts.ref); - const locator = refLocator(page, ref); - const timeout = Math.max( - 500, - Math.min(60_000, Math.floor(opts.timeoutMs ?? 8000)), - ); - try { - if (opts.doubleClick) { - await locator.dblclick({ - timeout, - button: opts.button, - modifiers: opts.modifiers, - }); - } else { - await locator.click({ - timeout, - button: opts.button, - modifiers: opts.modifiers, - }); - } - } catch (err) { - throw toAIFriendlyError(err, ref); - } -} - -export async function hoverViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - ref: string; - timeoutMs?: number; -}): Promise { - const ref = requireRef(opts.ref); - const page = await getPageForTargetId(opts); - ensurePageState(page); - try { - await refLocator(page, ref).hover({ - timeout: Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)), - }); - } catch (err) { - throw toAIFriendlyError(err, ref); - } -} - -export async function dragViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - startRef: string; - endRef: string; - timeoutMs?: number; -}): Promise { - const startRef = requireRef(opts.startRef); - const endRef = requireRef(opts.endRef); - if (!startRef || !endRef) throw new Error("startRef and endRef are required"); - const page = await getPageForTargetId(opts); - ensurePageState(page); - try { - await refLocator(page, startRef).dragTo(refLocator(page, endRef), { - timeout: Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)), - }); - } catch (err) { - throw toAIFriendlyError(err, `${startRef} -> ${endRef}`); - } -} - -export async function selectOptionViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - ref: string; - values: string[]; - timeoutMs?: number; -}): Promise { - const ref = requireRef(opts.ref); - if (!opts.values?.length) throw new Error("values are required"); - const page = await getPageForTargetId(opts); - ensurePageState(page); - try { - await refLocator(page, ref).selectOption(opts.values, { - timeout: Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)), - }); - } catch (err) { - throw toAIFriendlyError(err, ref); - } -} - -export async function pressKeyViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - key: string; - delayMs?: number; -}): Promise { - const key = String(opts.key ?? "").trim(); - if (!key) throw new Error("key is required"); - const page = await getPageForTargetId(opts); - ensurePageState(page); - await page.keyboard.press(key, { - delay: Math.max(0, Math.floor(opts.delayMs ?? 0)), - }); -} - -export async function cookiesGetViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; -}): Promise<{ cookies: unknown[] }> { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const cookies = await page.context().cookies(); - return { cookies }; -} - -export async function cookiesSetViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - cookie: { - name: string; - value: string; - url?: string; - domain?: string; - path?: string; - expires?: number; - httpOnly?: boolean; - secure?: boolean; - sameSite?: "Lax" | "None" | "Strict"; - }; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const cookie = opts.cookie; - if (!cookie.name || cookie.value === undefined) { - throw new Error("cookie name and value are required"); - } - const hasUrl = typeof cookie.url === "string" && cookie.url.trim(); - const hasDomainPath = - typeof cookie.domain === "string" && - cookie.domain.trim() && - typeof cookie.path === "string" && - cookie.path.trim(); - if (!hasUrl && !hasDomainPath) { - throw new Error("cookie requires url, or domain+path"); - } - await page.context().addCookies([cookie]); -} - -export async function cookiesClearViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - await page.context().clearCookies(); -} - -type StorageKind = "local" | "session"; - -export async function storageGetViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - kind: StorageKind; - key?: string; -}): Promise<{ values: Record }> { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const kind = opts.kind; - const key = typeof opts.key === "string" ? opts.key : undefined; - const values = await page.evaluate( - ({ kind: kind2, key: key2 }) => { - const store = - kind2 === "session" ? window.sessionStorage : window.localStorage; - if (key2) { - const value = store.getItem(key2); - return value === null ? {} : { [key2]: value }; - } - const out: Record = {}; - for (let i = 0; i < store.length; i += 1) { - const k = store.key(i); - if (!k) continue; - const v = store.getItem(k); - if (v !== null) out[k] = v; - } - return out; - }, - { kind, key }, - ); - return { values: values ?? {} }; -} - -export async function storageSetViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - kind: StorageKind; - key: string; - value: string; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const key = String(opts.key ?? ""); - if (!key) throw new Error("key is required"); - await page.evaluate( - ({ kind, key: k, value }) => { - const store = - kind === "session" ? window.sessionStorage : window.localStorage; - store.setItem(k, value); - }, - { kind: opts.kind, key, value: String(opts.value ?? "") }, - ); -} - -export async function storageClearViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - kind: StorageKind; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - await page.evaluate( - ({ kind }) => { - const store = - kind === "session" ? window.sessionStorage : window.localStorage; - store.clear(); - }, - { kind: opts.kind }, - ); -} - -export async function setOfflineViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - offline: boolean; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - await page.context().setOffline(Boolean(opts.offline)); -} - -export async function setExtraHTTPHeadersViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - headers: Record; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - await page.context().setExtraHTTPHeaders(opts.headers); -} - -export async function setHttpCredentialsViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - username?: string; - password?: string; - clear?: boolean; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - if (opts.clear) { - await page.context().setHTTPCredentials(null); - return; - } - const username = String(opts.username ?? ""); - const password = String(opts.password ?? ""); - if (!username) throw new Error("username is required (or set clear=true)"); - await page.context().setHTTPCredentials({ username, password }); -} - -export async function setGeolocationViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - latitude?: number; - longitude?: number; - accuracy?: number; - origin?: string; - clear?: boolean; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const context = page.context(); - if (opts.clear) { - await context.setGeolocation(null); - await context.clearPermissions().catch(() => {}); - return; - } - if (typeof opts.latitude !== "number" || typeof opts.longitude !== "number") { - throw new Error("latitude and longitude are required (or set clear=true)"); - } - await context.setGeolocation({ - latitude: opts.latitude, - longitude: opts.longitude, - accuracy: typeof opts.accuracy === "number" ? opts.accuracy : undefined, - }); - const origin = - opts.origin?.trim() || - (() => { - try { - return new URL(page.url()).origin; - } catch { - return ""; - } - })(); - if (origin) { - await context.grantPermissions(["geolocation"], { origin }).catch(() => {}); - } -} - -export async function emulateMediaViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - colorScheme: "dark" | "light" | "no-preference" | null; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - await page.emulateMedia({ colorScheme: opts.colorScheme }); -} - -async function withCdpSession( - page: Page, - fn: (session: CDPSession) => Promise, -): Promise { - const session = await page.context().newCDPSession(page); - try { - return await fn(session); - } finally { - await session.detach().catch(() => {}); - } -} - -export async function setLocaleViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - locale: string; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const locale = String(opts.locale ?? "").trim(); - if (!locale) throw new Error("locale is required"); - await withCdpSession(page, async (session) => { - try { - await session.send("Emulation.setLocaleOverride", { locale }); - } catch (err) { - if ( - String(err).includes("Another locale override is already in effect") - ) { - return; - } - throw err; - } - }); -} - -export async function setTimezoneViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - timezoneId: string; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const timezoneId = String(opts.timezoneId ?? "").trim(); - if (!timezoneId) throw new Error("timezoneId is required"); - await withCdpSession(page, async (session) => { - try { - await session.send("Emulation.setTimezoneOverride", { timezoneId }); - } catch (err) { - const msg = String(err); - if (msg.includes("Timezone override is already in effect")) return; - if (msg.includes("Invalid timezone")) - throw new Error(`Invalid timezone ID: ${timezoneId}`); - throw err; - } - }); -} - -export async function setDeviceViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - name: string; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const name = String(opts.name ?? "").trim(); - if (!name) throw new Error("device name is required"); - const descriptor = (playwrightDevices as Record)[name] as - | { - userAgent?: string; - viewport?: { width: number; height: number }; - deviceScaleFactor?: number; - isMobile?: boolean; - hasTouch?: boolean; - locale?: string; - } - | undefined; - if (!descriptor) { - throw new Error(`Unknown device "${name}".`); - } - - if (descriptor.viewport) { - await page.setViewportSize({ - width: descriptor.viewport.width, - height: descriptor.viewport.height, - }); - } - - await withCdpSession(page, async (session) => { - if (descriptor.userAgent || descriptor.locale) { - await session.send("Emulation.setUserAgentOverride", { - userAgent: descriptor.userAgent ?? "", - acceptLanguage: descriptor.locale ?? undefined, - }); - } - if (descriptor.viewport) { - await session.send("Emulation.setDeviceMetricsOverride", { - mobile: Boolean(descriptor.isMobile), - width: descriptor.viewport.width, - height: descriptor.viewport.height, - deviceScaleFactor: descriptor.deviceScaleFactor ?? 1, - screenWidth: descriptor.viewport.width, - screenHeight: descriptor.viewport.height, - }); - } - if (descriptor.hasTouch) { - await session.send("Emulation.setTouchEmulationEnabled", { - enabled: true, - }); - } - }); -} - -export async function typeViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - ref: string; - text: string; - submit?: boolean; - slowly?: boolean; - timeoutMs?: number; -}): Promise { - const text = String(opts.text ?? ""); - const page = await getPageForTargetId(opts); - ensurePageState(page); - const ref = requireRef(opts.ref); - const locator = refLocator(page, ref); - const timeout = Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)); - try { - if (opts.slowly) { - await locator.click({ timeout }); - await locator.type(text, { timeout, delay: 75 }); - } else { - await locator.fill(text, { timeout }); - } - if (opts.submit) { - await locator.press("Enter", { timeout }); - } - } catch (err) { - throw toAIFriendlyError(err, ref); - } -} - -export async function fillFormViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - fields: BrowserFormField[]; - timeoutMs?: number; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const timeout = Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)); - for (const field of opts.fields) { - const ref = field.ref.trim(); - const type = field.type.trim(); - const rawValue = field.value; - const value = - typeof rawValue === "string" - ? rawValue - : typeof rawValue === "number" || typeof rawValue === "boolean" - ? String(rawValue) - : ""; - if (!ref || !type) continue; - const locator = refLocator(page, ref); - if (type === "checkbox" || type === "radio") { - const checked = - rawValue === true || - rawValue === 1 || - rawValue === "1" || - rawValue === "true"; - try { - await locator.setChecked(checked, { timeout }); - } catch (err) { - throw toAIFriendlyError(err, ref); - } - continue; - } - try { - await locator.fill(value, { timeout }); - } catch (err) { - throw toAIFriendlyError(err, ref); - } - } -} - -export async function evaluateViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - fn: string; - ref?: string; -}): Promise { - const fnText = String(opts.fn ?? "").trim(); - if (!fnText) throw new Error("function is required"); - const page = await getPageForTargetId(opts); - ensurePageState(page); - if (opts.ref) { - const locator = refLocator(page, opts.ref); - // Use Function constructor at runtime to avoid esbuild adding __name helper - // which doesn't exist in the browser context - // eslint-disable-next-line @typescript-eslint/no-implied-eval -- required for browser-context eval - const elementEvaluator = new Function( - "el", - "fnBody", - ` - "use strict"; - try { - var candidate = eval("(" + fnBody + ")"); - return typeof candidate === "function" ? candidate(el) : candidate; - } catch (err) { - throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err))); - } - `, - ) as (el: Element, fnBody: string) => unknown; - return await locator.evaluate(elementEvaluator, fnText); - } - // Use Function constructor at runtime to avoid esbuild adding __name helper - // which doesn't exist in the browser context - // eslint-disable-next-line @typescript-eslint/no-implied-eval -- required for browser-context eval - const browserEvaluator = new Function( - "fnBody", - ` - "use strict"; - try { - var candidate = eval("(" + fnBody + ")"); - return typeof candidate === "function" ? candidate() : candidate; - } catch (err) { - throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err))); - } - `, - ) as (fnBody: string) => unknown; - return await page.evaluate(browserEvaluator, fnText); -} - -export async function armFileUploadViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - paths?: string[]; - timeoutMs?: number; -}): Promise { - const page = await getPageForTargetId(opts); - const state = ensurePageState(page); - const timeout = Math.max(500, Math.min(120_000, opts.timeoutMs ?? 120_000)); - - state.armIdUpload = nextUploadArmId += 1; - const armId = state.armIdUpload; - - void page - .waitForEvent("filechooser", { timeout }) - .then(async (fileChooser) => { - if (state.armIdUpload !== armId) return; - if (!opts.paths?.length) { - // Playwright removed `FileChooser.cancel()`; best-effort close the chooser instead. - try { - await page.keyboard.press("Escape"); - } catch { - // Best-effort. - } - return; - } - await fileChooser.setFiles(opts.paths); - try { - const input = - typeof fileChooser.element === "function" - ? await Promise.resolve(fileChooser.element()) - : null; - if (input) { - await input.evaluate((el) => { - el.dispatchEvent(new Event("input", { bubbles: true })); - el.dispatchEvent(new Event("change", { bubbles: true })); - }); - } - } catch { - // Best-effort for sites that don't react to setFiles alone. - } - }) - .catch(() => { - // Ignore timeouts; the chooser may never appear. - }); -} - -export async function setInputFilesViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - inputRef?: string; - element?: string; - paths: string[]; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - if (!opts.paths.length) throw new Error("paths are required"); - const inputRef = - typeof opts.inputRef === "string" ? opts.inputRef.trim() : ""; - const element = typeof opts.element === "string" ? opts.element.trim() : ""; - if (inputRef && element) { - throw new Error("inputRef and element are mutually exclusive"); - } - if (!inputRef && !element) { - throw new Error("inputRef or element is required"); - } - - const locator = inputRef - ? refLocator(page, inputRef) - : page.locator(element).first(); - - try { - await locator.setInputFiles(opts.paths); - } catch (err) { - throw toAIFriendlyError(err, inputRef || element); - } - try { - const handle = await locator.elementHandle(); - if (handle) { - await handle.evaluate((el) => { - el.dispatchEvent(new Event("input", { bubbles: true })); - el.dispatchEvent(new Event("change", { bubbles: true })); - }); - } - } catch { - // Best-effort for sites that don't react to setInputFiles alone. - } -} - -export async function armDialogViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - accept: boolean; - promptText?: string; - timeoutMs?: number; -}): Promise { - const page = await getPageForTargetId(opts); - const state = ensurePageState(page); - const timeout = normalizeTimeoutMs(opts.timeoutMs, 120_000); - - state.armIdDialog = nextDialogArmId += 1; - const armId = state.armIdDialog; - - void page - .waitForEvent("dialog", { timeout }) - .then(async (dialog) => { - if (state.armIdDialog !== armId) return; - if (opts.accept) await dialog.accept(opts.promptText); - else await dialog.dismiss(); - }) - .catch(() => { - // Ignore timeouts; the dialog may never appear. - }); -} - -export async function waitForDownloadViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - path?: string; - timeoutMs?: number; -}): Promise<{ - url: string; - suggestedFilename: string; - path: string; -}> { - const page = await getPageForTargetId(opts); - const state = ensurePageState(page); - const timeout = normalizeTimeoutMs(opts.timeoutMs, 120_000); - - state.armIdDownload = nextDownloadArmId += 1; - const armId = state.armIdDownload; - - const waiter = createPageDownloadWaiter(page, timeout); - try { - const download = (await waiter.promise) as { - url?: () => string; - suggestedFilename?: () => string; - saveAs?: (outPath: string) => Promise; - }; - if (state.armIdDownload !== armId) { - throw new Error("Download was superseded by another waiter"); - } - const suggested = download.suggestedFilename?.() || "download.bin"; - const outPath = opts.path?.trim() || buildTempDownloadPath(suggested); - await fs.mkdir(path.dirname(outPath), { recursive: true }); - await download.saveAs?.(outPath); - return { - url: download.url?.() || "", - suggestedFilename: suggested, - path: path.resolve(outPath), - }; - } catch (err) { - waiter.cancel(); - throw err; - } -} - -export async function downloadViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - ref: string; - path: string; - timeoutMs?: number; -}): Promise<{ - url: string; - suggestedFilename: string; - path: string; -}> { - const page = await getPageForTargetId(opts); - const state = ensurePageState(page); - const timeout = normalizeTimeoutMs(opts.timeoutMs, 120_000); - - const ref = requireRef(opts.ref); - const outPath = String(opts.path ?? "").trim(); - if (!outPath) throw new Error("path is required"); - - state.armIdDownload = nextDownloadArmId += 1; - const armId = state.armIdDownload; - - const waiter = createPageDownloadWaiter(page, timeout); - try { - const locator = refLocator(page, ref); - try { - await locator.click({ timeout }); - } catch (err) { - throw toAIFriendlyError(err, ref); - } - - const download = (await waiter.promise) as { - url?: () => string; - suggestedFilename?: () => string; - saveAs?: (outPath: string) => Promise; - }; - if (state.armIdDownload !== armId) { - throw new Error("Download was superseded by another waiter"); - } - const suggested = download.suggestedFilename?.() || "download.bin"; - await fs.mkdir(path.dirname(outPath), { recursive: true }); - await download.saveAs?.(outPath); - return { - url: download.url?.() || "", - suggestedFilename: suggested, - path: path.resolve(outPath), - }; - } catch (err) { - waiter.cancel(); - throw err; - } -} - -export async function responseBodyViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - url: string; - timeoutMs?: number; - maxChars?: number; -}): Promise<{ - url: string; - status?: number; - headers?: Record; - body: string; - truncated?: boolean; -}> { - const pattern = String(opts.url ?? "").trim(); - if (!pattern) throw new Error("url is required"); - const maxChars = - typeof opts.maxChars === "number" && Number.isFinite(opts.maxChars) - ? Math.max(1, Math.min(5_000_000, Math.floor(opts.maxChars))) - : 200_000; - const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000); - - const page = await getPageForTargetId(opts); - ensurePageState(page); - - const promise = new Promise((resolve, reject) => { - let done = false; - let timer: NodeJS.Timeout | undefined; - let handler: ((resp: unknown) => void) | undefined; - - const cleanup = () => { - if (timer) clearTimeout(timer); - timer = undefined; - if (handler) page.off("response", handler as never); - }; - - handler = (resp: unknown) => { - if (done) return; - const r = resp as { url?: () => string }; - const u = r.url?.() || ""; - if (!matchUrlPattern(pattern, u)) return; - done = true; - cleanup(); - resolve(resp); - }; - - page.on("response", handler as never); - timer = setTimeout(() => { - if (done) return; - done = true; - cleanup(); - reject( - new Error( - `Response not found for url pattern "${pattern}". Run 'clawdbot browser requests' to inspect recent network activity.`, - ), - ); - }, timeout); - }); - - const resp = (await promise) as { - url?: () => string; - status?: () => number; - headers?: () => Record; - body?: () => Promise; - text?: () => Promise; - }; - - const url = resp.url?.() || ""; - const status = resp.status?.(); - const headers = resp.headers?.(); - - let bodyText = ""; - try { - if (typeof resp.text === "function") { - bodyText = await resp.text(); - } else if (typeof resp.body === "function") { - const buf = await resp.body(); - bodyText = new TextDecoder("utf-8").decode(buf); - } - } catch (err) { - throw new Error( - `Failed to read response body for "${url}": ${String(err)}`, - ); - } - - const trimmed = - bodyText.length > maxChars ? bodyText.slice(0, maxChars) : bodyText; - return { - url, - status, - headers, - body: trimmed, - truncated: bodyText.length > maxChars ? true : undefined, - }; -} - -export async function scrollIntoViewViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - ref: string; - timeoutMs?: number; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000); - - const ref = requireRef(opts.ref); - const locator = refLocator(page, ref); - try { - await locator.scrollIntoViewIfNeeded({ timeout }); - } catch (err) { - throw toAIFriendlyError(err, ref); - } -} - -export async function navigateViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - url: string; - timeoutMs?: number; -}): Promise<{ url: string }> { - const url = String(opts.url ?? "").trim(); - if (!url) throw new Error("url is required"); - const page = await getPageForTargetId(opts); - ensurePageState(page); - await page.goto(url, { - timeout: Math.max(1000, Math.min(120_000, opts.timeoutMs ?? 20_000)), - }); - return { url: page.url() }; -} - -export async function waitForViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - timeMs?: number; - text?: string; - textGone?: string; - selector?: string; - url?: string; - loadState?: "load" | "domcontentloaded" | "networkidle"; - fn?: string; - timeoutMs?: number; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000); - - if (typeof opts.timeMs === "number" && Number.isFinite(opts.timeMs)) { - await page.waitForTimeout(Math.max(0, opts.timeMs)); - } - if (opts.text) { - await page.getByText(opts.text).first().waitFor({ - state: "visible", - timeout, - }); - } - if (opts.textGone) { - await page.getByText(opts.textGone).first().waitFor({ - state: "hidden", - timeout, - }); - } - if (opts.selector) { - const selector = String(opts.selector).trim(); - if (selector) { - await page - .locator(selector) - .first() - .waitFor({ state: "visible", timeout }); - } - } - if (opts.url) { - const url = String(opts.url).trim(); - if (url) { - await page.waitForURL(url, { timeout }); - } - } - if (opts.loadState) { - await page.waitForLoadState(opts.loadState, { timeout }); - } - if (opts.fn) { - const fn = String(opts.fn).trim(); - if (fn) { - await page.waitForFunction(fn, { timeout }); - } - } -} - -export async function takeScreenshotViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - ref?: string; - element?: string; - fullPage?: boolean; - type?: "png" | "jpeg"; -}): Promise<{ buffer: Buffer }> { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const type = opts.type ?? "png"; - if (opts.ref) { - if (opts.fullPage) - throw new Error("fullPage is not supported for element screenshots"); - const locator = refLocator(page, opts.ref); - const buffer = await locator.screenshot({ type }); - return { buffer }; - } - if (opts.element) { - if (opts.fullPage) - throw new Error("fullPage is not supported for element screenshots"); - const locator = page.locator(opts.element).first(); - const buffer = await locator.screenshot({ type }); - return { buffer }; - } - const buffer = await page.screenshot({ - type, - fullPage: Boolean(opts.fullPage), - }); - return { buffer }; -} - -export async function resizeViewportViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - width: number; - height: number; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - await page.setViewportSize({ - width: Math.max(1, Math.floor(opts.width)), - height: Math.max(1, Math.floor(opts.height)), - }); -} - -export async function closePageViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - await page.close(); -} - -export async function pdfViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; -}): Promise<{ buffer: Buffer }> { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const buffer = await page.pdf({ printBackground: true }); - return { buffer }; -} - -function consolePriority(level: string) { - switch (level) { - case "error": - return 3; - case "warning": - return 2; - case "info": - case "log": - return 1; - case "debug": - return 0; - default: - return 1; - } -} - -export async function getConsoleMessagesViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - level?: string; -}): Promise { - const page = await getPageForTargetId(opts); - const state = ensurePageState(page); - if (!opts.level) return [...state.console]; - const min = consolePriority(opts.level); - return state.console.filter((msg) => consolePriority(msg.type) >= min); -} +export * from "./pw-tools-core.activity.js"; +export * from "./pw-tools-core.downloads.js"; +export * from "./pw-tools-core.interactions.js"; +export * from "./pw-tools-core.responses.js"; +export * from "./pw-tools-core.snapshot.js"; +export * from "./pw-tools-core.state.js"; +export * from "./pw-tools-core.storage.js"; +export * from "./pw-tools-core.trace.js"; diff --git a/src/browser/pw-tools-core.part-3.test.ts b/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts similarity index 100% rename from src/browser/pw-tools-core.part-3.test.ts rename to src/browser/pw-tools-core.waits-next-download-saves-it.test.ts diff --git a/src/browser/routes/agent.act.shared.ts b/src/browser/routes/agent.act.shared.ts new file mode 100644 index 000000000..f093d365c --- /dev/null +++ b/src/browser/routes/agent.act.shared.ts @@ -0,0 +1,55 @@ +export const ACT_KINDS = [ + "click", + "close", + "drag", + "evaluate", + "fill", + "hover", + "scrollIntoView", + "press", + "resize", + "select", + "type", + "wait", +] as const; + +export type ActKind = (typeof ACT_KINDS)[number]; + +export function isActKind(value: unknown): value is ActKind { + if (typeof value !== "string") return false; + return (ACT_KINDS as readonly string[]).includes(value); +} + +export type ClickButton = "left" | "right" | "middle"; +export type ClickModifier = + | "Alt" + | "Control" + | "ControlOrMeta" + | "Meta" + | "Shift"; + +const ALLOWED_CLICK_MODIFIERS = new Set([ + "Alt", + "Control", + "ControlOrMeta", + "Meta", + "Shift", +]); + +export function parseClickButton(raw: string): ClickButton | undefined { + if (raw === "left" || raw === "right" || raw === "middle") return raw; + return undefined; +} + +export function parseClickModifiers(raw: string[]): { + modifiers?: ClickModifier[]; + error?: string; +} { + const invalid = raw.filter( + (m) => !ALLOWED_CLICK_MODIFIERS.has(m as ClickModifier), + ); + if (invalid.length) { + return { error: "modifiers must be Alt|Control|ControlOrMeta|Meta|Shift" }; + } + return { modifiers: raw.length ? (raw as ClickModifier[]) : undefined }; +} diff --git a/src/browser/routes/agent.act.ts b/src/browser/routes/agent.act.ts new file mode 100644 index 000000000..a58931afb --- /dev/null +++ b/src/browser/routes/agent.act.ts @@ -0,0 +1,460 @@ +import type express from "express"; + +import type { BrowserFormField } from "../client-actions-core.js"; +import type { BrowserRouteContext } from "../server-context.js"; +import { + type ActKind, + isActKind, + parseClickButton, + parseClickModifiers, +} from "./agent.act.shared.js"; +import { + handleRouteError, + readBody, + requirePwAi, + resolveProfileContext, + SELECTOR_UNSUPPORTED_MESSAGE, +} from "./agent.shared.js"; +import { + jsonError, + toBoolean, + toNumber, + toStringArray, + toStringOrEmpty, +} from "./utils.js"; + +export function registerBrowserAgentActRoutes( + app: express.Express, + ctx: BrowserRouteContext, +) { + app.post("/act", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const kindRaw = toStringOrEmpty(body.kind); + if (!isActKind(kindRaw)) { + return jsonError(res, 400, "kind is required"); + } + const kind: ActKind = kindRaw; + const targetId = toStringOrEmpty(body.targetId) || undefined; + if (Object.hasOwn(body, "selector") && kind !== "wait") { + return jsonError(res, 400, SELECTOR_UNSUPPORTED_MESSAGE); + } + + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const cdpUrl = profileCtx.profile.cdpUrl; + const pw = await requirePwAi(res, `act:${kind}`); + if (!pw) return; + + switch (kind) { + case "click": { + const ref = toStringOrEmpty(body.ref); + if (!ref) return jsonError(res, 400, "ref is required"); + const doubleClick = toBoolean(body.doubleClick) ?? false; + const timeoutMs = toNumber(body.timeoutMs); + const buttonRaw = toStringOrEmpty(body.button) || ""; + const button = buttonRaw ? parseClickButton(buttonRaw) : undefined; + if (buttonRaw && !button) + return jsonError(res, 400, "button must be left|right|middle"); + + const modifiersRaw = toStringArray(body.modifiers) ?? []; + const parsedModifiers = parseClickModifiers(modifiersRaw); + if (parsedModifiers.error) { + return jsonError(res, 400, parsedModifiers.error); + } + const modifiers = parsedModifiers.modifiers; + const clickRequest: Parameters[0] = { + cdpUrl, + targetId: tab.targetId, + ref, + doubleClick, + }; + if (button) clickRequest.button = button; + if (modifiers) clickRequest.modifiers = modifiers; + if (timeoutMs) clickRequest.timeoutMs = timeoutMs; + await pw.clickViaPlaywright(clickRequest); + return res.json({ ok: true, targetId: tab.targetId, url: tab.url }); + } + case "type": { + const ref = toStringOrEmpty(body.ref); + if (!ref) return jsonError(res, 400, "ref is required"); + if (typeof body.text !== "string") + return jsonError(res, 400, "text is required"); + const text = body.text; + const submit = toBoolean(body.submit) ?? false; + const slowly = toBoolean(body.slowly) ?? false; + const timeoutMs = toNumber(body.timeoutMs); + const typeRequest: Parameters[0] = { + cdpUrl, + targetId: tab.targetId, + ref, + text, + submit, + slowly, + }; + if (timeoutMs) typeRequest.timeoutMs = timeoutMs; + await pw.typeViaPlaywright(typeRequest); + return res.json({ ok: true, targetId: tab.targetId }); + } + case "press": { + const key = toStringOrEmpty(body.key); + if (!key) return jsonError(res, 400, "key is required"); + const delayMs = toNumber(body.delayMs); + await pw.pressKeyViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + key, + delayMs: delayMs ?? undefined, + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + case "hover": { + const ref = toStringOrEmpty(body.ref); + if (!ref) return jsonError(res, 400, "ref is required"); + const timeoutMs = toNumber(body.timeoutMs); + await pw.hoverViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + ref, + timeoutMs: timeoutMs ?? undefined, + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + case "scrollIntoView": { + const ref = toStringOrEmpty(body.ref); + if (!ref) return jsonError(res, 400, "ref is required"); + const timeoutMs = toNumber(body.timeoutMs); + const scrollRequest: Parameters< + typeof pw.scrollIntoViewViaPlaywright + >[0] = { + cdpUrl, + targetId: tab.targetId, + ref, + }; + if (timeoutMs) scrollRequest.timeoutMs = timeoutMs; + await pw.scrollIntoViewViaPlaywright(scrollRequest); + return res.json({ ok: true, targetId: tab.targetId }); + } + case "drag": { + const startRef = toStringOrEmpty(body.startRef); + const endRef = toStringOrEmpty(body.endRef); + if (!startRef || !endRef) + return jsonError(res, 400, "startRef and endRef are required"); + const timeoutMs = toNumber(body.timeoutMs); + await pw.dragViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + startRef, + endRef, + timeoutMs: timeoutMs ?? undefined, + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + case "select": { + const ref = toStringOrEmpty(body.ref); + const values = toStringArray(body.values); + if (!ref || !values?.length) + return jsonError(res, 400, "ref and values are required"); + const timeoutMs = toNumber(body.timeoutMs); + await pw.selectOptionViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + ref, + values, + timeoutMs: timeoutMs ?? undefined, + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + case "fill": { + const rawFields = Array.isArray(body.fields) ? body.fields : []; + const fields = rawFields + .map((field) => { + if (!field || typeof field !== "object") return null; + const rec = field as Record; + const ref = toStringOrEmpty(rec.ref); + const type = toStringOrEmpty(rec.type); + if (!ref || !type) return null; + const value = + typeof rec.value === "string" || + typeof rec.value === "number" || + typeof rec.value === "boolean" + ? rec.value + : undefined; + const parsed: BrowserFormField = + value === undefined ? { ref, type } : { ref, type, value }; + return parsed; + }) + .filter((field): field is BrowserFormField => field !== null); + if (!fields.length) return jsonError(res, 400, "fields are required"); + const timeoutMs = toNumber(body.timeoutMs); + await pw.fillFormViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + fields, + timeoutMs: timeoutMs ?? undefined, + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + case "resize": { + const width = toNumber(body.width); + const height = toNumber(body.height); + if (!width || !height) + return jsonError(res, 400, "width and height are required"); + await pw.resizeViewportViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + width, + height, + }); + return res.json({ ok: true, targetId: tab.targetId, url: tab.url }); + } + case "wait": { + const timeMs = toNumber(body.timeMs); + const text = toStringOrEmpty(body.text) || undefined; + const textGone = toStringOrEmpty(body.textGone) || undefined; + const selector = toStringOrEmpty(body.selector) || undefined; + const url = toStringOrEmpty(body.url) || undefined; + const loadStateRaw = toStringOrEmpty(body.loadState); + const loadState = + loadStateRaw === "load" || + loadStateRaw === "domcontentloaded" || + loadStateRaw === "networkidle" + ? (loadStateRaw as "load" | "domcontentloaded" | "networkidle") + : undefined; + const fn = toStringOrEmpty(body.fn) || undefined; + const timeoutMs = toNumber(body.timeoutMs) ?? undefined; + if ( + timeMs === undefined && + !text && + !textGone && + !selector && + !url && + !loadState && + !fn + ) { + return jsonError( + res, + 400, + "wait requires at least one of: timeMs, text, textGone, selector, url, loadState, fn", + ); + } + await pw.waitForViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + timeMs, + text, + textGone, + selector, + url, + loadState, + fn, + timeoutMs, + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + case "evaluate": { + const fn = toStringOrEmpty(body.fn); + if (!fn) return jsonError(res, 400, "fn is required"); + const ref = toStringOrEmpty(body.ref) || undefined; + const result = await pw.evaluateViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + fn, + ref, + }); + return res.json({ + ok: true, + targetId: tab.targetId, + url: tab.url, + result, + }); + } + case "close": { + await pw.closePageViaPlaywright({ cdpUrl, targetId: tab.targetId }); + return res.json({ ok: true, targetId: tab.targetId }); + } + default: { + return jsonError(res, 400, "unsupported kind"); + } + } + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/hooks/file-chooser", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const ref = toStringOrEmpty(body.ref) || undefined; + const inputRef = toStringOrEmpty(body.inputRef) || undefined; + const element = toStringOrEmpty(body.element) || undefined; + const paths = toStringArray(body.paths) ?? []; + const timeoutMs = toNumber(body.timeoutMs); + if (!paths.length) return jsonError(res, 400, "paths are required"); + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "file chooser hook"); + if (!pw) return; + if (inputRef || element) { + if (ref) { + return jsonError( + res, + 400, + "ref cannot be combined with inputRef/element", + ); + } + await pw.setInputFilesViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + inputRef, + element, + paths, + }); + } else { + await pw.armFileUploadViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + paths, + timeoutMs: timeoutMs ?? undefined, + }); + if (ref) { + await pw.clickViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + ref, + }); + } + } + res.json({ ok: true }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/hooks/dialog", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const accept = toBoolean(body.accept); + const promptText = toStringOrEmpty(body.promptText) || undefined; + const timeoutMs = toNumber(body.timeoutMs); + if (accept === undefined) return jsonError(res, 400, "accept is required"); + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "dialog hook"); + if (!pw) return; + await pw.armDialogViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + accept, + promptText, + timeoutMs: timeoutMs ?? undefined, + }); + res.json({ ok: true }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/wait/download", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const out = toStringOrEmpty(body.path) || undefined; + const timeoutMs = toNumber(body.timeoutMs); + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "wait for download"); + if (!pw) return; + const result = await pw.waitForDownloadViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + path: out, + timeoutMs: timeoutMs ?? undefined, + }); + res.json({ ok: true, targetId: tab.targetId, download: result }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/download", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const ref = toStringOrEmpty(body.ref); + const out = toStringOrEmpty(body.path); + const timeoutMs = toNumber(body.timeoutMs); + if (!ref) return jsonError(res, 400, "ref is required"); + if (!out) return jsonError(res, 400, "path is required"); + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "download"); + if (!pw) return; + const result = await pw.downloadViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + ref, + path: out, + timeoutMs: timeoutMs ?? undefined, + }); + res.json({ ok: true, targetId: tab.targetId, download: result }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/response/body", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const url = toStringOrEmpty(body.url); + const timeoutMs = toNumber(body.timeoutMs); + const maxChars = toNumber(body.maxChars); + if (!url) return jsonError(res, 400, "url is required"); + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "response body"); + if (!pw) return; + const result = await pw.responseBodyViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + url, + timeoutMs: timeoutMs ?? undefined, + maxChars: maxChars ?? undefined, + }); + res.json({ ok: true, targetId: tab.targetId, response: result }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/highlight", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const ref = toStringOrEmpty(body.ref); + if (!ref) return jsonError(res, 400, "ref is required"); + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "highlight"); + if (!pw) return; + await pw.highlightViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + ref, + }); + res.json({ ok: true, targetId: tab.targetId }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); +} diff --git a/src/browser/routes/agent.debug.ts b/src/browser/routes/agent.debug.ts new file mode 100644 index 000000000..2eb36f1e2 --- /dev/null +++ b/src/browser/routes/agent.debug.ts @@ -0,0 +1,141 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import type express from "express"; + +import type { BrowserRouteContext } from "../server-context.js"; +import { + handleRouteError, + readBody, + requirePwAi, + resolveProfileContext, +} from "./agent.shared.js"; +import { toBoolean, toStringOrEmpty } from "./utils.js"; + +export function registerBrowserAgentDebugRoutes( + app: express.Express, + ctx: BrowserRouteContext, +) { + app.get("/console", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const targetId = + typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; + const level = typeof req.query.level === "string" ? req.query.level : ""; + + try { + const tab = await profileCtx.ensureTabAvailable(targetId || undefined); + const pw = await requirePwAi(res, "console messages"); + if (!pw) return; + const messages = await pw.getConsoleMessagesViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + level: level.trim() || undefined, + }); + res.json({ ok: true, messages, targetId: tab.targetId }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.get("/errors", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const targetId = + typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; + const clear = toBoolean(req.query.clear) ?? false; + + try { + const tab = await profileCtx.ensureTabAvailable(targetId || undefined); + const pw = await requirePwAi(res, "page errors"); + if (!pw) return; + const result = await pw.getPageErrorsViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + clear, + }); + res.json({ ok: true, targetId: tab.targetId, ...result }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.get("/requests", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const targetId = + typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; + const filter = typeof req.query.filter === "string" ? req.query.filter : ""; + const clear = toBoolean(req.query.clear) ?? false; + + try { + const tab = await profileCtx.ensureTabAvailable(targetId || undefined); + const pw = await requirePwAi(res, "network requests"); + if (!pw) return; + const result = await pw.getNetworkRequestsViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + filter: filter.trim() || undefined, + clear, + }); + res.json({ ok: true, targetId: tab.targetId, ...result }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/trace/start", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const screenshots = toBoolean(body.screenshots) ?? undefined; + const snapshots = toBoolean(body.snapshots) ?? undefined; + const sources = toBoolean(body.sources) ?? undefined; + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "trace start"); + if (!pw) return; + await pw.traceStartViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + screenshots, + snapshots, + sources, + }); + res.json({ ok: true, targetId: tab.targetId }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/trace/stop", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const out = toStringOrEmpty(body.path) || ""; + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "trace stop"); + if (!pw) return; + const id = crypto.randomUUID(); + const dir = "/tmp/clawdbot"; + await fs.mkdir(dir, { recursive: true }); + const tracePath = out.trim() || path.join(dir, `browser-trace-${id}.zip`); + await pw.traceStopViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + path: tracePath, + }); + res.json({ + ok: true, + targetId: tab.targetId, + path: path.resolve(tracePath), + }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); +} diff --git a/src/browser/routes/agent.shared.ts b/src/browser/routes/agent.shared.ts new file mode 100644 index 000000000..ea6253013 --- /dev/null +++ b/src/browser/routes/agent.shared.ts @@ -0,0 +1,73 @@ +import type express from "express"; + +import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; +import { getProfileContext, jsonError } from "./utils.js"; + +export const SELECTOR_UNSUPPORTED_MESSAGE = [ + "Error: 'selector' is not supported. Use 'ref' from snapshot instead.", + "", + "Example workflow:", + "1. snapshot action to get page state with refs", + '2. act with ref: "e123" to interact with element', + "", + "This is more reliable for modern SPAs.", +].join("\n"); + +export function readBody(req: express.Request): Record { + const body = req.body as Record | undefined; + if (!body || typeof body !== "object" || Array.isArray(body)) return {}; + return body; +} + +export function handleRouteError( + ctx: BrowserRouteContext, + res: express.Response, + err: unknown, +) { + const mapped = ctx.mapTabError(err); + if (mapped) return jsonError(res, mapped.status, mapped.message); + jsonError(res, 500, String(err)); +} + +export function resolveProfileContext( + req: express.Request, + res: express.Response, + ctx: BrowserRouteContext, +): ProfileContext | null { + const profileCtx = getProfileContext(req, ctx); + if ("error" in profileCtx) { + jsonError(res, profileCtx.status, profileCtx.error); + return null; + } + return profileCtx; +} + +export type PwAiModule = typeof import("../pw-ai.js"); + +let pwAiModule: Promise | null = null; + +export async function getPwAiModule(): Promise { + if (pwAiModule) return pwAiModule; + pwAiModule = (async () => { + try { + return await import("../pw-ai.js"); + } catch { + return null; + } + })(); + return pwAiModule; +} + +export async function requirePwAi( + res: express.Response, + feature: string, +): Promise { + const mod = await getPwAiModule(); + if (mod) return mod; + jsonError( + res, + 501, + `Playwright is not available in this gateway build; '${feature}' is unsupported.`, + ); + return null; +} diff --git a/src/browser/routes/agent.snapshot.ts b/src/browser/routes/agent.snapshot.ts new file mode 100644 index 000000000..7ae3f1b71 --- /dev/null +++ b/src/browser/routes/agent.snapshot.ts @@ -0,0 +1,256 @@ +import path from "node:path"; + +import type express from "express"; + +import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js"; +import { captureScreenshot, snapshotAria } from "../cdp.js"; +import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../constants.js"; +import { + DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, + DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, + normalizeBrowserScreenshot, +} from "../screenshot.js"; +import type { BrowserRouteContext } from "../server-context.js"; +import { + getPwAiModule, + handleRouteError, + readBody, + requirePwAi, + resolveProfileContext, +} from "./agent.shared.js"; +import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js"; + +export function registerBrowserAgentSnapshotRoutes( + app: express.Express, + ctx: BrowserRouteContext, +) { + app.post("/navigate", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const url = toStringOrEmpty(body.url); + const targetId = toStringOrEmpty(body.targetId) || undefined; + if (!url) return jsonError(res, 400, "url is required"); + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "navigate"); + if (!pw) return; + const result = await pw.navigateViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + url, + }); + res.json({ ok: true, targetId: tab.targetId, ...result }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/pdf", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "pdf"); + if (!pw) return; + const pdf = await pw.pdfViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + }); + await ensureMediaDir(); + const saved = await saveMediaBuffer( + pdf.buffer, + "application/pdf", + "browser", + pdf.buffer.byteLength, + ); + res.json({ + ok: true, + path: path.resolve(saved.path), + targetId: tab.targetId, + url: tab.url, + }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/screenshot", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const fullPage = toBoolean(body.fullPage) ?? false; + const ref = toStringOrEmpty(body.ref) || undefined; + const element = toStringOrEmpty(body.element) || undefined; + const type = body.type === "jpeg" ? "jpeg" : "png"; + + if (fullPage && (ref || element)) { + return jsonError( + res, + 400, + "fullPage is not supported for element screenshots", + ); + } + + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + let buffer: Buffer; + if (ref || element) { + const pw = await requirePwAi(res, "element/ref screenshot"); + if (!pw) return; + const snap = await pw.takeScreenshotViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + ref, + element, + fullPage, + type, + }); + buffer = snap.buffer; + } else { + buffer = await captureScreenshot({ + wsUrl: tab.wsUrl ?? "", + fullPage, + format: type, + quality: type === "jpeg" ? 85 : undefined, + }); + } + + const normalized = await normalizeBrowserScreenshot(buffer, { + maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, + maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, + }); + await ensureMediaDir(); + const saved = await saveMediaBuffer( + normalized.buffer, + normalized.contentType ?? `image/${type}`, + "browser", + DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, + ); + res.json({ + ok: true, + path: path.resolve(saved.path), + targetId: tab.targetId, + url: tab.url, + }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.get("/snapshot", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const targetId = + typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; + const format = + req.query.format === "aria" + ? "aria" + : req.query.format === "ai" + ? "ai" + : (await getPwAiModule()) + ? "ai" + : "aria"; + const limitRaw = + typeof req.query.limit === "string" ? Number(req.query.limit) : undefined; + const hasMaxChars = Object.hasOwn(req.query, "maxChars"); + const maxCharsRaw = + typeof req.query.maxChars === "string" + ? Number(req.query.maxChars) + : undefined; + const limit = Number.isFinite(limitRaw) ? limitRaw : undefined; + const maxChars = + typeof maxCharsRaw === "number" && + Number.isFinite(maxCharsRaw) && + maxCharsRaw > 0 + ? Math.floor(maxCharsRaw) + : undefined; + const resolvedMaxChars = + format === "ai" + ? hasMaxChars + ? maxChars + : DEFAULT_AI_SNAPSHOT_MAX_CHARS + : undefined; + const interactive = toBoolean(req.query.interactive); + const compact = toBoolean(req.query.compact); + const depth = toNumber(req.query.depth); + const selector = toStringOrEmpty(req.query.selector); + const frameSelector = toStringOrEmpty(req.query.frame); + + try { + const tab = await profileCtx.ensureTabAvailable(targetId || undefined); + if (format === "ai") { + const pw = await requirePwAi(res, "ai snapshot"); + if (!pw) return; + const wantsRoleSnapshot = + interactive === true || + compact === true || + depth !== undefined || + Boolean(selector.trim()) || + Boolean(frameSelector.trim()); + + const snap = wantsRoleSnapshot + ? await pw.snapshotRoleViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + selector: selector.trim() || undefined, + frameSelector: frameSelector.trim() || undefined, + options: { + interactive: interactive ?? undefined, + compact: compact ?? undefined, + maxDepth: depth ?? undefined, + }, + }) + : await pw + .snapshotAiViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + ...(typeof resolvedMaxChars === "number" + ? { maxChars: resolvedMaxChars } + : {}), + }) + .catch(async (err) => { + // Public-API fallback when Playwright's private _snapshotForAI is missing. + if (String(err).toLowerCase().includes("_snapshotforai")) { + return await pw.snapshotRoleViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + selector: selector.trim() || undefined, + frameSelector: frameSelector.trim() || undefined, + options: { + interactive: interactive ?? undefined, + compact: compact ?? undefined, + maxDepth: depth ?? undefined, + }, + }); + } + throw err; + }); + return res.json({ + ok: true, + format, + targetId: tab.targetId, + url: tab.url, + ...snap, + }); + } + + const snap = await snapshotAria({ + wsUrl: tab.wsUrl ?? "", + limit, + }); + return res.json({ + ok: true, + format, + targetId: tab.targetId, + url: tab.url, + ...snap, + }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); +} diff --git a/src/browser/routes/agent.storage.ts b/src/browser/routes/agent.storage.ts new file mode 100644 index 000000000..8b3a5f039 --- /dev/null +++ b/src/browser/routes/agent.storage.ts @@ -0,0 +1,381 @@ +import type express from "express"; + +import type { BrowserRouteContext } from "../server-context.js"; +import { + handleRouteError, + readBody, + requirePwAi, + resolveProfileContext, +} from "./agent.shared.js"; +import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js"; + +export function registerBrowserAgentStorageRoutes( + app: express.Express, + ctx: BrowserRouteContext, +) { + app.get("/cookies", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const targetId = + typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; + try { + const tab = await profileCtx.ensureTabAvailable(targetId || undefined); + const pw = await requirePwAi(res, "cookies"); + if (!pw) return; + const result = await pw.cookiesGetViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + }); + res.json({ ok: true, targetId: tab.targetId, ...result }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/cookies/set", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const cookie = + body.cookie && + typeof body.cookie === "object" && + !Array.isArray(body.cookie) + ? (body.cookie as Record) + : null; + if (!cookie) return jsonError(res, 400, "cookie is required"); + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "cookies set"); + if (!pw) return; + await pw.cookiesSetViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + cookie: { + name: toStringOrEmpty(cookie.name), + value: toStringOrEmpty(cookie.value), + url: toStringOrEmpty(cookie.url) || undefined, + domain: toStringOrEmpty(cookie.domain) || undefined, + path: toStringOrEmpty(cookie.path) || undefined, + expires: toNumber(cookie.expires) ?? undefined, + httpOnly: toBoolean(cookie.httpOnly) ?? undefined, + secure: toBoolean(cookie.secure) ?? undefined, + sameSite: + cookie.sameSite === "Lax" || + cookie.sameSite === "None" || + cookie.sameSite === "Strict" + ? (cookie.sameSite as "Lax" | "None" | "Strict") + : undefined, + }, + }); + res.json({ ok: true, targetId: tab.targetId }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/cookies/clear", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "cookies clear"); + if (!pw) return; + await pw.cookiesClearViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + }); + res.json({ ok: true, targetId: tab.targetId }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.get("/storage/:kind", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const kind = toStringOrEmpty(req.params.kind); + if (kind !== "local" && kind !== "session") + return jsonError(res, 400, "kind must be local|session"); + const targetId = + typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; + const key = typeof req.query.key === "string" ? req.query.key : ""; + try { + const tab = await profileCtx.ensureTabAvailable(targetId || undefined); + const pw = await requirePwAi(res, "storage get"); + if (!pw) return; + const result = await pw.storageGetViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + kind, + key: key.trim() || undefined, + }); + res.json({ ok: true, targetId: tab.targetId, ...result }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/storage/:kind/set", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const kind = toStringOrEmpty(req.params.kind); + if (kind !== "local" && kind !== "session") + return jsonError(res, 400, "kind must be local|session"); + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const key = toStringOrEmpty(body.key); + if (!key) return jsonError(res, 400, "key is required"); + const value = typeof body.value === "string" ? body.value : ""; + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "storage set"); + if (!pw) return; + await pw.storageSetViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + kind, + key, + value, + }); + res.json({ ok: true, targetId: tab.targetId }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/storage/:kind/clear", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const kind = toStringOrEmpty(req.params.kind); + if (kind !== "local" && kind !== "session") + return jsonError(res, 400, "kind must be local|session"); + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "storage clear"); + if (!pw) return; + await pw.storageClearViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + kind, + }); + res.json({ ok: true, targetId: tab.targetId }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/set/offline", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const offline = toBoolean(body.offline); + if (offline === undefined) + return jsonError(res, 400, "offline is required"); + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "offline"); + if (!pw) return; + await pw.setOfflineViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + offline, + }); + res.json({ ok: true, targetId: tab.targetId }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/set/headers", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const headers = + body.headers && + typeof body.headers === "object" && + !Array.isArray(body.headers) + ? (body.headers as Record) + : null; + if (!headers) return jsonError(res, 400, "headers is required"); + const parsed: Record = {}; + for (const [k, v] of Object.entries(headers)) { + if (typeof v === "string") parsed[k] = v; + } + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "headers"); + if (!pw) return; + await pw.setExtraHTTPHeadersViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + headers: parsed, + }); + res.json({ ok: true, targetId: tab.targetId }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/set/credentials", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const clear = toBoolean(body.clear) ?? false; + const username = toStringOrEmpty(body.username) || undefined; + const password = + typeof body.password === "string" ? body.password : undefined; + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "http credentials"); + if (!pw) return; + await pw.setHttpCredentialsViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + username, + password, + clear, + }); + res.json({ ok: true, targetId: tab.targetId }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/set/geolocation", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const clear = toBoolean(body.clear) ?? false; + const latitude = toNumber(body.latitude); + const longitude = toNumber(body.longitude); + const accuracy = toNumber(body.accuracy) ?? undefined; + const origin = toStringOrEmpty(body.origin) || undefined; + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "geolocation"); + if (!pw) return; + await pw.setGeolocationViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + latitude, + longitude, + accuracy, + origin, + clear, + }); + res.json({ ok: true, targetId: tab.targetId }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/set/media", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const schemeRaw = toStringOrEmpty(body.colorScheme); + const colorScheme = + schemeRaw === "dark" || + schemeRaw === "light" || + schemeRaw === "no-preference" + ? (schemeRaw as "dark" | "light" | "no-preference") + : schemeRaw === "none" + ? null + : undefined; + if (colorScheme === undefined) + return jsonError( + res, + 400, + "colorScheme must be dark|light|no-preference|none", + ); + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "media emulation"); + if (!pw) return; + await pw.emulateMediaViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + colorScheme, + }); + res.json({ ok: true, targetId: tab.targetId }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/set/timezone", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const timezoneId = toStringOrEmpty(body.timezoneId); + if (!timezoneId) return jsonError(res, 400, "timezoneId is required"); + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "timezone"); + if (!pw) return; + await pw.setTimezoneViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + timezoneId, + }); + res.json({ ok: true, targetId: tab.targetId }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/set/locale", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const locale = toStringOrEmpty(body.locale); + if (!locale) return jsonError(res, 400, "locale is required"); + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "locale"); + if (!pw) return; + await pw.setLocaleViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + locale, + }); + res.json({ ok: true, targetId: tab.targetId }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/set/device", async (req, res) => { + const profileCtx = resolveProfileContext(req, res, ctx); + if (!profileCtx) return; + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const name = toStringOrEmpty(body.name); + if (!name) return jsonError(res, 400, "name is required"); + try { + const tab = await profileCtx.ensureTabAvailable(targetId); + const pw = await requirePwAi(res, "device emulation"); + if (!pw) return; + await pw.setDeviceViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + name, + }); + res.json({ ok: true, targetId: tab.targetId }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); +} diff --git a/src/browser/routes/agent.ts b/src/browser/routes/agent.ts index fd960ca99..8ee390cbe 100644 --- a/src/browser/routes/agent.ts +++ b/src/browser/routes/agent.ts @@ -1,1295 +1,17 @@ -import crypto from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; - import type express from "express"; -import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js"; -import { captureScreenshot, snapshotAria } from "../cdp.js"; -import type { BrowserFormField } from "../client-actions-core.js"; -import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../constants.js"; -import { - DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, - DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, - normalizeBrowserScreenshot, -} from "../screenshot.js"; -import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; -import { - getProfileContext, - jsonError, - toBoolean, - toNumber, - toStringArray, - toStringOrEmpty, -} from "./utils.js"; - -type ActKind = - | "click" - | "close" - | "drag" - | "evaluate" - | "fill" - | "hover" - | "scrollIntoView" - | "press" - | "resize" - | "select" - | "type" - | "wait"; - -type ClickButton = "left" | "right" | "middle"; -type ClickModifier = "Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift"; - -const SELECTOR_UNSUPPORTED_MESSAGE = [ - "Error: 'selector' is not supported. Use 'ref' from snapshot instead.", - "", - "Example workflow:", - "1. snapshot action to get page state with refs", - '2. act with ref: "e123" to interact with element', - "", - "This is more reliable for modern SPAs.", -].join("\n"); - -function readBody(req: express.Request): Record { - const body = req.body as Record | undefined; - if (!body || typeof body !== "object" || Array.isArray(body)) return {}; - return body; -} - -function handleRouteError( - ctx: BrowserRouteContext, - res: express.Response, - err: unknown, -) { - const mapped = ctx.mapTabError(err); - if (mapped) return jsonError(res, mapped.status, mapped.message); - jsonError(res, 500, String(err)); -} - -function resolveProfileContext( - req: express.Request, - res: express.Response, - ctx: BrowserRouteContext, -): ProfileContext | null { - const profileCtx = getProfileContext(req, ctx); - if ("error" in profileCtx) { - jsonError(res, profileCtx.status, profileCtx.error); - return null; - } - return profileCtx; -} - -function parseClickButton(raw: string): ClickButton | undefined { - if (raw === "left" || raw === "right" || raw === "middle") return raw; - return undefined; -} - -type PwAiModule = typeof import("../pw-ai.js"); -let pwAiModule: Promise | null = null; - -async function getPwAiModule(): Promise { - if (pwAiModule) return pwAiModule; - pwAiModule = (async () => { - try { - return await import("../pw-ai.js"); - } catch { - return null; - } - })(); - return pwAiModule; -} - -async function requirePwAi( - res: express.Response, - feature: string, -): Promise { - const mod = await getPwAiModule(); - if (mod) return mod; - jsonError( - res, - 501, - `Playwright is not available in this gateway build; '${feature}' is unsupported.`, - ); - return null; -} +import type { BrowserRouteContext } from "../server-context.js"; +import { registerBrowserAgentActRoutes } from "./agent.act.js"; +import { registerBrowserAgentDebugRoutes } from "./agent.debug.js"; +import { registerBrowserAgentSnapshotRoutes } from "./agent.snapshot.js"; +import { registerBrowserAgentStorageRoutes } from "./agent.storage.js"; export function registerBrowserAgentRoutes( app: express.Express, ctx: BrowserRouteContext, ) { - app.post("/navigate", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const url = toStringOrEmpty(body.url); - const targetId = toStringOrEmpty(body.targetId) || undefined; - if (!url) return jsonError(res, 400, "url is required"); - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "navigate"); - if (!pw) return; - const result = await pw.navigateViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - url, - }); - res.json({ ok: true, targetId: tab.targetId, ...result }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/act", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const kind = toStringOrEmpty(body.kind) as ActKind; - const targetId = toStringOrEmpty(body.targetId) || undefined; - if (Object.hasOwn(body, "selector") && kind !== "wait") { - return jsonError(res, 400, SELECTOR_UNSUPPORTED_MESSAGE); - } - - if ( - kind !== "click" && - kind !== "close" && - kind !== "drag" && - kind !== "evaluate" && - kind !== "fill" && - kind !== "hover" && - kind !== "scrollIntoView" && - kind !== "press" && - kind !== "resize" && - kind !== "select" && - kind !== "type" && - kind !== "wait" - ) { - return jsonError(res, 400, "kind is required"); - } - - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const cdpUrl = profileCtx.profile.cdpUrl; - const pw = await requirePwAi(res, `act:${kind}`); - if (!pw) return; - - switch (kind) { - case "click": { - const ref = toStringOrEmpty(body.ref); - if (!ref) return jsonError(res, 400, "ref is required"); - const doubleClick = toBoolean(body.doubleClick) ?? false; - const timeoutMs = toNumber(body.timeoutMs); - const buttonRaw = toStringOrEmpty(body.button) || ""; - const button = buttonRaw ? parseClickButton(buttonRaw) : undefined; - if (buttonRaw && !button) - return jsonError(res, 400, "button must be left|right|middle"); - - const modifiersRaw = toStringArray(body.modifiers) ?? []; - const allowedModifiers = new Set([ - "Alt", - "Control", - "ControlOrMeta", - "Meta", - "Shift", - ]); - const invalidModifiers = modifiersRaw.filter( - (m) => !allowedModifiers.has(m as ClickModifier), - ); - if (invalidModifiers.length) - return jsonError( - res, - 400, - "modifiers must be Alt|Control|ControlOrMeta|Meta|Shift", - ); - const modifiers = modifiersRaw.length - ? (modifiersRaw as ClickModifier[]) - : undefined; - const clickRequest: Parameters[0] = { - cdpUrl, - targetId: tab.targetId, - ref, - doubleClick, - }; - if (button) clickRequest.button = button; - if (modifiers) clickRequest.modifiers = modifiers; - if (timeoutMs) clickRequest.timeoutMs = timeoutMs; - await pw.clickViaPlaywright(clickRequest); - return res.json({ ok: true, targetId: tab.targetId, url: tab.url }); - } - case "type": { - const ref = toStringOrEmpty(body.ref); - if (!ref) return jsonError(res, 400, "ref is required"); - if (typeof body.text !== "string") - return jsonError(res, 400, "text is required"); - const text = body.text; - const submit = toBoolean(body.submit) ?? false; - const slowly = toBoolean(body.slowly) ?? false; - const timeoutMs = toNumber(body.timeoutMs); - const typeRequest: Parameters[0] = { - cdpUrl, - targetId: tab.targetId, - ref, - text, - submit, - slowly, - }; - if (timeoutMs) typeRequest.timeoutMs = timeoutMs; - await pw.typeViaPlaywright(typeRequest); - return res.json({ ok: true, targetId: tab.targetId }); - } - case "press": { - const key = toStringOrEmpty(body.key); - if (!key) return jsonError(res, 400, "key is required"); - const delayMs = toNumber(body.delayMs); - await pw.pressKeyViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - key, - delayMs: delayMs ?? undefined, - }); - return res.json({ ok: true, targetId: tab.targetId }); - } - case "hover": { - const ref = toStringOrEmpty(body.ref); - if (!ref) return jsonError(res, 400, "ref is required"); - const timeoutMs = toNumber(body.timeoutMs); - await pw.hoverViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - ref, - timeoutMs: timeoutMs ?? undefined, - }); - return res.json({ ok: true, targetId: tab.targetId }); - } - case "scrollIntoView": { - const ref = toStringOrEmpty(body.ref); - if (!ref) return jsonError(res, 400, "ref is required"); - const timeoutMs = toNumber(body.timeoutMs); - const scrollRequest: Parameters< - typeof pw.scrollIntoViewViaPlaywright - >[0] = { - cdpUrl, - targetId: tab.targetId, - ref, - }; - if (timeoutMs) scrollRequest.timeoutMs = timeoutMs; - await pw.scrollIntoViewViaPlaywright(scrollRequest); - return res.json({ ok: true, targetId: tab.targetId }); - } - case "drag": { - const startRef = toStringOrEmpty(body.startRef); - const endRef = toStringOrEmpty(body.endRef); - if (!startRef || !endRef) - return jsonError(res, 400, "startRef and endRef are required"); - const timeoutMs = toNumber(body.timeoutMs); - await pw.dragViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - startRef, - endRef, - timeoutMs: timeoutMs ?? undefined, - }); - return res.json({ ok: true, targetId: tab.targetId }); - } - case "select": { - const ref = toStringOrEmpty(body.ref); - const values = toStringArray(body.values); - if (!ref || !values?.length) - return jsonError(res, 400, "ref and values are required"); - const timeoutMs = toNumber(body.timeoutMs); - await pw.selectOptionViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - ref, - values, - timeoutMs: timeoutMs ?? undefined, - }); - return res.json({ ok: true, targetId: tab.targetId }); - } - case "fill": { - const rawFields = Array.isArray(body.fields) ? body.fields : []; - const fields = rawFields - .map((field) => { - if (!field || typeof field !== "object") return null; - const rec = field as Record; - const ref = toStringOrEmpty(rec.ref); - const type = toStringOrEmpty(rec.type); - if (!ref || !type) return null; - const value = - typeof rec.value === "string" || - typeof rec.value === "number" || - typeof rec.value === "boolean" - ? rec.value - : undefined; - const parsed: BrowserFormField = - value === undefined ? { ref, type } : { ref, type, value }; - return parsed; - }) - .filter((field): field is BrowserFormField => field !== null); - if (!fields.length) return jsonError(res, 400, "fields are required"); - const timeoutMs = toNumber(body.timeoutMs); - await pw.fillFormViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - fields, - timeoutMs: timeoutMs ?? undefined, - }); - return res.json({ ok: true, targetId: tab.targetId }); - } - case "resize": { - const width = toNumber(body.width); - const height = toNumber(body.height); - if (!width || !height) - return jsonError(res, 400, "width and height are required"); - await pw.resizeViewportViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - width, - height, - }); - return res.json({ ok: true, targetId: tab.targetId, url: tab.url }); - } - case "wait": { - const timeMs = toNumber(body.timeMs); - const text = toStringOrEmpty(body.text) || undefined; - const textGone = toStringOrEmpty(body.textGone) || undefined; - const selector = toStringOrEmpty(body.selector) || undefined; - const url = toStringOrEmpty(body.url) || undefined; - const loadStateRaw = toStringOrEmpty(body.loadState); - const loadState = - loadStateRaw === "load" || - loadStateRaw === "domcontentloaded" || - loadStateRaw === "networkidle" - ? (loadStateRaw as "load" | "domcontentloaded" | "networkidle") - : undefined; - const fn = toStringOrEmpty(body.fn) || undefined; - const timeoutMs = toNumber(body.timeoutMs) ?? undefined; - if ( - timeMs === undefined && - !text && - !textGone && - !selector && - !url && - !loadState && - !fn - ) { - return jsonError( - res, - 400, - "wait requires at least one of: timeMs, text, textGone, selector, url, loadState, fn", - ); - } - await pw.waitForViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - timeMs, - text, - textGone, - selector, - url, - loadState, - fn, - timeoutMs, - }); - return res.json({ ok: true, targetId: tab.targetId }); - } - case "evaluate": { - const fn = toStringOrEmpty(body.fn); - if (!fn) return jsonError(res, 400, "fn is required"); - const ref = toStringOrEmpty(body.ref) || undefined; - const result = await pw.evaluateViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - fn, - ref, - }); - return res.json({ - ok: true, - targetId: tab.targetId, - url: tab.url, - result, - }); - } - case "close": { - await pw.closePageViaPlaywright({ cdpUrl, targetId: tab.targetId }); - return res.json({ ok: true, targetId: tab.targetId }); - } - default: { - return jsonError(res, 400, "unsupported kind"); - } - } - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/hooks/file-chooser", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const ref = toStringOrEmpty(body.ref) || undefined; - const inputRef = toStringOrEmpty(body.inputRef) || undefined; - const element = toStringOrEmpty(body.element) || undefined; - const paths = toStringArray(body.paths) ?? []; - const timeoutMs = toNumber(body.timeoutMs); - if (!paths.length) return jsonError(res, 400, "paths are required"); - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "file chooser hook"); - if (!pw) return; - if (inputRef || element) { - if (ref) { - return jsonError( - res, - 400, - "ref cannot be combined with inputRef/element", - ); - } - await pw.setInputFilesViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - inputRef, - element, - paths, - }); - } else { - await pw.armFileUploadViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - paths, - timeoutMs: timeoutMs ?? undefined, - }); - if (ref) { - await pw.clickViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - ref, - }); - } - } - res.json({ ok: true }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/hooks/dialog", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const accept = toBoolean(body.accept); - const promptText = toStringOrEmpty(body.promptText) || undefined; - const timeoutMs = toNumber(body.timeoutMs); - if (accept === undefined) return jsonError(res, 400, "accept is required"); - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "dialog hook"); - if (!pw) return; - await pw.armDialogViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - accept, - promptText, - timeoutMs: timeoutMs ?? undefined, - }); - res.json({ ok: true }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/wait/download", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const out = toStringOrEmpty(body.path) || undefined; - const timeoutMs = toNumber(body.timeoutMs); - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "wait for download"); - if (!pw) return; - const result = await pw.waitForDownloadViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - path: out, - timeoutMs: timeoutMs ?? undefined, - }); - res.json({ ok: true, targetId: tab.targetId, download: result }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/download", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const ref = toStringOrEmpty(body.ref); - const out = toStringOrEmpty(body.path); - const timeoutMs = toNumber(body.timeoutMs); - if (!ref) return jsonError(res, 400, "ref is required"); - if (!out) return jsonError(res, 400, "path is required"); - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "download"); - if (!pw) return; - const result = await pw.downloadViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - ref, - path: out, - timeoutMs: timeoutMs ?? undefined, - }); - res.json({ ok: true, targetId: tab.targetId, download: result }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/response/body", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const url = toStringOrEmpty(body.url); - const timeoutMs = toNumber(body.timeoutMs); - const maxChars = toNumber(body.maxChars); - if (!url) return jsonError(res, 400, "url is required"); - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "response body"); - if (!pw) return; - const result = await pw.responseBodyViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - url, - timeoutMs: timeoutMs ?? undefined, - maxChars: maxChars ?? undefined, - }); - res.json({ ok: true, targetId: tab.targetId, response: result }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.get("/console", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const targetId = - typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; - const level = typeof req.query.level === "string" ? req.query.level : ""; - - try { - const tab = await profileCtx.ensureTabAvailable(targetId || undefined); - const pw = await requirePwAi(res, "console messages"); - if (!pw) return; - const messages = await pw.getConsoleMessagesViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - level: level.trim() || undefined, - }); - res.json({ ok: true, messages, targetId: tab.targetId }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.get("/errors", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const targetId = - typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; - const clear = toBoolean(req.query.clear) ?? false; - - try { - const tab = await profileCtx.ensureTabAvailable(targetId || undefined); - const pw = await requirePwAi(res, "page errors"); - if (!pw) return; - const result = await pw.getPageErrorsViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - clear, - }); - res.json({ ok: true, targetId: tab.targetId, ...result }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.get("/requests", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const targetId = - typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; - const filter = typeof req.query.filter === "string" ? req.query.filter : ""; - const clear = toBoolean(req.query.clear) ?? false; - - try { - const tab = await profileCtx.ensureTabAvailable(targetId || undefined); - const pw = await requirePwAi(res, "network requests"); - if (!pw) return; - const result = await pw.getNetworkRequestsViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - filter: filter.trim() || undefined, - clear, - }); - res.json({ ok: true, targetId: tab.targetId, ...result }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/trace/start", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const screenshots = toBoolean(body.screenshots) ?? undefined; - const snapshots = toBoolean(body.snapshots) ?? undefined; - const sources = toBoolean(body.sources) ?? undefined; - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "trace start"); - if (!pw) return; - await pw.traceStartViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - screenshots, - snapshots, - sources, - }); - res.json({ ok: true, targetId: tab.targetId }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/trace/stop", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const out = toStringOrEmpty(body.path) || ""; - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "trace stop"); - if (!pw) return; - const id = crypto.randomUUID(); - const dir = "/tmp/clawdbot"; - await fs.mkdir(dir, { recursive: true }); - const tracePath = out.trim() || path.join(dir, `browser-trace-${id}.zip`); - await pw.traceStopViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - path: tracePath, - }); - res.json({ - ok: true, - targetId: tab.targetId, - path: path.resolve(tracePath), - }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/highlight", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const ref = toStringOrEmpty(body.ref); - if (!ref) return jsonError(res, 400, "ref is required"); - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "highlight"); - if (!pw) return; - await pw.highlightViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - ref, - }); - res.json({ ok: true, targetId: tab.targetId }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.get("/cookies", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const targetId = - typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; - try { - const tab = await profileCtx.ensureTabAvailable(targetId || undefined); - const pw = await requirePwAi(res, "cookies"); - if (!pw) return; - const result = await pw.cookiesGetViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - }); - res.json({ ok: true, targetId: tab.targetId, ...result }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/cookies/set", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const cookie = - body.cookie && - typeof body.cookie === "object" && - !Array.isArray(body.cookie) - ? (body.cookie as Record) - : null; - if (!cookie) return jsonError(res, 400, "cookie is required"); - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "cookies set"); - if (!pw) return; - await pw.cookiesSetViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - cookie: { - name: toStringOrEmpty(cookie.name), - value: toStringOrEmpty(cookie.value), - url: toStringOrEmpty(cookie.url) || undefined, - domain: toStringOrEmpty(cookie.domain) || undefined, - path: toStringOrEmpty(cookie.path) || undefined, - expires: toNumber(cookie.expires) ?? undefined, - httpOnly: toBoolean(cookie.httpOnly) ?? undefined, - secure: toBoolean(cookie.secure) ?? undefined, - sameSite: - cookie.sameSite === "Lax" || - cookie.sameSite === "None" || - cookie.sameSite === "Strict" - ? (cookie.sameSite as "Lax" | "None" | "Strict") - : undefined, - }, - }); - res.json({ ok: true, targetId: tab.targetId }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/cookies/clear", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "cookies clear"); - if (!pw) return; - await pw.cookiesClearViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - }); - res.json({ ok: true, targetId: tab.targetId }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.get("/storage/:kind", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const kind = toStringOrEmpty(req.params.kind); - if (kind !== "local" && kind !== "session") - return jsonError(res, 400, "kind must be local|session"); - const targetId = - typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; - const key = typeof req.query.key === "string" ? req.query.key : ""; - try { - const tab = await profileCtx.ensureTabAvailable(targetId || undefined); - const pw = await requirePwAi(res, "storage get"); - if (!pw) return; - const result = await pw.storageGetViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - kind, - key: key.trim() || undefined, - }); - res.json({ ok: true, targetId: tab.targetId, ...result }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/storage/:kind/set", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const kind = toStringOrEmpty(req.params.kind); - if (kind !== "local" && kind !== "session") - return jsonError(res, 400, "kind must be local|session"); - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const key = toStringOrEmpty(body.key); - if (!key) return jsonError(res, 400, "key is required"); - const value = typeof body.value === "string" ? body.value : ""; - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "storage set"); - if (!pw) return; - await pw.storageSetViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - kind, - key, - value, - }); - res.json({ ok: true, targetId: tab.targetId }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/storage/:kind/clear", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const kind = toStringOrEmpty(req.params.kind); - if (kind !== "local" && kind !== "session") - return jsonError(res, 400, "kind must be local|session"); - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "storage clear"); - if (!pw) return; - await pw.storageClearViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - kind, - }); - res.json({ ok: true, targetId: tab.targetId }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/set/offline", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const offline = toBoolean(body.offline); - if (offline === undefined) - return jsonError(res, 400, "offline is required"); - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "offline"); - if (!pw) return; - await pw.setOfflineViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - offline, - }); - res.json({ ok: true, targetId: tab.targetId }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/set/headers", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const headers = - body.headers && - typeof body.headers === "object" && - !Array.isArray(body.headers) - ? (body.headers as Record) - : null; - if (!headers) return jsonError(res, 400, "headers is required"); - const parsed: Record = {}; - for (const [k, v] of Object.entries(headers)) { - if (typeof v === "string") parsed[k] = v; - } - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "headers"); - if (!pw) return; - await pw.setExtraHTTPHeadersViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - headers: parsed, - }); - res.json({ ok: true, targetId: tab.targetId }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/set/credentials", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const clear = toBoolean(body.clear) ?? false; - const username = toStringOrEmpty(body.username) || undefined; - const password = - typeof body.password === "string" ? body.password : undefined; - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "http credentials"); - if (!pw) return; - await pw.setHttpCredentialsViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - username, - password, - clear, - }); - res.json({ ok: true, targetId: tab.targetId }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/set/geolocation", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const clear = toBoolean(body.clear) ?? false; - const latitude = toNumber(body.latitude); - const longitude = toNumber(body.longitude); - const accuracy = toNumber(body.accuracy) ?? undefined; - const origin = toStringOrEmpty(body.origin) || undefined; - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "geolocation"); - if (!pw) return; - await pw.setGeolocationViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - latitude, - longitude, - accuracy, - origin, - clear, - }); - res.json({ ok: true, targetId: tab.targetId }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/set/media", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const schemeRaw = toStringOrEmpty(body.colorScheme); - const colorScheme = - schemeRaw === "dark" || - schemeRaw === "light" || - schemeRaw === "no-preference" - ? (schemeRaw as "dark" | "light" | "no-preference") - : schemeRaw === "none" - ? null - : undefined; - if (colorScheme === undefined) - return jsonError( - res, - 400, - "colorScheme must be dark|light|no-preference|none", - ); - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "media emulation"); - if (!pw) return; - await pw.emulateMediaViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - colorScheme, - }); - res.json({ ok: true, targetId: tab.targetId }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/set/timezone", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const timezoneId = toStringOrEmpty(body.timezoneId); - if (!timezoneId) return jsonError(res, 400, "timezoneId is required"); - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "timezone"); - if (!pw) return; - await pw.setTimezoneViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - timezoneId, - }); - res.json({ ok: true, targetId: tab.targetId }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/set/locale", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const locale = toStringOrEmpty(body.locale); - if (!locale) return jsonError(res, 400, "locale is required"); - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "locale"); - if (!pw) return; - await pw.setLocaleViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - locale, - }); - res.json({ ok: true, targetId: tab.targetId }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/set/device", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const name = toStringOrEmpty(body.name); - if (!name) return jsonError(res, 400, "name is required"); - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "device emulation"); - if (!pw) return; - await pw.setDeviceViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - name, - }); - res.json({ ok: true, targetId: tab.targetId }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/pdf", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - const pw = await requirePwAi(res, "pdf"); - if (!pw) return; - const pdf = await pw.pdfViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - }); - await ensureMediaDir(); - const saved = await saveMediaBuffer( - pdf.buffer, - "application/pdf", - "browser", - pdf.buffer.byteLength, - ); - res.json({ - ok: true, - path: path.resolve(saved.path), - targetId: tab.targetId, - url: tab.url, - }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.post("/screenshot", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const body = readBody(req); - const targetId = toStringOrEmpty(body.targetId) || undefined; - const fullPage = toBoolean(body.fullPage) ?? false; - const ref = toStringOrEmpty(body.ref) || undefined; - const element = toStringOrEmpty(body.element) || undefined; - const type = body.type === "jpeg" ? "jpeg" : "png"; - - if (fullPage && (ref || element)) { - return jsonError( - res, - 400, - "fullPage is not supported for element screenshots", - ); - } - - try { - const tab = await profileCtx.ensureTabAvailable(targetId); - let buffer: Buffer; - if (ref || element) { - const pw = await requirePwAi(res, "element/ref screenshot"); - if (!pw) return; - const snap = await pw.takeScreenshotViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - ref, - element, - fullPage, - type, - }); - buffer = snap.buffer; - } else { - buffer = await captureScreenshot({ - wsUrl: tab.wsUrl ?? "", - fullPage, - format: type, - quality: type === "jpeg" ? 85 : undefined, - }); - } - - const normalized = await normalizeBrowserScreenshot(buffer, { - maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, - maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, - }); - await ensureMediaDir(); - const saved = await saveMediaBuffer( - normalized.buffer, - normalized.contentType ?? `image/${type}`, - "browser", - DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, - ); - res.json({ - ok: true, - path: path.resolve(saved.path), - targetId: tab.targetId, - url: tab.url, - }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); - - app.get("/snapshot", async (req, res) => { - const profileCtx = resolveProfileContext(req, res, ctx); - if (!profileCtx) return; - const targetId = - typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; - const format = - req.query.format === "aria" - ? "aria" - : req.query.format === "ai" - ? "ai" - : (await getPwAiModule()) - ? "ai" - : "aria"; - const limitRaw = - typeof req.query.limit === "string" ? Number(req.query.limit) : undefined; - const hasMaxChars = Object.hasOwn(req.query, "maxChars"); - const maxCharsRaw = - typeof req.query.maxChars === "string" - ? Number(req.query.maxChars) - : undefined; - const limit = Number.isFinite(limitRaw) ? limitRaw : undefined; - const maxChars = - typeof maxCharsRaw === "number" && - Number.isFinite(maxCharsRaw) && - maxCharsRaw > 0 - ? Math.floor(maxCharsRaw) - : undefined; - const resolvedMaxChars = - format === "ai" - ? hasMaxChars - ? maxChars - : DEFAULT_AI_SNAPSHOT_MAX_CHARS - : undefined; - const interactive = toBoolean(req.query.interactive); - const compact = toBoolean(req.query.compact); - const depth = toNumber(req.query.depth); - const selector = toStringOrEmpty(req.query.selector); - const frameSelector = toStringOrEmpty(req.query.frame); - - try { - const tab = await profileCtx.ensureTabAvailable(targetId || undefined); - if (format === "ai") { - const pw = await requirePwAi(res, "ai snapshot"); - if (!pw) return; - const wantsRoleSnapshot = - interactive === true || - compact === true || - depth !== undefined || - Boolean(selector.trim()) || - Boolean(frameSelector.trim()); - - const snap = wantsRoleSnapshot - ? await pw.snapshotRoleViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - selector: selector.trim() || undefined, - frameSelector: frameSelector.trim() || undefined, - options: { - interactive: interactive ?? undefined, - compact: compact ?? undefined, - maxDepth: depth ?? undefined, - }, - }) - : await pw - .snapshotAiViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - ...(typeof resolvedMaxChars === "number" - ? { maxChars: resolvedMaxChars } - : {}), - }) - .catch(async (err) => { - // Public-API fallback when Playwright's private _snapshotForAI is missing. - if (String(err).toLowerCase().includes("_snapshotforai")) { - return await pw.snapshotRoleViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - selector: selector.trim() || undefined, - frameSelector: frameSelector.trim() || undefined, - options: { - interactive: interactive ?? undefined, - compact: compact ?? undefined, - maxDepth: depth ?? undefined, - }, - }); - } - throw err; - }); - return res.json({ - ok: true, - format, - targetId: tab.targetId, - url: tab.url, - ...snap, - }); - } - - const snap = await snapshotAria({ - wsUrl: tab.wsUrl ?? "", - limit, - }); - return res.json({ - ok: true, - format, - targetId: tab.targetId, - url: tab.url, - ...snap, - }); - } catch (err) { - handleRouteError(ctx, res, err); - } - }); + registerBrowserAgentSnapshotRoutes(app, ctx); + registerBrowserAgentActRoutes(app, ctx); + registerBrowserAgentDebugRoutes(app, ctx); + registerBrowserAgentStorageRoutes(app, ctx); } diff --git a/src/browser/server.part-6.test.ts b/src/browser/server.agent-contract-form-layout-act-commands.test.ts similarity index 100% rename from src/browser/server.part-6.test.ts rename to src/browser/server.agent-contract-form-layout-act-commands.test.ts diff --git a/src/browser/server.part-2.test.ts b/src/browser/server.agent-contract-snapshot-endpoints.test.ts similarity index 100% rename from src/browser/server.part-2.test.ts rename to src/browser/server.agent-contract-snapshot-endpoints.test.ts diff --git a/src/browser/server.part-4.test.ts b/src/browser/server.covers-additional-endpoint-branches.test.ts similarity index 100% rename from src/browser/server.part-4.test.ts rename to src/browser/server.covers-additional-endpoint-branches.test.ts diff --git a/src/browser/server.part-5.test.ts b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts similarity index 100% rename from src/browser/server.part-5.test.ts rename to src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts diff --git a/src/browser/server.part-1.test.ts b/src/browser/server.serves-status-starts-browser-requested.test.ts similarity index 100% rename from src/browser/server.part-1.test.ts rename to src/browser/server.serves-status-starts-browser-requested.test.ts diff --git a/src/browser/server.part-3.test.ts b/src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts similarity index 100% rename from src/browser/server.part-3.test.ts rename to src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts