1328 lines
37 KiB
TypeScript
1328 lines
37 KiB
TypeScript
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<unknown>((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<string, { role: string; name?: string; nth?: number }>;
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string, string> }> {
|
|
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<string, string> = {};
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string, string>;
|
|
}): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
const page = await getPageForTargetId(opts);
|
|
ensurePageState(page);
|
|
await page.emulateMedia({ colorScheme: opts.colorScheme });
|
|
}
|
|
|
|
async function withCdpSession<T>(
|
|
page: Page,
|
|
fn: (session: CDPSession) => Promise<T>,
|
|
): Promise<T> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string, unknown>)[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<void> {
|
|
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<void> {
|
|
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<unknown> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void>;
|
|
};
|
|
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<void>;
|
|
};
|
|
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<string, string>;
|
|
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<unknown>((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<string, string>;
|
|
body?: () => Promise<Buffer>;
|
|
text?: () => Promise<string>;
|
|
};
|
|
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<BrowserConsoleMessage[]> {
|
|
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);
|
|
}
|