refactor(browser): split pw tools + agent routes
This commit is contained in:
64
src/browser/pw-tools-core.activity.ts
Normal file
64
src/browser/pw-tools-core.activity.ts
Normal file
@@ -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<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);
|
||||
}
|
||||
234
src/browser/pw-tools-core.downloads.ts
Normal file
234
src/browser/pw-tools-core.downloads.ts
Normal file
@@ -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<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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 = 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<void> {
|
||||
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<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 = 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<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;
|
||||
}
|
||||
}
|
||||
409
src/browser/pw-tools-core.interactions.ts
Normal file
409
src/browser/pw-tools-core.interactions.ts
Normal file
@@ -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<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 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 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 scrollIntoViewViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
ref: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
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<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 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.
|
||||
}
|
||||
}
|
||||
111
src/browser/pw-tools-core.responses.ts
Normal file
111
src/browser/pw-tools-core.responses.ts
Normal file
@@ -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<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,
|
||||
};
|
||||
}
|
||||
71
src/browser/pw-tools-core.shared.ts
Normal file
71
src/browser/pw-tools-core.shared.ts
Normal file
@@ -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);
|
||||
}
|
||||
141
src/browser/pw-tools-core.snapshot.ts
Normal file
141
src/browser/pw-tools-core.snapshot.ts
Normal file
@@ -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<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 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<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 as Page).pdf({ printBackground: true });
|
||||
return { buffer };
|
||||
}
|
||||
204
src/browser/pw-tools-core.state.ts
Normal file
204
src/browser/pw-tools-core.state.ts
Normal file
@@ -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<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 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 });
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
125
src/browser/pw-tools-core.storage.ts
Normal file
125
src/browser/pw-tools-core.storage.ts
Normal file
@@ -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<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 },
|
||||
);
|
||||
}
|
||||
39
src/browser/pw-tools-core.trace.ts
Normal file
39
src/browser/pw-tools-core.trace.ts
Normal file
@@ -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<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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
55
src/browser/routes/agent.act.shared.ts
Normal file
55
src/browser/routes/agent.act.shared.ts
Normal file
@@ -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<ClickModifier>([
|
||||
"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 };
|
||||
}
|
||||
460
src/browser/routes/agent.act.ts
Normal file
460
src/browser/routes/agent.act.ts
Normal file
@@ -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<typeof pw.clickViaPlaywright>[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<typeof pw.typeViaPlaywright>[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<string, unknown>;
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
141
src/browser/routes/agent.debug.ts
Normal file
141
src/browser/routes/agent.debug.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
73
src/browser/routes/agent.shared.ts
Normal file
73
src/browser/routes/agent.shared.ts
Normal file
@@ -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<string, unknown> {
|
||||
const body = req.body as Record<string, unknown> | 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<PwAiModule | null> | null = null;
|
||||
|
||||
export async function getPwAiModule(): Promise<PwAiModule | null> {
|
||||
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<PwAiModule | null> {
|
||||
const mod = await getPwAiModule();
|
||||
if (mod) return mod;
|
||||
jsonError(
|
||||
res,
|
||||
501,
|
||||
`Playwright is not available in this gateway build; '${feature}' is unsupported.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
256
src/browser/routes/agent.snapshot.ts
Normal file
256
src/browser/routes/agent.snapshot.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
381
src/browser/routes/agent.storage.ts
Normal file
381
src/browser/routes/agent.storage.ts
Normal file
@@ -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<string, unknown>)
|
||||
: 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<string, unknown>)
|
||||
: null;
|
||||
if (!headers) return jsonError(res, 400, "headers is required");
|
||||
const parsed: Record<string, string> = {};
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user