refactor(src): split oversized modules

This commit is contained in:
Peter Steinberger
2026-01-14 01:08:15 +00:00
parent b2179de839
commit bcbfb357be
675 changed files with 91476 additions and 73453 deletions

BIN
src/browser/.DS_Store vendored Normal file

Binary file not shown.

122
src/browser/cdp.helpers.ts Normal file
View File

@@ -0,0 +1,122 @@
import WebSocket from "ws";
import { rawDataToString } from "../infra/ws.js";
type CdpResponse = {
id: number;
result?: unknown;
error?: { message?: string };
};
type Pending = {
resolve: (value: unknown) => void;
reject: (err: Error) => void;
};
export type CdpSendFn = (
method: string,
params?: Record<string, unknown>,
) => Promise<unknown>;
export function isLoopbackHost(host: string) {
const h = host.trim().toLowerCase();
return (
h === "localhost" ||
h === "127.0.0.1" ||
h === "0.0.0.0" ||
h === "[::1]" ||
h === "::1" ||
h === "[::]" ||
h === "::"
);
}
function createCdpSender(ws: WebSocket) {
let nextId = 1;
const pending = new Map<number, Pending>();
const send: CdpSendFn = (
method: string,
params?: Record<string, unknown>,
) => {
const id = nextId++;
const msg = { id, method, params };
ws.send(JSON.stringify(msg));
return new Promise<unknown>((resolve, reject) => {
pending.set(id, { resolve, reject });
});
};
const closeWithError = (err: Error) => {
for (const [, p] of pending) p.reject(err);
pending.clear();
try {
ws.close();
} catch {
// ignore
}
};
ws.on("message", (data) => {
try {
const parsed = JSON.parse(rawDataToString(data)) as CdpResponse;
if (typeof parsed.id !== "number") return;
const p = pending.get(parsed.id);
if (!p) return;
pending.delete(parsed.id);
if (parsed.error?.message) {
p.reject(new Error(parsed.error.message));
return;
}
p.resolve(parsed.result);
} catch {
// ignore
}
});
ws.on("close", () => {
closeWithError(new Error("CDP socket closed"));
});
return { send, closeWithError };
}
export async function fetchJson<T>(url: string, timeoutMs = 1500): Promise<T> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as T;
} finally {
clearTimeout(t);
}
}
export async function withCdpSocket<T>(
wsUrl: string,
fn: (send: CdpSendFn) => Promise<T>,
): Promise<T> {
const ws = new WebSocket(wsUrl, { handshakeTimeout: 5000 });
const { send, closeWithError } = createCdpSender(ws);
const openPromise = new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", (err) => reject(err));
});
await openPromise;
try {
return await fn(send);
} catch (err) {
closeWithError(err instanceof Error ? err : new Error(String(err)));
throw err;
} finally {
try {
ws.close();
} catch {
// ignore
}
}
}

View File

@@ -1,35 +1,4 @@
import WebSocket from "ws";
import { rawDataToString } from "../infra/ws.js";
type CdpResponse = {
id: number;
result?: unknown;
error?: { message?: string };
};
type Pending = {
resolve: (value: unknown) => void;
reject: (err: Error) => void;
};
type CdpSendFn = (
method: string,
params?: Record<string, unknown>,
) => Promise<unknown>;
function isLoopbackHost(host: string) {
const h = host.trim().toLowerCase();
return (
h === "localhost" ||
h === "127.0.0.1" ||
h === "0.0.0.0" ||
h === "[::1]" ||
h === "::1" ||
h === "[::]" ||
h === "::"
);
}
import { fetchJson, isLoopbackHost, withCdpSocket } from "./cdp.helpers.js";
export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
const ws = new URL(wsUrl);
@@ -43,96 +12,6 @@ export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
return ws.toString();
}
function createCdpSender(ws: WebSocket) {
let nextId = 1;
const pending = new Map<number, Pending>();
const send: CdpSendFn = (
method: string,
params?: Record<string, unknown>,
) => {
const id = nextId++;
const msg = { id, method, params };
ws.send(JSON.stringify(msg));
return new Promise<unknown>((resolve, reject) => {
pending.set(id, { resolve, reject });
});
};
const closeWithError = (err: Error) => {
for (const [, p] of pending) p.reject(err);
pending.clear();
try {
ws.close();
} catch {
// ignore
}
};
ws.on("message", (data) => {
try {
const parsed = JSON.parse(rawDataToString(data)) as CdpResponse;
if (typeof parsed.id !== "number") return;
const p = pending.get(parsed.id);
if (!p) return;
pending.delete(parsed.id);
if (parsed.error?.message) {
p.reject(new Error(parsed.error.message));
return;
}
p.resolve(parsed.result);
} catch {
// ignore
}
});
ws.on("close", () => {
closeWithError(new Error("CDP socket closed"));
});
return { send, closeWithError };
}
async function fetchJson<T>(url: string, timeoutMs = 1500): Promise<T> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as T;
} finally {
clearTimeout(t);
}
}
async function withCdpSocket<T>(
wsUrl: string,
fn: (send: CdpSendFn) => Promise<T>,
): Promise<T> {
const ws = new WebSocket(wsUrl, { handshakeTimeout: 5000 });
const { send, closeWithError } = createCdpSender(ws);
const openPromise = new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", (err) => reject(err));
});
await openPromise;
try {
return await fn(send);
} catch (err) {
closeWithError(err instanceof Error ? err : new Error(String(err)));
throw err;
} finally {
try {
ws.close();
} catch {
// ignore
}
}
}
export async function captureScreenshotPng(opts: {
wsUrl: string;
fullPage?: boolean;

View File

@@ -0,0 +1,166 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { ResolvedBrowserConfig } from "./config.js";
export type BrowserExecutable = {
kind: "canary" | "chromium" | "chrome" | "custom";
path: string;
};
function exists(filePath: string) {
try {
return fs.existsSync(filePath);
} catch {
return false;
}
}
function findFirstExecutable(
candidates: Array<BrowserExecutable>,
): BrowserExecutable | null {
for (const candidate of candidates) {
if (exists(candidate.path)) return candidate;
}
return null;
}
export function findChromeExecutableMac(): BrowserExecutable | null {
const candidates: Array<BrowserExecutable> = [
{
kind: "canary",
path: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
},
{
kind: "canary",
path: path.join(
os.homedir(),
"Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
),
},
{
kind: "chromium",
path: "/Applications/Chromium.app/Contents/MacOS/Chromium",
},
{
kind: "chromium",
path: path.join(
os.homedir(),
"Applications/Chromium.app/Contents/MacOS/Chromium",
),
},
{
kind: "chrome",
path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
},
{
kind: "chrome",
path: path.join(
os.homedir(),
"Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
),
},
];
return findFirstExecutable(candidates);
}
export function findChromeExecutableLinux(): BrowserExecutable | null {
const candidates: Array<BrowserExecutable> = [
{ kind: "chrome", path: "/usr/bin/google-chrome" },
{ kind: "chrome", path: "/usr/bin/google-chrome-stable" },
{ kind: "chromium", path: "/usr/bin/chromium" },
{ kind: "chromium", path: "/usr/bin/chromium-browser" },
{ kind: "chromium", path: "/snap/bin/chromium" },
{ kind: "chrome", path: "/usr/bin/chrome" },
];
return findFirstExecutable(candidates);
}
export function findChromeExecutableWindows(): BrowserExecutable | null {
const localAppData = process.env.LOCALAPPDATA ?? "";
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
// Must use bracket notation: variable name contains parentheses
const programFilesX86 =
process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
const joinWin = path.win32.join;
const candidates: Array<BrowserExecutable> = [];
if (localAppData) {
// Chrome Canary (user install)
candidates.push({
kind: "canary",
path: joinWin(
localAppData,
"Google",
"Chrome SxS",
"Application",
"chrome.exe",
),
});
// Chromium (user install)
candidates.push({
kind: "chromium",
path: joinWin(localAppData, "Chromium", "Application", "chrome.exe"),
});
// Chrome (user install)
candidates.push({
kind: "chrome",
path: joinWin(
localAppData,
"Google",
"Chrome",
"Application",
"chrome.exe",
),
});
}
// Chrome (system install, 64-bit)
candidates.push({
kind: "chrome",
path: joinWin(
programFiles,
"Google",
"Chrome",
"Application",
"chrome.exe",
),
});
// Chrome (system install, 32-bit on 64-bit Windows)
candidates.push({
kind: "chrome",
path: joinWin(
programFilesX86,
"Google",
"Chrome",
"Application",
"chrome.exe",
),
});
return findFirstExecutable(candidates);
}
export function resolveBrowserExecutableForPlatform(
resolved: ResolvedBrowserConfig,
platform: NodeJS.Platform,
): BrowserExecutable | null {
if (resolved.executablePath) {
if (!exists(resolved.executablePath)) {
throw new Error(
`browser.executablePath not found: ${resolved.executablePath}`,
);
}
return { kind: "custom", path: resolved.executablePath };
}
if (platform === "darwin") return findChromeExecutableMac();
if (platform === "linux") return findChromeExecutableLinux();
if (platform === "win32") return findChromeExecutableWindows();
return null;
}

View File

@@ -0,0 +1,221 @@
import fs from "node:fs";
import path from "node:path";
import {
DEFAULT_CLAWD_BROWSER_COLOR,
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
} from "./constants.js";
function decoratedMarkerPath(userDataDir: string) {
return path.join(userDataDir, ".clawd-profile-decorated");
}
function safeReadJson(filePath: string): Record<string, unknown> | null {
try {
if (!fs.existsSync(filePath)) return null;
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
function safeWriteJson(filePath: string, data: Record<string, unknown>) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
}
function setDeep(obj: Record<string, unknown>, keys: string[], value: unknown) {
let node: Record<string, unknown> = obj;
for (const key of keys.slice(0, -1)) {
const next = node[key];
if (typeof next !== "object" || next === null || Array.isArray(next)) {
node[key] = {};
}
node = node[key] as Record<string, unknown>;
}
node[keys[keys.length - 1] ?? ""] = value;
}
function parseHexRgbToSignedArgbInt(hex: string): number | null {
const cleaned = hex.trim().replace(/^#/, "");
if (!/^[0-9a-fA-F]{6}$/.test(cleaned)) return null;
const rgb = Number.parseInt(cleaned, 16);
const argbUnsigned = (0xff << 24) | rgb;
// Chrome stores colors as signed 32-bit ints (SkColor).
return argbUnsigned > 0x7fffffff
? argbUnsigned - 0x1_0000_0000
: argbUnsigned;
}
export function isProfileDecorated(
userDataDir: string,
desiredName: string,
desiredColorHex: string,
): boolean {
const desiredColorInt = parseHexRgbToSignedArgbInt(desiredColorHex);
const localStatePath = path.join(userDataDir, "Local State");
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
const localState = safeReadJson(localStatePath);
const profile = localState?.profile;
const infoCache =
typeof profile === "object" && profile !== null && !Array.isArray(profile)
? (profile as Record<string, unknown>).info_cache
: null;
const info =
typeof infoCache === "object" &&
infoCache !== null &&
!Array.isArray(infoCache) &&
typeof (infoCache as Record<string, unknown>).Default === "object" &&
(infoCache as Record<string, unknown>).Default !== null &&
!Array.isArray((infoCache as Record<string, unknown>).Default)
? ((infoCache as Record<string, unknown>).Default as Record<
string,
unknown
>)
: null;
const prefs = safeReadJson(preferencesPath);
const browserTheme = (() => {
const browser = prefs?.browser;
const theme =
typeof browser === "object" && browser !== null && !Array.isArray(browser)
? (browser as Record<string, unknown>).theme
: null;
return typeof theme === "object" && theme !== null && !Array.isArray(theme)
? (theme as Record<string, unknown>)
: null;
})();
const autogeneratedTheme = (() => {
const autogenerated = prefs?.autogenerated;
const theme =
typeof autogenerated === "object" &&
autogenerated !== null &&
!Array.isArray(autogenerated)
? (autogenerated as Record<string, unknown>).theme
: null;
return typeof theme === "object" && theme !== null && !Array.isArray(theme)
? (theme as Record<string, unknown>)
: null;
})();
const nameOk =
typeof info?.name === "string" ? info.name === desiredName : true;
if (desiredColorInt == null) {
// If the user provided a non-#RRGGBB value, we can only do best-effort.
return nameOk;
}
const localSeedOk =
typeof info?.profile_color_seed === "number"
? info.profile_color_seed === desiredColorInt
: false;
const prefOk =
(typeof browserTheme?.user_color2 === "number" &&
browserTheme.user_color2 === desiredColorInt) ||
(typeof autogeneratedTheme?.color === "number" &&
autogeneratedTheme.color === desiredColorInt);
return nameOk && localSeedOk && prefOk;
}
/**
* Best-effort profile decoration (name + lobster-orange). Chrome preference keys
* vary by version; we keep this conservative and idempotent.
*/
export function decorateClawdProfile(
userDataDir: string,
opts?: { name?: string; color?: string },
) {
const desiredName = opts?.name ?? DEFAULT_CLAWD_BROWSER_PROFILE_NAME;
const desiredColor = (
opts?.color ?? DEFAULT_CLAWD_BROWSER_COLOR
).toUpperCase();
const desiredColorInt = parseHexRgbToSignedArgbInt(desiredColor);
const localStatePath = path.join(userDataDir, "Local State");
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
const localState = safeReadJson(localStatePath) ?? {};
// Common-ish shape: profile.info_cache.Default
setDeep(
localState,
["profile", "info_cache", "Default", "name"],
desiredName,
);
setDeep(
localState,
["profile", "info_cache", "Default", "shortcut_name"],
desiredName,
);
setDeep(
localState,
["profile", "info_cache", "Default", "user_name"],
desiredName,
);
// Color keys are best-effort (Chrome changes these frequently).
setDeep(
localState,
["profile", "info_cache", "Default", "profile_color"],
desiredColor,
);
setDeep(
localState,
["profile", "info_cache", "Default", "user_color"],
desiredColor,
);
if (desiredColorInt != null) {
// These are the fields Chrome actually uses for profile/avatar tinting.
setDeep(
localState,
["profile", "info_cache", "Default", "profile_color_seed"],
desiredColorInt,
);
setDeep(
localState,
["profile", "info_cache", "Default", "profile_highlight_color"],
desiredColorInt,
);
setDeep(
localState,
["profile", "info_cache", "Default", "default_avatar_fill_color"],
desiredColorInt,
);
setDeep(
localState,
["profile", "info_cache", "Default", "default_avatar_stroke_color"],
desiredColorInt,
);
}
safeWriteJson(localStatePath, localState);
const prefs = safeReadJson(preferencesPath) ?? {};
setDeep(prefs, ["profile", "name"], desiredName);
setDeep(prefs, ["profile", "profile_color"], desiredColor);
setDeep(prefs, ["profile", "user_color"], desiredColor);
if (desiredColorInt != null) {
// Chrome refresh stores the autogenerated theme in these prefs (SkColor ints).
setDeep(prefs, ["autogenerated", "theme", "color"], desiredColorInt);
// User-selected browser theme color (pref name: browser.theme.user_color2).
setDeep(prefs, ["browser", "theme", "user_color2"], desiredColorInt);
}
safeWriteJson(preferencesPath, prefs);
try {
fs.writeFileSync(
decoratedMarkerPath(userDataDir),
`${Date.now()}\n`,
"utf-8",
);
} catch {
// ignore
}
}

View File

@@ -8,6 +8,14 @@ import { ensurePortAvailable } from "../infra/ports.js";
import { createSubsystemLogger } from "../logging.js";
import { CONFIG_DIR } from "../utils.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import {
type BrowserExecutable,
resolveBrowserExecutableForPlatform,
} from "./chrome.executables.js";
import {
decorateClawdProfile,
isProfileDecorated,
} from "./chrome.profile-decoration.js";
import type {
ResolvedBrowserConfig,
ResolvedBrowserProfile,
@@ -19,19 +27,17 @@ import {
const log = createSubsystemLogger("browser").child("chrome");
export type BrowserExecutable = {
kind: "canary" | "chromium" | "chrome" | "custom";
path: string;
};
export type RunningChrome = {
pid: number;
exe: BrowserExecutable;
userDataDir: string;
cdpPort: number;
startedAt: number;
proc: ChildProcessWithoutNullStreams;
};
export type { BrowserExecutable } from "./chrome.executables.js";
export {
findChromeExecutableLinux,
findChromeExecutableMac,
findChromeExecutableWindows,
resolveBrowserExecutableForPlatform,
} from "./chrome.executables.js";
export {
decorateClawdProfile,
isProfileDecorated,
} from "./chrome.profile-decoration.js";
function exists(filePath: string) {
try {
@@ -41,153 +47,14 @@ function exists(filePath: string) {
}
}
function findFirstExecutable(
candidates: Array<BrowserExecutable>,
): BrowserExecutable | null {
for (const candidate of candidates) {
if (exists(candidate.path)) return candidate;
}
return null;
}
export function findChromeExecutableMac(): BrowserExecutable | null {
const candidates: Array<BrowserExecutable> = [
{
kind: "canary",
path: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
},
{
kind: "canary",
path: path.join(
os.homedir(),
"Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
),
},
{
kind: "chromium",
path: "/Applications/Chromium.app/Contents/MacOS/Chromium",
},
{
kind: "chromium",
path: path.join(
os.homedir(),
"Applications/Chromium.app/Contents/MacOS/Chromium",
),
},
{
kind: "chrome",
path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
},
{
kind: "chrome",
path: path.join(
os.homedir(),
"Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
),
},
];
return findFirstExecutable(candidates);
}
export function findChromeExecutableLinux(): BrowserExecutable | null {
const candidates: Array<BrowserExecutable> = [
{ kind: "chrome", path: "/usr/bin/google-chrome" },
{ kind: "chrome", path: "/usr/bin/google-chrome-stable" },
{ kind: "chromium", path: "/usr/bin/chromium" },
{ kind: "chromium", path: "/usr/bin/chromium-browser" },
{ kind: "chromium", path: "/snap/bin/chromium" },
{ kind: "chrome", path: "/usr/bin/chrome" },
];
return findFirstExecutable(candidates);
}
export function findChromeExecutableWindows(): BrowserExecutable | null {
const localAppData = process.env.LOCALAPPDATA ?? "";
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
// Must use bracket notation: variable name contains parentheses
const programFilesX86 =
process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
const joinWin = path.win32.join;
const candidates: Array<BrowserExecutable> = [];
if (localAppData) {
// Chrome Canary (user install)
candidates.push({
kind: "canary",
path: joinWin(
localAppData,
"Google",
"Chrome SxS",
"Application",
"chrome.exe",
),
});
// Chromium (user install)
candidates.push({
kind: "chromium",
path: joinWin(localAppData, "Chromium", "Application", "chrome.exe"),
});
// Chrome (user install)
candidates.push({
kind: "chrome",
path: joinWin(
localAppData,
"Google",
"Chrome",
"Application",
"chrome.exe",
),
});
}
// Chrome (system install, 64-bit)
candidates.push({
kind: "chrome",
path: joinWin(
programFiles,
"Google",
"Chrome",
"Application",
"chrome.exe",
),
});
// Chrome (system install, 32-bit on 64-bit Windows)
candidates.push({
kind: "chrome",
path: joinWin(
programFilesX86,
"Google",
"Chrome",
"Application",
"chrome.exe",
),
});
return findFirstExecutable(candidates);
}
export function resolveBrowserExecutableForPlatform(
resolved: ResolvedBrowserConfig,
platform: NodeJS.Platform,
): BrowserExecutable | null {
if (resolved.executablePath) {
if (!exists(resolved.executablePath)) {
throw new Error(
`browser.executablePath not found: ${resolved.executablePath}`,
);
}
return { kind: "custom", path: resolved.executablePath };
}
if (platform === "darwin") return findChromeExecutableMac();
if (platform === "linux") return findChromeExecutableLinux();
if (platform === "win32") return findChromeExecutableWindows();
return null;
}
export type RunningChrome = {
pid: number;
exe: BrowserExecutable;
userDataDir: string;
cdpPort: number;
startedAt: number;
proc: ChildProcessWithoutNullStreams;
};
function resolveBrowserExecutable(
resolved: ResolvedBrowserConfig,
@@ -201,223 +68,10 @@ export function resolveClawdUserDataDir(
return path.join(CONFIG_DIR, "browser", profileName, "user-data");
}
function decoratedMarkerPath(userDataDir: string) {
return path.join(userDataDir, ".clawd-profile-decorated");
}
function safeReadJson(filePath: string): Record<string, unknown> | null {
try {
if (!exists(filePath)) return null;
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
function safeWriteJson(filePath: string, data: Record<string, unknown>) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
}
function cdpUrlForPort(cdpPort: number) {
return `http://127.0.0.1:${cdpPort}`;
}
function setDeep(obj: Record<string, unknown>, keys: string[], value: unknown) {
let node: Record<string, unknown> = obj;
for (const key of keys.slice(0, -1)) {
const next = node[key];
if (typeof next !== "object" || next === null || Array.isArray(next)) {
node[key] = {};
}
node = node[key] as Record<string, unknown>;
}
node[keys[keys.length - 1] ?? ""] = value;
}
function parseHexRgbToSignedArgbInt(hex: string): number | null {
const cleaned = hex.trim().replace(/^#/, "");
if (!/^[0-9a-fA-F]{6}$/.test(cleaned)) return null;
const rgb = Number.parseInt(cleaned, 16);
const argbUnsigned = (0xff << 24) | rgb;
// Chrome stores colors as signed 32-bit ints (SkColor).
return argbUnsigned > 0x7fffffff
? argbUnsigned - 0x1_0000_0000
: argbUnsigned;
}
function isProfileDecorated(
userDataDir: string,
desiredName: string,
desiredColorHex: string,
): boolean {
const desiredColorInt = parseHexRgbToSignedArgbInt(desiredColorHex);
const localStatePath = path.join(userDataDir, "Local State");
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
const localState = safeReadJson(localStatePath);
const profile = localState?.profile;
const infoCache =
typeof profile === "object" && profile !== null && !Array.isArray(profile)
? (profile as Record<string, unknown>).info_cache
: null;
const info =
typeof infoCache === "object" &&
infoCache !== null &&
!Array.isArray(infoCache) &&
typeof (infoCache as Record<string, unknown>).Default === "object" &&
(infoCache as Record<string, unknown>).Default !== null &&
!Array.isArray((infoCache as Record<string, unknown>).Default)
? ((infoCache as Record<string, unknown>).Default as Record<
string,
unknown
>)
: null;
const prefs = safeReadJson(preferencesPath);
const browserTheme = (() => {
const browser = prefs?.browser;
const theme =
typeof browser === "object" && browser !== null && !Array.isArray(browser)
? (browser as Record<string, unknown>).theme
: null;
return typeof theme === "object" && theme !== null && !Array.isArray(theme)
? (theme as Record<string, unknown>)
: null;
})();
const autogeneratedTheme = (() => {
const autogenerated = prefs?.autogenerated;
const theme =
typeof autogenerated === "object" &&
autogenerated !== null &&
!Array.isArray(autogenerated)
? (autogenerated as Record<string, unknown>).theme
: null;
return typeof theme === "object" && theme !== null && !Array.isArray(theme)
? (theme as Record<string, unknown>)
: null;
})();
const nameOk =
typeof info?.name === "string" ? info.name === desiredName : true;
if (desiredColorInt == null) {
// If the user provided a non-#RRGGBB value, we can only do best-effort.
return nameOk;
}
const localSeedOk =
typeof info?.profile_color_seed === "number"
? info.profile_color_seed === desiredColorInt
: false;
const prefOk =
(typeof browserTheme?.user_color2 === "number" &&
browserTheme.user_color2 === desiredColorInt) ||
(typeof autogeneratedTheme?.color === "number" &&
autogeneratedTheme.color === desiredColorInt);
return nameOk && localSeedOk && prefOk;
}
/**
* Best-effort profile decoration (name + lobster-orange). Chrome preference keys
* vary by version; we keep this conservative and idempotent.
*/
export function decorateClawdProfile(
userDataDir: string,
opts?: { name?: string; color?: string },
) {
const desiredName = opts?.name ?? DEFAULT_CLAWD_BROWSER_PROFILE_NAME;
const desiredColor = (
opts?.color ?? DEFAULT_CLAWD_BROWSER_COLOR
).toUpperCase();
const desiredColorInt = parseHexRgbToSignedArgbInt(desiredColor);
const localStatePath = path.join(userDataDir, "Local State");
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
const localState = safeReadJson(localStatePath) ?? {};
// Common-ish shape: profile.info_cache.Default
setDeep(
localState,
["profile", "info_cache", "Default", "name"],
desiredName,
);
setDeep(
localState,
["profile", "info_cache", "Default", "shortcut_name"],
desiredName,
);
setDeep(
localState,
["profile", "info_cache", "Default", "user_name"],
desiredName,
);
// Color keys are best-effort (Chrome changes these frequently).
setDeep(
localState,
["profile", "info_cache", "Default", "profile_color"],
desiredColor,
);
setDeep(
localState,
["profile", "info_cache", "Default", "user_color"],
desiredColor,
);
if (desiredColorInt != null) {
// These are the fields Chrome actually uses for profile/avatar tinting.
setDeep(
localState,
["profile", "info_cache", "Default", "profile_color_seed"],
desiredColorInt,
);
setDeep(
localState,
["profile", "info_cache", "Default", "profile_highlight_color"],
desiredColorInt,
);
setDeep(
localState,
["profile", "info_cache", "Default", "default_avatar_fill_color"],
desiredColorInt,
);
setDeep(
localState,
["profile", "info_cache", "Default", "default_avatar_stroke_color"],
desiredColorInt,
);
}
safeWriteJson(localStatePath, localState);
const prefs = safeReadJson(preferencesPath) ?? {};
setDeep(prefs, ["profile", "name"], desiredName);
setDeep(prefs, ["profile", "profile_color"], desiredColor);
setDeep(prefs, ["profile", "user_color"], desiredColor);
if (desiredColorInt != null) {
// Chrome refresh stores the autogenerated theme in these prefs (SkColor ints).
setDeep(prefs, ["autogenerated", "theme", "color"], desiredColorInt);
// User-selected browser theme color (pref name: browser.theme.user_color2).
setDeep(prefs, ["browser", "theme", "user_color2"], desiredColorInt);
}
safeWriteJson(preferencesPath, prefs);
try {
fs.writeFileSync(
decoratedMarkerPath(userDataDir),
`${Date.now()}\n`,
"utf-8",
);
} catch {
// ignore
}
}
export async function isChromeReachable(
cdpUrl: string,
timeoutMs = 500,

View File

@@ -0,0 +1,160 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
let currentPage: Record<string, unknown> | null = null;
let currentRefLocator: Record<string, unknown> | null = null;
let pageState: {
console: unknown[];
armIdUpload: number;
armIdDialog: number;
armIdDownload: number;
};
const sessionMocks = vi.hoisted(() => ({
getPageForTargetId: vi.fn(async () => {
if (!currentPage) throw new Error("missing page");
return currentPage;
}),
ensurePageState: vi.fn(() => pageState),
refLocator: vi.fn(() => {
if (!currentRefLocator) throw new Error("missing locator");
return currentRefLocator;
}),
}));
vi.mock("./pw-session.js", () => sessionMocks);
async function importModule() {
return await import("./pw-tools-core.js");
}
describe("pw-tools-core", () => {
beforeEach(() => {
currentPage = null;
currentRefLocator = null;
pageState = {
console: [],
armIdUpload: 0,
armIdDialog: 0,
armIdDownload: 0,
};
for (const fn of Object.values(sessionMocks)) fn.mockClear();
});
it("screenshots an element selector", async () => {
const elementScreenshot = vi.fn(async () => Buffer.from("E"));
currentPage = {
locator: vi.fn(() => ({
first: () => ({ screenshot: elementScreenshot }),
})),
screenshot: vi.fn(async () => Buffer.from("P")),
};
const mod = await importModule();
const res = await mod.takeScreenshotViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
element: "#main",
type: "png",
});
expect(res.buffer.toString()).toBe("E");
expect(sessionMocks.getPageForTargetId).toHaveBeenCalled();
expect(
currentPage.locator as ReturnType<typeof vi.fn>,
).toHaveBeenCalledWith("#main");
expect(elementScreenshot).toHaveBeenCalledWith({ type: "png" });
});
it("screenshots a ref locator", async () => {
const refScreenshot = vi.fn(async () => Buffer.from("R"));
currentRefLocator = { screenshot: refScreenshot };
currentPage = {
locator: vi.fn(),
screenshot: vi.fn(async () => Buffer.from("P")),
};
const mod = await importModule();
const res = await mod.takeScreenshotViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "76",
type: "jpeg",
});
expect(res.buffer.toString()).toBe("R");
expect(sessionMocks.refLocator).toHaveBeenCalledWith(currentPage, "76");
expect(refScreenshot).toHaveBeenCalledWith({ type: "jpeg" });
});
it("rejects fullPage for element or ref screenshots", async () => {
currentRefLocator = { screenshot: vi.fn(async () => Buffer.from("R")) };
currentPage = {
locator: vi.fn(() => ({
first: () => ({ screenshot: vi.fn(async () => Buffer.from("E")) }),
})),
screenshot: vi.fn(async () => Buffer.from("P")),
};
const mod = await importModule();
await expect(
mod.takeScreenshotViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
element: "#x",
fullPage: true,
}),
).rejects.toThrow(/fullPage is not supported/i);
await expect(
mod.takeScreenshotViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
fullPage: true,
}),
).rejects.toThrow(/fullPage is not supported/i);
});
it("arms the next file chooser and sets files (default timeout)", async () => {
const fileChooser = { setFiles: vi.fn(async () => {}) };
const waitForEvent = vi.fn(
async (_event: string, _opts: unknown) => fileChooser,
);
currentPage = {
waitForEvent,
keyboard: { press: vi.fn(async () => {}) },
};
const mod = await importModule();
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
paths: ["/tmp/a.txt"],
});
// waitForEvent is awaited immediately; handler continues async.
await Promise.resolve();
expect(waitForEvent).toHaveBeenCalledWith("filechooser", {
timeout: 120_000,
});
expect(fileChooser.setFiles).toHaveBeenCalledWith(["/tmp/a.txt"]);
});
it("arms the next file chooser and escapes if no paths provided", async () => {
const fileChooser = { setFiles: vi.fn(async () => {}) };
const press = vi.fn(async () => {});
const waitForEvent = vi.fn(async () => fileChooser);
currentPage = {
waitForEvent,
keyboard: { press },
};
const mod = await importModule();
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
paths: [],
});
await Promise.resolve();
expect(fileChooser.setFiles).not.toHaveBeenCalled();
expect(press).toHaveBeenCalledWith("Escape");
});
});

View File

@@ -0,0 +1,167 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
let currentPage: Record<string, unknown> | null = null;
let currentRefLocator: Record<string, unknown> | null = null;
let pageState: {
console: unknown[];
armIdUpload: number;
armIdDialog: number;
armIdDownload: number;
};
const sessionMocks = vi.hoisted(() => ({
getPageForTargetId: vi.fn(async () => {
if (!currentPage) throw new Error("missing page");
return currentPage;
}),
ensurePageState: vi.fn(() => pageState),
refLocator: vi.fn(() => {
if (!currentRefLocator) throw new Error("missing locator");
return currentRefLocator;
}),
}));
vi.mock("./pw-session.js", () => sessionMocks);
async function importModule() {
return await import("./pw-tools-core.js");
}
describe("pw-tools-core", () => {
beforeEach(() => {
currentPage = null;
currentRefLocator = null;
pageState = {
console: [],
armIdUpload: 0,
armIdDialog: 0,
armIdDownload: 0,
};
for (const fn of Object.values(sessionMocks)) fn.mockClear();
});
it("last file-chooser arm wins", async () => {
let resolve1: ((value: unknown) => void) | null = null;
let resolve2: ((value: unknown) => void) | null = null;
const fc1 = { setFiles: vi.fn(async () => {}) };
const fc2 = { setFiles: vi.fn(async () => {}) };
const waitForEvent = vi
.fn()
.mockImplementationOnce(
() =>
new Promise((r) => {
resolve1 = r;
}) as Promise<unknown>,
)
.mockImplementationOnce(
() =>
new Promise((r) => {
resolve2 = r;
}) as Promise<unknown>,
);
currentPage = {
waitForEvent,
keyboard: { press: vi.fn(async () => {}) },
};
const mod = await importModule();
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
paths: ["/tmp/1"],
});
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
paths: ["/tmp/2"],
});
resolve1?.(fc1);
resolve2?.(fc2);
await Promise.resolve();
expect(fc1.setFiles).not.toHaveBeenCalled();
expect(fc2.setFiles).toHaveBeenCalledWith(["/tmp/2"]);
});
it("arms the next dialog and accepts/dismisses (default timeout)", async () => {
const accept = vi.fn(async () => {});
const dismiss = vi.fn(async () => {});
const dialog = { accept, dismiss };
const waitForEvent = vi.fn(async () => dialog);
currentPage = {
waitForEvent,
};
const mod = await importModule();
await mod.armDialogViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
accept: true,
promptText: "x",
});
await Promise.resolve();
expect(waitForEvent).toHaveBeenCalledWith("dialog", { timeout: 120_000 });
expect(accept).toHaveBeenCalledWith("x");
expect(dismiss).not.toHaveBeenCalled();
accept.mockClear();
dismiss.mockClear();
waitForEvent.mockClear();
await mod.armDialogViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
accept: false,
});
await Promise.resolve();
expect(waitForEvent).toHaveBeenCalledWith("dialog", { timeout: 120_000 });
expect(dismiss).toHaveBeenCalled();
expect(accept).not.toHaveBeenCalled();
});
it("waits for selector, url, load state, and function", async () => {
const waitForSelector = vi.fn(async () => {});
const waitForURL = vi.fn(async () => {});
const waitForLoadState = vi.fn(async () => {});
const waitForFunction = vi.fn(async () => {});
const waitForTimeout = vi.fn(async () => {});
currentPage = {
locator: vi.fn(() => ({
first: () => ({ waitFor: waitForSelector }),
})),
waitForURL,
waitForLoadState,
waitForFunction,
waitForTimeout,
getByText: vi.fn(() => ({ first: () => ({ waitFor: vi.fn() }) })),
};
const mod = await importModule();
await mod.waitForViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
selector: "#main",
url: "**/dash",
loadState: "networkidle",
fn: "window.ready===true",
timeoutMs: 1234,
timeMs: 50,
});
expect(waitForTimeout).toHaveBeenCalledWith(50);
expect(
currentPage.locator as ReturnType<typeof vi.fn>,
).toHaveBeenCalledWith("#main");
expect(waitForSelector).toHaveBeenCalledWith({
state: "visible",
timeout: 1234,
});
expect(waitForURL).toHaveBeenCalledWith("**/dash", { timeout: 1234 });
expect(waitForLoadState).toHaveBeenCalledWith("networkidle", {
timeout: 1234,
});
expect(waitForFunction).toHaveBeenCalledWith("window.ready===true", {
timeout: 1234,
});
});
});

View File

@@ -0,0 +1,178 @@
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
let currentPage: Record<string, unknown> | null = null;
let currentRefLocator: Record<string, unknown> | null = null;
let pageState: {
console: unknown[];
armIdUpload: number;
armIdDialog: number;
armIdDownload: number;
};
const sessionMocks = vi.hoisted(() => ({
getPageForTargetId: vi.fn(async () => {
if (!currentPage) throw new Error("missing page");
return currentPage;
}),
ensurePageState: vi.fn(() => pageState),
refLocator: vi.fn(() => {
if (!currentRefLocator) throw new Error("missing locator");
return currentRefLocator;
}),
}));
vi.mock("./pw-session.js", () => sessionMocks);
async function importModule() {
return await import("./pw-tools-core.js");
}
describe("pw-tools-core", () => {
beforeEach(() => {
currentPage = null;
currentRefLocator = null;
pageState = {
console: [],
armIdUpload: 0,
armIdDialog: 0,
armIdDownload: 0,
};
for (const fn of Object.values(sessionMocks)) fn.mockClear();
});
it("waits for the next download and saves it", async () => {
let downloadHandler: ((download: unknown) => void) | undefined;
const on = vi.fn((event: string, handler: (download: unknown) => void) => {
if (event === "download") downloadHandler = handler;
});
const off = vi.fn();
const saveAs = vi.fn(async () => {});
const download = {
url: () => "https://example.com/file.bin",
suggestedFilename: () => "file.bin",
saveAs,
};
currentPage = { on, off };
const mod = await importModule();
const targetPath = path.resolve("/tmp/file.bin");
const p = mod.waitForDownloadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
path: targetPath,
timeoutMs: 1000,
});
await Promise.resolve();
expect(downloadHandler).toBeDefined();
downloadHandler?.(download);
const res = await p;
expect(saveAs).toHaveBeenCalledWith(targetPath);
expect(res.path).toBe(targetPath);
});
it("clicks a ref and saves the resulting download", async () => {
let downloadHandler: ((download: unknown) => void) | undefined;
const on = vi.fn((event: string, handler: (download: unknown) => void) => {
if (event === "download") downloadHandler = handler;
});
const off = vi.fn();
const click = vi.fn(async () => {});
currentRefLocator = { click };
const saveAs = vi.fn(async () => {});
const download = {
url: () => "https://example.com/report.pdf",
suggestedFilename: () => "report.pdf",
saveAs,
};
currentPage = { on, off };
const mod = await importModule();
const targetPath = path.resolve("/tmp/report.pdf");
const p = mod.downloadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "e12",
path: targetPath,
timeoutMs: 1000,
});
await Promise.resolve();
expect(downloadHandler).toBeDefined();
expect(click).toHaveBeenCalledWith({ timeout: 1000 });
downloadHandler?.(download);
const res = await p;
expect(saveAs).toHaveBeenCalledWith(targetPath);
expect(res.path).toBe(targetPath);
});
it("waits for a matching response and returns its body", async () => {
let responseHandler: ((resp: unknown) => void) | undefined;
const on = vi.fn((event: string, handler: (resp: unknown) => void) => {
if (event === "response") responseHandler = handler;
});
const off = vi.fn();
currentPage = { on, off };
const resp = {
url: () => "https://example.com/api/data",
status: () => 200,
headers: () => ({ "content-type": "application/json" }),
text: async () => '{"ok":true,"value":123}',
};
const mod = await importModule();
const p = mod.responseBodyViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
url: "**/api/data",
timeoutMs: 1000,
maxChars: 10,
});
await Promise.resolve();
expect(responseHandler).toBeDefined();
responseHandler?.(resp);
const res = await p;
expect(res.url).toBe("https://example.com/api/data");
expect(res.status).toBe(200);
expect(res.body).toBe('{"ok":true');
expect(res.truncated).toBe(true);
});
it("scrolls a ref into view (default timeout)", async () => {
const scrollIntoViewIfNeeded = vi.fn(async () => {});
currentRefLocator = { scrollIntoViewIfNeeded };
currentPage = {};
const mod = await importModule();
await mod.scrollIntoViewViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
});
expect(sessionMocks.refLocator).toHaveBeenCalledWith(currentPage, "1");
expect(scrollIntoViewIfNeeded).toHaveBeenCalledWith({ timeout: 20_000 });
});
it("requires a ref for scrollIntoView", async () => {
currentRefLocator = { scrollIntoViewIfNeeded: vi.fn(async () => {}) };
currentPage = {};
const mod = await importModule();
await expect(
mod.scrollIntoViewViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: " ",
}),
).rejects.toThrow(/ref is required/i);
});
});

View File

@@ -0,0 +1,148 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
let currentPage: Record<string, unknown> | null = null;
let currentRefLocator: Record<string, unknown> | null = null;
let pageState: {
console: unknown[];
armIdUpload: number;
armIdDialog: number;
armIdDownload: number;
};
const sessionMocks = vi.hoisted(() => ({
getPageForTargetId: vi.fn(async () => {
if (!currentPage) throw new Error("missing page");
return currentPage;
}),
ensurePageState: vi.fn(() => pageState),
refLocator: vi.fn(() => {
if (!currentRefLocator) throw new Error("missing locator");
return currentRefLocator;
}),
}));
vi.mock("./pw-session.js", () => sessionMocks);
async function importModule() {
return await import("./pw-tools-core.js");
}
describe("pw-tools-core", () => {
beforeEach(() => {
currentPage = null;
currentRefLocator = null;
pageState = {
console: [],
armIdUpload: 0,
armIdDialog: 0,
armIdDownload: 0,
};
for (const fn of Object.values(sessionMocks)) fn.mockClear();
});
it("clamps timeoutMs for scrollIntoView", async () => {
const scrollIntoViewIfNeeded = vi.fn(async () => {});
currentRefLocator = { scrollIntoViewIfNeeded };
currentPage = {};
const mod = await importModule();
await mod.scrollIntoViewViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
timeoutMs: 50,
});
expect(scrollIntoViewIfNeeded).toHaveBeenCalledWith({ timeout: 500 });
});
it("rewrites strict mode violations for scrollIntoView", async () => {
const scrollIntoViewIfNeeded = vi.fn(async () => {
throw new Error(
'Error: strict mode violation: locator("aria-ref=1") resolved to 2 elements',
);
});
currentRefLocator = { scrollIntoViewIfNeeded };
currentPage = {};
const mod = await importModule();
await expect(
mod.scrollIntoViewViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
}),
).rejects.toThrow(/Run a new snapshot/i);
});
it("rewrites not-visible timeouts for scrollIntoView", async () => {
const scrollIntoViewIfNeeded = vi.fn(async () => {
throw new Error(
'Timeout 5000ms exceeded. waiting for locator("aria-ref=1") to be visible',
);
});
currentRefLocator = { scrollIntoViewIfNeeded };
currentPage = {};
const mod = await importModule();
await expect(
mod.scrollIntoViewViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
}),
).rejects.toThrow(/not found or not visible/i);
});
it("rewrites strict mode violations into snapshot hints", async () => {
const click = vi.fn(async () => {
throw new Error(
'Error: strict mode violation: locator("aria-ref=1") resolved to 2 elements',
);
});
currentRefLocator = { click };
currentPage = {};
const mod = await importModule();
await expect(
mod.clickViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
}),
).rejects.toThrow(/Run a new snapshot/i);
});
it("rewrites not-visible timeouts into snapshot hints", async () => {
const click = vi.fn(async () => {
throw new Error(
'Timeout 5000ms exceeded. waiting for locator("aria-ref=1") to be visible',
);
});
currentRefLocator = { click };
currentPage = {};
const mod = await importModule();
await expect(
mod.clickViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
}),
).rejects.toThrow(/not found or not visible/i);
});
it("rewrites covered/hidden errors into interactable hints", async () => {
const click = vi.fn(async () => {
throw new Error(
"Element is not receiving pointer events because another element intercepts pointer events",
);
});
currentRefLocator = { click };
currentPage = {};
const mod = await importModule();
await expect(
mod.clickViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
}),
).rejects.toThrow(/not interactable/i);
});
});

View File

@@ -1,542 +0,0 @@
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
let currentPage: Record<string, unknown> | null = null;
let currentRefLocator: Record<string, unknown> | null = null;
let pageState: {
console: unknown[];
armIdUpload: number;
armIdDialog: number;
armIdDownload: number;
};
const sessionMocks = vi.hoisted(() => ({
getPageForTargetId: vi.fn(async () => {
if (!currentPage) throw new Error("missing page");
return currentPage;
}),
ensurePageState: vi.fn(() => pageState),
refLocator: vi.fn(() => {
if (!currentRefLocator) throw new Error("missing locator");
return currentRefLocator;
}),
}));
vi.mock("./pw-session.js", () => sessionMocks);
async function importModule() {
return await import("./pw-tools-core.js");
}
describe("pw-tools-core", () => {
beforeEach(() => {
currentPage = null;
currentRefLocator = null;
pageState = {
console: [],
armIdUpload: 0,
armIdDialog: 0,
armIdDownload: 0,
};
for (const fn of Object.values(sessionMocks)) fn.mockClear();
});
it("screenshots an element selector", async () => {
const elementScreenshot = vi.fn(async () => Buffer.from("E"));
currentPage = {
locator: vi.fn(() => ({
first: () => ({ screenshot: elementScreenshot }),
})),
screenshot: vi.fn(async () => Buffer.from("P")),
};
const mod = await importModule();
const res = await mod.takeScreenshotViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
element: "#main",
type: "png",
});
expect(res.buffer.toString()).toBe("E");
expect(sessionMocks.getPageForTargetId).toHaveBeenCalled();
expect(
currentPage.locator as ReturnType<typeof vi.fn>,
).toHaveBeenCalledWith("#main");
expect(elementScreenshot).toHaveBeenCalledWith({ type: "png" });
});
it("screenshots a ref locator", async () => {
const refScreenshot = vi.fn(async () => Buffer.from("R"));
currentRefLocator = { screenshot: refScreenshot };
currentPage = {
locator: vi.fn(),
screenshot: vi.fn(async () => Buffer.from("P")),
};
const mod = await importModule();
const res = await mod.takeScreenshotViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "76",
type: "jpeg",
});
expect(res.buffer.toString()).toBe("R");
expect(sessionMocks.refLocator).toHaveBeenCalledWith(currentPage, "76");
expect(refScreenshot).toHaveBeenCalledWith({ type: "jpeg" });
});
it("rejects fullPage for element or ref screenshots", async () => {
currentRefLocator = { screenshot: vi.fn(async () => Buffer.from("R")) };
currentPage = {
locator: vi.fn(() => ({
first: () => ({ screenshot: vi.fn(async () => Buffer.from("E")) }),
})),
screenshot: vi.fn(async () => Buffer.from("P")),
};
const mod = await importModule();
await expect(
mod.takeScreenshotViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
element: "#x",
fullPage: true,
}),
).rejects.toThrow(/fullPage is not supported/i);
await expect(
mod.takeScreenshotViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
fullPage: true,
}),
).rejects.toThrow(/fullPage is not supported/i);
});
it("arms the next file chooser and sets files (default timeout)", async () => {
const fileChooser = { setFiles: vi.fn(async () => {}) };
const waitForEvent = vi.fn(
async (_event: string, _opts: unknown) => fileChooser,
);
currentPage = {
waitForEvent,
keyboard: { press: vi.fn(async () => {}) },
};
const mod = await importModule();
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
paths: ["/tmp/a.txt"],
});
// waitForEvent is awaited immediately; handler continues async.
await Promise.resolve();
expect(waitForEvent).toHaveBeenCalledWith("filechooser", {
timeout: 120_000,
});
expect(fileChooser.setFiles).toHaveBeenCalledWith(["/tmp/a.txt"]);
});
it("arms the next file chooser and escapes if no paths provided", async () => {
const fileChooser = { setFiles: vi.fn(async () => {}) };
const press = vi.fn(async () => {});
const waitForEvent = vi.fn(async () => fileChooser);
currentPage = {
waitForEvent,
keyboard: { press },
};
const mod = await importModule();
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
paths: [],
});
await Promise.resolve();
expect(fileChooser.setFiles).not.toHaveBeenCalled();
expect(press).toHaveBeenCalledWith("Escape");
});
it("last file-chooser arm wins", async () => {
let resolve1: ((value: unknown) => void) | null = null;
let resolve2: ((value: unknown) => void) | null = null;
const fc1 = { setFiles: vi.fn(async () => {}) };
const fc2 = { setFiles: vi.fn(async () => {}) };
const waitForEvent = vi
.fn()
.mockImplementationOnce(
() =>
new Promise((r) => {
resolve1 = r;
}) as Promise<unknown>,
)
.mockImplementationOnce(
() =>
new Promise((r) => {
resolve2 = r;
}) as Promise<unknown>,
);
currentPage = {
waitForEvent,
keyboard: { press: vi.fn(async () => {}) },
};
const mod = await importModule();
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
paths: ["/tmp/1"],
});
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
paths: ["/tmp/2"],
});
resolve1?.(fc1);
resolve2?.(fc2);
await Promise.resolve();
expect(fc1.setFiles).not.toHaveBeenCalled();
expect(fc2.setFiles).toHaveBeenCalledWith(["/tmp/2"]);
});
it("arms the next dialog and accepts/dismisses (default timeout)", async () => {
const accept = vi.fn(async () => {});
const dismiss = vi.fn(async () => {});
const dialog = { accept, dismiss };
const waitForEvent = vi.fn(async () => dialog);
currentPage = {
waitForEvent,
};
const mod = await importModule();
await mod.armDialogViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
accept: true,
promptText: "x",
});
await Promise.resolve();
expect(waitForEvent).toHaveBeenCalledWith("dialog", { timeout: 120_000 });
expect(accept).toHaveBeenCalledWith("x");
expect(dismiss).not.toHaveBeenCalled();
accept.mockClear();
dismiss.mockClear();
waitForEvent.mockClear();
await mod.armDialogViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
accept: false,
});
await Promise.resolve();
expect(waitForEvent).toHaveBeenCalledWith("dialog", { timeout: 120_000 });
expect(dismiss).toHaveBeenCalled();
expect(accept).not.toHaveBeenCalled();
});
it("waits for selector, url, load state, and function", async () => {
const waitForSelector = vi.fn(async () => {});
const waitForURL = vi.fn(async () => {});
const waitForLoadState = vi.fn(async () => {});
const waitForFunction = vi.fn(async () => {});
const waitForTimeout = vi.fn(async () => {});
currentPage = {
locator: vi.fn(() => ({
first: () => ({ waitFor: waitForSelector }),
})),
waitForURL,
waitForLoadState,
waitForFunction,
waitForTimeout,
getByText: vi.fn(() => ({ first: () => ({ waitFor: vi.fn() }) })),
};
const mod = await importModule();
await mod.waitForViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
selector: "#main",
url: "**/dash",
loadState: "networkidle",
fn: "window.ready===true",
timeoutMs: 1234,
timeMs: 50,
});
expect(waitForTimeout).toHaveBeenCalledWith(50);
expect(
currentPage.locator as ReturnType<typeof vi.fn>,
).toHaveBeenCalledWith("#main");
expect(waitForSelector).toHaveBeenCalledWith({
state: "visible",
timeout: 1234,
});
expect(waitForURL).toHaveBeenCalledWith("**/dash", { timeout: 1234 });
expect(waitForLoadState).toHaveBeenCalledWith("networkidle", {
timeout: 1234,
});
expect(waitForFunction).toHaveBeenCalledWith("window.ready===true", {
timeout: 1234,
});
});
it("waits for the next download and saves it", async () => {
let downloadHandler: ((download: unknown) => void) | undefined;
const on = vi.fn((event: string, handler: (download: unknown) => void) => {
if (event === "download") downloadHandler = handler;
});
const off = vi.fn();
const saveAs = vi.fn(async () => {});
const download = {
url: () => "https://example.com/file.bin",
suggestedFilename: () => "file.bin",
saveAs,
};
currentPage = { on, off };
const mod = await importModule();
const targetPath = path.resolve("/tmp/file.bin");
const p = mod.waitForDownloadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
path: targetPath,
timeoutMs: 1000,
});
await Promise.resolve();
expect(downloadHandler).toBeDefined();
downloadHandler?.(download);
const res = await p;
expect(saveAs).toHaveBeenCalledWith(targetPath);
expect(res.path).toBe(targetPath);
});
it("clicks a ref and saves the resulting download", async () => {
let downloadHandler: ((download: unknown) => void) | undefined;
const on = vi.fn((event: string, handler: (download: unknown) => void) => {
if (event === "download") downloadHandler = handler;
});
const off = vi.fn();
const click = vi.fn(async () => {});
currentRefLocator = { click };
const saveAs = vi.fn(async () => {});
const download = {
url: () => "https://example.com/report.pdf",
suggestedFilename: () => "report.pdf",
saveAs,
};
currentPage = { on, off };
const mod = await importModule();
const targetPath = path.resolve("/tmp/report.pdf");
const p = mod.downloadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "e12",
path: targetPath,
timeoutMs: 1000,
});
await Promise.resolve();
expect(downloadHandler).toBeDefined();
expect(click).toHaveBeenCalledWith({ timeout: 1000 });
downloadHandler?.(download);
const res = await p;
expect(saveAs).toHaveBeenCalledWith(targetPath);
expect(res.path).toBe(targetPath);
});
it("waits for a matching response and returns its body", async () => {
let responseHandler: ((resp: unknown) => void) | undefined;
const on = vi.fn((event: string, handler: (resp: unknown) => void) => {
if (event === "response") responseHandler = handler;
});
const off = vi.fn();
currentPage = { on, off };
const resp = {
url: () => "https://example.com/api/data",
status: () => 200,
headers: () => ({ "content-type": "application/json" }),
text: async () => '{"ok":true,"value":123}',
};
const mod = await importModule();
const p = mod.responseBodyViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
url: "**/api/data",
timeoutMs: 1000,
maxChars: 10,
});
await Promise.resolve();
expect(responseHandler).toBeDefined();
responseHandler?.(resp);
const res = await p;
expect(res.url).toBe("https://example.com/api/data");
expect(res.status).toBe(200);
expect(res.body).toBe('{"ok":true');
expect(res.truncated).toBe(true);
});
it("scrolls a ref into view (default timeout)", async () => {
const scrollIntoViewIfNeeded = vi.fn(async () => {});
currentRefLocator = { scrollIntoViewIfNeeded };
currentPage = {};
const mod = await importModule();
await mod.scrollIntoViewViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
});
expect(sessionMocks.refLocator).toHaveBeenCalledWith(currentPage, "1");
expect(scrollIntoViewIfNeeded).toHaveBeenCalledWith({ timeout: 20_000 });
});
it("requires a ref for scrollIntoView", async () => {
currentRefLocator = { scrollIntoViewIfNeeded: vi.fn(async () => {}) };
currentPage = {};
const mod = await importModule();
await expect(
mod.scrollIntoViewViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: " ",
}),
).rejects.toThrow(/ref is required/i);
});
it("clamps timeoutMs for scrollIntoView", async () => {
const scrollIntoViewIfNeeded = vi.fn(async () => {});
currentRefLocator = { scrollIntoViewIfNeeded };
currentPage = {};
const mod = await importModule();
await mod.scrollIntoViewViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
timeoutMs: 50,
});
expect(scrollIntoViewIfNeeded).toHaveBeenCalledWith({ timeout: 500 });
});
it("rewrites strict mode violations for scrollIntoView", async () => {
const scrollIntoViewIfNeeded = vi.fn(async () => {
throw new Error(
'Error: strict mode violation: locator("aria-ref=1") resolved to 2 elements',
);
});
currentRefLocator = { scrollIntoViewIfNeeded };
currentPage = {};
const mod = await importModule();
await expect(
mod.scrollIntoViewViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
}),
).rejects.toThrow(/Run a new snapshot/i);
});
it("rewrites not-visible timeouts for scrollIntoView", async () => {
const scrollIntoViewIfNeeded = vi.fn(async () => {
throw new Error(
'Timeout 5000ms exceeded. waiting for locator("aria-ref=1") to be visible',
);
});
currentRefLocator = { scrollIntoViewIfNeeded };
currentPage = {};
const mod = await importModule();
await expect(
mod.scrollIntoViewViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
}),
).rejects.toThrow(/not found or not visible/i);
});
it("rewrites strict mode violations into snapshot hints", async () => {
const click = vi.fn(async () => {
throw new Error(
'Error: strict mode violation: locator("aria-ref=1") resolved to 2 elements',
);
});
currentRefLocator = { click };
currentPage = {};
const mod = await importModule();
await expect(
mod.clickViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
}),
).rejects.toThrow(/Run a new snapshot/i);
});
it("rewrites not-visible timeouts into snapshot hints", async () => {
const click = vi.fn(async () => {
throw new Error(
'Timeout 5000ms exceeded. waiting for locator("aria-ref=1") to be visible',
);
});
currentRefLocator = { click };
currentPage = {};
const mod = await importModule();
await expect(
mod.clickViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
}),
).rejects.toThrow(/not found or not visible/i);
});
it("rewrites covered/hidden errors into interactable hints", async () => {
const click = vi.fn(async () => {
throw new Error(
"Element is not receiving pointer events because another element intercepts pointer events",
);
});
currentRefLocator = { click };
currentPage = {};
const mod = await importModule();
await expect(
mod.clickViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
ref: "1",
}),
).rejects.toThrow(/not interactable/i);
});
});

View File

@@ -1,92 +1,34 @@
import fs from "node:fs";
import type { Server } from "node:http";
import { createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
import {
isChromeCdpReady,
isChromeReachable,
launchClawdChrome,
type RunningChrome,
resolveClawdUserDataDir,
stopClawdChrome,
} from "./chrome.js";
import type { BrowserTab } from "./client.js";
import type {
ResolvedBrowserConfig,
ResolvedBrowserProfile,
} from "./config.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { resolveProfile } from "./config.js";
import type {
BrowserRouteContext,
BrowserTab,
ContextOptions,
ProfileContext,
ProfileRuntimeState,
ProfileStatus,
} from "./server-context.types.js";
import { resolveTargetIdFromTabs } from "./target-id.js";
import { movePathToTrash } from "./trash.js";
export type { BrowserTab };
/**
* Runtime state for a single profile's Chrome instance.
*/
export type ProfileRuntimeState = {
profile: ResolvedBrowserProfile;
running: RunningChrome | null;
};
export type BrowserServerState = {
server: Server;
port: number;
resolved: ResolvedBrowserConfig;
profiles: Map<string, ProfileRuntimeState>;
};
export type BrowserRouteContext = {
state: () => BrowserServerState;
forProfile: (profileName?: string) => ProfileContext;
listProfiles: () => Promise<ProfileStatus[]>;
// Legacy methods delegate to default profile for backward compatibility
ensureBrowserAvailable: () => Promise<void>;
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
isReachable: (timeoutMs?: number) => Promise<boolean>;
listTabs: () => Promise<BrowserTab[]>;
openTab: (url: string) => Promise<BrowserTab>;
focusTab: (targetId: string) => Promise<void>;
closeTab: (targetId: string) => Promise<void>;
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
resetProfile: () => Promise<{
moved: boolean;
from: string;
to?: string;
}>;
mapTabError: (err: unknown) => { status: number; message: string } | null;
};
export type ProfileContext = {
profile: ResolvedBrowserProfile;
ensureBrowserAvailable: () => Promise<void>;
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
isReachable: (timeoutMs?: number) => Promise<boolean>;
listTabs: () => Promise<BrowserTab[]>;
openTab: (url: string) => Promise<BrowserTab>;
focusTab: (targetId: string) => Promise<void>;
closeTab: (targetId: string) => Promise<void>;
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>;
};
export type ProfileStatus = {
name: string;
cdpPort: number;
cdpUrl: string;
color: string;
running: boolean;
tabCount: number;
isDefault: boolean;
isRemote: boolean;
};
type ContextOptions = {
getState: () => BrowserServerState | null;
onEnsureAttachTarget?: (profile: ResolvedBrowserProfile) => Promise<void>;
};
export type {
BrowserRouteContext,
BrowserServerState,
BrowserTab,
ProfileContext,
ProfileRuntimeState,
ProfileStatus,
} from "./server-context.types.js";
/**
* Normalize a CDP WebSocket URL to use the correct base URL.
@@ -157,7 +99,7 @@ function createProfileContext(
return profileState;
};
const setProfileRunning = (running: RunningChrome | null) => {
const setProfileRunning = (running: ProfileRuntimeState["running"]) => {
const profileState = getProfileState();
profileState.running = running;
};
@@ -241,7 +183,9 @@ function createProfileContext(
return await isChromeReachable(profile.cdpUrl, timeoutMs);
};
const attachRunning = (running: RunningChrome) => {
const attachRunning = (
running: NonNullable<ProfileRuntimeState["running"]>,
) => {
setProfileRunning(running);
running.proc.on("exit", () => {
// Guard against server teardown (e.g., SIGUSR1 restart)

View File

@@ -0,0 +1,77 @@
import type { Server } from "node:http";
import type { RunningChrome } from "./chrome.js";
import type { BrowserTab } from "./client.js";
import type {
ResolvedBrowserConfig,
ResolvedBrowserProfile,
} from "./config.js";
export type { BrowserTab };
/**
* Runtime state for a single profile's Chrome instance.
*/
export type ProfileRuntimeState = {
profile: ResolvedBrowserProfile;
running: RunningChrome | null;
};
export type BrowserServerState = {
server: Server;
port: number;
resolved: ResolvedBrowserConfig;
profiles: Map<string, ProfileRuntimeState>;
};
export type BrowserRouteContext = {
state: () => BrowserServerState;
forProfile: (profileName?: string) => ProfileContext;
listProfiles: () => Promise<ProfileStatus[]>;
// Legacy methods delegate to default profile for backward compatibility
ensureBrowserAvailable: () => Promise<void>;
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
isReachable: (timeoutMs?: number) => Promise<boolean>;
listTabs: () => Promise<BrowserTab[]>;
openTab: (url: string) => Promise<BrowserTab>;
focusTab: (targetId: string) => Promise<void>;
closeTab: (targetId: string) => Promise<void>;
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
resetProfile: () => Promise<{
moved: boolean;
from: string;
to?: string;
}>;
mapTabError: (err: unknown) => { status: number; message: string } | null;
};
export type ProfileContext = {
profile: ResolvedBrowserProfile;
ensureBrowserAvailable: () => Promise<void>;
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
isReachable: (timeoutMs?: number) => Promise<boolean>;
listTabs: () => Promise<BrowserTab[]>;
openTab: (url: string) => Promise<BrowserTab>;
focusTab: (targetId: string) => Promise<void>;
closeTab: (targetId: string) => Promise<void>;
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>;
};
export type ProfileStatus = {
name: string;
cdpPort: number;
cdpUrl: string;
color: string;
running: boolean;
tabCount: number;
isDefault: boolean;
isRemote: boolean;
};
export type ContextOptions = {
getState: () => BrowserServerState | null;
onEnsureAttachTarget?: (profile: ResolvedBrowserProfile) => Promise<void>;
};

View File

@@ -0,0 +1,303 @@
import { type AddressInfo, createServer } from "node:net";
import { fetch as realFetch } from "undici";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let testPort = 0;
let _cdpBaseUrl = "";
let reachable = false;
let cfgAttachOnly = false;
let createTargetId: string | null = null;
const cdpMocks = vi.hoisted(() => ({
createTargetViaCdp: vi.fn(async () => {
throw new Error("cdp disabled");
}),
snapshotAria: vi.fn(async () => ({
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
})),
}));
const pwMocks = vi.hoisted(() => ({
armDialogViaPlaywright: vi.fn(async () => {}),
armFileUploadViaPlaywright: vi.fn(async () => {}),
clickViaPlaywright: vi.fn(async () => {}),
closePageViaPlaywright: vi.fn(async () => {}),
closePlaywrightBrowserConnection: vi.fn(async () => {}),
downloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
dragViaPlaywright: vi.fn(async () => {}),
evaluateViaPlaywright: vi.fn(async () => "ok"),
fillFormViaPlaywright: vi.fn(async () => {}),
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
hoverViaPlaywright: vi.fn(async () => {}),
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
pressKeyViaPlaywright: vi.fn(async () => {}),
responseBodyViaPlaywright: vi.fn(async () => ({
url: "https://example.com/api/data",
status: 200,
headers: { "content-type": "application/json" },
body: '{"ok":true}',
})),
resizeViewportViaPlaywright: vi.fn(async () => {}),
selectOptionViaPlaywright: vi.fn(async () => {}),
setInputFilesViaPlaywright: vi.fn(async () => {}),
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
takeScreenshotViaPlaywright: vi.fn(async () => ({
buffer: Buffer.from("png"),
})),
typeViaPlaywright: vi.fn(async () => {}),
waitForDownloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
waitForViaPlaywright: vi.fn(async () => {}),
}));
function makeProc(pid = 123) {
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
return {
pid,
killed: false,
exitCode: null as number | null,
on: (event: string, cb: (...args: unknown[]) => void) => {
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
return undefined;
},
emitExit: () => {
for (const cb of handlers.get("exit") ?? []) cb(0);
},
kill: () => {
return true;
},
};
}
const proc = makeProc();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
browser: {
enabled: true,
controlUrl: `http://127.0.0.1:${testPort}`,
color: "#FF4500",
attachOnly: cfgAttachOnly,
headless: true,
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: testPort + 1, color: "#FF4500" },
},
},
}),
writeConfigFile: vi.fn(async () => {}),
};
});
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => reachable),
isChromeReachable: vi.fn(async () => reachable),
launchClawdChrome: vi.fn(
async (_resolved: unknown, profile: { cdpPort: number }) => {
launchCalls.push({ port: profile.cdpPort });
reachable = true;
return {
pid: 123,
exe: { kind: "chrome", path: "/fake/chrome" },
userDataDir: "/tmp/clawd",
cdpPort: profile.cdpPort,
startedAt: Date.now(),
proc,
};
},
),
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd"),
stopClawdChrome: vi.fn(async () => {
reachable = false;
}),
}));
vi.mock("./cdp.js", () => ({
createTargetViaCdp: cdpMocks.createTargetViaCdp,
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: cdpMocks.snapshotAria,
}));
vi.mock("./pw-ai.js", () => pwMocks);
vi.mock("../media/store.js", () => ({
ensureMediaDir: vi.fn(async () => {}),
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
}));
vi.mock("./screenshot.js", () => ({
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({
buffer: buf,
contentType: "image/png",
})),
}));
async function getFreePort(): Promise<number> {
while (true) {
const port = await new Promise<number>((resolve, reject) => {
const s = createServer();
s.once("error", reject);
s.listen(0, "127.0.0.1", () => {
const assigned = (s.address() as AddressInfo).port;
s.close((err) => (err ? reject(err) : resolve(assigned)));
});
});
if (port < 65535) return port;
}
}
function makeResponse(
body: unknown,
init?: { ok?: boolean; status?: number; text?: string },
): Response {
const ok = init?.ok ?? true;
const status = init?.status ?? 200;
const text = init?.text ?? "";
return {
ok,
status,
json: async () => body,
text: async () => text,
} as unknown as Response;
}
describe("browser control server", () => {
beforeEach(async () => {
reachable = false;
cfgAttachOnly = false;
createTargetId = null;
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
if (createTargetId) return { targetId: createTargetId };
throw new Error("cdp disabled");
});
for (const fn of Object.values(pwMocks)) fn.mockClear();
for (const fn of Object.values(cdpMocks)) fn.mockClear();
testPort = await getFreePort();
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
// Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) return makeResponse([]);
return makeResponse([
{
id: "abcd1234",
title: "Tab",
url: "https://example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
type: "page",
},
{
id: "abce9999",
title: "Other",
url: "https://other",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999",
type: "page",
},
]);
}
if (u.includes("/json/new?")) {
if (init?.method === "PUT") {
putNewCalls += 1;
if (putNewCalls === 1) {
return makeResponse({}, { ok: false, status: 405, text: "" });
}
}
return makeResponse({
id: "newtab1",
title: "",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
type: "page",
});
}
if (u.includes("/json/activate/")) return makeResponse("ok");
if (u.includes("/json/close/")) return makeResponse("ok");
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
it("serves status + starts browser when requested", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
const started = await startBrowserControlServerFromConfig();
expect(started?.port).toBe(testPort);
const base = `http://127.0.0.1:${testPort}`;
const s1 = (await realFetch(`${base}/`).then((r) => r.json())) as {
running: boolean;
pid: number | null;
};
expect(s1.running).toBe(false);
expect(s1.pid).toBe(null);
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
const s2 = (await realFetch(`${base}/`).then((r) => r.json())) as {
running: boolean;
pid: number | null;
chosenBrowser: string | null;
};
expect(s2.running).toBe(true);
expect(s2.pid).toBe(123);
expect(s2.chosenBrowser).toBe("chrome");
expect(launchCalls.length).toBeGreaterThan(0);
});
it("handles tabs: list, open, focus conflict on ambiguous prefix", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
const tabs = (await realFetch(`${base}/tabs`).then((r) => r.json())) as {
running: boolean;
tabs: Array<{ targetId: string }>;
};
expect(tabs.running).toBe(true);
expect(tabs.tabs.length).toBeGreaterThan(0);
const opened = await realFetch(`${base}/tabs/open`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com" }),
}).then((r) => r.json());
expect(opened).toMatchObject({ targetId: "newtab1" });
const focus = await realFetch(`${base}/tabs/focus`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: "abc" }),
});
expect(focus.status).toBe(409);
});
});

View File

@@ -0,0 +1,395 @@
import { type AddressInfo, createServer } from "node:net";
import { fetch as realFetch } from "undici";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./constants.js";
let testPort = 0;
let cdpBaseUrl = "";
let reachable = false;
let cfgAttachOnly = false;
let createTargetId: string | null = null;
const cdpMocks = vi.hoisted(() => ({
createTargetViaCdp: vi.fn(async () => {
throw new Error("cdp disabled");
}),
snapshotAria: vi.fn(async () => ({
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
})),
}));
const pwMocks = vi.hoisted(() => ({
armDialogViaPlaywright: vi.fn(async () => {}),
armFileUploadViaPlaywright: vi.fn(async () => {}),
clickViaPlaywright: vi.fn(async () => {}),
closePageViaPlaywright: vi.fn(async () => {}),
closePlaywrightBrowserConnection: vi.fn(async () => {}),
downloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
dragViaPlaywright: vi.fn(async () => {}),
evaluateViaPlaywright: vi.fn(async () => "ok"),
fillFormViaPlaywright: vi.fn(async () => {}),
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
hoverViaPlaywright: vi.fn(async () => {}),
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
pressKeyViaPlaywright: vi.fn(async () => {}),
responseBodyViaPlaywright: vi.fn(async () => ({
url: "https://example.com/api/data",
status: 200,
headers: { "content-type": "application/json" },
body: '{"ok":true}',
})),
resizeViewportViaPlaywright: vi.fn(async () => {}),
selectOptionViaPlaywright: vi.fn(async () => {}),
setInputFilesViaPlaywright: vi.fn(async () => {}),
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
takeScreenshotViaPlaywright: vi.fn(async () => ({
buffer: Buffer.from("png"),
})),
typeViaPlaywright: vi.fn(async () => {}),
waitForDownloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
waitForViaPlaywright: vi.fn(async () => {}),
}));
function makeProc(pid = 123) {
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
return {
pid,
killed: false,
exitCode: null as number | null,
on: (event: string, cb: (...args: unknown[]) => void) => {
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
return undefined;
},
emitExit: () => {
for (const cb of handlers.get("exit") ?? []) cb(0);
},
kill: () => {
return true;
},
};
}
const proc = makeProc();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
browser: {
enabled: true,
controlUrl: `http://127.0.0.1:${testPort}`,
color: "#FF4500",
attachOnly: cfgAttachOnly,
headless: true,
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: testPort + 1, color: "#FF4500" },
},
},
}),
writeConfigFile: vi.fn(async () => {}),
};
});
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => reachable),
isChromeReachable: vi.fn(async () => reachable),
launchClawdChrome: vi.fn(
async (_resolved: unknown, profile: { cdpPort: number }) => {
launchCalls.push({ port: profile.cdpPort });
reachable = true;
return {
pid: 123,
exe: { kind: "chrome", path: "/fake/chrome" },
userDataDir: "/tmp/clawd",
cdpPort: profile.cdpPort,
startedAt: Date.now(),
proc,
};
},
),
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd"),
stopClawdChrome: vi.fn(async () => {
reachable = false;
}),
}));
vi.mock("./cdp.js", () => ({
createTargetViaCdp: cdpMocks.createTargetViaCdp,
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: cdpMocks.snapshotAria,
}));
vi.mock("./pw-ai.js", () => pwMocks);
vi.mock("../media/store.js", () => ({
ensureMediaDir: vi.fn(async () => {}),
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
}));
vi.mock("./screenshot.js", () => ({
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({
buffer: buf,
contentType: "image/png",
})),
}));
async function getFreePort(): Promise<number> {
while (true) {
const port = await new Promise<number>((resolve, reject) => {
const s = createServer();
s.once("error", reject);
s.listen(0, "127.0.0.1", () => {
const assigned = (s.address() as AddressInfo).port;
s.close((err) => (err ? reject(err) : resolve(assigned)));
});
});
if (port < 65535) return port;
}
}
function makeResponse(
body: unknown,
init?: { ok?: boolean; status?: number; text?: string },
): Response {
const ok = init?.ok ?? true;
const status = init?.status ?? 200;
const text = init?.text ?? "";
return {
ok,
status,
json: async () => body,
text: async () => text,
} as unknown as Response;
}
describe("browser control server", () => {
beforeEach(async () => {
reachable = false;
cfgAttachOnly = false;
createTargetId = null;
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
if (createTargetId) return { targetId: createTargetId };
throw new Error("cdp disabled");
});
for (const fn of Object.values(pwMocks)) fn.mockClear();
for (const fn of Object.values(cdpMocks)) fn.mockClear();
testPort = await getFreePort();
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
// Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) return makeResponse([]);
return makeResponse([
{
id: "abcd1234",
title: "Tab",
url: "https://example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
type: "page",
},
{
id: "abce9999",
title: "Other",
url: "https://other",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999",
type: "page",
},
]);
}
if (u.includes("/json/new?")) {
if (init?.method === "PUT") {
putNewCalls += 1;
if (putNewCalls === 1) {
return makeResponse({}, { ok: false, status: 405, text: "" });
}
}
return makeResponse({
id: "newtab1",
title: "",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
type: "page",
});
}
if (u.includes("/json/activate/")) return makeResponse("ok");
if (u.includes("/json/close/")) return makeResponse("ok");
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
const startServerAndBase = async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
return base;
};
const postJson = async <T>(url: string, body?: unknown): Promise<T> => {
const res = await realFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
return (await res.json()) as T;
};
it("agent contract: snapshot endpoints", async () => {
const base = await startServerAndBase();
const snapAria = (await realFetch(
`${base}/snapshot?format=aria&limit=1`,
).then((r) => r.json())) as { ok: boolean; format?: string };
expect(snapAria.ok).toBe(true);
expect(snapAria.format).toBe("aria");
expect(cdpMocks.snapshotAria).toHaveBeenCalledWith({
wsUrl: "ws://127.0.0.1/devtools/page/abcd1234",
limit: 1,
});
const snapAi = (await realFetch(`${base}/snapshot?format=ai`).then((r) =>
r.json(),
)) as { ok: boolean; format?: string };
expect(snapAi.ok).toBe(true);
expect(snapAi.format).toBe("ai");
expect(pwMocks.snapshotAiViaPlaywright).toHaveBeenCalledWith({
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
});
});
it("agent contract: navigation + common act commands", async () => {
const base = await startServerAndBase();
const nav = (await postJson(`${base}/navigate`, {
url: "https://example.com",
})) as { ok: boolean; targetId?: string };
expect(nav.ok).toBe(true);
expect(typeof nav.targetId).toBe("string");
expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith({
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
url: "https://example.com",
});
const click = (await postJson(`${base}/act`, {
kind: "click",
ref: "1",
button: "left",
modifiers: ["Shift"],
})) as { ok: boolean };
expect(click.ok).toBe(true);
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(1, {
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
doubleClick: false,
button: "left",
modifiers: ["Shift"],
});
const clickSelector = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "click", selector: "button.save" }),
});
expect(clickSelector.status).toBe(400);
expect(((await clickSelector.json()) as { error?: string }).error).toMatch(
/'selector' is not supported/i,
);
const type = (await postJson(`${base}/act`, {
kind: "type",
ref: "1",
text: "",
})) as { ok: boolean };
expect(type.ok).toBe(true);
expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith(1, {
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
text: "",
submit: false,
slowly: false,
});
const press = (await postJson(`${base}/act`, {
kind: "press",
key: "Enter",
})) as { ok: boolean };
expect(press.ok).toBe(true);
expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith({
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
key: "Enter",
});
const hover = (await postJson(`${base}/act`, {
kind: "hover",
ref: "2",
})) as { ok: boolean };
expect(hover.ok).toBe(true);
expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith({
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
});
const scroll = (await postJson(`${base}/act`, {
kind: "scrollIntoView",
ref: "2",
})) as { ok: boolean };
expect(scroll.ok).toBe(true);
expect(pwMocks.scrollIntoViewViaPlaywright).toHaveBeenCalledWith({
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
});
const drag = (await postJson(`${base}/act`, {
kind: "drag",
startRef: "3",
endRef: "4",
})) as { ok: boolean };
expect(drag.ok).toBe(true);
expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith({
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
startRef: "3",
endRef: "4",
});
});
});

View File

@@ -0,0 +1,438 @@
import { type AddressInfo, createServer } from "node:net";
import { fetch as realFetch } from "undici";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let testPort = 0;
let cdpBaseUrl = "";
let reachable = false;
let cfgAttachOnly = false;
let createTargetId: string | null = null;
const cdpMocks = vi.hoisted(() => ({
createTargetViaCdp: vi.fn(async () => {
throw new Error("cdp disabled");
}),
snapshotAria: vi.fn(async () => ({
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
})),
}));
const pwMocks = vi.hoisted(() => ({
armDialogViaPlaywright: vi.fn(async () => {}),
armFileUploadViaPlaywright: vi.fn(async () => {}),
clickViaPlaywright: vi.fn(async () => {}),
closePageViaPlaywright: vi.fn(async () => {}),
closePlaywrightBrowserConnection: vi.fn(async () => {}),
downloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
dragViaPlaywright: vi.fn(async () => {}),
evaluateViaPlaywright: vi.fn(async () => "ok"),
fillFormViaPlaywright: vi.fn(async () => {}),
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
hoverViaPlaywright: vi.fn(async () => {}),
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
pressKeyViaPlaywright: vi.fn(async () => {}),
responseBodyViaPlaywright: vi.fn(async () => ({
url: "https://example.com/api/data",
status: 200,
headers: { "content-type": "application/json" },
body: '{"ok":true}',
})),
resizeViewportViaPlaywright: vi.fn(async () => {}),
selectOptionViaPlaywright: vi.fn(async () => {}),
setInputFilesViaPlaywright: vi.fn(async () => {}),
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
takeScreenshotViaPlaywright: vi.fn(async () => ({
buffer: Buffer.from("png"),
})),
typeViaPlaywright: vi.fn(async () => {}),
waitForDownloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
waitForViaPlaywright: vi.fn(async () => {}),
}));
function makeProc(pid = 123) {
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
return {
pid,
killed: false,
exitCode: null as number | null,
on: (event: string, cb: (...args: unknown[]) => void) => {
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
return undefined;
},
emitExit: () => {
for (const cb of handlers.get("exit") ?? []) cb(0);
},
kill: () => {
return true;
},
};
}
const proc = makeProc();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
browser: {
enabled: true,
controlUrl: `http://127.0.0.1:${testPort}`,
color: "#FF4500",
attachOnly: cfgAttachOnly,
headless: true,
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: testPort + 1, color: "#FF4500" },
},
},
}),
writeConfigFile: vi.fn(async () => {}),
};
});
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => reachable),
isChromeReachable: vi.fn(async () => reachable),
launchClawdChrome: vi.fn(
async (_resolved: unknown, profile: { cdpPort: number }) => {
launchCalls.push({ port: profile.cdpPort });
reachable = true;
return {
pid: 123,
exe: { kind: "chrome", path: "/fake/chrome" },
userDataDir: "/tmp/clawd",
cdpPort: profile.cdpPort,
startedAt: Date.now(),
proc,
};
},
),
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd"),
stopClawdChrome: vi.fn(async () => {
reachable = false;
}),
}));
vi.mock("./cdp.js", () => ({
createTargetViaCdp: cdpMocks.createTargetViaCdp,
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: cdpMocks.snapshotAria,
}));
vi.mock("./pw-ai.js", () => pwMocks);
vi.mock("../media/store.js", () => ({
ensureMediaDir: vi.fn(async () => {}),
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
}));
vi.mock("./screenshot.js", () => ({
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({
buffer: buf,
contentType: "image/png",
})),
}));
async function getFreePort(): Promise<number> {
while (true) {
const port = await new Promise<number>((resolve, reject) => {
const s = createServer();
s.once("error", reject);
s.listen(0, "127.0.0.1", () => {
const assigned = (s.address() as AddressInfo).port;
s.close((err) => (err ? reject(err) : resolve(assigned)));
});
});
if (port < 65535) return port;
}
}
function makeResponse(
body: unknown,
init?: { ok?: boolean; status?: number; text?: string },
): Response {
const ok = init?.ok ?? true;
const status = init?.status ?? 200;
const text = init?.text ?? "";
return {
ok,
status,
json: async () => body,
text: async () => text,
} as unknown as Response;
}
describe("browser control server", () => {
beforeEach(async () => {
reachable = false;
cfgAttachOnly = false;
createTargetId = null;
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
if (createTargetId) return { targetId: createTargetId };
throw new Error("cdp disabled");
});
for (const fn of Object.values(pwMocks)) fn.mockClear();
for (const fn of Object.values(cdpMocks)) fn.mockClear();
testPort = await getFreePort();
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
// Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) return makeResponse([]);
return makeResponse([
{
id: "abcd1234",
title: "Tab",
url: "https://example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
type: "page",
},
{
id: "abce9999",
title: "Other",
url: "https://other",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999",
type: "page",
},
]);
}
if (u.includes("/json/new?")) {
if (init?.method === "PUT") {
putNewCalls += 1;
if (putNewCalls === 1) {
return makeResponse({}, { ok: false, status: 405, text: "" });
}
}
return makeResponse({
id: "newtab1",
title: "",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
type: "page",
});
}
if (u.includes("/json/activate/")) return makeResponse("ok");
if (u.includes("/json/close/")) return makeResponse("ok");
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
it("skips default maxChars when explicitly set to zero", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
const snapAi = (await realFetch(
`${base}/snapshot?format=ai&maxChars=0`,
).then((r) => r.json())) as { ok: boolean; format?: string };
expect(snapAi.ok).toBe(true);
expect(snapAi.format).toBe("ai");
const [call] = pwMocks.snapshotAiViaPlaywright.mock.calls.at(-1) ?? [];
expect(call).toEqual({
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
});
});
it("validates agent inputs (agent routes)", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
const navMissing = await realFetch(`${base}/navigate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(navMissing.status).toBe(400);
const actMissing = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(actMissing.status).toBe(400);
const clickMissingRef = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "click" }),
});
expect(clickMissingRef.status).toBe(400);
const scrollMissingRef = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "scrollIntoView" }),
});
expect(scrollMissingRef.status).toBe(400);
const scrollSelectorUnsupported = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "scrollIntoView", selector: "button.save" }),
});
expect(scrollSelectorUnsupported.status).toBe(400);
const clickBadButton = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "click", ref: "1", button: "nope" }),
});
expect(clickBadButton.status).toBe(400);
const clickBadModifiers = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "click", ref: "1", modifiers: ["Nope"] }),
});
expect(clickBadModifiers.status).toBe(400);
const typeBadText = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "type", ref: "1", text: 123 }),
});
expect(typeBadText.status).toBe(400);
const uploadMissingPaths = await realFetch(`${base}/hooks/file-chooser`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(uploadMissingPaths.status).toBe(400);
const dialogMissingAccept = await realFetch(`${base}/hooks/dialog`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(dialogMissingAccept.status).toBe(400);
const snapDefault = (await realFetch(`${base}/snapshot?format=wat`).then(
(r) => r.json(),
)) as { ok: boolean; format?: string };
expect(snapDefault.ok).toBe(true);
expect(snapDefault.format).toBe("ai");
const screenshotBadCombo = await realFetch(`${base}/screenshot`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fullPage: true, element: "body" }),
});
expect(screenshotBadCombo.status).toBe(400);
});
it("covers common error branches", async () => {
cfgAttachOnly = true;
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const missing = await realFetch(`${base}/tabs/open`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(missing.status).toBe(400);
reachable = false;
const started = (await realFetch(`${base}/start`, {
method: "POST",
}).then((r) => r.json())) as { error?: string };
expect(started.error ?? "").toMatch(/attachOnly/i);
});
it("allows attachOnly servers to ensure reachability via callback", async () => {
cfgAttachOnly = true;
reachable = false;
const { startBrowserBridgeServer } = await import("./bridge-server.js");
const ensured = vi.fn(async () => {
reachable = true;
});
const bridge = await startBrowserBridgeServer({
resolved: {
enabled: true,
controlUrl: "http://127.0.0.1:0",
controlHost: "127.0.0.1",
controlPort: 0,
cdpProtocol: "http",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
color: "#FF4500",
headless: true,
noSandbox: false,
attachOnly: true,
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: testPort + 1, color: "#FF4500" },
},
},
onEnsureAttachTarget: ensured,
});
const started = (await realFetch(`${bridge.baseUrl}/start`, {
method: "POST",
}).then((r) => r.json())) as { ok?: boolean; error?: string };
expect(started.error).toBeUndefined();
expect(started.ok).toBe(true);
const status = (await realFetch(`${bridge.baseUrl}/`).then((r) =>
r.json(),
)) as { running?: boolean };
expect(status.running).toBe(true);
expect(ensured).toHaveBeenCalledTimes(1);
await new Promise<void>((resolve) => bridge.server.close(() => resolve()));
});
it("opens tabs via CDP createTarget path", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
createTargetId = "abcd1234";
const opened = (await realFetch(`${base}/tabs/open`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com" }),
}).then((r) => r.json())) as { targetId?: string };
expect(opened.targetId).toBe("abcd1234");
});
});

View File

@@ -0,0 +1,463 @@
import { type AddressInfo, createServer } from "node:net";
import { fetch as realFetch } from "undici";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let testPort = 0;
let _cdpBaseUrl = "";
let reachable = false;
let cfgAttachOnly = false;
let createTargetId: string | null = null;
const cdpMocks = vi.hoisted(() => ({
createTargetViaCdp: vi.fn(async () => {
throw new Error("cdp disabled");
}),
snapshotAria: vi.fn(async () => ({
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
})),
}));
const pwMocks = vi.hoisted(() => ({
armDialogViaPlaywright: vi.fn(async () => {}),
armFileUploadViaPlaywright: vi.fn(async () => {}),
clickViaPlaywright: vi.fn(async () => {}),
closePageViaPlaywright: vi.fn(async () => {}),
closePlaywrightBrowserConnection: vi.fn(async () => {}),
downloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
dragViaPlaywright: vi.fn(async () => {}),
evaluateViaPlaywright: vi.fn(async () => "ok"),
fillFormViaPlaywright: vi.fn(async () => {}),
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
hoverViaPlaywright: vi.fn(async () => {}),
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
pressKeyViaPlaywright: vi.fn(async () => {}),
responseBodyViaPlaywright: vi.fn(async () => ({
url: "https://example.com/api/data",
status: 200,
headers: { "content-type": "application/json" },
body: '{"ok":true}',
})),
resizeViewportViaPlaywright: vi.fn(async () => {}),
selectOptionViaPlaywright: vi.fn(async () => {}),
setInputFilesViaPlaywright: vi.fn(async () => {}),
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
takeScreenshotViaPlaywright: vi.fn(async () => ({
buffer: Buffer.from("png"),
})),
typeViaPlaywright: vi.fn(async () => {}),
waitForDownloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
waitForViaPlaywright: vi.fn(async () => {}),
}));
function makeProc(pid = 123) {
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
return {
pid,
killed: false,
exitCode: null as number | null,
on: (event: string, cb: (...args: unknown[]) => void) => {
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
return undefined;
},
emitExit: () => {
for (const cb of handlers.get("exit") ?? []) cb(0);
},
kill: () => {
return true;
},
};
}
const proc = makeProc();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
browser: {
enabled: true,
controlUrl: `http://127.0.0.1:${testPort}`,
color: "#FF4500",
attachOnly: cfgAttachOnly,
headless: true,
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: testPort + 1, color: "#FF4500" },
},
},
}),
writeConfigFile: vi.fn(async () => {}),
};
});
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => reachable),
isChromeReachable: vi.fn(async () => reachable),
launchClawdChrome: vi.fn(
async (_resolved: unknown, profile: { cdpPort: number }) => {
launchCalls.push({ port: profile.cdpPort });
reachable = true;
return {
pid: 123,
exe: { kind: "chrome", path: "/fake/chrome" },
userDataDir: "/tmp/clawd",
cdpPort: profile.cdpPort,
startedAt: Date.now(),
proc,
};
},
),
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd"),
stopClawdChrome: vi.fn(async () => {
reachable = false;
}),
}));
vi.mock("./cdp.js", () => ({
createTargetViaCdp: cdpMocks.createTargetViaCdp,
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: cdpMocks.snapshotAria,
}));
vi.mock("./pw-ai.js", () => pwMocks);
vi.mock("../media/store.js", () => ({
ensureMediaDir: vi.fn(async () => {}),
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
}));
vi.mock("./screenshot.js", () => ({
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({
buffer: buf,
contentType: "image/png",
})),
}));
async function getFreePort(): Promise<number> {
while (true) {
const port = await new Promise<number>((resolve, reject) => {
const s = createServer();
s.once("error", reject);
s.listen(0, "127.0.0.1", () => {
const assigned = (s.address() as AddressInfo).port;
s.close((err) => (err ? reject(err) : resolve(assigned)));
});
});
if (port < 65535) return port;
}
}
function makeResponse(
body: unknown,
init?: { ok?: boolean; status?: number; text?: string },
): Response {
const ok = init?.ok ?? true;
const status = init?.status ?? 200;
const text = init?.text ?? "";
return {
ok,
status,
json: async () => body,
text: async () => text,
} as unknown as Response;
}
describe("browser control server", () => {
beforeEach(async () => {
reachable = false;
cfgAttachOnly = false;
createTargetId = null;
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
if (createTargetId) return { targetId: createTargetId };
throw new Error("cdp disabled");
});
for (const fn of Object.values(pwMocks)) fn.mockClear();
for (const fn of Object.values(cdpMocks)) fn.mockClear();
testPort = await getFreePort();
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
// Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) return makeResponse([]);
return makeResponse([
{
id: "abcd1234",
title: "Tab",
url: "https://example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
type: "page",
},
{
id: "abce9999",
title: "Other",
url: "https://other",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999",
type: "page",
},
]);
}
if (u.includes("/json/new?")) {
if (init?.method === "PUT") {
putNewCalls += 1;
if (putNewCalls === 1) {
return makeResponse({}, { ok: false, status: 405, text: "" });
}
}
return makeResponse({
id: "newtab1",
title: "",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
type: "page",
});
}
if (u.includes("/json/activate/")) return makeResponse("ok");
if (u.includes("/json/close/")) return makeResponse("ok");
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
it("covers additional endpoint branches", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const tabsWhenStopped = (await realFetch(`${base}/tabs`).then((r) =>
r.json(),
)) as { running: boolean; tabs: unknown[] };
expect(tabsWhenStopped.running).toBe(false);
expect(Array.isArray(tabsWhenStopped.tabs)).toBe(true);
const focusStopped = await realFetch(`${base}/tabs/focus`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: "abcd" }),
});
expect(focusStopped.status).toBe(409);
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
const focusMissing = await realFetch(`${base}/tabs/focus`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: "zzz" }),
});
expect(focusMissing.status).toBe(404);
const delAmbiguous = await realFetch(`${base}/tabs/abc`, {
method: "DELETE",
});
expect(delAmbiguous.status).toBe(409);
const snapAmbiguous = await realFetch(
`${base}/snapshot?format=aria&targetId=abc`,
);
expect(snapAmbiguous.status).toBe(409);
});
});
describe("backward compatibility (profile parameter)", () => {
beforeEach(async () => {
reachable = false;
cfgAttachOnly = false;
createTargetId = null;
for (const fn of Object.values(pwMocks)) fn.mockClear();
for (const fn of Object.values(cdpMocks)) fn.mockClear();
testPort = await getFreePort();
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) return makeResponse([]);
return makeResponse([
{
id: "abcd1234",
title: "Tab",
url: "https://example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
type: "page",
},
]);
}
if (u.includes("/json/new?")) {
return makeResponse({
id: "newtab1",
title: "",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
type: "page",
});
}
if (u.includes("/json/activate/")) return makeResponse("ok");
if (u.includes("/json/close/")) return makeResponse("ok");
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
it("GET / without profile uses default profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const status = (await realFetch(`${base}/`).then((r) => r.json())) as {
running: boolean;
profile?: string;
};
expect(status.running).toBe(false);
// Should use default profile (clawd)
expect(status.profile).toBe("clawd");
});
it("POST /start without profile uses default profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = (await realFetch(`${base}/start`, { method: "POST" }).then(
(r) => r.json(),
)) as { ok: boolean; profile?: string };
expect(result.ok).toBe(true);
expect(result.profile).toBe("clawd");
});
it("POST /stop without profile uses default profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" });
const result = (await realFetch(`${base}/stop`, { method: "POST" }).then(
(r) => r.json(),
)) as { ok: boolean; profile?: string };
expect(result.ok).toBe(true);
expect(result.profile).toBe("clawd");
});
it("GET /tabs without profile uses default profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" });
const result = (await realFetch(`${base}/tabs`).then((r) => r.json())) as {
running: boolean;
tabs: unknown[];
};
expect(result.running).toBe(true);
expect(Array.isArray(result.tabs)).toBe(true);
});
it("POST /tabs/open without profile uses default profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" });
const result = (await realFetch(`${base}/tabs/open`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com" }),
}).then((r) => r.json())) as { targetId?: string };
expect(result.targetId).toBe("newtab1");
});
it("GET /profiles returns list of profiles", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = (await realFetch(`${base}/profiles`).then((r) =>
r.json(),
)) as { profiles: Array<{ name: string }> };
expect(Array.isArray(result.profiles)).toBe(true);
// Should at least have the default clawd profile
expect(result.profiles.some((p) => p.name === "clawd")).toBe(true);
});
it("GET /tabs?profile=clawd returns tabs for specified profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" });
const result = (await realFetch(`${base}/tabs?profile=clawd`).then((r) =>
r.json(),
)) as { running: boolean; tabs: unknown[] };
expect(result.running).toBe(true);
expect(Array.isArray(result.tabs)).toBe(true);
});
it("POST /tabs/open?profile=clawd opens tab in specified profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" });
const result = (await realFetch(`${base}/tabs/open?profile=clawd`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com" }),
}).then((r) => r.json())) as { targetId?: string };
expect(result.targetId).toBe("newtab1");
});
it("GET /tabs?profile=unknown returns 404", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/tabs?profile=unknown`);
expect(result.status).toBe(404);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("not found");
});
});

View File

@@ -0,0 +1,416 @@
import { type AddressInfo, createServer } from "node:net";
import { fetch as realFetch } from "undici";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let testPort = 0;
let _cdpBaseUrl = "";
let reachable = false;
let cfgAttachOnly = false;
let createTargetId: string | null = null;
const cdpMocks = vi.hoisted(() => ({
createTargetViaCdp: vi.fn(async () => {
throw new Error("cdp disabled");
}),
snapshotAria: vi.fn(async () => ({
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
})),
}));
const pwMocks = vi.hoisted(() => ({
armDialogViaPlaywright: vi.fn(async () => {}),
armFileUploadViaPlaywright: vi.fn(async () => {}),
clickViaPlaywright: vi.fn(async () => {}),
closePageViaPlaywright: vi.fn(async () => {}),
closePlaywrightBrowserConnection: vi.fn(async () => {}),
downloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
dragViaPlaywright: vi.fn(async () => {}),
evaluateViaPlaywright: vi.fn(async () => "ok"),
fillFormViaPlaywright: vi.fn(async () => {}),
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
hoverViaPlaywright: vi.fn(async () => {}),
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
pressKeyViaPlaywright: vi.fn(async () => {}),
responseBodyViaPlaywright: vi.fn(async () => ({
url: "https://example.com/api/data",
status: 200,
headers: { "content-type": "application/json" },
body: '{"ok":true}',
})),
resizeViewportViaPlaywright: vi.fn(async () => {}),
selectOptionViaPlaywright: vi.fn(async () => {}),
setInputFilesViaPlaywright: vi.fn(async () => {}),
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
takeScreenshotViaPlaywright: vi.fn(async () => ({
buffer: Buffer.from("png"),
})),
typeViaPlaywright: vi.fn(async () => {}),
waitForDownloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
waitForViaPlaywright: vi.fn(async () => {}),
}));
function makeProc(pid = 123) {
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
return {
pid,
killed: false,
exitCode: null as number | null,
on: (event: string, cb: (...args: unknown[]) => void) => {
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
return undefined;
},
emitExit: () => {
for (const cb of handlers.get("exit") ?? []) cb(0);
},
kill: () => {
return true;
},
};
}
const proc = makeProc();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
browser: {
enabled: true,
controlUrl: `http://127.0.0.1:${testPort}`,
color: "#FF4500",
attachOnly: cfgAttachOnly,
headless: true,
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: testPort + 1, color: "#FF4500" },
},
},
}),
writeConfigFile: vi.fn(async () => {}),
};
});
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => reachable),
isChromeReachable: vi.fn(async () => reachable),
launchClawdChrome: vi.fn(
async (_resolved: unknown, profile: { cdpPort: number }) => {
launchCalls.push({ port: profile.cdpPort });
reachable = true;
return {
pid: 123,
exe: { kind: "chrome", path: "/fake/chrome" },
userDataDir: "/tmp/clawd",
cdpPort: profile.cdpPort,
startedAt: Date.now(),
proc,
};
},
),
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd"),
stopClawdChrome: vi.fn(async () => {
reachable = false;
}),
}));
vi.mock("./cdp.js", () => ({
createTargetViaCdp: cdpMocks.createTargetViaCdp,
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: cdpMocks.snapshotAria,
}));
vi.mock("./pw-ai.js", () => pwMocks);
vi.mock("../media/store.js", () => ({
ensureMediaDir: vi.fn(async () => {}),
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
}));
vi.mock("./screenshot.js", () => ({
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({
buffer: buf,
contentType: "image/png",
})),
}));
async function getFreePort(): Promise<number> {
while (true) {
const port = await new Promise<number>((resolve, reject) => {
const s = createServer();
s.once("error", reject);
s.listen(0, "127.0.0.1", () => {
const assigned = (s.address() as AddressInfo).port;
s.close((err) => (err ? reject(err) : resolve(assigned)));
});
});
if (port < 65535) return port;
}
}
function makeResponse(
body: unknown,
init?: { ok?: boolean; status?: number; text?: string },
): Response {
const ok = init?.ok ?? true;
const status = init?.status ?? 200;
const text = init?.text ?? "";
return {
ok,
status,
json: async () => body,
text: async () => text,
} as unknown as Response;
}
describe("browser control server", () => {
beforeEach(async () => {
reachable = false;
cfgAttachOnly = false;
createTargetId = null;
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
if (createTargetId) return { targetId: createTargetId };
throw new Error("cdp disabled");
});
for (const fn of Object.values(pwMocks)) fn.mockClear();
for (const fn of Object.values(cdpMocks)) fn.mockClear();
testPort = await getFreePort();
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
// Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) return makeResponse([]);
return makeResponse([
{
id: "abcd1234",
title: "Tab",
url: "https://example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
type: "page",
},
{
id: "abce9999",
title: "Other",
url: "https://other",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999",
type: "page",
},
]);
}
if (u.includes("/json/new?")) {
if (init?.method === "PUT") {
putNewCalls += 1;
if (putNewCalls === 1) {
return makeResponse({}, { ok: false, status: 405, text: "" });
}
}
return makeResponse({
id: "newtab1",
title: "",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
type: "page",
});
}
if (u.includes("/json/activate/")) return makeResponse("ok");
if (u.includes("/json/close/")) return makeResponse("ok");
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
it("POST /tabs/open?profile=unknown returns 404", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/tabs/open?profile=unknown`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com" }),
});
expect(result.status).toBe(404);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("not found");
});
});
describe("profile CRUD endpoints", () => {
beforeEach(async () => {
reachable = false;
cfgAttachOnly = false;
for (const fn of Object.values(pwMocks)) fn.mockClear();
for (const fn of Object.values(cdpMocks)) fn.mockClear();
testPort = await getFreePort();
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
const u = String(url);
if (u.includes("/json/list")) return makeResponse([]);
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
it("POST /profiles/create returns 400 for missing name", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("name is required");
});
it("POST /profiles/create returns 400 for invalid name format", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Invalid Name!" }),
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("invalid profile name");
});
it("POST /profiles/create returns 409 for duplicate name", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
// "clawd" already exists as the default profile
const result = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "clawd" }),
});
expect(result.status).toBe(409);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("already exists");
});
it("POST /profiles/create accepts cdpUrl for remote profiles", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "remote", cdpUrl: "http://10.0.0.42:9222" }),
});
expect(result.status).toBe(200);
const body = (await result.json()) as {
profile?: string;
cdpUrl?: string;
isRemote?: boolean;
};
expect(body.profile).toBe("remote");
expect(body.cdpUrl).toBe("http://10.0.0.42:9222");
expect(body.isRemote).toBe(true);
});
it("POST /profiles/create returns 400 for invalid cdpUrl", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "badremote", cdpUrl: "ws://bad" }),
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("cdpUrl");
});
it("DELETE /profiles/:name returns 404 for non-existent profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/nonexistent`, {
method: "DELETE",
});
expect(result.status).toBe(404);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("not found");
});
it("DELETE /profiles/:name returns 400 for default profile deletion", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
// clawd is the default profile
const result = await realFetch(`${base}/profiles/clawd`, {
method: "DELETE",
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("cannot delete the default profile");
});
it("DELETE /profiles/:name returns 400 for invalid name format", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/Invalid-Name!`, {
method: "DELETE",
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("invalid profile name");
});
});

View File

@@ -0,0 +1,423 @@
import { type AddressInfo, createServer } from "node:net";
import { fetch as realFetch } from "undici";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let testPort = 0;
let cdpBaseUrl = "";
let reachable = false;
let cfgAttachOnly = false;
let createTargetId: string | null = null;
const cdpMocks = vi.hoisted(() => ({
createTargetViaCdp: vi.fn(async () => {
throw new Error("cdp disabled");
}),
snapshotAria: vi.fn(async () => ({
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
})),
}));
const pwMocks = vi.hoisted(() => ({
armDialogViaPlaywright: vi.fn(async () => {}),
armFileUploadViaPlaywright: vi.fn(async () => {}),
clickViaPlaywright: vi.fn(async () => {}),
closePageViaPlaywright: vi.fn(async () => {}),
closePlaywrightBrowserConnection: vi.fn(async () => {}),
downloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
dragViaPlaywright: vi.fn(async () => {}),
evaluateViaPlaywright: vi.fn(async () => "ok"),
fillFormViaPlaywright: vi.fn(async () => {}),
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
hoverViaPlaywright: vi.fn(async () => {}),
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
pressKeyViaPlaywright: vi.fn(async () => {}),
responseBodyViaPlaywright: vi.fn(async () => ({
url: "https://example.com/api/data",
status: 200,
headers: { "content-type": "application/json" },
body: '{"ok":true}',
})),
resizeViewportViaPlaywright: vi.fn(async () => {}),
selectOptionViaPlaywright: vi.fn(async () => {}),
setInputFilesViaPlaywright: vi.fn(async () => {}),
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
takeScreenshotViaPlaywright: vi.fn(async () => ({
buffer: Buffer.from("png"),
})),
typeViaPlaywright: vi.fn(async () => {}),
waitForDownloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
waitForViaPlaywright: vi.fn(async () => {}),
}));
function makeProc(pid = 123) {
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
return {
pid,
killed: false,
exitCode: null as number | null,
on: (event: string, cb: (...args: unknown[]) => void) => {
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
return undefined;
},
emitExit: () => {
for (const cb of handlers.get("exit") ?? []) cb(0);
},
kill: () => {
return true;
},
};
}
const proc = makeProc();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
browser: {
enabled: true,
controlUrl: `http://127.0.0.1:${testPort}`,
color: "#FF4500",
attachOnly: cfgAttachOnly,
headless: true,
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: testPort + 1, color: "#FF4500" },
},
},
}),
writeConfigFile: vi.fn(async () => {}),
};
});
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => reachable),
isChromeReachable: vi.fn(async () => reachable),
launchClawdChrome: vi.fn(
async (_resolved: unknown, profile: { cdpPort: number }) => {
launchCalls.push({ port: profile.cdpPort });
reachable = true;
return {
pid: 123,
exe: { kind: "chrome", path: "/fake/chrome" },
userDataDir: "/tmp/clawd",
cdpPort: profile.cdpPort,
startedAt: Date.now(),
proc,
};
},
),
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd"),
stopClawdChrome: vi.fn(async () => {
reachable = false;
}),
}));
vi.mock("./cdp.js", () => ({
createTargetViaCdp: cdpMocks.createTargetViaCdp,
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: cdpMocks.snapshotAria,
}));
vi.mock("./pw-ai.js", () => pwMocks);
vi.mock("../media/store.js", () => ({
ensureMediaDir: vi.fn(async () => {}),
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
}));
vi.mock("./screenshot.js", () => ({
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({
buffer: buf,
contentType: "image/png",
})),
}));
async function getFreePort(): Promise<number> {
while (true) {
const port = await new Promise<number>((resolve, reject) => {
const s = createServer();
s.once("error", reject);
s.listen(0, "127.0.0.1", () => {
const assigned = (s.address() as AddressInfo).port;
s.close((err) => (err ? reject(err) : resolve(assigned)));
});
});
if (port < 65535) return port;
}
}
function makeResponse(
body: unknown,
init?: { ok?: boolean; status?: number; text?: string },
): Response {
const ok = init?.ok ?? true;
const status = init?.status ?? 200;
const text = init?.text ?? "";
return {
ok,
status,
json: async () => body,
text: async () => text,
} as unknown as Response;
}
describe("browser control server", () => {
beforeEach(async () => {
reachable = false;
cfgAttachOnly = false;
createTargetId = null;
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
if (createTargetId) return { targetId: createTargetId };
throw new Error("cdp disabled");
});
for (const fn of Object.values(pwMocks)) fn.mockClear();
for (const fn of Object.values(cdpMocks)) fn.mockClear();
testPort = await getFreePort();
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
// Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) return makeResponse([]);
return makeResponse([
{
id: "abcd1234",
title: "Tab",
url: "https://example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
type: "page",
},
{
id: "abce9999",
title: "Other",
url: "https://other",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999",
type: "page",
},
]);
}
if (u.includes("/json/new?")) {
if (init?.method === "PUT") {
putNewCalls += 1;
if (putNewCalls === 1) {
return makeResponse({}, { ok: false, status: 405, text: "" });
}
}
return makeResponse({
id: "newtab1",
title: "",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
type: "page",
});
}
if (u.includes("/json/activate/")) return makeResponse("ok");
if (u.includes("/json/close/")) return makeResponse("ok");
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
const startServerAndBase = async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
return base;
};
const postJson = async <T>(url: string, body?: unknown): Promise<T> => {
const res = await realFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
return (await res.json()) as T;
};
it("agent contract: form + layout act commands", async () => {
const base = await startServerAndBase();
const select = (await postJson(`${base}/act`, {
kind: "select",
ref: "5",
values: ["a", "b"],
})) as { ok: boolean };
expect(select.ok).toBe(true);
expect(pwMocks.selectOptionViaPlaywright).toHaveBeenCalledWith({
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
ref: "5",
values: ["a", "b"],
});
const fill = (await postJson(`${base}/act`, {
kind: "fill",
fields: [{ ref: "6", type: "textbox", value: "hello" }],
})) as { ok: boolean };
expect(fill.ok).toBe(true);
expect(pwMocks.fillFormViaPlaywright).toHaveBeenCalledWith({
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
fields: [{ ref: "6", type: "textbox", value: "hello" }],
});
const resize = (await postJson(`${base}/act`, {
kind: "resize",
width: 800,
height: 600,
})) as { ok: boolean };
expect(resize.ok).toBe(true);
expect(pwMocks.resizeViewportViaPlaywright).toHaveBeenCalledWith({
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
width: 800,
height: 600,
});
const wait = (await postJson(`${base}/act`, {
kind: "wait",
timeMs: 5,
})) as { ok: boolean };
expect(wait.ok).toBe(true);
expect(pwMocks.waitForViaPlaywright).toHaveBeenCalledWith({
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
timeMs: 5,
text: undefined,
textGone: undefined,
});
const evalRes = (await postJson(`${base}/act`, {
kind: "evaluate",
fn: "() => 1",
})) as { ok: boolean; result?: unknown };
expect(evalRes.ok).toBe(true);
expect(evalRes.result).toBe("ok");
expect(pwMocks.evaluateViaPlaywright).toHaveBeenCalledWith({
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
fn: "() => 1",
ref: undefined,
});
});
it("agent contract: hooks + response + downloads + screenshot", async () => {
const base = await startServerAndBase();
const upload = await postJson(`${base}/hooks/file-chooser`, {
paths: ["/tmp/a.txt"],
timeoutMs: 1234,
});
expect(upload).toMatchObject({ ok: true });
expect(pwMocks.armFileUploadViaPlaywright).toHaveBeenCalledWith({
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
paths: ["/tmp/a.txt"],
timeoutMs: 1234,
});
const uploadWithRef = await postJson(`${base}/hooks/file-chooser`, {
paths: ["/tmp/b.txt"],
ref: "e12",
});
expect(uploadWithRef).toMatchObject({ ok: true });
const uploadWithInputRef = await postJson(`${base}/hooks/file-chooser`, {
paths: ["/tmp/c.txt"],
inputRef: "e99",
});
expect(uploadWithInputRef).toMatchObject({ ok: true });
const uploadWithElement = await postJson(`${base}/hooks/file-chooser`, {
paths: ["/tmp/d.txt"],
element: "input[type=file]",
});
expect(uploadWithElement).toMatchObject({ ok: true });
const dialog = await postJson(`${base}/hooks/dialog`, {
accept: true,
timeoutMs: 5678,
});
expect(dialog).toMatchObject({ ok: true });
const waitDownload = await postJson(`${base}/wait/download`, {
path: "/tmp/report.pdf",
timeoutMs: 1111,
});
expect(waitDownload).toMatchObject({ ok: true });
const download = await postJson(`${base}/download`, {
ref: "e12",
path: "/tmp/report.pdf",
});
expect(download).toMatchObject({ ok: true });
const responseBody = await postJson(`${base}/response/body`, {
url: "**/api/data",
timeoutMs: 2222,
maxChars: 10,
});
expect(responseBody).toMatchObject({ ok: true });
const consoleRes = (await realFetch(`${base}/console?level=error`).then(
(r) => r.json(),
)) as { ok: boolean; messages?: unknown[] };
expect(consoleRes.ok).toBe(true);
expect(Array.isArray(consoleRes.messages)).toBe(true);
const pdf = (await postJson(`${base}/pdf`, {})) as {
ok: boolean;
path?: string;
};
expect(pdf.ok).toBe(true);
expect(typeof pdf.path).toBe("string");
const shot = (await postJson(`${base}/screenshot`, {
element: "body",
type: "jpeg",
})) as { ok: boolean; path?: string };
expect(shot.ok).toBe(true);
expect(typeof shot.path).toBe("string");
});
it("agent contract: stop endpoint", async () => {
const base = await startServerAndBase();
const stopped = (await realFetch(`${base}/stop`, {
method: "POST",
}).then((r) => r.json())) as { ok: boolean; stopped?: boolean };
expect(stopped.ok).toBe(true);
expect(stopped.stopped).toBe(true);
});
});

File diff suppressed because it is too large Load Diff