Files
clawdbot/src/browser/pw-tools-core.ts
2025-12-24 21:49:31 +01:00

475 lines
13 KiB
TypeScript

import type { BrowserFormField } from "./client-actions-core.js";
import {
type BrowserConsoleMessage,
ensurePageState,
getPageForTargetId,
refLocator,
type WithSnapshotForAI,
} from "./pw-session.js";
let nextUploadArmId = 0;
let nextDialogArmId = 0;
type LocatorPage = Parameters<typeof refLocator>[0];
function resolveLocator(
page: LocatorPage,
opts: { ref?: string; selector?: string },
) {
const selector =
typeof opts.selector === "string" ? opts.selector.trim() : "";
if (selector) return page.locator(selector);
const ref = typeof opts.ref === "string" ? opts.ref.trim() : "";
if (ref) return refLocator(page, ref);
throw new Error("ref or selector is required");
}
export async function snapshotAiViaPlaywright(opts: {
cdpPort: number;
targetId?: string;
timeoutMs?: number;
}): Promise<{ snapshot: string }> {
const page = await getPageForTargetId({
cdpPort: opts.cdpPort,
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",
});
return { snapshot: String(result?.full ?? "") };
}
export async function clickViaPlaywright(opts: {
cdpPort: number;
targetId?: string;
ref?: string;
selector?: string;
doubleClick?: boolean;
button?: "left" | "right" | "middle";
modifiers?: Array<"Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift">;
timeoutMs?: number;
}): Promise<void> {
const page = await getPageForTargetId({
cdpPort: opts.cdpPort,
targetId: opts.targetId,
});
ensurePageState(page);
const locator = resolveLocator(page, {
ref: opts.ref,
selector: opts.selector,
});
const timeout = Math.max(
500,
Math.min(60_000, Math.floor(opts.timeoutMs ?? 8000)),
);
if (opts.doubleClick) {
await locator.dblclick({
timeout,
button: opts.button,
modifiers: opts.modifiers,
});
} else {
await locator.click({
timeout,
button: opts.button,
modifiers: opts.modifiers,
});
}
}
export async function hoverViaPlaywright(opts: {
cdpPort: number;
targetId?: string;
ref: string;
timeoutMs?: number;
}): Promise<void> {
const ref = String(opts.ref ?? "").trim();
if (!ref) throw new Error("ref is required");
const page = await getPageForTargetId(opts);
ensurePageState(page);
await refLocator(page, ref).hover({
timeout: Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)),
});
}
export async function dragViaPlaywright(opts: {
cdpPort: number;
targetId?: string;
startRef: string;
endRef: string;
timeoutMs?: number;
}): Promise<void> {
const startRef = String(opts.startRef ?? "").trim();
const endRef = String(opts.endRef ?? "").trim();
if (!startRef || !endRef) throw new Error("startRef and endRef are required");
const page = await getPageForTargetId(opts);
ensurePageState(page);
await refLocator(page, startRef).dragTo(refLocator(page, endRef), {
timeout: Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)),
});
}
export async function selectOptionViaPlaywright(opts: {
cdpPort: number;
targetId?: string;
ref: string;
values: string[];
timeoutMs?: number;
}): Promise<void> {
const ref = String(opts.ref ?? "").trim();
if (!ref) throw new Error("ref is required");
if (!opts.values?.length) throw new Error("values are required");
const page = await getPageForTargetId(opts);
ensurePageState(page);
await refLocator(page, ref).selectOption(opts.values, {
timeout: Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000)),
});
}
export async function pressKeyViaPlaywright(opts: {
cdpPort: number;
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 typeViaPlaywright(opts: {
cdpPort: number;
targetId?: string;
ref?: string;
selector?: string;
text: string;
submit?: boolean;
slowly?: boolean;
timeoutMs?: number;
}): Promise<void> {
const text = String(opts.text ?? "");
const page = await getPageForTargetId(opts);
ensurePageState(page);
const locator = resolveLocator(page, {
ref: opts.ref,
selector: opts.selector,
});
const timeout = Math.max(500, Math.min(60_000, opts.timeoutMs ?? 8000));
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 });
}
}
export async function fillFormViaPlaywright(opts: {
cdpPort: number;
targetId?: string;
fields: BrowserFormField[];
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
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";
await locator.setChecked(checked);
continue;
}
await locator.fill(value);
}
}
export async function evaluateViaPlaywright(opts: {
cdpPort: number;
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);
return await locator.evaluate((el, fnBody) => {
const compileRunner = (body: string) => {
const inner = `"use strict"; const candidate = ${body}; return typeof candidate === "function" ? candidate(element) : candidate;`;
// This intentionally evaluates user-supplied code in the browser context.
// oxlint-disable-next-line typescript-eslint/no-implied-eval
return new Function("element", inner) as (element: Element) => unknown;
};
let compiled: unknown;
try {
compiled = compileRunner(fnBody);
} catch (err) {
const message =
err instanceof Error
? err.message
: typeof err === "string"
? err
: "invalid expression";
throw new Error(`Invalid evaluate function: ${message}`);
}
return (compiled as (element: Element) => unknown)(el as Element);
}, fnText);
}
return await page.evaluate((fnBody) => {
const compileRunner = (body: string) => {
const inner = `"use strict"; const candidate = ${body}; return typeof candidate === "function" ? candidate() : candidate;`;
// This intentionally evaluates user-supplied code in the browser context.
// oxlint-disable-next-line typescript-eslint/no-implied-eval
return new Function(inner) as () => unknown;
};
let compiled: unknown;
try {
compiled = compileRunner(fnBody);
} catch (err) {
const message =
err instanceof Error
? err.message
: typeof err === "string"
? err
: "invalid expression";
throw new Error(`Invalid evaluate function: ${message}`);
}
return (compiled as () => unknown)();
}, fnText);
}
export async function armFileUploadViaPlaywright(opts: {
cdpPort: number;
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);
})
.catch(() => {
// Ignore timeouts; the chooser may never appear.
});
}
export async function armDialogViaPlaywright(opts: {
cdpPort: number;
targetId?: string;
accept: boolean;
promptText?: 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.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 navigateViaPlaywright(opts: {
cdpPort: number;
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: {
cdpPort: number;
targetId?: string;
timeMs?: number;
text?: string;
textGone?: string;
timeoutMs?: number;
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
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: Math.max(500, Math.min(120_000, opts.timeoutMs ?? 20_000)),
});
}
if (opts.textGone) {
await page
.getByText(opts.textGone)
.first()
.waitFor({
state: "hidden",
timeout: Math.max(500, Math.min(120_000, opts.timeoutMs ?? 20_000)),
});
}
}
export async function takeScreenshotViaPlaywright(opts: {
cdpPort: number;
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: {
cdpPort: number;
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: {
cdpPort: number;
targetId?: string;
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
await page.close();
}
export async function pdfViaPlaywright(opts: {
cdpPort: number;
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: {
cdpPort: number;
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);
}