* feat(browser): add batch actions, CSS selector support, and click delayMs Adds three improvements to the browser act tool: 1. CSS selector support: All element-targeting actions (click, type, hover, drag, scrollIntoView, select) now accept an optional 'selector' parameter alongside 'ref'. When selector is provided, Playwright's page.locator() is used directly, skipping the need for a snapshot to obtain refs. This reduces roundtrips for agents that already know the DOM structure. 2. Click delay (delayMs): The click action now accepts an optional 'delayMs' parameter. When set, the element is hovered first, then after the specified delay, clicked. This enables human-like hover-before-click in a single tool call instead of three (hover + wait + click). 3. Batch actions: New 'batch' action kind that accepts an array of actions to execute sequentially in a single tool call. Supports 'stopOnError' (default true) to control whether execution halts on first failure. Results are returned as an array. This eliminates the AI inference roundtrip between each action, dramatically reducing latency and token cost for multi-step flows. Addresses: #44431, #38844 * fix(browser): address security review — batch evaluateEnabled guard, input validation, recursion limit Fixes all 4 issues raised by Greptile review: 1. Security: batch actions now respect evaluateEnabled flag. executeSingleAction and batchViaPlaywright accept evaluateEnabled param. evaluate and wait-with-fn inside batches are rejected when evaluateEnabled=false, matching the direct route guards. 2. Security: batch input validation. Each action in body.actions is validated as a plain object with a known kind string before dispatch. Applies same normalization as direct action handlers. 3. Perf: SELECTOR_ALLOWED_KINDS moved to module scope as a ReadonlySet<string> constant (was re-created on every request). 4. Security: max batch nesting depth of 5. Nested batch actions track depth and throw if MAX_BATCH_DEPTH exceeded, preventing call stack exhaustion from crafted payloads. * fix(browser): normalize batch act dispatch * fix(browser): tighten existing-session act typing * fix(browser): preserve batch type text * fix(browser): complete batch action execution * test(browser): cover batch route normalization * test(browser): cover batch interaction dispatch * fix(browser): bound batch route action inputs * fix(browser): harden batch interaction limits * test(browser): cover batch security guardrails --------- Co-authored-by: Diwakar <diwakarrankawat@gmail.com>
892 lines
27 KiB
TypeScript
892 lines
27 KiB
TypeScript
import type { BrowserActRequest, BrowserFormField } from "./client-actions-core.js";
|
|
import { DEFAULT_FILL_FIELD_TYPE } from "./form-fields.js";
|
|
import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js";
|
|
import {
|
|
ensurePageState,
|
|
forceDisconnectPlaywrightForTarget,
|
|
getPageForTargetId,
|
|
refLocator,
|
|
restoreRoleRefsForTarget,
|
|
} from "./pw-session.js";
|
|
import {
|
|
normalizeTimeoutMs,
|
|
requireRef,
|
|
requireRefOrSelector,
|
|
toAIFriendlyError,
|
|
} from "./pw-tools-core.shared.js";
|
|
import { closePageViaPlaywright, resizeViewportViaPlaywright } from "./pw-tools-core.snapshot.js";
|
|
|
|
type TargetOpts = {
|
|
cdpUrl: string;
|
|
targetId?: string;
|
|
};
|
|
const MAX_CLICK_DELAY_MS = 5_000;
|
|
const MAX_WAIT_TIME_MS = 30_000;
|
|
const MAX_BATCH_ACTIONS = 100;
|
|
|
|
function resolveBoundedDelayMs(value: number | undefined, label: string, maxMs: number): number {
|
|
const normalized = Math.floor(value ?? 0);
|
|
if (!Number.isFinite(normalized) || normalized < 0) {
|
|
throw new Error(`${label} must be >= 0`);
|
|
}
|
|
if (normalized > maxMs) {
|
|
throw new Error(`${label} exceeds maximum of ${maxMs}ms`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
async function getRestoredPageForTarget(opts: TargetOpts) {
|
|
const page = await getPageForTargetId(opts);
|
|
ensurePageState(page);
|
|
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
|
|
return page;
|
|
}
|
|
|
|
function resolveInteractionTimeoutMs(timeoutMs?: number): number {
|
|
return Math.max(500, Math.min(60_000, Math.floor(timeoutMs ?? 8000)));
|
|
}
|
|
|
|
async function awaitEvalWithAbort<T>(
|
|
evalPromise: Promise<T>,
|
|
abortPromise?: Promise<never>,
|
|
): Promise<T> {
|
|
if (!abortPromise) {
|
|
return await evalPromise;
|
|
}
|
|
try {
|
|
return await Promise.race([evalPromise, abortPromise]);
|
|
} catch (err) {
|
|
// If abort wins the race, evaluate may reject later; avoid unhandled rejections.
|
|
void evalPromise.catch(() => {});
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function highlightViaPlaywright(opts: {
|
|
cdpUrl: string;
|
|
targetId?: string;
|
|
ref: string;
|
|
}): Promise<void> {
|
|
const page = await getRestoredPageForTarget(opts);
|
|
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;
|
|
selector?: string;
|
|
doubleClick?: boolean;
|
|
button?: "left" | "right" | "middle";
|
|
modifiers?: Array<"Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift">;
|
|
delayMs?: number;
|
|
timeoutMs?: number;
|
|
}): Promise<void> {
|
|
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
|
const page = await getRestoredPageForTarget(opts);
|
|
const label = resolved.ref ?? resolved.selector!;
|
|
const locator = resolved.ref
|
|
? refLocator(page, requireRef(resolved.ref))
|
|
: page.locator(resolved.selector!);
|
|
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
|
|
try {
|
|
const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS);
|
|
if (delayMs > 0) {
|
|
await locator.hover({ timeout });
|
|
await new Promise((r) => setTimeout(r, delayMs));
|
|
}
|
|
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, label);
|
|
}
|
|
}
|
|
|
|
export async function hoverViaPlaywright(opts: {
|
|
cdpUrl: string;
|
|
targetId?: string;
|
|
ref?: string;
|
|
selector?: string;
|
|
timeoutMs?: number;
|
|
}): Promise<void> {
|
|
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
|
const page = await getRestoredPageForTarget(opts);
|
|
const label = resolved.ref ?? resolved.selector!;
|
|
const locator = resolved.ref
|
|
? refLocator(page, requireRef(resolved.ref))
|
|
: page.locator(resolved.selector!);
|
|
try {
|
|
await locator.hover({
|
|
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
|
|
});
|
|
} catch (err) {
|
|
throw toAIFriendlyError(err, label);
|
|
}
|
|
}
|
|
|
|
export async function dragViaPlaywright(opts: {
|
|
cdpUrl: string;
|
|
targetId?: string;
|
|
startRef?: string;
|
|
startSelector?: string;
|
|
endRef?: string;
|
|
endSelector?: string;
|
|
timeoutMs?: number;
|
|
}): Promise<void> {
|
|
const resolvedStart = requireRefOrSelector(opts.startRef, opts.startSelector);
|
|
const resolvedEnd = requireRefOrSelector(opts.endRef, opts.endSelector);
|
|
const page = await getRestoredPageForTarget(opts);
|
|
const startLocator = resolvedStart.ref
|
|
? refLocator(page, requireRef(resolvedStart.ref))
|
|
: page.locator(resolvedStart.selector!);
|
|
const endLocator = resolvedEnd.ref
|
|
? refLocator(page, requireRef(resolvedEnd.ref))
|
|
: page.locator(resolvedEnd.selector!);
|
|
const startLabel = resolvedStart.ref ?? resolvedStart.selector!;
|
|
const endLabel = resolvedEnd.ref ?? resolvedEnd.selector!;
|
|
try {
|
|
await startLocator.dragTo(endLocator, {
|
|
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
|
|
});
|
|
} catch (err) {
|
|
throw toAIFriendlyError(err, `${startLabel} -> ${endLabel}`);
|
|
}
|
|
}
|
|
|
|
export async function selectOptionViaPlaywright(opts: {
|
|
cdpUrl: string;
|
|
targetId?: string;
|
|
ref?: string;
|
|
selector?: string;
|
|
values: string[];
|
|
timeoutMs?: number;
|
|
}): Promise<void> {
|
|
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
|
if (!opts.values?.length) {
|
|
throw new Error("values are required");
|
|
}
|
|
const page = await getRestoredPageForTarget(opts);
|
|
const label = resolved.ref ?? resolved.selector!;
|
|
const locator = resolved.ref
|
|
? refLocator(page, requireRef(resolved.ref))
|
|
: page.locator(resolved.selector!);
|
|
try {
|
|
await locator.selectOption(opts.values, {
|
|
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
|
|
});
|
|
} catch (err) {
|
|
throw toAIFriendlyError(err, label);
|
|
}
|
|
}
|
|
|
|
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;
|
|
selector?: string;
|
|
text: string;
|
|
submit?: boolean;
|
|
slowly?: boolean;
|
|
timeoutMs?: number;
|
|
}): Promise<void> {
|
|
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
|
const text = String(opts.text ?? "");
|
|
const page = await getRestoredPageForTarget(opts);
|
|
const label = resolved.ref ?? resolved.selector!;
|
|
const locator = resolved.ref
|
|
? refLocator(page, requireRef(resolved.ref))
|
|
: page.locator(resolved.selector!);
|
|
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
|
|
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, label);
|
|
}
|
|
}
|
|
|
|
export async function fillFormViaPlaywright(opts: {
|
|
cdpUrl: string;
|
|
targetId?: string;
|
|
fields: BrowserFormField[];
|
|
timeoutMs?: number;
|
|
}): Promise<void> {
|
|
const page = await getRestoredPageForTarget(opts);
|
|
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
|
|
for (const field of opts.fields) {
|
|
const ref = field.ref.trim();
|
|
const type = (field.type || DEFAULT_FILL_FIELD_TYPE).trim() || DEFAULT_FILL_FIELD_TYPE;
|
|
const rawValue = field.value;
|
|
const value =
|
|
typeof rawValue === "string"
|
|
? rawValue
|
|
: typeof rawValue === "number" || typeof rawValue === "boolean"
|
|
? String(rawValue)
|
|
: "";
|
|
if (!ref) {
|
|
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;
|
|
timeoutMs?: number;
|
|
signal?: AbortSignal;
|
|
}): Promise<unknown> {
|
|
const fnText = String(opts.fn ?? "").trim();
|
|
if (!fnText) {
|
|
throw new Error("function is required");
|
|
}
|
|
const page = await getRestoredPageForTarget(opts);
|
|
// Clamp evaluate timeout to prevent permanently blocking Playwright's command queue.
|
|
// Without this, a long-running async evaluate blocks all subsequent page operations
|
|
// because Playwright serializes CDP commands per page.
|
|
//
|
|
// NOTE: Playwright's { timeout } on evaluate only applies to installing the function,
|
|
// NOT to its execution time. We must inject a Promise.race timeout into the browser
|
|
// context itself so async functions are bounded.
|
|
const outerTimeout = normalizeTimeoutMs(opts.timeoutMs, 20_000);
|
|
// Leave headroom for routing/serialization overhead so the outer request timeout
|
|
// doesn't fire first and strand a long-running evaluate.
|
|
let evaluateTimeout = Math.max(1000, Math.min(120_000, outerTimeout - 500));
|
|
evaluateTimeout = Math.min(evaluateTimeout, outerTimeout);
|
|
|
|
const signal = opts.signal;
|
|
let abortListener: (() => void) | undefined;
|
|
let abortReject: ((reason: unknown) => void) | undefined;
|
|
let abortPromise: Promise<never> | undefined;
|
|
if (signal) {
|
|
abortPromise = new Promise((_, reject) => {
|
|
abortReject = reject;
|
|
});
|
|
// Ensure the abort promise never becomes an unhandled rejection if we throw early.
|
|
void abortPromise.catch(() => {});
|
|
}
|
|
if (signal) {
|
|
const disconnect = () => {
|
|
void forceDisconnectPlaywrightForTarget({
|
|
cdpUrl: opts.cdpUrl,
|
|
targetId: opts.targetId,
|
|
reason: "evaluate aborted",
|
|
}).catch(() => {});
|
|
};
|
|
if (signal.aborted) {
|
|
disconnect();
|
|
throw signal.reason ?? new Error("aborted");
|
|
}
|
|
abortListener = () => {
|
|
disconnect();
|
|
abortReject?.(signal.reason ?? new Error("aborted"));
|
|
};
|
|
signal.addEventListener("abort", abortListener, { once: true });
|
|
// If the signal aborted between the initial check and listener registration, handle it.
|
|
if (signal.aborted) {
|
|
abortListener();
|
|
throw signal.reason ?? new Error("aborted");
|
|
}
|
|
}
|
|
|
|
try {
|
|
if (opts.ref) {
|
|
const locator = refLocator(page, opts.ref);
|
|
// eslint-disable-next-line @typescript-eslint/no-implied-eval -- required for browser-context eval
|
|
const elementEvaluator = new Function(
|
|
"el",
|
|
"args",
|
|
`
|
|
"use strict";
|
|
var fnBody = args.fnBody, timeoutMs = args.timeoutMs;
|
|
try {
|
|
var candidate = eval("(" + fnBody + ")");
|
|
var result = typeof candidate === "function" ? candidate(el) : candidate;
|
|
if (result && typeof result.then === "function") {
|
|
return Promise.race([
|
|
result,
|
|
new Promise(function(_, reject) {
|
|
setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);
|
|
})
|
|
]);
|
|
}
|
|
return result;
|
|
} catch (err) {
|
|
throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));
|
|
}
|
|
`,
|
|
) as (el: Element, args: { fnBody: string; timeoutMs: number }) => unknown;
|
|
const evalPromise = locator.evaluate(elementEvaluator, {
|
|
fnBody: fnText,
|
|
timeoutMs: evaluateTimeout,
|
|
});
|
|
return await awaitEvalWithAbort(evalPromise, abortPromise);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-implied-eval -- required for browser-context eval
|
|
const browserEvaluator = new Function(
|
|
"args",
|
|
`
|
|
"use strict";
|
|
var fnBody = args.fnBody, timeoutMs = args.timeoutMs;
|
|
try {
|
|
var candidate = eval("(" + fnBody + ")");
|
|
var result = typeof candidate === "function" ? candidate() : candidate;
|
|
if (result && typeof result.then === "function") {
|
|
return Promise.race([
|
|
result,
|
|
new Promise(function(_, reject) {
|
|
setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);
|
|
})
|
|
]);
|
|
}
|
|
return result;
|
|
} catch (err) {
|
|
throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));
|
|
}
|
|
`,
|
|
) as (args: { fnBody: string; timeoutMs: number }) => unknown;
|
|
const evalPromise = page.evaluate(browserEvaluator, {
|
|
fnBody: fnText,
|
|
timeoutMs: evaluateTimeout,
|
|
});
|
|
return await awaitEvalWithAbort(evalPromise, abortPromise);
|
|
} finally {
|
|
if (signal && abortListener) {
|
|
signal.removeEventListener("abort", abortListener);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function scrollIntoViewViaPlaywright(opts: {
|
|
cdpUrl: string;
|
|
targetId?: string;
|
|
ref?: string;
|
|
selector?: string;
|
|
timeoutMs?: number;
|
|
}): Promise<void> {
|
|
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
|
const page = await getRestoredPageForTarget(opts);
|
|
const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000);
|
|
|
|
const label = resolved.ref ?? resolved.selector!;
|
|
const locator = resolved.ref
|
|
? refLocator(page, requireRef(resolved.ref))
|
|
: page.locator(resolved.selector!);
|
|
try {
|
|
await locator.scrollIntoViewIfNeeded({ timeout });
|
|
} catch (err) {
|
|
throw toAIFriendlyError(err, label);
|
|
}
|
|
}
|
|
|
|
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(resolveBoundedDelayMs(opts.timeMs, "wait timeMs", MAX_WAIT_TIME_MS));
|
|
}
|
|
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);
|
|
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, 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 screenshotWithLabelsViaPlaywright(opts: {
|
|
cdpUrl: string;
|
|
targetId?: string;
|
|
refs: Record<string, { role: string; name?: string; nth?: number }>;
|
|
maxLabels?: number;
|
|
type?: "png" | "jpeg";
|
|
}): Promise<{ buffer: Buffer; labels: number; skipped: number }> {
|
|
const page = await getPageForTargetId(opts);
|
|
ensurePageState(page);
|
|
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
|
|
const type = opts.type ?? "png";
|
|
const maxLabels =
|
|
typeof opts.maxLabels === "number" && Number.isFinite(opts.maxLabels)
|
|
? Math.max(1, Math.floor(opts.maxLabels))
|
|
: 150;
|
|
|
|
const viewport = await page.evaluate(() => ({
|
|
scrollX: window.scrollX || 0,
|
|
scrollY: window.scrollY || 0,
|
|
width: window.innerWidth || 0,
|
|
height: window.innerHeight || 0,
|
|
}));
|
|
|
|
const refs = Object.keys(opts.refs ?? {});
|
|
const boxes: Array<{ ref: string; x: number; y: number; w: number; h: number }> = [];
|
|
let skipped = 0;
|
|
|
|
for (const ref of refs) {
|
|
if (boxes.length >= maxLabels) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
try {
|
|
const box = await refLocator(page, ref).boundingBox();
|
|
if (!box) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
const x0 = box.x;
|
|
const y0 = box.y;
|
|
const x1 = box.x + box.width;
|
|
const y1 = box.y + box.height;
|
|
const vx0 = viewport.scrollX;
|
|
const vy0 = viewport.scrollY;
|
|
const vx1 = viewport.scrollX + viewport.width;
|
|
const vy1 = viewport.scrollY + viewport.height;
|
|
if (x1 < vx0 || x0 > vx1 || y1 < vy0 || y0 > vy1) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
boxes.push({
|
|
ref,
|
|
x: x0 - viewport.scrollX,
|
|
y: y0 - viewport.scrollY,
|
|
w: Math.max(1, box.width),
|
|
h: Math.max(1, box.height),
|
|
});
|
|
} catch {
|
|
skipped += 1;
|
|
}
|
|
}
|
|
|
|
try {
|
|
if (boxes.length > 0) {
|
|
await page.evaluate((labels) => {
|
|
const existing = document.querySelectorAll("[data-openclaw-labels]");
|
|
existing.forEach((el) => el.remove());
|
|
|
|
const root = document.createElement("div");
|
|
root.setAttribute("data-openclaw-labels", "1");
|
|
root.style.position = "fixed";
|
|
root.style.left = "0";
|
|
root.style.top = "0";
|
|
root.style.zIndex = "2147483647";
|
|
root.style.pointerEvents = "none";
|
|
root.style.fontFamily =
|
|
'"SF Mono","SFMono-Regular",Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace';
|
|
|
|
const clamp = (value: number, min: number, max: number) =>
|
|
Math.min(max, Math.max(min, value));
|
|
|
|
for (const label of labels) {
|
|
const box = document.createElement("div");
|
|
box.setAttribute("data-openclaw-labels", "1");
|
|
box.style.position = "absolute";
|
|
box.style.left = `${label.x}px`;
|
|
box.style.top = `${label.y}px`;
|
|
box.style.width = `${label.w}px`;
|
|
box.style.height = `${label.h}px`;
|
|
box.style.border = "2px solid #ffb020";
|
|
box.style.boxSizing = "border-box";
|
|
|
|
const tag = document.createElement("div");
|
|
tag.setAttribute("data-openclaw-labels", "1");
|
|
tag.textContent = label.ref;
|
|
tag.style.position = "absolute";
|
|
tag.style.left = `${label.x}px`;
|
|
tag.style.top = `${clamp(label.y - 18, 0, 20000)}px`;
|
|
tag.style.background = "#ffb020";
|
|
tag.style.color = "#1a1a1a";
|
|
tag.style.fontSize = "12px";
|
|
tag.style.lineHeight = "14px";
|
|
tag.style.padding = "1px 4px";
|
|
tag.style.borderRadius = "3px";
|
|
tag.style.boxShadow = "0 1px 2px rgba(0,0,0,0.35)";
|
|
tag.style.whiteSpace = "nowrap";
|
|
|
|
root.appendChild(box);
|
|
root.appendChild(tag);
|
|
}
|
|
|
|
document.documentElement.appendChild(root);
|
|
}, boxes);
|
|
}
|
|
|
|
const buffer = await page.screenshot({ type });
|
|
return { buffer, labels: boxes.length, skipped };
|
|
} finally {
|
|
await page
|
|
.evaluate(() => {
|
|
const existing = document.querySelectorAll("[data-openclaw-labels]");
|
|
existing.forEach((el) => el.remove());
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
}
|
|
|
|
export async function setInputFilesViaPlaywright(opts: {
|
|
cdpUrl: string;
|
|
targetId?: string;
|
|
inputRef?: string;
|
|
element?: string;
|
|
paths: string[];
|
|
}): Promise<void> {
|
|
const page = await getPageForTargetId(opts);
|
|
ensurePageState(page);
|
|
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, 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();
|
|
const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
|
|
rootDir: DEFAULT_UPLOAD_DIR,
|
|
requestedPaths: opts.paths,
|
|
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
|
|
});
|
|
if (!uploadPathsResult.ok) {
|
|
throw new Error(uploadPathsResult.error);
|
|
}
|
|
const resolvedPaths = uploadPathsResult.paths;
|
|
|
|
try {
|
|
await locator.setInputFiles(resolvedPaths);
|
|
} 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.
|
|
}
|
|
}
|
|
|
|
const MAX_BATCH_DEPTH = 5;
|
|
|
|
async function executeSingleAction(
|
|
action: BrowserActRequest,
|
|
cdpUrl: string,
|
|
targetId?: string,
|
|
evaluateEnabled?: boolean,
|
|
depth = 0,
|
|
): Promise<void> {
|
|
if (depth > MAX_BATCH_DEPTH) {
|
|
throw new Error(`Batch nesting depth exceeds maximum of ${MAX_BATCH_DEPTH}`);
|
|
}
|
|
const effectiveTargetId = action.targetId ?? targetId;
|
|
switch (action.kind) {
|
|
case "click":
|
|
await clickViaPlaywright({
|
|
cdpUrl,
|
|
targetId: effectiveTargetId,
|
|
ref: action.ref,
|
|
selector: action.selector,
|
|
doubleClick: action.doubleClick,
|
|
button: action.button as "left" | "right" | "middle" | undefined,
|
|
modifiers: action.modifiers as Array<
|
|
"Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift"
|
|
>,
|
|
delayMs: action.delayMs,
|
|
timeoutMs: action.timeoutMs,
|
|
});
|
|
break;
|
|
case "type":
|
|
await typeViaPlaywright({
|
|
cdpUrl,
|
|
targetId: effectiveTargetId,
|
|
ref: action.ref,
|
|
selector: action.selector,
|
|
text: action.text,
|
|
submit: action.submit,
|
|
slowly: action.slowly,
|
|
timeoutMs: action.timeoutMs,
|
|
});
|
|
break;
|
|
case "press":
|
|
await pressKeyViaPlaywright({
|
|
cdpUrl,
|
|
targetId: effectiveTargetId,
|
|
key: action.key,
|
|
delayMs: action.delayMs,
|
|
});
|
|
break;
|
|
case "hover":
|
|
await hoverViaPlaywright({
|
|
cdpUrl,
|
|
targetId: effectiveTargetId,
|
|
ref: action.ref,
|
|
selector: action.selector,
|
|
timeoutMs: action.timeoutMs,
|
|
});
|
|
break;
|
|
case "scrollIntoView":
|
|
await scrollIntoViewViaPlaywright({
|
|
cdpUrl,
|
|
targetId: effectiveTargetId,
|
|
ref: action.ref,
|
|
selector: action.selector,
|
|
timeoutMs: action.timeoutMs,
|
|
});
|
|
break;
|
|
case "drag":
|
|
await dragViaPlaywright({
|
|
cdpUrl,
|
|
targetId: effectiveTargetId,
|
|
startRef: action.startRef,
|
|
startSelector: action.startSelector,
|
|
endRef: action.endRef,
|
|
endSelector: action.endSelector,
|
|
timeoutMs: action.timeoutMs,
|
|
});
|
|
break;
|
|
case "select":
|
|
await selectOptionViaPlaywright({
|
|
cdpUrl,
|
|
targetId: effectiveTargetId,
|
|
ref: action.ref,
|
|
selector: action.selector,
|
|
values: action.values,
|
|
timeoutMs: action.timeoutMs,
|
|
});
|
|
break;
|
|
case "fill":
|
|
await fillFormViaPlaywright({
|
|
cdpUrl,
|
|
targetId: effectiveTargetId,
|
|
fields: action.fields,
|
|
timeoutMs: action.timeoutMs,
|
|
});
|
|
break;
|
|
case "resize":
|
|
await resizeViewportViaPlaywright({
|
|
cdpUrl,
|
|
targetId: effectiveTargetId,
|
|
width: action.width,
|
|
height: action.height,
|
|
});
|
|
break;
|
|
case "wait":
|
|
if (action.fn && !evaluateEnabled) {
|
|
throw new Error("wait --fn is disabled by config (browser.evaluateEnabled=false)");
|
|
}
|
|
await waitForViaPlaywright({
|
|
cdpUrl,
|
|
targetId: effectiveTargetId,
|
|
timeMs: action.timeMs,
|
|
text: action.text,
|
|
textGone: action.textGone,
|
|
selector: action.selector,
|
|
url: action.url,
|
|
loadState: action.loadState,
|
|
fn: action.fn,
|
|
timeoutMs: action.timeoutMs,
|
|
});
|
|
break;
|
|
case "evaluate":
|
|
if (!evaluateEnabled) {
|
|
throw new Error("act:evaluate is disabled by config (browser.evaluateEnabled=false)");
|
|
}
|
|
await evaluateViaPlaywright({
|
|
cdpUrl,
|
|
targetId: effectiveTargetId,
|
|
fn: action.fn,
|
|
ref: action.ref,
|
|
timeoutMs: action.timeoutMs,
|
|
});
|
|
break;
|
|
case "close":
|
|
await closePageViaPlaywright({
|
|
cdpUrl,
|
|
targetId: effectiveTargetId,
|
|
});
|
|
break;
|
|
case "batch":
|
|
// Nested batches: delegate recursively
|
|
await batchViaPlaywright({
|
|
cdpUrl,
|
|
targetId: effectiveTargetId,
|
|
actions: action.actions,
|
|
stopOnError: action.stopOnError,
|
|
evaluateEnabled,
|
|
depth: depth + 1,
|
|
});
|
|
break;
|
|
default:
|
|
throw new Error(`Unsupported batch action kind: ${(action as { kind: string }).kind}`);
|
|
}
|
|
}
|
|
|
|
export async function batchViaPlaywright(opts: {
|
|
cdpUrl: string;
|
|
targetId?: string;
|
|
actions: BrowserActRequest[];
|
|
stopOnError?: boolean;
|
|
evaluateEnabled?: boolean;
|
|
depth?: number;
|
|
}): Promise<{ results: Array<{ ok: boolean; error?: string }> }> {
|
|
const depth = opts.depth ?? 0;
|
|
if (depth > MAX_BATCH_DEPTH) {
|
|
throw new Error(`Batch nesting depth exceeds maximum of ${MAX_BATCH_DEPTH}`);
|
|
}
|
|
if (opts.actions.length > MAX_BATCH_ACTIONS) {
|
|
throw new Error(`Batch exceeds maximum of ${MAX_BATCH_ACTIONS} actions`);
|
|
}
|
|
const results: Array<{ ok: boolean; error?: string }> = [];
|
|
for (const action of opts.actions) {
|
|
try {
|
|
await executeSingleAction(action, opts.cdpUrl, opts.targetId, opts.evaluateEnabled, depth);
|
|
results.push({ ok: true });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
results.push({ ok: false, error: message });
|
|
if (opts.stopOnError !== false) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return { results };
|
|
}
|