mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-14 20:32:46 +00:00
1122 lines
33 KiB
TypeScript
1122 lines
33 KiB
TypeScript
import { isAbsolute, relative, resolve as resolvePath } from "node:path";
|
|
|
|
import type {
|
|
BrowserAutomationCommand,
|
|
BrowserAutomationConsoleLogEntry,
|
|
BrowserAutomationErrorCode,
|
|
BrowserAutomationExecuteResponse,
|
|
BrowserAutomationExecuteRequest,
|
|
BrowserAutomationNetworkLogEntry,
|
|
} from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
|
import { BrowserSnapshotEngine } from "./snapshot-engine.js";
|
|
|
|
export interface TabContents {
|
|
readonly id: number;
|
|
getURL(): string;
|
|
getTitle(): string;
|
|
canGoBack(): boolean;
|
|
canGoForward(): boolean;
|
|
isLoading(): boolean;
|
|
isDestroyed(): boolean;
|
|
executeJavaScript(code: string): Promise<unknown>;
|
|
loadURL(url: string): Promise<void>;
|
|
goBack(): void;
|
|
goForward(): void;
|
|
reload(): void;
|
|
capturePage(options?: TabCapturePageOptions): Promise<TabImage>;
|
|
prepareForPixelCapture(): Promise<TabPixelCapturePreparation>;
|
|
restorePixelCapture(preparation: TabPixelCapturePreparation): Promise<void>;
|
|
invalidate(): void;
|
|
isBackgroundThrottlingAllowed(): boolean;
|
|
setBackgroundThrottling(allowed: boolean): void;
|
|
getConsoleMessages?(): BrowserAutomationConsoleLogEntry[];
|
|
sendDebugCommand?(command: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
}
|
|
|
|
export interface TabPixelCapturePreparation {
|
|
token: string;
|
|
}
|
|
|
|
export interface TabImage {
|
|
toPNG(): Uint8Array;
|
|
getSize(): { width: number; height: number };
|
|
}
|
|
|
|
export interface TabCapturePageOptions {
|
|
stayHidden?: boolean;
|
|
}
|
|
|
|
export interface BrowserRegistry {
|
|
listRegisteredBrowserIds(): string[];
|
|
listRegisteredBrowserIdsForWorkspace(workspaceId: string): string[];
|
|
getTabContents(browserId: string): TabContents | null;
|
|
getBrowserWorkspaceId(browserId: string): string | null;
|
|
getWorkspaceActiveBrowserId(workspaceId: string): string | null;
|
|
}
|
|
|
|
export type AutomationCommandPayload = BrowserAutomationExecuteResponse["payload"];
|
|
type FailurePayload = Extract<AutomationCommandPayload, { ok: false }>;
|
|
|
|
const defaultSnapshotEngine = new BrowserSnapshotEngine();
|
|
const DEFAULT_WAIT_TIMEOUT_MS = 5_000;
|
|
const WAIT_POLL_INTERVAL_MS = 25;
|
|
const PIXEL_CAPTURE_TIMEOUT_MS = 5_000;
|
|
const SCREENSHOT_NO_FRAME_MESSAGE =
|
|
"The browser tab has no painted frame. Focus the tab in the app, then try again.";
|
|
const ALLOWED_PAGE_URL_PROTOCOLS = new Set(["http:", "https:"]);
|
|
const pixelCaptureQueuesByContentsId = new Map<number, Promise<void>>();
|
|
|
|
function fail(
|
|
requestId: string,
|
|
code: BrowserAutomationErrorCode,
|
|
message: string,
|
|
retryable = false,
|
|
): FailurePayload {
|
|
return { requestId, ok: false, error: { code, message, retryable } };
|
|
}
|
|
|
|
class ScreenshotNoFrameError extends Error {
|
|
public constructor() {
|
|
super(SCREENSHOT_NO_FRAME_MESSAGE);
|
|
this.name = "ScreenshotNoFrameError";
|
|
}
|
|
}
|
|
|
|
function isScreenshotNoFrameError(error: unknown): error is ScreenshotNoFrameError {
|
|
return error instanceof ScreenshotNoFrameError;
|
|
}
|
|
|
|
function screenshotNoFrameFailure(requestId: string): FailurePayload {
|
|
return fail(requestId, "screenshot_no_frame", SCREENSHOT_NO_FRAME_MESSAGE);
|
|
}
|
|
|
|
async function withPixelCaptureTimeout<T>(capture: Promise<T>): Promise<T> {
|
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
const timeout = new Promise<never>((_, reject) => {
|
|
timeoutId = setTimeout(() => {
|
|
reject(new ScreenshotNoFrameError());
|
|
}, PIXEL_CAPTURE_TIMEOUT_MS);
|
|
});
|
|
|
|
try {
|
|
return await Promise.race([capture, timeout]);
|
|
} finally {
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runSerializedPixelCapture<T>(
|
|
contents: TabContents,
|
|
capture: () => Promise<T>,
|
|
): Promise<T> {
|
|
const previous = pixelCaptureQueuesByContentsId.get(contents.id) ?? Promise.resolve();
|
|
let releaseCurrent = () => {};
|
|
const current = new Promise<void>((resolve) => {
|
|
releaseCurrent = resolve;
|
|
});
|
|
const tail = previous.catch(() => {}).then(() => current);
|
|
pixelCaptureQueuesByContentsId.set(contents.id, tail);
|
|
|
|
await previous.catch(() => {});
|
|
try {
|
|
return await capture();
|
|
} finally {
|
|
releaseCurrent();
|
|
if (pixelCaptureQueuesByContentsId.get(contents.id) === tail) {
|
|
pixelCaptureQueuesByContentsId.delete(contents.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function prepareForPixelCapture(contents: TabContents): Promise<TabPixelCapturePreparation> {
|
|
try {
|
|
return await withPixelCaptureTimeout(contents.prepareForPixelCapture());
|
|
} catch {
|
|
throw new ScreenshotNoFrameError();
|
|
}
|
|
}
|
|
|
|
async function restorePixelCapture(
|
|
contents: TabContents,
|
|
preparation: TabPixelCapturePreparation,
|
|
): Promise<void> {
|
|
try {
|
|
await withPixelCaptureTimeout(contents.restorePixelCapture(preparation));
|
|
} catch {
|
|
throw new ScreenshotNoFrameError();
|
|
}
|
|
}
|
|
|
|
async function runPreparedPixelCapture<T>(
|
|
contents: TabContents,
|
|
capture: () => Promise<T>,
|
|
): Promise<T> {
|
|
return runSerializedPixelCapture(contents, async () => {
|
|
const previousBackgroundThrottling = contents.isBackgroundThrottlingAllowed();
|
|
let preparation: TabPixelCapturePreparation | null = null;
|
|
try {
|
|
// Offscreen-parked webview guests have no compositor surface for
|
|
// capturePage/CDP to copy. The renderer must briefly make the host
|
|
// paintable before capture; see ~/.paseo/plans/browser-capture-harness-results.md.
|
|
preparation = await prepareForPixelCapture(contents);
|
|
contents.setBackgroundThrottling(false);
|
|
contents.invalidate();
|
|
return await withPixelCaptureTimeout(capture());
|
|
} finally {
|
|
try {
|
|
if (preparation) {
|
|
await restorePixelCapture(contents, preparation);
|
|
}
|
|
} finally {
|
|
contents.setBackgroundThrottling(previousBackgroundThrottling);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function capturePaintedViewport(contents: TabContents): Promise<TabImage> {
|
|
return runPreparedPixelCapture(contents, () => contents.capturePage({ stayHidden: false }));
|
|
}
|
|
|
|
function tabInfoFromContents(
|
|
browserId: string,
|
|
contents: TabContents,
|
|
activeBrowserId: string | null,
|
|
workspaceId: string | null,
|
|
) {
|
|
return {
|
|
browserId,
|
|
...(workspaceId ? { workspaceId } : {}),
|
|
url: contents.getURL(),
|
|
title: contents.getTitle(),
|
|
isActive: activeBrowserId === browserId,
|
|
isLoading: contents.isLoading(),
|
|
canGoBack: contents.canGoBack(),
|
|
canGoForward: contents.canGoForward(),
|
|
};
|
|
}
|
|
|
|
export function executeAutomationCommand(
|
|
request: BrowserAutomationExecuteRequest,
|
|
registry: BrowserRegistry,
|
|
options?: { snapshotEngine?: BrowserSnapshotEngine },
|
|
): AutomationCommandPayload | Promise<AutomationCommandPayload> {
|
|
const { requestId, command } = request;
|
|
const workspaceId = request.workspaceId;
|
|
const snapshotEngine = options?.snapshotEngine ?? defaultSnapshotEngine;
|
|
const handler = commandHandlers[command.command];
|
|
|
|
return handler({ request, command, requestId, workspaceId, registry, snapshotEngine });
|
|
}
|
|
|
|
interface CommandHandlerContext {
|
|
request: BrowserAutomationExecuteRequest;
|
|
command: BrowserAutomationCommand;
|
|
requestId: string;
|
|
workspaceId: string | undefined;
|
|
registry: BrowserRegistry;
|
|
snapshotEngine: BrowserSnapshotEngine;
|
|
}
|
|
|
|
type CommandHandler = (
|
|
context: CommandHandlerContext,
|
|
) => AutomationCommandPayload | Promise<AutomationCommandPayload>;
|
|
|
|
const commandHandlers: Record<BrowserAutomationCommand["command"], CommandHandler> = {
|
|
list_tabs: ({ requestId, workspaceId, registry }) =>
|
|
executeListTabs(requestId, workspaceId, registry),
|
|
new_tab: ({ requestId }) =>
|
|
fail(requestId, "browser_unsupported", "browser_new_tab is handled by the app runtime."),
|
|
snapshot: ({ command, requestId, workspaceId, registry, snapshotEngine }) => {
|
|
const snapshotCommand = command as Extract<BrowserAutomationCommand, { command: "snapshot" }>;
|
|
return executeSnapshot(
|
|
requestId,
|
|
workspaceId,
|
|
snapshotCommand.args.browserId,
|
|
registry,
|
|
snapshotEngine,
|
|
);
|
|
},
|
|
click: ({ command, requestId, workspaceId, registry, snapshotEngine }) => {
|
|
const clickCommand = command as Extract<BrowserAutomationCommand, { command: "click" }>;
|
|
return executeClick(
|
|
requestId,
|
|
workspaceId,
|
|
clickCommand.args.browserId,
|
|
clickCommand.args.ref,
|
|
registry,
|
|
snapshotEngine,
|
|
);
|
|
},
|
|
fill: ({ command, requestId, workspaceId, registry, snapshotEngine }) => {
|
|
const fillCommand = command as Extract<BrowserAutomationCommand, { command: "fill" }>;
|
|
return executeFill(
|
|
requestId,
|
|
workspaceId,
|
|
fillCommand.args.browserId,
|
|
fillCommand.args.ref,
|
|
fillCommand.args.value,
|
|
registry,
|
|
snapshotEngine,
|
|
);
|
|
},
|
|
wait: ({ command, requestId, workspaceId, registry }) => {
|
|
const waitCommand = command as Extract<BrowserAutomationCommand, { command: "wait" }>;
|
|
return executeWait(
|
|
requestId,
|
|
workspaceId,
|
|
waitCommand.args.browserId,
|
|
{
|
|
text: waitCommand.args.text,
|
|
url: waitCommand.args.url,
|
|
timeoutMs: waitCommand.args.timeoutMs,
|
|
},
|
|
registry,
|
|
);
|
|
},
|
|
type: ({ command, requestId, workspaceId, registry, snapshotEngine }) => {
|
|
const typeCommand = command as Extract<BrowserAutomationCommand, { command: "type" }>;
|
|
return executeType(
|
|
requestId,
|
|
workspaceId,
|
|
typeCommand.args.browserId,
|
|
typeCommand.args.ref,
|
|
typeCommand.args.text,
|
|
registry,
|
|
snapshotEngine,
|
|
);
|
|
},
|
|
keypress: ({ command, requestId, workspaceId, registry, snapshotEngine }) => {
|
|
const keypressCommand = command as Extract<BrowserAutomationCommand, { command: "keypress" }>;
|
|
return executeKeypress(
|
|
requestId,
|
|
workspaceId,
|
|
keypressCommand.args.browserId,
|
|
keypressCommand.args.ref,
|
|
keypressCommand.args.key,
|
|
registry,
|
|
snapshotEngine,
|
|
);
|
|
},
|
|
navigate: ({ command, requestId, workspaceId, registry }) => {
|
|
const navigateCommand = command as Extract<BrowserAutomationCommand, { command: "navigate" }>;
|
|
return executeNavigate(
|
|
requestId,
|
|
workspaceId,
|
|
navigateCommand.args.browserId,
|
|
navigateCommand.args.url,
|
|
registry,
|
|
);
|
|
},
|
|
back: ({ command, requestId, workspaceId, registry }) => {
|
|
const backCommand = command as Extract<BrowserAutomationCommand, { command: "back" }>;
|
|
return executeNavigationAction(
|
|
requestId,
|
|
workspaceId,
|
|
backCommand.args.browserId,
|
|
"back",
|
|
registry,
|
|
);
|
|
},
|
|
forward: ({ command, requestId, workspaceId, registry }) => {
|
|
const forwardCommand = command as Extract<BrowserAutomationCommand, { command: "forward" }>;
|
|
return executeNavigationAction(
|
|
requestId,
|
|
workspaceId,
|
|
forwardCommand.args.browserId,
|
|
"forward",
|
|
registry,
|
|
);
|
|
},
|
|
reload: ({ command, requestId, workspaceId, registry }) => {
|
|
const reloadCommand = command as Extract<BrowserAutomationCommand, { command: "reload" }>;
|
|
return executeNavigationAction(
|
|
requestId,
|
|
workspaceId,
|
|
reloadCommand.args.browserId,
|
|
"reload",
|
|
registry,
|
|
);
|
|
},
|
|
screenshot: ({ command, requestId, workspaceId, registry }) => {
|
|
const screenshotCommand = command as Extract<
|
|
BrowserAutomationCommand,
|
|
{ command: "screenshot" }
|
|
>;
|
|
return executeScreenshot(
|
|
requestId,
|
|
workspaceId,
|
|
screenshotCommand.args.browserId,
|
|
screenshotCommand.args.fullPage,
|
|
registry,
|
|
);
|
|
},
|
|
upload: ({ request, command, requestId, workspaceId, registry, snapshotEngine }) => {
|
|
const uploadCommand = command as Extract<BrowserAutomationCommand, { command: "upload" }>;
|
|
return executeUpload(
|
|
requestId,
|
|
request.cwd,
|
|
workspaceId,
|
|
uploadCommand.args.browserId,
|
|
{ ref: uploadCommand.args.ref, filePaths: uploadCommand.args.filePaths },
|
|
registry,
|
|
snapshotEngine,
|
|
);
|
|
},
|
|
select: ({ command, requestId, workspaceId, registry, snapshotEngine }) => {
|
|
const selectCommand = command as Extract<BrowserAutomationCommand, { command: "select" }>;
|
|
return executeSelect(
|
|
requestId,
|
|
workspaceId,
|
|
selectCommand.args.browserId,
|
|
selectCommand.args.ref,
|
|
selectCommand.args.value,
|
|
registry,
|
|
snapshotEngine,
|
|
);
|
|
},
|
|
hover: ({ command, requestId, workspaceId, registry, snapshotEngine }) => {
|
|
const hoverCommand = command as Extract<BrowserAutomationCommand, { command: "hover" }>;
|
|
return executeHover(
|
|
requestId,
|
|
workspaceId,
|
|
hoverCommand.args.browserId,
|
|
hoverCommand.args.ref,
|
|
registry,
|
|
snapshotEngine,
|
|
);
|
|
},
|
|
drag: ({ command, requestId, workspaceId, registry, snapshotEngine }) => {
|
|
const dragCommand = command as Extract<BrowserAutomationCommand, { command: "drag" }>;
|
|
return executeDrag(
|
|
requestId,
|
|
workspaceId,
|
|
dragCommand.args.browserId,
|
|
dragCommand.args.sourceRef,
|
|
dragCommand.args.targetRef,
|
|
registry,
|
|
snapshotEngine,
|
|
);
|
|
},
|
|
logs: ({ command, requestId, workspaceId, registry }) => {
|
|
const logsCommand = command as Extract<BrowserAutomationCommand, { command: "logs" }>;
|
|
return executeLogs(
|
|
requestId,
|
|
workspaceId,
|
|
logsCommand.args.browserId,
|
|
logsCommand.args.maxEntries,
|
|
registry,
|
|
);
|
|
},
|
|
};
|
|
|
|
interface ResolvedTabTarget {
|
|
browserId: string;
|
|
contents: TabContents;
|
|
}
|
|
|
|
function executeListTabs(
|
|
requestId: string,
|
|
workspaceId: string | undefined,
|
|
registry: BrowserRegistry,
|
|
): AutomationCommandPayload {
|
|
const browserIds = workspaceId
|
|
? registry.listRegisteredBrowserIdsForWorkspace(workspaceId)
|
|
: registry.listRegisteredBrowserIds();
|
|
const activeBrowserId = workspaceId ? registry.getWorkspaceActiveBrowserId(workspaceId) : null;
|
|
const tabs: Array<ReturnType<typeof tabInfoFromContents>> = [];
|
|
|
|
for (const browserId of browserIds) {
|
|
const contents = registry.getTabContents(browserId);
|
|
if (contents && !contents.isDestroyed()) {
|
|
tabs.push(
|
|
tabInfoFromContents(
|
|
browserId,
|
|
contents,
|
|
activeBrowserId,
|
|
registry.getBrowserWorkspaceId(browserId),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
return { requestId, ok: true, result: { command: "list_tabs", tabs } };
|
|
}
|
|
|
|
async function executeSnapshot(
|
|
requestId: string,
|
|
workspaceId: string | undefined,
|
|
browserId: string,
|
|
registry: BrowserRegistry,
|
|
snapshotEngine: BrowserSnapshotEngine,
|
|
): Promise<AutomationCommandPayload> {
|
|
const target = resolveTabTarget({
|
|
requestId,
|
|
workspaceId,
|
|
browserId,
|
|
registry,
|
|
});
|
|
if ("ok" in target) {
|
|
return target;
|
|
}
|
|
|
|
const elements = await snapshotEngine.snapshot({
|
|
browserId: target.browserId,
|
|
page: target.contents,
|
|
});
|
|
|
|
return {
|
|
requestId,
|
|
ok: true,
|
|
result: {
|
|
command: "snapshot",
|
|
browserId: target.browserId,
|
|
...(registry.getBrowserWorkspaceId(target.browserId)
|
|
? { workspaceId: registry.getBrowserWorkspaceId(target.browserId) ?? undefined }
|
|
: {}),
|
|
url: target.contents.getURL(),
|
|
title: target.contents.getTitle(),
|
|
elements,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function executeClick(
|
|
requestId: string,
|
|
workspaceId: string | undefined,
|
|
browserId: string,
|
|
ref: string,
|
|
registry: BrowserRegistry,
|
|
snapshotEngine: BrowserSnapshotEngine,
|
|
): Promise<AutomationCommandPayload> {
|
|
const target = resolveTabTarget({ requestId, workspaceId, browserId, registry });
|
|
if ("ok" in target) {
|
|
return target;
|
|
}
|
|
const result = await snapshotEngine.click({
|
|
browserId: target.browserId,
|
|
page: target.contents,
|
|
ref,
|
|
});
|
|
if (!result.ok) {
|
|
return staleRefFailure(requestId, ref);
|
|
}
|
|
return { requestId, ok: true, result: { command: "click", browserId: target.browserId, ref } };
|
|
}
|
|
|
|
async function executeFill(
|
|
requestId: string,
|
|
workspaceId: string | undefined,
|
|
browserId: string,
|
|
ref: string,
|
|
value: string,
|
|
registry: BrowserRegistry,
|
|
snapshotEngine: BrowserSnapshotEngine,
|
|
): Promise<AutomationCommandPayload> {
|
|
const target = resolveTabTarget({ requestId, workspaceId, browserId, registry });
|
|
if ("ok" in target) {
|
|
return target;
|
|
}
|
|
const result = await snapshotEngine.fill({
|
|
browserId: target.browserId,
|
|
page: target.contents,
|
|
ref,
|
|
value,
|
|
});
|
|
if (!result.ok) {
|
|
return staleRefFailure(requestId, ref);
|
|
}
|
|
return { requestId, ok: true, result: { command: "fill", browserId: target.browserId, ref } };
|
|
}
|
|
|
|
async function executeSelect(
|
|
requestId: string,
|
|
workspaceId: string | undefined,
|
|
browserId: string,
|
|
ref: string,
|
|
value: string,
|
|
registry: BrowserRegistry,
|
|
snapshotEngine: BrowserSnapshotEngine,
|
|
): Promise<AutomationCommandPayload> {
|
|
const target = resolveTabTarget({ requestId, workspaceId, browserId, registry });
|
|
if ("ok" in target) {
|
|
return target;
|
|
}
|
|
const result = await snapshotEngine.select({
|
|
browserId: target.browserId,
|
|
page: target.contents,
|
|
ref,
|
|
value,
|
|
});
|
|
if (!result.ok) {
|
|
return staleRefFailure(requestId, ref);
|
|
}
|
|
return {
|
|
requestId,
|
|
ok: true,
|
|
result: { command: "select", browserId: target.browserId, ref, value },
|
|
};
|
|
}
|
|
|
|
async function executeHover(
|
|
requestId: string,
|
|
workspaceId: string | undefined,
|
|
browserId: string,
|
|
ref: string,
|
|
registry: BrowserRegistry,
|
|
snapshotEngine: BrowserSnapshotEngine,
|
|
): Promise<AutomationCommandPayload> {
|
|
const target = resolveTabTarget({ requestId, workspaceId, browserId, registry });
|
|
if ("ok" in target) {
|
|
return target;
|
|
}
|
|
const result = await snapshotEngine.hover({
|
|
browserId: target.browserId,
|
|
page: target.contents,
|
|
ref,
|
|
});
|
|
if (!result.ok) {
|
|
return staleRefFailure(requestId, ref);
|
|
}
|
|
return { requestId, ok: true, result: { command: "hover", browserId: target.browserId, ref } };
|
|
}
|
|
|
|
async function executeDrag(
|
|
requestId: string,
|
|
workspaceId: string | undefined,
|
|
browserId: string,
|
|
sourceRef: string,
|
|
targetRef: string,
|
|
registry: BrowserRegistry,
|
|
snapshotEngine: BrowserSnapshotEngine,
|
|
): Promise<AutomationCommandPayload> {
|
|
const target = resolveTabTarget({ requestId, workspaceId, browserId, registry });
|
|
if ("ok" in target) {
|
|
return target;
|
|
}
|
|
const result = await snapshotEngine.drag({
|
|
browserId: target.browserId,
|
|
page: target.contents,
|
|
sourceRef,
|
|
targetRef,
|
|
});
|
|
if (!result.ok) {
|
|
return staleRefFailure(requestId, `${sourceRef}/${targetRef}`);
|
|
}
|
|
return {
|
|
requestId,
|
|
ok: true,
|
|
result: { command: "drag", browserId: target.browserId, sourceRef, targetRef },
|
|
};
|
|
}
|
|
|
|
async function executeLogs(
|
|
requestId: string,
|
|
workspaceId: string | undefined,
|
|
browserId: string,
|
|
maxEntries: number,
|
|
registry: BrowserRegistry,
|
|
): Promise<AutomationCommandPayload> {
|
|
const target = resolveTabTarget({ requestId, workspaceId, browserId, registry });
|
|
if ("ok" in target) {
|
|
return target;
|
|
}
|
|
const consoleMessages = target.contents.getConsoleMessages?.() ?? [];
|
|
const networkEntries = parseNetworkEntries(
|
|
await target.contents.executeJavaScript(NETWORK_PERFORMANCE_SCRIPT),
|
|
);
|
|
return {
|
|
requestId,
|
|
ok: true,
|
|
result: {
|
|
command: "logs",
|
|
browserId: target.browserId,
|
|
console: consoleMessages.slice(-maxEntries),
|
|
network: networkEntries.slice(-maxEntries),
|
|
},
|
|
};
|
|
}
|
|
|
|
function staleRefFailure(requestId: string, ref: string): FailurePayload {
|
|
return fail(
|
|
requestId,
|
|
"browser_stale_ref",
|
|
`Browser element reference ${ref} is stale. Take a new snapshot and try again.`,
|
|
);
|
|
}
|
|
|
|
async function executeWait(
|
|
requestId: string,
|
|
workspaceId: string | undefined,
|
|
browserId: string,
|
|
condition: { text?: string; url?: string; timeoutMs?: number },
|
|
registry: BrowserRegistry,
|
|
): Promise<AutomationCommandPayload> {
|
|
const target = resolveTabTarget({ requestId, workspaceId, browserId, registry });
|
|
if ("ok" in target) {
|
|
return target;
|
|
}
|
|
|
|
if (!condition.text && !condition.url) {
|
|
return fail(requestId, "browser_unsupported", "browser_wait requires text or url");
|
|
}
|
|
|
|
const timeoutMs = condition.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
|
|
const deadline = Date.now() + timeoutMs;
|
|
do {
|
|
if (condition.url && target.contents.getURL().includes(condition.url)) {
|
|
return {
|
|
requestId,
|
|
ok: true,
|
|
result: { command: "wait", browserId: target.browserId, matched: "url" },
|
|
};
|
|
}
|
|
if (condition.text) {
|
|
const pageText = await target.contents.executeJavaScript("document.body.innerText || ''");
|
|
if (typeof pageText === "string" && pageText.includes(condition.text)) {
|
|
return {
|
|
requestId,
|
|
ok: true,
|
|
result: { command: "wait", browserId: target.browserId, matched: "text" },
|
|
};
|
|
}
|
|
}
|
|
await delay(WAIT_POLL_INTERVAL_MS);
|
|
} while (Date.now() < deadline);
|
|
|
|
if (condition.text) {
|
|
return fail(
|
|
requestId,
|
|
"browser_timeout",
|
|
`Timed out waiting for browser text: ${condition.text}`,
|
|
true,
|
|
);
|
|
}
|
|
if (condition.url) {
|
|
return fail(
|
|
requestId,
|
|
"browser_timeout",
|
|
`Timed out waiting for browser URL: ${condition.url}`,
|
|
true,
|
|
);
|
|
}
|
|
return fail(requestId, "browser_unsupported", "browser_wait requires text or url");
|
|
}
|
|
|
|
async function executeType(
|
|
requestId: string,
|
|
workspaceId: string | undefined,
|
|
browserId: string,
|
|
ref: string | undefined,
|
|
text: string,
|
|
registry: BrowserRegistry,
|
|
snapshotEngine: BrowserSnapshotEngine,
|
|
): Promise<AutomationCommandPayload> {
|
|
const target = resolveTabTarget({ requestId, workspaceId, browserId, registry });
|
|
if ("ok" in target) {
|
|
return target;
|
|
}
|
|
const result = await snapshotEngine.typeText({
|
|
browserId: target.browserId,
|
|
page: target.contents,
|
|
...(ref ? { ref } : {}),
|
|
text,
|
|
});
|
|
if (!result.ok) {
|
|
return staleRefFailure(requestId, ref ?? "@e0");
|
|
}
|
|
return {
|
|
requestId,
|
|
ok: true,
|
|
result: { command: "type", browserId: target.browserId, ...(ref ? { ref } : {}) },
|
|
};
|
|
}
|
|
|
|
async function executeKeypress(
|
|
requestId: string,
|
|
workspaceId: string | undefined,
|
|
browserId: string,
|
|
ref: string | undefined,
|
|
key: string,
|
|
registry: BrowserRegistry,
|
|
snapshotEngine: BrowserSnapshotEngine,
|
|
): Promise<AutomationCommandPayload> {
|
|
const target = resolveTabTarget({ requestId, workspaceId, browserId, registry });
|
|
if ("ok" in target) {
|
|
return target;
|
|
}
|
|
const result = await snapshotEngine.keypress({
|
|
browserId: target.browserId,
|
|
page: target.contents,
|
|
...(ref ? { ref } : {}),
|
|
key,
|
|
});
|
|
if (!result.ok) {
|
|
return staleRefFailure(requestId, ref ?? "@e0");
|
|
}
|
|
return {
|
|
requestId,
|
|
ok: true,
|
|
result: { command: "keypress", browserId: target.browserId, key, ...(ref ? { ref } : {}) },
|
|
};
|
|
}
|
|
|
|
async function executeNavigate(
|
|
requestId: string,
|
|
workspaceId: string | undefined,
|
|
browserId: string,
|
|
url: string,
|
|
registry: BrowserRegistry,
|
|
): Promise<AutomationCommandPayload> {
|
|
const target = resolveTabTarget({ requestId, workspaceId, browserId, registry });
|
|
if ("ok" in target) {
|
|
return target;
|
|
}
|
|
if (!isAllowedPageUrl(url)) {
|
|
return fail(
|
|
requestId,
|
|
"browser_denied",
|
|
"Browser navigation only supports http and https URLs.",
|
|
);
|
|
}
|
|
await target.contents.loadURL(url);
|
|
return { requestId, ok: true, result: { command: "navigate", browserId: target.browserId, url } };
|
|
}
|
|
|
|
function executeNavigationAction(
|
|
requestId: string,
|
|
workspaceId: string | undefined,
|
|
browserId: string,
|
|
action: "back" | "forward" | "reload",
|
|
registry: BrowserRegistry,
|
|
): AutomationCommandPayload {
|
|
const target = resolveTabTarget({ requestId, workspaceId, browserId, registry });
|
|
if ("ok" in target) {
|
|
return target;
|
|
}
|
|
if (action === "back") {
|
|
target.contents.goBack();
|
|
return { requestId, ok: true, result: { command: "back", browserId: target.browserId } };
|
|
}
|
|
if (action === "forward") {
|
|
target.contents.goForward();
|
|
return { requestId, ok: true, result: { command: "forward", browserId: target.browserId } };
|
|
}
|
|
target.contents.reload();
|
|
return { requestId, ok: true, result: { command: "reload", browserId: target.browserId } };
|
|
}
|
|
|
|
async function executeScreenshot(
|
|
requestId: string,
|
|
workspaceId: string | undefined,
|
|
browserId: string,
|
|
fullPage: boolean,
|
|
registry: BrowserRegistry,
|
|
): Promise<AutomationCommandPayload> {
|
|
if (fullPage) {
|
|
return executeFullPageScreenshot(requestId, workspaceId, browserId, registry);
|
|
}
|
|
|
|
const target = resolveTabTarget({ requestId, workspaceId, browserId, registry });
|
|
if ("ok" in target) {
|
|
return target;
|
|
}
|
|
let image: TabImage;
|
|
try {
|
|
image = await capturePaintedViewport(target.contents);
|
|
} catch (error) {
|
|
if (isScreenshotNoFrameError(error)) {
|
|
return screenshotNoFrameFailure(requestId);
|
|
}
|
|
throw error;
|
|
}
|
|
const size = image.getSize();
|
|
return {
|
|
requestId,
|
|
ok: true,
|
|
result: {
|
|
command: "screenshot",
|
|
browserId: target.browserId,
|
|
mimeType: "image/png",
|
|
dataBase64: Buffer.from(image.toPNG()).toString("base64"),
|
|
width: size.width,
|
|
height: size.height,
|
|
},
|
|
};
|
|
}
|
|
|
|
interface CdpLayoutMetrics {
|
|
cssLayoutViewport?: {
|
|
clientWidth?: number;
|
|
clientHeight?: number;
|
|
};
|
|
layoutViewport?: {
|
|
clientWidth?: number;
|
|
clientHeight?: number;
|
|
};
|
|
cssContentSize?: {
|
|
width?: number;
|
|
height?: number;
|
|
};
|
|
contentSize?: {
|
|
width?: number;
|
|
height?: number;
|
|
};
|
|
}
|
|
|
|
interface CdpCaptureScreenshotResult {
|
|
data?: string;
|
|
}
|
|
|
|
async function getCdpLayoutMetrics(contents: TabContents): Promise<{
|
|
viewportWidth: number;
|
|
viewportHeight: number;
|
|
contentWidth: number;
|
|
contentHeight: number;
|
|
}> {
|
|
if (!contents.sendDebugCommand) {
|
|
return { viewportWidth: 0, viewportHeight: 0, contentWidth: 0, contentHeight: 0 };
|
|
}
|
|
const metrics = (await contents.sendDebugCommand("Page.getLayoutMetrics")) as CdpLayoutMetrics;
|
|
const viewport = metrics.cssLayoutViewport ?? metrics.layoutViewport;
|
|
const contentSize = metrics.cssContentSize ?? metrics.contentSize;
|
|
return {
|
|
viewportWidth: Math.ceil(viewport?.clientWidth ?? 0),
|
|
viewportHeight: Math.ceil(viewport?.clientHeight ?? 0),
|
|
contentWidth: Math.ceil(contentSize?.width ?? 0),
|
|
contentHeight: Math.ceil(contentSize?.height ?? 0),
|
|
};
|
|
}
|
|
|
|
async function executeFullPageScreenshot(
|
|
requestId: string,
|
|
workspaceId: string | undefined,
|
|
browserId: string,
|
|
registry: BrowserRegistry,
|
|
): Promise<AutomationCommandPayload> {
|
|
const target = resolveTabTarget({ requestId, workspaceId, browserId, registry });
|
|
if ("ok" in target) {
|
|
return target;
|
|
}
|
|
if (!target.contents.sendDebugCommand) {
|
|
return fail(requestId, "browser_unsupported", "browser_screenshot fullPage requires CDP");
|
|
}
|
|
const sendDebugCommand = target.contents.sendDebugCommand.bind(target.contents);
|
|
let screenshot: CdpCaptureScreenshotResult;
|
|
let width = 0;
|
|
let height = 0;
|
|
try {
|
|
screenshot = await runPreparedPixelCapture(target.contents, async () => {
|
|
const metrics = await getCdpLayoutMetrics(target.contents);
|
|
width = metrics.contentWidth;
|
|
height = metrics.contentHeight;
|
|
return (await sendDebugCommand("Page.captureScreenshot", {
|
|
format: "png",
|
|
captureBeyondViewport: true,
|
|
clip: { x: 0, y: 0, width, height, scale: 1 },
|
|
})) as CdpCaptureScreenshotResult;
|
|
});
|
|
} catch (error) {
|
|
if (isScreenshotNoFrameError(error)) {
|
|
return screenshotNoFrameFailure(requestId);
|
|
}
|
|
throw error;
|
|
}
|
|
if (!screenshot.data) {
|
|
return fail(requestId, "browser_unsupported", "browser_screenshot fullPage returned no data");
|
|
}
|
|
return {
|
|
requestId,
|
|
ok: true,
|
|
result: {
|
|
command: "screenshot",
|
|
browserId: target.browserId,
|
|
mimeType: "image/png",
|
|
dataBase64: screenshot.data,
|
|
width,
|
|
height,
|
|
},
|
|
};
|
|
}
|
|
|
|
function isAllowedPageUrl(value: string): boolean {
|
|
try {
|
|
return ALLOWED_PAGE_URL_PROTOCOLS.has(new URL(value).protocol);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function executeUpload(
|
|
requestId: string,
|
|
cwd: string | undefined,
|
|
workspaceId: string | undefined,
|
|
browserId: string,
|
|
input: { ref: string; filePaths: string[] },
|
|
registry: BrowserRegistry,
|
|
snapshotEngine: BrowserSnapshotEngine,
|
|
): Promise<AutomationCommandPayload> {
|
|
const target = resolveTabTarget({ requestId, workspaceId, browserId, registry });
|
|
if ("ok" in target) {
|
|
return target;
|
|
}
|
|
if (!target.contents.sendDebugCommand) {
|
|
return fail(requestId, "browser_unsupported", "browser_upload requires CDP");
|
|
}
|
|
const resolved = snapshotEngine.selectorForRef({
|
|
browserId: target.browserId,
|
|
page: target.contents,
|
|
ref: input.ref,
|
|
});
|
|
if (!resolved.ok) {
|
|
return staleRefFailure(requestId, input.ref);
|
|
}
|
|
const document = (await target.contents.sendDebugCommand("DOM.getDocument", {
|
|
depth: -1,
|
|
pierce: true,
|
|
})) as { root?: { nodeId?: number } };
|
|
const rootNodeId = document.root?.nodeId;
|
|
if (typeof rootNodeId !== "number") {
|
|
return fail(requestId, "browser_unsupported", "browser_upload could not read DOM");
|
|
}
|
|
const queried = (await target.contents.sendDebugCommand("DOM.querySelector", {
|
|
nodeId: rootNodeId,
|
|
selector: resolved.selector,
|
|
})) as { nodeId?: number };
|
|
if (typeof queried.nodeId !== "number" || queried.nodeId <= 0) {
|
|
return staleRefFailure(requestId, input.ref);
|
|
}
|
|
const workspaceRoot = resolveUploadWorkspaceRoot(cwd);
|
|
if (!workspaceRoot) {
|
|
return fail(requestId, "browser_unsupported", "browser_upload requires request cwd");
|
|
}
|
|
const filePaths = resolveWorkspaceFilePaths(input.filePaths, workspaceRoot);
|
|
if (!filePaths) {
|
|
return fail(
|
|
requestId,
|
|
"browser_unsupported",
|
|
"browser_upload only accepts files inside the agent workspace.",
|
|
);
|
|
}
|
|
|
|
await target.contents.sendDebugCommand("DOM.setFileInputFiles", {
|
|
nodeId: queried.nodeId,
|
|
files: filePaths,
|
|
});
|
|
return {
|
|
requestId,
|
|
ok: true,
|
|
result: {
|
|
command: "upload",
|
|
browserId: target.browserId,
|
|
ref: input.ref,
|
|
filePaths,
|
|
},
|
|
};
|
|
}
|
|
|
|
function resolveUploadWorkspaceRoot(cwd: string | undefined): string | null {
|
|
return cwd ? resolvePath(cwd) : null;
|
|
}
|
|
|
|
function resolveWorkspaceFilePaths(filePaths: string[], workspaceRoot: string): string[] | null {
|
|
const resolvedPaths = filePaths.map((filePath) =>
|
|
isAbsolute(filePath) ? resolvePath(filePath) : resolvePath(workspaceRoot, filePath),
|
|
);
|
|
if (resolvedPaths.some((filePath) => !isPathInsideDirectory(filePath, workspaceRoot))) {
|
|
return null;
|
|
}
|
|
return resolvedPaths;
|
|
}
|
|
|
|
function isPathInsideDirectory(filePath: string, directory: string): boolean {
|
|
const relativePath = relative(directory, filePath);
|
|
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
|
}
|
|
|
|
function delay(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function parseNetworkEntries(value: unknown): BrowserAutomationNetworkLogEntry[] {
|
|
const parsed = typeof value === "string" ? JSON.parse(value) : value;
|
|
if (!Array.isArray(parsed)) {
|
|
return [];
|
|
}
|
|
return parsed.flatMap((entry): BrowserAutomationNetworkLogEntry[] => {
|
|
if (!entry || typeof entry !== "object") {
|
|
return [];
|
|
}
|
|
const record = entry as Record<string, unknown>;
|
|
const url = readString(record.url);
|
|
const startTime = readNumber(record.startTime);
|
|
const duration = readNumber(record.duration);
|
|
if (!url || startTime === null || duration === null) {
|
|
return [];
|
|
}
|
|
return [
|
|
{
|
|
url,
|
|
...(readString(record.method) ? { method: readString(record.method) ?? undefined } : {}),
|
|
...(readNumber(record.status) !== null
|
|
? { status: readNumber(record.status) ?? undefined }
|
|
: {}),
|
|
...(readString(record.type) ? { type: readString(record.type) ?? undefined } : {}),
|
|
startTime,
|
|
duration,
|
|
...(readNumber(record.transferSize) !== null
|
|
? { transferSize: readNumber(record.transferSize) ?? undefined }
|
|
: {}),
|
|
},
|
|
];
|
|
});
|
|
}
|
|
|
|
function readString(value: unknown): string | null {
|
|
return typeof value === "string" ? value : null;
|
|
}
|
|
|
|
function readNumber(value: unknown): number | null {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
const NETWORK_PERFORMANCE_SCRIPT = String.raw`(() => {
|
|
const entries = performance.getEntriesByType('resource')
|
|
.concat(performance.getEntriesByType('navigation'))
|
|
.slice(-200)
|
|
.map((entry) => ({
|
|
url: entry.name,
|
|
method: entry.initiatorType === 'navigation' ? 'GET' : undefined,
|
|
type: entry.initiatorType,
|
|
startTime: entry.startTime,
|
|
duration: entry.duration,
|
|
transferSize: typeof entry.transferSize === 'number' ? entry.transferSize : undefined,
|
|
}));
|
|
return JSON.stringify(entries);
|
|
})()`;
|
|
|
|
function resolveTabTarget(input: {
|
|
requestId: string;
|
|
workspaceId: string | undefined;
|
|
browserId: string;
|
|
registry: BrowserRegistry;
|
|
}): ResolvedTabTarget | FailurePayload {
|
|
const { requestId, workspaceId, browserId, registry } = input;
|
|
if (workspaceId && registry.getBrowserWorkspaceId(browserId) !== workspaceId) {
|
|
return fail(requestId, "browser_tab_not_found", `No browser tab found for ID: ${browserId}`);
|
|
}
|
|
|
|
const contents = registry.getTabContents(browserId);
|
|
if (!contents) {
|
|
return fail(requestId, "browser_tab_not_found", `No browser tab found for ID: ${browserId}`);
|
|
}
|
|
|
|
if (contents.isDestroyed()) {
|
|
return fail(requestId, "browser_tab_closed", `Browser tab ${browserId} has been closed`);
|
|
}
|
|
|
|
return { browserId, contents };
|
|
}
|