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