chore: migrate to oxlint and oxfmt

Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
This commit is contained in:
Peter Steinberger
2026-01-14 14:31:43 +00:00
parent 912ebffc63
commit c379191f80
1480 changed files with 28608 additions and 43547 deletions

View File

@@ -13,10 +13,7 @@ type Pending = {
reject: (err: Error) => void;
};
export type CdpSendFn = (
method: string,
params?: Record<string, unknown>,
) => Promise<unknown>;
export type CdpSendFn = (method: string, params?: Record<string, unknown>) => Promise<unknown>;
export function isLoopbackHost(host: string) {
const h = host.trim().toLowerCase();
@@ -35,10 +32,7 @@ function createCdpSender(ws: WebSocket) {
let nextId = 1;
const pending = new Map<number, Pending>();
const send: CdpSendFn = (
method: string,
params?: Record<string, unknown>,
) => {
const send: CdpSendFn = (method: string, params?: Record<string, unknown>) => {
const id = nextId++;
const msg = { id, method, params };
ws.send(JSON.stringify(msg));

View File

@@ -3,12 +3,7 @@ import { createServer } from "node:http";
import { afterEach, describe, expect, it } from "vitest";
import { WebSocketServer } from "ws";
import { rawDataToString } from "../infra/ws.js";
import {
createTargetViaCdp,
evaluateJavaScript,
normalizeCdpWsUrl,
snapshotAria,
} from "./cdp.js";
import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
describe("cdp", () => {
let httpServer: ReturnType<typeof createServer> | null = null;
@@ -63,9 +58,7 @@ describe("cdp", () => {
res.end("not found");
});
await new Promise<void>((resolve) =>
httpServer?.listen(0, "127.0.0.1", resolve),
);
await new Promise<void>((resolve) => httpServer?.listen(0, "127.0.0.1", resolve));
const httpPort = (httpServer.address() as { port: number }).port;
const created = await createTargetViaCdp({

View File

@@ -32,9 +32,7 @@ export async function captureScreenshot(opts: {
return await withCdpSocket(opts.wsUrl, async (send) => {
await send("Page.enable");
let clip:
| { x: number; y: number; width: number; height: number; scale: number }
| undefined;
let clip: { x: number; y: number; width: number; height: number; scale: number } | undefined;
if (opts.fullPage) {
const metrics = (await send("Page.getLayoutMetrics")) as {
cssContentSize?: { width?: number; height?: number };
@@ -50,9 +48,7 @@ export async function captureScreenshot(opts: {
const format = opts.format ?? "png";
const quality =
format === "jpeg"
? Math.max(0, Math.min(100, Math.round(opts.quality ?? 85)))
: undefined;
format === "jpeg" ? Math.max(0, Math.min(100, Math.round(opts.quality ?? 85))) : undefined;
const result = (await send("Page.captureScreenshot", {
format,
@@ -73,10 +69,7 @@ export async function createTargetViaCdp(opts: {
url: string;
}): Promise<{ targetId: string }> {
const base = opts.cdpUrl.replace(/\/$/, "");
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
`${base}/json/version`,
1500,
);
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`${base}/json/version`, 1500);
const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();
const wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
if (!wsUrl) throw new Error("CDP /json/version missing webSocketDebuggerUrl");
@@ -86,8 +79,7 @@ export async function createTargetViaCdp(opts: {
targetId?: string;
};
const targetId = String(created?.targetId ?? "").trim();
if (!targetId)
throw new Error("CDP Target.createTarget returned no targetId");
if (!targetId) throw new Error("CDP Target.createTarget returned no targetId");
return { targetId };
});
}
@@ -167,10 +159,7 @@ function axValue(v: unknown): string {
return "";
}
function formatAriaSnapshot(
nodes: RawAXNode[],
limit: number,
): AriaSnapshotNode[] {
function formatAriaSnapshot(nodes: RawAXNode[], limit: number): AriaSnapshotNode[] {
const byId = new Map<string, RawAXNode>();
for (const n of nodes) {
if (n.nodeId) byId.set(n.nodeId, n);
@@ -181,14 +170,11 @@ function formatAriaSnapshot(
for (const n of nodes) {
for (const c of n.childIds ?? []) referenced.add(c);
}
const root =
nodes.find((n) => n.nodeId && !referenced.has(n.nodeId)) ?? nodes[0];
const root = nodes.find((n) => n.nodeId && !referenced.has(n.nodeId)) ?? nodes[0];
if (!root?.nodeId) return [];
const out: AriaSnapshotNode[] = [];
const stack: Array<{ id: string; depth: number }> = [
{ id: root.nodeId, depth: 0 },
];
const stack: Array<{ id: string; depth: number }> = [{ id: root.nodeId, depth: 0 }];
while (stack.length && out.length < limit) {
const popped = stack.pop();
if (!popped) break;
@@ -206,9 +192,7 @@ function formatAriaSnapshot(
name: name || "",
...(value ? { value } : {}),
...(description ? { description } : {}),
...(typeof n.backendDOMNodeId === "number"
? { backendDOMNodeId: n.backendDOMNodeId }
: {}),
...(typeof n.backendDOMNodeId === "number" ? { backendDOMNodeId: n.backendDOMNodeId } : {}),
depth,
});
@@ -245,10 +229,7 @@ export async function snapshotDom(opts: {
nodes: DomSnapshotNode[];
}> {
const limit = Math.max(1, Math.min(5000, Math.floor(opts.limit ?? 800)));
const maxTextChars = Math.max(
0,
Math.min(5000, Math.floor(opts.maxTextChars ?? 220)),
);
const maxTextChars = Math.max(0, Math.min(5000, Math.floor(opts.maxTextChars ?? 220)));
const expression = `(() => {
const maxNodes = ${JSON.stringify(limit)};
@@ -328,10 +309,7 @@ export async function getDomText(opts: {
maxChars?: number;
selector?: string;
}): Promise<{ text: string }> {
const maxChars = Math.max(
0,
Math.min(5_000_000, Math.floor(opts.maxChars ?? 200_000)),
);
const maxChars = Math.max(0, Math.min(5_000_000, Math.floor(opts.maxChars ?? 200_000)));
const selectorExpr = opts.selector ? JSON.stringify(opts.selector) : "null";
const expression = `(() => {
const fmt = ${JSON.stringify(opts.format)};
@@ -376,14 +354,8 @@ export async function querySelector(opts: {
matches: QueryMatch[];
}> {
const limit = Math.max(1, Math.min(200, Math.floor(opts.limit ?? 20)));
const maxText = Math.max(
0,
Math.min(5000, Math.floor(opts.maxTextChars ?? 500)),
);
const maxHtml = Math.max(
0,
Math.min(20000, Math.floor(opts.maxHtmlChars ?? 1500)),
);
const maxText = Math.max(0, Math.min(5000, Math.floor(opts.maxTextChars ?? 500)));
const maxHtml = Math.max(0, Math.min(20000, Math.floor(opts.maxHtmlChars ?? 1500)));
const expression = `(() => {
const sel = ${JSON.stringify(opts.selector)};

View File

@@ -17,9 +17,7 @@ function exists(filePath: string) {
}
}
function findFirstExecutable(
candidates: Array<BrowserExecutable>,
): BrowserExecutable | null {
function findFirstExecutable(candidates: Array<BrowserExecutable>): BrowserExecutable | null {
for (const candidate of candidates) {
if (exists(candidate.path)) return candidate;
}
@@ -46,10 +44,7 @@ export function findChromeExecutableMac(): BrowserExecutable | null {
},
{
kind: "chromium",
path: path.join(
os.homedir(),
"Applications/Chromium.app/Contents/MacOS/Chromium",
),
path: path.join(os.homedir(), "Applications/Chromium.app/Contents/MacOS/Chromium"),
},
{
kind: "chrome",
@@ -57,10 +52,7 @@ export function findChromeExecutableMac(): BrowserExecutable | null {
},
{
kind: "chrome",
path: path.join(
os.homedir(),
"Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
),
path: path.join(os.homedir(), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
},
];
@@ -84,8 +76,7 @@ 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 programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
const joinWin = path.win32.join;
const candidates: Array<BrowserExecutable> = [];
@@ -94,13 +85,7 @@ export function findChromeExecutableWindows(): BrowserExecutable | null {
// Chrome Canary (user install)
candidates.push({
kind: "canary",
path: joinWin(
localAppData,
"Google",
"Chrome SxS",
"Application",
"chrome.exe",
),
path: joinWin(localAppData, "Google", "Chrome SxS", "Application", "chrome.exe"),
});
// Chromium (user install)
candidates.push({
@@ -110,37 +95,19 @@ export function findChromeExecutableWindows(): BrowserExecutable | null {
// Chrome (user install)
candidates.push({
kind: "chrome",
path: joinWin(
localAppData,
"Google",
"Chrome",
"Application",
"chrome.exe",
),
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",
),
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",
),
path: joinWin(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
});
return findFirstExecutable(candidates);
@@ -152,9 +119,7 @@ export function resolveBrowserExecutableForPlatform(
): BrowserExecutable | null {
if (resolved.executablePath) {
if (!exists(resolved.executablePath)) {
throw new Error(
`browser.executablePath not found: ${resolved.executablePath}`,
);
throw new Error(`browser.executablePath not found: ${resolved.executablePath}`);
}
return { kind: "custom", path: resolved.executablePath };
}

View File

@@ -1,10 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import {
DEFAULT_CLAWD_BROWSER_COLOR,
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
} from "./constants.js";
import { DEFAULT_CLAWD_BROWSER_COLOR, DEFAULT_CLAWD_BROWSER_PROFILE_NAME } from "./constants.js";
function decoratedMarkerPath(userDataDir: string) {
return path.join(userDataDir, ".clawd-profile-decorated");
@@ -15,8 +12,7 @@ function safeReadJson(filePath: string): Record<string, unknown> | null {
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;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
@@ -46,9 +42,7 @@ function parseHexRgbToSignedArgbInt(hex: string): number | 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;
return argbUnsigned > 0x7fffffff ? argbUnsigned - 0x1_0000_0000 : argbUnsigned;
}
export function isProfileDecorated(
@@ -74,10 +68,7 @@ export function isProfileDecorated(
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
>)
? ((infoCache as Record<string, unknown>).Default as Record<string, unknown>)
: null;
const prefs = safeReadJson(preferencesPath);
@@ -95,9 +86,7 @@ export function isProfileDecorated(
const autogeneratedTheme = (() => {
const autogenerated = prefs?.autogenerated;
const theme =
typeof autogenerated === "object" &&
autogenerated !== null &&
!Array.isArray(autogenerated)
typeof autogenerated === "object" && autogenerated !== null && !Array.isArray(autogenerated)
? (autogenerated as Record<string, unknown>).theme
: null;
return typeof theme === "object" && theme !== null && !Array.isArray(theme)
@@ -105,8 +94,7 @@ export function isProfileDecorated(
: null;
})();
const nameOk =
typeof info?.name === "string" ? info.name === desiredName : true;
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.
@@ -121,8 +109,7 @@ export function isProfileDecorated(
const prefOk =
(typeof browserTheme?.user_color2 === "number" &&
browserTheme.user_color2 === desiredColorInt) ||
(typeof autogeneratedTheme?.color === "number" &&
autogeneratedTheme.color === desiredColorInt);
(typeof autogeneratedTheme?.color === "number" && autogeneratedTheme.color === desiredColorInt);
return nameOk && localSeedOk && prefOk;
}
@@ -136,9 +123,7 @@ export function decorateClawdProfile(
opts?: { name?: string; color?: string },
) {
const desiredName = opts?.name ?? DEFAULT_CLAWD_BROWSER_PROFILE_NAME;
const desiredColor = (
opts?.color ?? DEFAULT_CLAWD_BROWSER_COLOR
).toUpperCase();
const desiredColor = (opts?.color ?? DEFAULT_CLAWD_BROWSER_COLOR).toUpperCase();
const desiredColorInt = parseHexRgbToSignedArgbInt(desiredColor);
const localStatePath = path.join(userDataDir, "Local State");
@@ -146,32 +131,12 @@ export function decorateClawdProfile(
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,
);
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,
);
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(
@@ -210,11 +175,7 @@ export function decorateClawdProfile(
safeWriteJson(preferencesPath, prefs);
try {
fs.writeFileSync(
decoratedMarkerPath(userDataDir),
`${Date.now()}\n`,
"utf-8",
);
fs.writeFileSync(decoratedMarkerPath(userDataDir), `${Date.now()}\n`, "utf-8");
} catch {
// ignore
}

View File

@@ -13,10 +13,7 @@ import {
resolveBrowserExecutableForPlatform,
stopClawdChrome,
} from "./chrome.js";
import {
DEFAULT_CLAWD_BROWSER_COLOR,
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
} from "./constants.js";
import { DEFAULT_CLAWD_BROWSER_COLOR, DEFAULT_CLAWD_BROWSER_PROFILE_NAME } from "./constants.js";
async function readJson(filePath: string): Promise<Record<string, unknown>> {
const raw = await fsp.readFile(filePath, "utf-8");
@@ -30,9 +27,7 @@ describe("browser chrome profile decoration", () => {
});
it("writes expected name + signed ARGB seed to Chrome prefs", async () => {
const userDataDir = await fsp.mkdtemp(
path.join(os.tmpdir(), "clawdbot-chrome-test-"),
);
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdbot-chrome-test-"));
try {
decorateClawdProfile(userDataDir, { color: DEFAULT_CLAWD_BROWSER_COLOR });
@@ -50,9 +45,7 @@ describe("browser chrome profile decoration", () => {
expect(def.default_avatar_fill_color).toBe(expectedSignedArgb);
expect(def.default_avatar_stroke_color).toBe(expectedSignedArgb);
const prefs = await readJson(
path.join(userDataDir, "Default", "Preferences"),
);
const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
const browser = prefs.browser as Record<string, unknown>;
const theme = browser.theme as Record<string, unknown>;
const autogenerated = prefs.autogenerated as Record<string, unknown>;
@@ -72,9 +65,7 @@ describe("browser chrome profile decoration", () => {
});
it("best-effort writes name when color is invalid", async () => {
const userDataDir = await fsp.mkdtemp(
path.join(os.tmpdir(), "clawdbot-chrome-test-"),
);
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdbot-chrome-test-"));
try {
decorateClawdProfile(userDataDir, { color: "lobster-orange" });
const localState = await readJson(path.join(userDataDir, "Local State"));
@@ -90,9 +81,7 @@ describe("browser chrome profile decoration", () => {
});
it("recovers from missing/invalid preference files", async () => {
const userDataDir = await fsp.mkdtemp(
path.join(os.tmpdir(), "clawdbot-chrome-test-"),
);
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdbot-chrome-test-"));
try {
await fsp.mkdir(path.join(userDataDir, "Default"), { recursive: true });
await fsp.writeFile(path.join(userDataDir, "Local State"), "{", "utf-8"); // invalid JSON
@@ -107,9 +96,7 @@ describe("browser chrome profile decoration", () => {
const localState = await readJson(path.join(userDataDir, "Local State"));
expect(typeof localState.profile).toBe("object");
const prefs = await readJson(
path.join(userDataDir, "Default", "Preferences"),
);
const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
expect(typeof prefs.profile).toBe("object");
} finally {
await fsp.rm(userDataDir, { recursive: true, force: true });
@@ -117,16 +104,12 @@ describe("browser chrome profile decoration", () => {
});
it("is idempotent when rerun on an existing profile", async () => {
const userDataDir = await fsp.mkdtemp(
path.join(os.tmpdir(), "clawdbot-chrome-test-"),
);
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdbot-chrome-test-"));
try {
decorateClawdProfile(userDataDir, { color: DEFAULT_CLAWD_BROWSER_COLOR });
decorateClawdProfile(userDataDir, { color: DEFAULT_CLAWD_BROWSER_COLOR });
const prefs = await readJson(
path.join(userDataDir, "Default", "Preferences"),
);
const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
const profile = prefs.profile as Record<string, unknown>;
expect(profile.name).toBe(DEFAULT_CLAWD_BROWSER_PROFILE_NAME);
} finally {
@@ -171,9 +154,7 @@ describe("browser chrome helpers", () => {
it("finds Chrome in Program Files on Windows", () => {
const marker = path.win32.join("Program Files", "Google", "Chrome");
const exists = vi
.spyOn(fs, "existsSync")
.mockImplementation((p) => String(p).includes(marker));
const exists = vi.spyOn(fs, "existsSync").mockImplementation((p) => String(p).includes(marker));
const exe = findChromeExecutableWindows();
expect(exe?.kind).toBe("chrome");
expect(exe?.path).toMatch(/chrome\.exe$/);
@@ -197,9 +178,7 @@ describe("browser chrome helpers", () => {
"Application",
"chrome.exe",
);
const exists = vi
.spyOn(fs, "existsSync")
.mockImplementation((p) => String(p).includes(marker));
const exists = vi.spyOn(fs, "existsSync").mockImplementation((p) => String(p).includes(marker));
const exe = resolveBrowserExecutableForPlatform(
{} as Parameters<typeof resolveBrowserExecutableForPlatform>[0],
"win32",
@@ -217,9 +196,7 @@ describe("browser chrome helpers", () => {
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
} as unknown as Response),
);
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(
true,
);
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(true);
vi.stubGlobal(
"fetch",
@@ -228,14 +205,10 @@ describe("browser chrome helpers", () => {
json: async () => ({}),
} as unknown as Response),
);
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(
false,
);
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false);
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("boom")));
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(
false,
);
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false);
});
it("stopClawdChrome no-ops when process is already killed", async () => {

View File

@@ -12,18 +12,9 @@ import {
type BrowserExecutable,
resolveBrowserExecutableForPlatform,
} from "./chrome.executables.js";
import {
decorateClawdProfile,
isProfileDecorated,
} from "./chrome.profile-decoration.js";
import type {
ResolvedBrowserConfig,
ResolvedBrowserProfile,
} from "./config.js";
import {
DEFAULT_CLAWD_BROWSER_COLOR,
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
} from "./constants.js";
import { decorateClawdProfile, isProfileDecorated } from "./chrome.profile-decoration.js";
import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
import { DEFAULT_CLAWD_BROWSER_COLOR, DEFAULT_CLAWD_BROWSER_PROFILE_NAME } from "./constants.js";
const log = createSubsystemLogger("browser").child("chrome");
@@ -34,10 +25,7 @@ export {
findChromeExecutableWindows,
resolveBrowserExecutableForPlatform,
} from "./chrome.executables.js";
export {
decorateClawdProfile,
isProfileDecorated,
} from "./chrome.profile-decoration.js";
export { decorateClawdProfile, isProfileDecorated } from "./chrome.profile-decoration.js";
function exists(filePath: string) {
try {
@@ -56,15 +44,11 @@ export type RunningChrome = {
proc: ChildProcessWithoutNullStreams;
};
function resolveBrowserExecutable(
resolved: ResolvedBrowserConfig,
): BrowserExecutable | null {
function resolveBrowserExecutable(resolved: ResolvedBrowserConfig): BrowserExecutable | null {
return resolveBrowserExecutableForPlatform(resolved, process.platform);
}
export function resolveClawdUserDataDir(
profileName = DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
) {
export function resolveClawdUserDataDir(profileName = DEFAULT_CLAWD_BROWSER_PROFILE_NAME) {
return path.join(CONFIG_DIR, "browser", profileName, "user-data");
}
@@ -72,10 +56,7 @@ function cdpUrlForPort(cdpPort: number) {
return `http://127.0.0.1:${cdpPort}`;
}
export async function isChromeReachable(
cdpUrl: string,
timeoutMs = 500,
): Promise<boolean> {
export async function isChromeReachable(cdpUrl: string, timeoutMs = 500): Promise<boolean> {
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
return Boolean(version);
}
@@ -86,10 +67,7 @@ type ChromeVersion = {
"User-Agent"?: string;
};
async function fetchChromeVersion(
cdpUrl: string,
timeoutMs = 500,
): Promise<ChromeVersion | null> {
async function fetchChromeVersion(cdpUrl: string, timeoutMs = 500): Promise<ChromeVersion | null> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
@@ -118,10 +96,7 @@ export async function getChromeWebSocketUrl(
return normalizeCdpWsUrl(wsUrl, cdpUrl);
}
async function canOpenWebSocket(
wsUrl: string,
timeoutMs = 800,
): Promise<boolean> {
async function canOpenWebSocket(wsUrl: string, timeoutMs = 800): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const ws = new WebSocket(wsUrl, { handshakeTimeout: timeoutMs });
const timer = setTimeout(
@@ -166,17 +141,13 @@ export async function launchClawdChrome(
profile: ResolvedBrowserProfile,
): Promise<RunningChrome> {
if (!profile.cdpIsLoopback) {
throw new Error(
`Profile "${profile.name}" is remote; cannot launch local Chrome.`,
);
throw new Error(`Profile "${profile.name}" is remote; cannot launch local Chrome.`);
}
await ensurePortAvailable(profile.cdpPort);
const exe = resolveBrowserExecutable(resolved);
if (!exe) {
throw new Error(
"No supported browser found (Chrome/Chromium on macOS, Linux, or Windows).",
);
throw new Error("No supported browser found (Chrome/Chromium on macOS, Linux, or Windows).");
}
const userDataDir = resolveClawdUserDataDir(profile.name);
@@ -301,10 +272,7 @@ export async function launchClawdChrome(
};
}
export async function stopClawdChrome(
running: RunningChrome,
timeoutMs = 2500,
) {
export async function stopClawdChrome(running: RunningChrome, timeoutMs = 2500) {
const proc = running.proc;
if (proc.killed) return;
try {

View File

@@ -96,15 +96,12 @@ export async function browserNavigate(
opts: { url: string; targetId?: string; profile?: string },
): Promise<BrowserActionTabResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTabResult>(
`${baseUrl}/navigate${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: opts.url, targetId: opts.targetId }),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/navigate${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: opts.url, targetId: opts.targetId }),
timeoutMs: 20000,
});
}
export async function browserArmDialog(
@@ -118,20 +115,17 @@ export async function browserArmDialog(
},
): Promise<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(
`${baseUrl}/hooks/dialog${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
accept: opts.accept,
promptText: opts.promptText,
targetId: opts.targetId,
timeoutMs: opts.timeoutMs,
}),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/hooks/dialog${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
accept: opts.accept,
promptText: opts.promptText,
targetId: opts.targetId,
timeoutMs: opts.timeoutMs,
}),
timeoutMs: 20000,
});
}
export async function browserArmFileChooser(
@@ -147,22 +141,19 @@ export async function browserArmFileChooser(
},
): Promise<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(
`${baseUrl}/hooks/file-chooser${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
paths: opts.paths,
ref: opts.ref,
inputRef: opts.inputRef,
element: opts.element,
targetId: opts.targetId,
timeoutMs: opts.timeoutMs,
}),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/hooks/file-chooser${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
paths: opts.paths,
ref: opts.ref,
inputRef: opts.inputRef,
element: opts.element,
targetId: opts.targetId,
timeoutMs: opts.timeoutMs,
}),
timeoutMs: 20000,
});
}
export async function browserWaitForDownload(
@@ -245,19 +236,16 @@ export async function browserScreenshotAction(
},
): Promise<BrowserActionPathResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionPathResult>(
`${baseUrl}/screenshot${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
fullPage: opts.fullPage,
ref: opts.ref,
element: opts.element,
type: opts.type,
}),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/screenshot${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
fullPage: opts.fullPage,
ref: opts.ref,
element: opts.element,
type: opts.type,
}),
timeoutMs: 20000,
});
}

View File

@@ -1,7 +1,4 @@
import type {
BrowserActionPathResult,
BrowserActionTargetOk,
} from "./client-actions-types.js";
import type { BrowserActionPathResult, BrowserActionTargetOk } from "./client-actions-types.js";
import { fetchBrowserJson } from "./client-fetch.js";
import type {
BrowserConsoleMessage,
@@ -91,20 +88,17 @@ export async function browserTraceStart(
} = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
`${baseUrl}/trace/start${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
screenshots: opts.screenshots,
snapshots: opts.snapshots,
sources: opts.sources,
}),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/trace/start${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
screenshots: opts.screenshots,
snapshots: opts.snapshots,
sources: opts.sources,
}),
timeoutMs: 20000,
});
}
export async function browserTraceStop(
@@ -112,15 +106,12 @@ export async function browserTraceStop(
opts: { targetId?: string; path?: string; profile?: string } = {},
): Promise<BrowserActionPathResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionPathResult>(
`${baseUrl}/trace/stop${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, path: opts.path }),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/trace/stop${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, path: opts.path }),
timeoutMs: 20000,
});
}
export async function browserHighlight(
@@ -128,15 +119,12 @@ export async function browserHighlight(
opts: { ref: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
`${baseUrl}/highlight${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, ref: opts.ref }),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/highlight${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, ref: opts.ref }),
timeoutMs: 20000,
});
}
export async function browserResponseBody(

View File

@@ -1,7 +1,4 @@
import type {
BrowserActionOk,
BrowserActionTargetOk,
} from "./client-actions-types.js";
import type { BrowserActionOk, BrowserActionTargetOk } from "./client-actions-types.js";
import { fetchBrowserJson } from "./client-fetch.js";
function buildProfileQuery(profile?: string): string {
@@ -32,15 +29,12 @@ export async function browserCookiesSet(
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
`${baseUrl}/cookies/set${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, cookie: opts.cookie }),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/cookies/set${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, cookie: opts.cookie }),
timeoutMs: 20000,
});
}
export async function browserCookiesClear(
@@ -48,15 +42,12 @@ export async function browserCookiesClear(
opts: { targetId?: string; profile?: string } = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
`${baseUrl}/cookies/clear${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId }),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/cookies/clear${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId }),
timeoutMs: 20000,
});
}
export async function browserStorageGet(
@@ -91,19 +82,16 @@ export async function browserStorageSet(
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
`${baseUrl}/storage/${opts.kind}/set${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
key: opts.key,
value: opts.value,
}),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/storage/${opts.kind}/set${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
key: opts.key,
value: opts.value,
}),
timeoutMs: 20000,
});
}
export async function browserStorageClear(
@@ -127,15 +115,12 @@ export async function browserSetOffline(
opts: { offline: boolean; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
`${baseUrl}/set/offline${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, offline: opts.offline }),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/offline${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, offline: opts.offline }),
timeoutMs: 20000,
});
}
export async function browserSetHeaders(
@@ -147,15 +132,12 @@ export async function browserSetHeaders(
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
`${baseUrl}/set/headers${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, headers: opts.headers }),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/headers${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, headers: opts.headers }),
timeoutMs: 20000,
});
}
export async function browserSetHttpCredentials(
@@ -169,20 +151,17 @@ export async function browserSetHttpCredentials(
} = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
`${baseUrl}/set/credentials${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
username: opts.username,
password: opts.password,
clear: opts.clear,
}),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/credentials${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
username: opts.username,
password: opts.password,
clear: opts.clear,
}),
timeoutMs: 20000,
});
}
export async function browserSetGeolocation(
@@ -198,22 +177,19 @@ export async function browserSetGeolocation(
} = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
`${baseUrl}/set/geolocation${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
latitude: opts.latitude,
longitude: opts.longitude,
accuracy: opts.accuracy,
origin: opts.origin,
clear: opts.clear,
}),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/geolocation${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
latitude: opts.latitude,
longitude: opts.longitude,
accuracy: opts.accuracy,
origin: opts.origin,
clear: opts.clear,
}),
timeoutMs: 20000,
});
}
export async function browserSetMedia(
@@ -225,18 +201,15 @@ export async function browserSetMedia(
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
`${baseUrl}/set/media${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
colorScheme: opts.colorScheme,
}),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/media${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
colorScheme: opts.colorScheme,
}),
timeoutMs: 20000,
});
}
export async function browserSetTimezone(
@@ -244,18 +217,15 @@ export async function browserSetTimezone(
opts: { timezoneId: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
`${baseUrl}/set/timezone${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
timezoneId: opts.timezoneId,
}),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/timezone${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
timezoneId: opts.timezoneId,
}),
timeoutMs: 20000,
});
}
export async function browserSetLocale(
@@ -263,15 +233,12 @@ export async function browserSetLocale(
opts: { locale: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
`${baseUrl}/set/locale${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, locale: opts.locale }),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/locale${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, locale: opts.locale }),
timeoutMs: 20000,
});
}
export async function browserSetDevice(
@@ -279,15 +246,12 @@ export async function browserSetDevice(
opts: { name: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
`${baseUrl}/set/device${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, name: opts.name }),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/device${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, name: opts.name }),
timeoutMs: 20000,
});
}
export async function browserClearPermissions(
@@ -295,13 +259,10 @@ export async function browserClearPermissions(
opts: { targetId?: string; profile?: string } = {},
): Promise<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(
`${baseUrl}/set/geolocation${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, clear: true }),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/set/geolocation${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, clear: true }),
timeoutMs: 20000,
});
}

View File

@@ -6,11 +6,7 @@ function unwrapCause(err: unknown): unknown {
return cause ?? null;
}
function enhanceBrowserFetchError(
url: string,
err: unknown,
timeoutMs: number,
): Error {
function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error {
const cause = unwrapCause(err);
const code = extractErrorCode(cause) ?? extractErrorCode(err) ?? "";
@@ -35,9 +31,7 @@ function enhanceBrowserFetchError(
);
}
return new Error(
`Can't reach the clawd browser control server at ${url}. ${hint} (${msg})`,
);
return new Error(`Can't reach the clawd browser control server at ${url}. ${hint} (${msg})`);
}
export async function fetchBrowserJson<T>(

View File

@@ -1,11 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
browserOpenTab,
browserSnapshot,
browserStatus,
browserTabs,
} from "./client.js";
import { browserOpenTab, browserSnapshot, browserStatus, browserTabs } from "./client.js";
import {
browserAct,
browserArmDialog,
@@ -31,16 +26,12 @@ describe("browser client", () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(fetchFailed));
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(
/Start .*gateway/i,
);
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/Start .*gateway/i);
});
it("adds useful timeout messaging for abort-like failures", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("aborted")));
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(
/timed out/i,
);
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/timed out/i);
});
it("surfaces non-2xx responses with body text", async () => {
@@ -182,16 +173,12 @@ describe("browser client", () => {
}),
);
await expect(
browserStatus("http://127.0.0.1:18791"),
).resolves.toMatchObject({
await expect(browserStatus("http://127.0.0.1:18791")).resolves.toMatchObject({
running: true,
cdpPort: 18792,
});
await expect(browserTabs("http://127.0.0.1:18791")).resolves.toHaveLength(
1,
);
await expect(browserTabs("http://127.0.0.1:18791")).resolves.toHaveLength(1);
await expect(
browserOpenTab("http://127.0.0.1:18791", "https://example.com"),
).resolves.toMatchObject({ targetId: "t2" });
@@ -217,9 +204,10 @@ describe("browser client", () => {
await expect(
browserConsoleMessages("http://127.0.0.1:18791", { level: "error" }),
).resolves.toMatchObject({ ok: true, targetId: "t1" });
await expect(
browserPdfSave("http://127.0.0.1:18791"),
).resolves.toMatchObject({ ok: true, path: "/tmp/a.pdf" });
await expect(browserPdfSave("http://127.0.0.1:18791")).resolves.toMatchObject({
ok: true,
path: "/tmp/a.pdf",
});
await expect(
browserScreenshotAction("http://127.0.0.1:18791", { fullPage: true }),
).resolves.toMatchObject({ ok: true, path: "/tmp/a.png" });

View File

@@ -102,20 +102,14 @@ export async function browserStatus(
});
}
export async function browserProfiles(
baseUrl: string,
): Promise<ProfileStatus[]> {
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(
`${baseUrl}/profiles`,
{ timeoutMs: 3000 },
);
export async function browserProfiles(baseUrl: string): Promise<ProfileStatus[]> {
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(`${baseUrl}/profiles`, {
timeoutMs: 3000,
});
return res.profiles ?? [];
}
export async function browserStart(
baseUrl: string,
opts?: { profile?: string },
): Promise<void> {
export async function browserStart(baseUrl: string, opts?: { profile?: string }): Promise<void> {
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(`${baseUrl}/start${q}`, {
method: "POST",
@@ -123,10 +117,7 @@ export async function browserStart(
});
}
export async function browserStop(
baseUrl: string,
opts?: { profile?: string },
): Promise<void> {
export async function browserStop(baseUrl: string, opts?: { profile?: string }): Promise<void> {
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(`${baseUrl}/stop${q}`, {
method: "POST",
@@ -139,13 +130,10 @@ export async function browserResetProfile(
opts?: { profile?: string },
): Promise<BrowserResetProfileResult> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserResetProfileResult>(
`${baseUrl}/reset-profile${q}`,
{
method: "POST",
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserResetProfileResult>(`${baseUrl}/reset-profile${q}`, {
method: "POST",
timeoutMs: 20000,
});
}
export type BrowserCreateProfileResult = {
@@ -161,19 +149,16 @@ export async function browserCreateProfile(
baseUrl: string,
opts: { name: string; color?: string; cdpUrl?: string },
): Promise<BrowserCreateProfileResult> {
return await fetchBrowserJson<BrowserCreateProfileResult>(
`${baseUrl}/profiles/create`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: opts.name,
color: opts.color,
cdpUrl: opts.cdpUrl,
}),
timeoutMs: 10000,
},
);
return await fetchBrowserJson<BrowserCreateProfileResult>(`${baseUrl}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: opts.name,
color: opts.color,
cdpUrl: opts.cdpUrl,
}),
timeoutMs: 10000,
});
}
export type BrowserDeleteProfileResult = {
@@ -241,13 +226,10 @@ export async function browserCloseTab(
opts?: { profile?: string },
): Promise<void> {
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(
`${baseUrl}/tabs/${encodeURIComponent(targetId)}${q}`,
{
method: "DELETE",
timeoutMs: 5000,
},
);
await fetchBrowserJson(`${baseUrl}/tabs/${encodeURIComponent(targetId)}${q}`, {
method: "DELETE",
timeoutMs: 5000,
});
}
export async function browserTabAction(
@@ -292,20 +274,16 @@ export async function browserSnapshot(
if (typeof opts.maxChars === "number" && Number.isFinite(opts.maxChars)) {
q.set("maxChars", String(opts.maxChars));
}
if (typeof opts.interactive === "boolean")
q.set("interactive", String(opts.interactive));
if (typeof opts.interactive === "boolean") q.set("interactive", String(opts.interactive));
if (typeof opts.compact === "boolean") q.set("compact", String(opts.compact));
if (typeof opts.depth === "number" && Number.isFinite(opts.depth))
q.set("depth", String(opts.depth));
if (opts.selector?.trim()) q.set("selector", opts.selector.trim());
if (opts.frame?.trim()) q.set("frame", opts.frame.trim());
if (opts.profile) q.set("profile", opts.profile);
return await fetchBrowserJson<SnapshotResult>(
`${baseUrl}/snapshot?${q.toString()}`,
{
timeoutMs: 20000,
},
);
return await fetchBrowserJson<SnapshotResult>(`${baseUrl}/snapshot?${q.toString()}`, {
timeoutMs: 20000,
});
}
// Actions beyond the basic read-only commands live in client-actions.ts.

View File

@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import {
resolveBrowserConfig,
resolveProfile,
shouldStartLocalBrowserServer,
} from "./config.js";
import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js";
describe("browser config", () => {
it("defaults to enabled with loopback control url and lobster-orange color", () => {
@@ -108,8 +104,8 @@ describe("browser config", () => {
});
it("rejects unsupported protocols", () => {
expect(() =>
resolveBrowserConfig({ controlUrl: "ws://127.0.0.1:18791" }),
).toThrow(/must be http/i);
expect(() => resolveBrowserConfig({ controlUrl: "ws://127.0.0.1:18791" })).toThrow(
/must be http/i,
);
});
});

View File

@@ -62,9 +62,7 @@ export function parseHttpUrl(raw: string, label: string) {
const trimmed = raw.trim();
const parsed = new URL(trimmed);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(
`${label} must be http(s), got: ${parsed.protocol.replace(":", "")}`,
);
throw new Error(`${label} must be http(s), got: ${parsed.protocol.replace(":", "")}`);
}
const port =
@@ -104,9 +102,7 @@ function ensureDefaultProfile(
}
return result;
}
export function resolveBrowserConfig(
cfg: BrowserConfig | undefined,
): ResolvedBrowserConfig {
export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBrowserConfig {
const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED;
const envControlUrl = process.env.CLAWDBOT_BROWSER_CONTROL_URL?.trim();
const derivedControlPort = (() => {
@@ -116,15 +112,10 @@ export function resolveBrowserConfig(
if (!Number.isFinite(gatewayPort) || gatewayPort <= 0) return null;
return deriveDefaultBrowserControlPort(gatewayPort);
})();
const derivedControlUrl = derivedControlPort
? `http://127.0.0.1:${derivedControlPort}`
: null;
const derivedControlUrl = derivedControlPort ? `http://127.0.0.1:${derivedControlPort}` : null;
const controlInfo = parseHttpUrl(
cfg?.controlUrl ??
envControlUrl ??
derivedControlUrl ??
DEFAULT_CLAWD_BROWSER_CONTROL_URL,
cfg?.controlUrl ?? envControlUrl ?? derivedControlUrl ?? DEFAULT_CLAWD_BROWSER_CONTROL_URL,
"browser.controlUrl",
);
const controlPort = controlInfo.port;
@@ -163,8 +154,7 @@ export function resolveBrowserConfig(
const attachOnly = cfg?.attachOnly === true;
const executablePath = cfg?.executablePath?.trim() || undefined;
const defaultProfile =
cfg?.defaultProfile ?? DEFAULT_CLAWD_BROWSER_PROFILE_NAME;
const defaultProfile = cfg?.defaultProfile ?? DEFAULT_CLAWD_BROWSER_PROFILE_NAME;
// Use legacy cdpUrl port for backward compatibility when no profiles configured
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
const profiles = ensureDefaultProfile(
@@ -210,10 +200,7 @@ export function resolveProfile(
let cdpUrl = "";
if (rawProfileUrl) {
const parsed = parseHttpUrl(
rawProfileUrl,
`browser.profiles.${profileName}.cdpUrl`,
);
const parsed = parseHttpUrl(rawProfileUrl, `browser.profiles.${profileName}.cdpUrl`);
cdpHost = parsed.parsed.hostname;
cdpPort = parsed.port;
cdpUrl = parsed.normalized;

View File

@@ -5,10 +5,7 @@ import { describe, expect, it, vi } from "vitest";
import { resolveBrowserConfig } from "./config.js";
import { createBrowserProfilesService } from "./profiles-service.js";
import type {
BrowserRouteContext,
BrowserServerState,
} from "./server-context.js";
import type { BrowserRouteContext, BrowserServerState } from "./server-context.js";
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();

View File

@@ -44,16 +44,12 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
return await ctx.listProfiles();
};
const createProfile = async (
params: CreateProfileParams,
): Promise<CreateProfileResult> => {
const createProfile = async (params: CreateProfileParams): Promise<CreateProfileResult> => {
const name = params.name.trim();
const rawCdpUrl = params.cdpUrl?.trim() || undefined;
if (!isValidProfileName(name)) {
throw new Error(
"invalid profile name: use lowercase letters, numbers, and hyphens only",
);
throw new Error("invalid profile name: use lowercase letters, numbers, and hyphens only");
}
const state = ctx.state();
@@ -70,9 +66,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
const usedColors = getUsedColors(resolvedProfiles);
const profileColor =
params.color && HEX_COLOR_RE.test(params.color)
? params.color
: allocateColor(usedColors);
params.color && HEX_COLOR_RE.test(params.color) ? params.color : allocateColor(usedColors);
let profileConfig: BrowserProfileConfig;
if (rawCdpUrl) {
@@ -80,9 +74,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
profileConfig = { cdpUrl: parsed.normalized, color: profileColor };
} else {
const usedPorts = getUsedPorts(resolvedProfiles);
const range = deriveDefaultBrowserCdpPortRange(
state.resolved.controlPort,
);
const range = deriveDefaultBrowserCdpPortRange(state.resolved.controlPort);
const cdpPort = allocateCdpPort(usedPorts, range);
if (cdpPort === null) {
throw new Error("no available CDP ports in range");
@@ -119,9 +111,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
};
};
const deleteProfile = async (
nameRaw: string,
): Promise<DeleteProfileResult> => {
const deleteProfile = async (nameRaw: string): Promise<DeleteProfileResult> => {
const name = nameRaw.trim();
if (!name) throw new Error("profile name is required");
if (!isValidProfileName(name)) {

View File

@@ -66,13 +66,9 @@ describe("port allocation", () => {
it("allocates within an explicit range", () => {
const usedPorts = new Set<number>();
expect(allocateCdpPort(usedPorts, { start: 20000, end: 20002 })).toBe(
20000,
);
expect(allocateCdpPort(usedPorts, { start: 20000, end: 20002 })).toBe(20000);
usedPorts.add(20000);
expect(allocateCdpPort(usedPorts, { start: 20000, end: 20002 })).toBe(
20001,
);
expect(allocateCdpPort(usedPorts, { start: 20000, end: 20002 })).toBe(20001);
});
it("skips used ports and returns next available", () => {

View File

@@ -28,12 +28,7 @@ export function allocateCdpPort(
): number | null {
const start = range?.start ?? CDP_PORT_RANGE_START;
const end = range?.end ?? CDP_PORT_RANGE_END;
if (
!Number.isFinite(start) ||
!Number.isFinite(end) ||
start <= 0 ||
end <= 0
) {
if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end <= 0) {
return null;
}
if (start > end) return null;

View File

@@ -11,11 +11,7 @@ type FakeSession = {
detach: ReturnType<typeof vi.fn>;
};
function createPage(opts: {
targetId: string;
snapshotFull?: string;
hasSnapshotForAI?: boolean;
}) {
function createPage(opts: { targetId: string; snapshotFull?: string; hasSnapshotForAI?: boolean }) {
const session: FakeSession = {
send: vi.fn().mockResolvedValue({
targetInfo: { targetId: opts.targetId },
@@ -39,9 +35,7 @@ function createPage(opts: {
...(opts.hasSnapshotForAI === false
? {}
: {
_snapshotForAI: vi
.fn()
.mockResolvedValue({ full: opts.snapshotFull ?? "SNAP" }),
_snapshotForAI: vi.fn().mockResolvedValue({ full: opts.snapshotFull ?? "SNAP" }),
}),
};
@@ -77,9 +71,7 @@ describe("pw-ai", () => {
const p2 = createPage({ targetId: "T2", snapshotFull: "TWO" });
const browser = createBrowser([p1.page, p2.page]);
(
chromium.connectOverCDP as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue(browser);
(chromium.connectOverCDP as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(browser);
const mod = await importModule();
const res = await mod.snapshotAiViaPlaywright({
@@ -98,9 +90,7 @@ describe("pw-ai", () => {
const p1 = createPage({ targetId: "T1", snapshotFull: longSnapshot });
const browser = createBrowser([p1.page]);
(
chromium.connectOverCDP as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue(browser);
(chromium.connectOverCDP as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(browser);
const mod = await importModule();
const res = await mod.snapshotAiViaPlaywright({
@@ -118,9 +108,7 @@ describe("pw-ai", () => {
const { chromium } = await import("playwright-core");
const p1 = createPage({ targetId: "T1" });
const browser = createBrowser([p1.page]);
(
chromium.connectOverCDP as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue(browser);
(chromium.connectOverCDP as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(browser);
const mod = await importModule();
await mod.clickViaPlaywright({
@@ -137,9 +125,7 @@ describe("pw-ai", () => {
const { chromium } = await import("playwright-core");
const p1 = createPage({ targetId: "T1", hasSnapshotForAI: false });
const browser = createBrowser([p1.page]);
(
chromium.connectOverCDP as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue(browser);
(chromium.connectOverCDP as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(browser);
const mod = await importModule();
await expect(

View File

@@ -27,9 +27,7 @@ describe("pw-role-snapshot", () => {
});
it("uses nth only when duplicates exist", () => {
const aria = ['- button "OK"', '- button "OK"', '- button "Cancel"'].join(
"\n",
);
const aria = ['- button "OK"', '- button "OK"', '- button "Cancel"'].join("\n");
const res = buildRoleSnapshotFromAriaSnapshot(aria);
expect(res.snapshot).toContain("[ref=e1]");
expect(res.snapshot).toContain("[ref=e2] [nth=1]");
@@ -38,9 +36,7 @@ describe("pw-role-snapshot", () => {
expect(res.refs.e3?.nth).toBeUndefined();
});
it("respects maxDepth", () => {
const aria = ['- region "Main"', " - group", ' - button "Deep"'].join(
"\n",
);
const aria = ['- region "Main"', " - group", ' - button "Deep"'].join("\n");
const res = buildRoleSnapshotFromAriaSnapshot(aria, { maxDepth: 1 });
expect(res.snapshot).toContain('- region "Main"');
expect(res.snapshot).toContain(" - group");

View File

@@ -77,13 +77,8 @@ const STRUCTURAL_ROLES = new Set([
"none",
]);
export function getRoleSnapshotStats(
snapshot: string,
refs: RoleRefMap,
): RoleSnapshotStats {
const interactive = Object.values(refs).filter((r) =>
INTERACTIVE_ROLES.has(r.role),
).length;
export function getRoleSnapshotStats(snapshot: string, refs: RoleRefMap): RoleSnapshotStats {
const interactive = Object.values(refs).filter((r) => INTERACTIVE_ROLES.has(r.role)).length;
return {
lines: snapshot.split("\n").length,
chars: snapshot.length,
@@ -137,10 +132,7 @@ function createRoleNameTracker(): RoleNameTracker {
};
}
function removeNthFromNonDuplicates(
refs: RoleRefMap,
tracker: RoleNameTracker,
) {
function removeNthFromNonDuplicates(refs: RoleRefMap, tracker: RoleNameTracker) {
const duplicates = tracker.getDuplicateKeys();
for (const [ref, data] of Object.entries(refs)) {
const key = tracker.getKey(data.role, data.name);

View File

@@ -39,9 +39,7 @@ type SnapshotForAIResult = { full: string; incremental?: string };
type SnapshotForAIOptions = { timeout?: number; track?: string };
export type WithSnapshotForAI = {
_snapshotForAI?: (
options?: SnapshotForAIOptions,
) => Promise<SnapshotForAIResult>;
_snapshotForAI?: (options?: SnapshotForAIOptions) => Promise<SnapshotForAIResult>;
};
type TargetInfoResponse = {
@@ -213,9 +211,7 @@ async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const timeout = 5000 + attempt * 2000;
const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch(
() => null,
);
const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch(() => null);
const endpoint = wsUrl ?? normalized;
const browser = await chromium.connectOverCDP(endpoint, { timeout });
const connected: ConnectedBrowser = { browser, cdpUrl: normalized };
@@ -234,9 +230,7 @@ async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
if (lastErr instanceof Error) {
throw lastErr;
}
const message = lastErr
? formatErrorMessage(lastErr)
: "CDP connect failed";
const message = lastErr ? formatErrorMessage(lastErr) : "CDP connect failed";
throw new Error(message);
};
@@ -256,9 +250,7 @@ async function getAllPages(browser: Browser): Promise<Page[]> {
async function pageTargetId(page: Page): Promise<string | null> {
const session = await page.context().newCDPSession(page);
try {
const info = (await session.send(
"Target.getTargetInfo",
)) as TargetInfoResponse;
const info = (await session.send("Target.getTargetInfo")) as TargetInfoResponse;
const targetId = String(info?.targetInfo?.targetId ?? "").trim();
return targetId || null;
} finally {
@@ -266,10 +258,7 @@ async function pageTargetId(page: Page): Promise<string | null> {
}
}
async function findPageByTargetId(
browser: Browser,
targetId: string,
): Promise<Page | null> {
async function findPageByTargetId(browser: Browser, targetId: string): Promise<Page | null> {
const pages = await getAllPages(browser);
for (const page of pages) {
const tid = await pageTargetId(page).catch(() => null);
@@ -284,8 +273,7 @@ export async function getPageForTargetId(opts: {
}): Promise<Page> {
const { browser } = await connectBrowser(opts.cdpUrl);
const pages = await getAllPages(browser);
if (!pages.length)
throw new Error("No pages available in the connected browser.");
if (!pages.length) throw new Error("No pages available in the connected browser.");
const first = pages[0];
if (!opts.targetId) return first;
const found = await findPageByTargetId(browser, opts.targetId);

View File

@@ -57,9 +57,7 @@ describe("pw-tools-core", () => {
});
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',
);
throw new Error('Error: strict mode violation: locator("aria-ref=1") resolved to 2 elements');
});
currentRefLocator = { scrollIntoViewIfNeeded };
currentPage = {};
@@ -75,9 +73,7 @@ describe("pw-tools-core", () => {
});
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',
);
throw new Error('Timeout 5000ms exceeded. waiting for locator("aria-ref=1") to be visible');
});
currentRefLocator = { scrollIntoViewIfNeeded };
currentPage = {};
@@ -93,9 +89,7 @@ describe("pw-tools-core", () => {
});
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',
);
throw new Error('Error: strict mode violation: locator("aria-ref=1") resolved to 2 elements');
});
currentRefLocator = { click };
currentPage = {};
@@ -111,9 +105,7 @@ describe("pw-tools-core", () => {
});
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',
);
throw new Error('Timeout 5000ms exceeded. waiting for locator("aria-ref=1") to be visible');
});
currentRefLocator = { click };
currentPage = {};

View File

@@ -4,11 +4,7 @@ import path from "node:path";
import type { Page } from "playwright-core";
import {
ensurePageState,
getPageForTargetId,
refLocator,
} from "./pw-session.js";
import { ensurePageState, getPageForTargetId, refLocator } from "./pw-session.js";
import {
bumpDialogArmId,
bumpDownloadArmId,

View File

@@ -1,14 +1,6 @@
import type { BrowserFormField } from "./client-actions-core.js";
import {
ensurePageState,
getPageForTargetId,
refLocator,
} from "./pw-session.js";
import {
normalizeTimeoutMs,
requireRef,
toAIFriendlyError,
} from "./pw-tools-core.shared.js";
import { ensurePageState, getPageForTargetId, refLocator } from "./pw-session.js";
import { normalizeTimeoutMs, requireRef, toAIFriendlyError } from "./pw-tools-core.shared.js";
export async function highlightViaPlaywright(opts: {
cdpUrl: string;
@@ -41,10 +33,7 @@ export async function clickViaPlaywright(opts: {
ensurePageState(page);
const ref = requireRef(opts.ref);
const locator = refLocator(page, ref);
const timeout = Math.max(
500,
Math.min(60_000, Math.floor(opts.timeoutMs ?? 8000)),
);
const timeout = Math.max(500, Math.min(60_000, Math.floor(opts.timeoutMs ?? 8000)));
try {
if (opts.doubleClick) {
await locator.dblclick({
@@ -191,10 +180,7 @@ export async function fillFormViaPlaywright(opts: {
const locator = refLocator(page, ref);
if (type === "checkbox" || type === "radio") {
const checked =
rawValue === true ||
rawValue === 1 ||
rawValue === "1" ||
rawValue === "true";
rawValue === true || rawValue === 1 || rawValue === "1" || rawValue === "true";
try {
await locator.setChecked(checked, { timeout });
} catch (err) {
@@ -311,10 +297,7 @@ export async function waitForViaPlaywright(opts: {
if (opts.selector) {
const selector = String(opts.selector).trim();
if (selector) {
await page
.locator(selector)
.first()
.waitFor({ state: "visible", timeout });
await page.locator(selector).first().waitFor({ state: "visible", timeout });
}
}
if (opts.url) {
@@ -346,15 +329,13 @@ export async function takeScreenshotViaPlaywright(opts: {
ensurePageState(page);
const type = opts.type ?? "png";
if (opts.ref) {
if (opts.fullPage)
throw new Error("fullPage is not supported for element screenshots");
if (opts.fullPage) throw new Error("fullPage is not supported for element screenshots");
const locator = refLocator(page, opts.ref);
const buffer = await locator.screenshot({ type });
return { buffer };
}
if (opts.element) {
if (opts.fullPage)
throw new Error("fullPage is not supported for element screenshots");
if (opts.fullPage) throw new Error("fullPage is not supported for element screenshots");
const locator = page.locator(opts.element).first();
const buffer = await locator.screenshot({ type });
return { buffer };
@@ -376,8 +357,7 @@ export async function setInputFilesViaPlaywright(opts: {
const page = await getPageForTargetId(opts);
ensurePageState(page);
if (!opts.paths.length) throw new Error("paths are required");
const inputRef =
typeof opts.inputRef === "string" ? opts.inputRef.trim() : "";
const inputRef = typeof opts.inputRef === "string" ? opts.inputRef.trim() : "";
const element = typeof opts.element === "string" ? opts.element.trim() : "";
if (inputRef && element) {
throw new Error("inputRef and element are mutually exclusive");
@@ -386,9 +366,7 @@ export async function setInputFilesViaPlaywright(opts: {
throw new Error("inputRef or element is required");
}
const locator = inputRef
? refLocator(page, inputRef)
: page.locator(element).first();
const locator = inputRef ? refLocator(page, inputRef) : page.locator(element).first();
try {
await locator.setInputFiles(opts.paths);

View File

@@ -149,9 +149,7 @@ describe("pw-tools-core", () => {
});
expect(waitForTimeout).toHaveBeenCalledWith(50);
expect(
currentPage.locator as ReturnType<typeof vi.fn>,
).toHaveBeenCalledWith("#main");
expect(currentPage.locator as ReturnType<typeof vi.fn>).toHaveBeenCalledWith("#main");
expect(waitForSelector).toHaveBeenCalledWith({
state: "visible",
timeout: 1234,

View File

@@ -7,9 +7,7 @@ function matchUrlPattern(pattern: string, url: string): boolean {
if (p === url) return true;
if (p.includes("*")) {
const escaped = p.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
const regex = new RegExp(
`^${escaped.replace(/\*\*/g, ".*").replace(/\*/g, ".*")}$`,
);
const regex = new RegExp(`^${escaped.replace(/\*\*/g, ".*").replace(/\*/g, ".*")}$`);
return regex.test(url);
}
return url.includes(p);
@@ -94,13 +92,10 @@ export async function responseBodyViaPlaywright(opts: {
bodyText = new TextDecoder("utf-8").decode(buf);
}
} catch (err) {
throw new Error(
`Failed to read response body for "${url}": ${String(err)}`,
);
throw new Error(`Failed to read response body for "${url}": ${String(err)}`);
}
const trimmed =
bodyText.length > maxChars ? bodyText.slice(0, maxChars) : bodyText;
const trimmed = bodyText.length > maxChars ? bodyText.slice(0, maxChars) : bodyText;
return {
url,
status,

View File

@@ -59,9 +59,7 @@ describe("pw-tools-core", () => {
expect(res.buffer.toString()).toBe("E");
expect(sessionMocks.getPageForTargetId).toHaveBeenCalled();
expect(
currentPage.locator as ReturnType<typeof vi.fn>,
).toHaveBeenCalledWith("#main");
expect(currentPage.locator as ReturnType<typeof vi.fn>).toHaveBeenCalledWith("#main");
expect(elementScreenshot).toHaveBeenCalledWith({ type: "png" });
});
it("screenshots a ref locator", async () => {
@@ -115,9 +113,7 @@ describe("pw-tools-core", () => {
});
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,
);
const waitForEvent = vi.fn(async (_event: string, _opts: unknown) => fileChooser);
currentPage = {
waitForEvent,
keyboard: { press: vi.fn(async () => {}) },

View File

@@ -27,10 +27,7 @@ export function requireRef(value: unknown): string {
return ref;
}
export function normalizeTimeoutMs(
timeoutMs: number | undefined,
fallback: number,
) {
export function normalizeTimeoutMs(timeoutMs: number | undefined, fallback: number) {
return Math.max(500, Math.min(120_000, timeoutMs ?? fallback));
}

View File

@@ -5,11 +5,7 @@ import {
getRoleSnapshotStats,
type RoleSnapshotOptions,
} from "./pw-role-snapshot.js";
import {
ensurePageState,
getPageForTargetId,
type WithSnapshotForAI,
} from "./pw-session.js";
import { ensurePageState, getPageForTargetId, type WithSnapshotForAI } from "./pw-session.js";
export async function snapshotAiViaPlaywright(opts: {
cdpUrl: string;
@@ -25,16 +21,11 @@ export async function snapshotAiViaPlaywright(opts: {
const maybe = page as unknown as WithSnapshotForAI;
if (!maybe._snapshotForAI) {
throw new Error(
"Playwright _snapshotForAI is not available. Upgrade playwright-core.",
);
throw new Error("Playwright _snapshotForAI is not available. Upgrade playwright-core.");
}
const result = await maybe._snapshotForAI({
timeout: Math.max(
500,
Math.min(60_000, Math.floor(opts.timeoutMs ?? 5000)),
),
timeout: Math.max(500, Math.min(60_000, Math.floor(opts.timeoutMs ?? 5000))),
track: "response",
});
let snapshot = String(result?.full ?? "");
@@ -78,10 +69,7 @@ export async function snapshotRoleViaPlaywright(opts: {
: page.locator(":root");
const ariaSnapshot = await locator.ariaSnapshot();
const built = buildRoleSnapshotFromAriaSnapshot(
String(ariaSnapshot ?? ""),
opts.options,
);
const built = buildRoleSnapshotFromAriaSnapshot(String(ariaSnapshot ?? ""), opts.options);
state.roleRefs = built.refs;
state.roleRefsFrameSelector = frameSelector || undefined;
return {

View File

@@ -3,10 +3,7 @@ import { devices as playwrightDevices } from "playwright-core";
import { ensurePageState, getPageForTargetId } from "./pw-session.js";
async function withCdpSession<T>(
page: Page,
fn: (session: CDPSession) => Promise<T>,
): Promise<T> {
async function withCdpSession<T>(page: Page, fn: (session: CDPSession) => Promise<T>): Promise<T> {
const session = await page.context().newCDPSession(page);
try {
return await fn(session);
@@ -116,9 +113,7 @@ export async function setLocaleViaPlaywright(opts: {
try {
await session.send("Emulation.setLocaleOverride", { locale });
} catch (err) {
if (
String(err).includes("Another locale override is already in effect")
) {
if (String(err).includes("Another locale override is already in effect")) {
return;
}
throw err;
@@ -141,8 +136,7 @@ export async function setTimezoneViaPlaywright(opts: {
} catch (err) {
const msg = String(err);
if (msg.includes("Timezone override is already in effect")) return;
if (msg.includes("Invalid timezone"))
throw new Error(`Invalid timezone ID: ${timezoneId}`);
if (msg.includes("Invalid timezone")) throw new Error(`Invalid timezone ID: ${timezoneId}`);
throw err;
}
});

View File

@@ -66,8 +66,7 @@ export async function storageGetViaPlaywright(opts: {
const key = typeof opts.key === "string" ? opts.key : undefined;
const values = await page.evaluate(
({ kind: kind2, key: key2 }) => {
const store =
kind2 === "session" ? window.sessionStorage : window.localStorage;
const store = kind2 === "session" ? window.sessionStorage : window.localStorage;
if (key2) {
const value = store.getItem(key2);
return value === null ? {} : { [key2]: value };
@@ -99,8 +98,7 @@ export async function storageSetViaPlaywright(opts: {
if (!key) throw new Error("key is required");
await page.evaluate(
({ kind, key: k, value }) => {
const store =
kind === "session" ? window.sessionStorage : window.localStorage;
const store = kind === "session" ? window.sessionStorage : window.localStorage;
store.setItem(k, value);
},
{ kind: opts.kind, key, value: String(opts.value ?? "") },
@@ -116,8 +114,7 @@ export async function storageClearViaPlaywright(opts: {
ensurePageState(page);
await page.evaluate(
({ kind }) => {
const store =
kind === "session" ? window.sessionStorage : window.localStorage;
const store = kind === "session" ? window.sessionStorage : window.localStorage;
store.clear();
},
{ kind: opts.kind },

View File

@@ -11,9 +11,7 @@ export async function traceStartViaPlaywright(opts: {
const context = page.context();
const ctxState = ensureContextState(context);
if (ctxState.traceActive) {
throw new Error(
"Trace already running. Stop the current trace before starting a new one.",
);
throw new Error("Trace already running. Stop the current trace before starting a new one.");
}
await context.tracing.start({
screenshots: opts.screenshots ?? true,

View File

@@ -21,12 +21,7 @@ export function isActKind(value: unknown): value is ActKind {
}
export type ClickButton = "left" | "right" | "middle";
export type ClickModifier =
| "Alt"
| "Control"
| "ControlOrMeta"
| "Meta"
| "Shift";
export type ClickModifier = "Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift";
const ALLOWED_CLICK_MODIFIERS = new Set<ClickModifier>([
"Alt",
@@ -45,9 +40,7 @@ export function parseClickModifiers(raw: string[]): {
modifiers?: ClickModifier[];
error?: string;
} {
const invalid = raw.filter(
(m) => !ALLOWED_CLICK_MODIFIERS.has(m as ClickModifier),
);
const invalid = raw.filter((m) => !ALLOWED_CLICK_MODIFIERS.has(m as ClickModifier));
if (invalid.length) {
return { error: "modifiers must be Alt|Control|ControlOrMeta|Meta|Shift" };
}

View File

@@ -15,18 +15,9 @@ import {
resolveProfileContext,
SELECTOR_UNSUPPORTED_MESSAGE,
} from "./agent.shared.js";
import {
jsonError,
toBoolean,
toNumber,
toStringArray,
toStringOrEmpty,
} from "./utils.js";
import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
export function registerBrowserAgentActRoutes(
app: express.Express,
ctx: BrowserRouteContext,
) {
export function registerBrowserAgentActRoutes(app: express.Express, ctx: BrowserRouteContext) {
app.post("/act", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
@@ -55,8 +46,7 @@ export function registerBrowserAgentActRoutes(
const timeoutMs = toNumber(body.timeoutMs);
const buttonRaw = toStringOrEmpty(body.button) || "";
const button = buttonRaw ? parseClickButton(buttonRaw) : undefined;
if (buttonRaw && !button)
return jsonError(res, 400, "button must be left|right|middle");
if (buttonRaw && !button) return jsonError(res, 400, "button must be left|right|middle");
const modifiersRaw = toStringArray(body.modifiers) ?? [];
const parsedModifiers = parseClickModifiers(modifiersRaw);
@@ -79,8 +69,7 @@ export function registerBrowserAgentActRoutes(
case "type": {
const ref = toStringOrEmpty(body.ref);
if (!ref) return jsonError(res, 400, "ref is required");
if (typeof body.text !== "string")
return jsonError(res, 400, "text is required");
if (typeof body.text !== "string") return jsonError(res, 400, "text is required");
const text = body.text;
const submit = toBoolean(body.submit) ?? false;
const slowly = toBoolean(body.slowly) ?? false;
@@ -125,9 +114,7 @@ export function registerBrowserAgentActRoutes(
const ref = toStringOrEmpty(body.ref);
if (!ref) return jsonError(res, 400, "ref is required");
const timeoutMs = toNumber(body.timeoutMs);
const scrollRequest: Parameters<
typeof pw.scrollIntoViewViaPlaywright
>[0] = {
const scrollRequest: Parameters<typeof pw.scrollIntoViewViaPlaywright>[0] = {
cdpUrl,
targetId: tab.targetId,
ref,
@@ -139,8 +126,7 @@ export function registerBrowserAgentActRoutes(
case "drag": {
const startRef = toStringOrEmpty(body.startRef);
const endRef = toStringOrEmpty(body.endRef);
if (!startRef || !endRef)
return jsonError(res, 400, "startRef and endRef are required");
if (!startRef || !endRef) return jsonError(res, 400, "startRef and endRef are required");
const timeoutMs = toNumber(body.timeoutMs);
await pw.dragViaPlaywright({
cdpUrl,
@@ -154,8 +140,7 @@ export function registerBrowserAgentActRoutes(
case "select": {
const ref = toStringOrEmpty(body.ref);
const values = toStringArray(body.values);
if (!ref || !values?.length)
return jsonError(res, 400, "ref and values are required");
if (!ref || !values?.length) return jsonError(res, 400, "ref and values are required");
const timeoutMs = toNumber(body.timeoutMs);
await pw.selectOptionViaPlaywright({
cdpUrl,
@@ -199,8 +184,7 @@ export function registerBrowserAgentActRoutes(
case "resize": {
const width = toNumber(body.width);
const height = toNumber(body.height);
if (!width || !height)
return jsonError(res, 400, "width and height are required");
if (!width || !height) return jsonError(res, 400, "width and height are required");
await pw.resizeViewportViaPlaywright({
cdpUrl,
targetId: tab.targetId,
@@ -300,11 +284,7 @@ export function registerBrowserAgentActRoutes(
if (!pw) return;
if (inputRef || element) {
if (ref) {
return jsonError(
res,
400,
"ref cannot be combined with inputRef/element",
);
return jsonError(res, 400, "ref cannot be combined with inputRef/element");
}
await pw.setInputFilesViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,

View File

@@ -5,23 +5,14 @@ import path from "node:path";
import type express from "express";
import type { BrowserRouteContext } from "../server-context.js";
import {
handleRouteError,
readBody,
requirePwAi,
resolveProfileContext,
} from "./agent.shared.js";
import { handleRouteError, readBody, requirePwAi, resolveProfileContext } from "./agent.shared.js";
import { toBoolean, toStringOrEmpty } from "./utils.js";
export function registerBrowserAgentDebugRoutes(
app: express.Express,
ctx: BrowserRouteContext,
) {
export function registerBrowserAgentDebugRoutes(app: express.Express, ctx: BrowserRouteContext) {
app.get("/console", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const targetId =
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const level = typeof req.query.level === "string" ? req.query.level : "";
try {
@@ -42,8 +33,7 @@ export function registerBrowserAgentDebugRoutes(
app.get("/errors", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const targetId =
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const clear = toBoolean(req.query.clear) ?? false;
try {
@@ -64,8 +54,7 @@ export function registerBrowserAgentDebugRoutes(
app.get("/requests", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const targetId =
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const filter = typeof req.query.filter === "string" ? req.query.filter : "";
const clear = toBoolean(req.query.clear) ?? false;

View File

@@ -19,11 +19,7 @@ export function readBody(req: express.Request): Record<string, unknown> {
return body;
}
export function handleRouteError(
ctx: BrowserRouteContext,
res: express.Response,
err: unknown,
) {
export function handleRouteError(ctx: BrowserRouteContext, res: express.Response, err: unknown) {
const mapped = ctx.mapTabError(err);
if (mapped) return jsonError(res, mapped.status, mapped.message);
jsonError(res, 500, String(err));

View File

@@ -20,10 +20,7 @@ import {
} from "./agent.shared.js";
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
export function registerBrowserAgentSnapshotRoutes(
app: express.Express,
ctx: BrowserRouteContext,
) {
export function registerBrowserAgentSnapshotRoutes(app: express.Express, ctx: BrowserRouteContext) {
app.post("/navigate", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
@@ -88,11 +85,7 @@ export function registerBrowserAgentSnapshotRoutes(
const type = body.type === "jpeg" ? "jpeg" : "png";
if (fullPage && (ref || element)) {
return jsonError(
res,
400,
"fullPage is not supported for element screenshots",
);
return jsonError(res, 400, "fullPage is not supported for element screenshots");
}
try {
@@ -144,8 +137,7 @@ export function registerBrowserAgentSnapshotRoutes(
app.get("/snapshot", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const targetId =
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const format =
req.query.format === "aria"
? "aria"
@@ -154,26 +146,17 @@ export function registerBrowserAgentSnapshotRoutes(
: (await getPwAiModule())
? "ai"
: "aria";
const limitRaw =
typeof req.query.limit === "string" ? Number(req.query.limit) : undefined;
const limitRaw = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined;
const hasMaxChars = Object.hasOwn(req.query, "maxChars");
const maxCharsRaw =
typeof req.query.maxChars === "string"
? Number(req.query.maxChars)
: undefined;
typeof req.query.maxChars === "string" ? Number(req.query.maxChars) : undefined;
const limit = Number.isFinite(limitRaw) ? limitRaw : undefined;
const maxChars =
typeof maxCharsRaw === "number" &&
Number.isFinite(maxCharsRaw) &&
maxCharsRaw > 0
typeof maxCharsRaw === "number" && Number.isFinite(maxCharsRaw) && maxCharsRaw > 0
? Math.floor(maxCharsRaw)
: undefined;
const resolvedMaxChars =
format === "ai"
? hasMaxChars
? maxChars
: DEFAULT_AI_SNAPSHOT_MAX_CHARS
: undefined;
format === "ai" ? (hasMaxChars ? maxChars : DEFAULT_AI_SNAPSHOT_MAX_CHARS) : undefined;
const interactive = toBoolean(req.query.interactive);
const compact = toBoolean(req.query.compact);
const depth = toNumber(req.query.depth);
@@ -208,9 +191,7 @@ export function registerBrowserAgentSnapshotRoutes(
.snapshotAiViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
...(typeof resolvedMaxChars === "number"
? { maxChars: resolvedMaxChars }
: {}),
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
})
.catch(async (err) => {
// Public-API fallback when Playwright's private _snapshotForAI is missing.

View File

@@ -1,23 +1,14 @@
import type express from "express";
import type { BrowserRouteContext } from "../server-context.js";
import {
handleRouteError,
readBody,
requirePwAi,
resolveProfileContext,
} from "./agent.shared.js";
import { handleRouteError, readBody, requirePwAi, resolveProfileContext } from "./agent.shared.js";
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
export function registerBrowserAgentStorageRoutes(
app: express.Express,
ctx: BrowserRouteContext,
) {
export function registerBrowserAgentStorageRoutes(app: express.Express, ctx: BrowserRouteContext) {
app.get("/cookies", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const targetId =
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
try {
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
const pw = await requirePwAi(res, "cookies");
@@ -38,9 +29,7 @@ export function registerBrowserAgentStorageRoutes(
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const cookie =
body.cookie &&
typeof body.cookie === "object" &&
!Array.isArray(body.cookie)
body.cookie && typeof body.cookie === "object" && !Array.isArray(body.cookie)
? (body.cookie as Record<string, unknown>)
: null;
if (!cookie) return jsonError(res, 400, "cookie is required");
@@ -61,9 +50,7 @@ export function registerBrowserAgentStorageRoutes(
httpOnly: toBoolean(cookie.httpOnly) ?? undefined,
secure: toBoolean(cookie.secure) ?? undefined,
sameSite:
cookie.sameSite === "Lax" ||
cookie.sameSite === "None" ||
cookie.sameSite === "Strict"
cookie.sameSite === "Lax" || cookie.sameSite === "None" || cookie.sameSite === "Strict"
? (cookie.sameSite as "Lax" | "None" | "Strict")
: undefined,
},
@@ -99,8 +86,7 @@ export function registerBrowserAgentStorageRoutes(
const kind = toStringOrEmpty(req.params.kind);
if (kind !== "local" && kind !== "session")
return jsonError(res, 400, "kind must be local|session");
const targetId =
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const key = typeof req.query.key === "string" ? req.query.key : "";
try {
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
@@ -175,8 +161,7 @@ export function registerBrowserAgentStorageRoutes(
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const offline = toBoolean(body.offline);
if (offline === undefined)
return jsonError(res, 400, "offline is required");
if (offline === undefined) return jsonError(res, 400, "offline is required");
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "offline");
@@ -198,9 +183,7 @@ export function registerBrowserAgentStorageRoutes(
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const headers =
body.headers &&
typeof body.headers === "object" &&
!Array.isArray(body.headers)
body.headers && typeof body.headers === "object" && !Array.isArray(body.headers)
? (body.headers as Record<string, unknown>)
: null;
if (!headers) return jsonError(res, 400, "headers is required");
@@ -230,8 +213,7 @@ export function registerBrowserAgentStorageRoutes(
const targetId = toStringOrEmpty(body.targetId) || undefined;
const clear = toBoolean(body.clear) ?? false;
const username = toStringOrEmpty(body.username) || undefined;
const password =
typeof body.password === "string" ? body.password : undefined;
const password = typeof body.password === "string" ? body.password : undefined;
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "http credentials");
@@ -285,19 +267,13 @@ export function registerBrowserAgentStorageRoutes(
const targetId = toStringOrEmpty(body.targetId) || undefined;
const schemeRaw = toStringOrEmpty(body.colorScheme);
const colorScheme =
schemeRaw === "dark" ||
schemeRaw === "light" ||
schemeRaw === "no-preference"
schemeRaw === "dark" || schemeRaw === "light" || schemeRaw === "no-preference"
? (schemeRaw as "dark" | "light" | "no-preference")
: schemeRaw === "none"
? null
: undefined;
if (colorScheme === undefined)
return jsonError(
res,
400,
"colorScheme must be dark|light|no-preference|none",
);
return jsonError(res, 400, "colorScheme must be dark|light|no-preference|none");
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "media emulation");

View File

@@ -6,10 +6,7 @@ import { registerBrowserAgentDebugRoutes } from "./agent.debug.js";
import { registerBrowserAgentSnapshotRoutes } from "./agent.snapshot.js";
import { registerBrowserAgentStorageRoutes } from "./agent.storage.js";
export function registerBrowserAgentRoutes(
app: express.Express,
ctx: BrowserRouteContext,
) {
export function registerBrowserAgentRoutes(app: express.Express, ctx: BrowserRouteContext) {
registerBrowserAgentSnapshotRoutes(app, ctx);
registerBrowserAgentActRoutes(app, ctx);
registerBrowserAgentDebugRoutes(app, ctx);

View File

@@ -4,10 +4,7 @@ import { createBrowserProfilesService } from "../profiles-service.js";
import type { BrowserRouteContext } from "../server-context.js";
import { getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
export function registerBrowserBasicRoutes(
app: express.Express,
ctx: BrowserRouteContext,
) {
export function registerBrowserBasicRoutes(app: express.Express, ctx: BrowserRouteContext) {
// List all profiles with their status
app.get("/profiles", async (_req, res) => {
try {

View File

@@ -5,10 +5,7 @@ import { registerBrowserAgentRoutes } from "./agent.js";
import { registerBrowserBasicRoutes } from "./basic.js";
import { registerBrowserTabRoutes } from "./tabs.js";
export function registerBrowserRoutes(
app: express.Express,
ctx: BrowserRouteContext,
) {
export function registerBrowserRoutes(app: express.Express, ctx: BrowserRouteContext) {
registerBrowserBasicRoutes(app, ctx);
registerBrowserTabRoutes(app, ctx);
registerBrowserAgentRoutes(app, ctx);

View File

@@ -1,25 +1,15 @@
import type express from "express";
import type { BrowserRouteContext } from "../server-context.js";
import {
getProfileContext,
jsonError,
toNumber,
toStringOrEmpty,
} from "./utils.js";
import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
export function registerBrowserTabRoutes(
app: express.Express,
ctx: BrowserRouteContext,
) {
export function registerBrowserTabRoutes(app: express.Express, ctx: BrowserRouteContext) {
app.get("/tabs", async (req, res) => {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx)
return jsonError(res, profileCtx.status, profileCtx.error);
if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error);
try {
const reachable = await profileCtx.isReachable(300);
if (!reachable)
return res.json({ running: false, tabs: [] as unknown[] });
if (!reachable) return res.json({ running: false, tabs: [] as unknown[] });
const tabs = await profileCtx.listTabs();
res.json({ running: true, tabs });
} catch (err) {
@@ -29,8 +19,7 @@ export function registerBrowserTabRoutes(
app.post("/tabs/open", async (req, res) => {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx)
return jsonError(res, profileCtx.status, profileCtx.error);
if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error);
const url = toStringOrEmpty((req.body as { url?: unknown })?.url);
if (!url) return jsonError(res, 400, "url is required");
try {
@@ -44,15 +33,11 @@ export function registerBrowserTabRoutes(
app.post("/tabs/focus", async (req, res) => {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx)
return jsonError(res, profileCtx.status, profileCtx.error);
const targetId = toStringOrEmpty(
(req.body as { targetId?: unknown })?.targetId,
);
if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error);
const targetId = toStringOrEmpty((req.body as { targetId?: unknown })?.targetId);
if (!targetId) return jsonError(res, 400, "targetId is required");
try {
if (!(await profileCtx.isReachable(300)))
return jsonError(res, 409, "browser not running");
if (!(await profileCtx.isReachable(300))) return jsonError(res, 409, "browser not running");
await profileCtx.focusTab(targetId);
res.json({ ok: true });
} catch (err) {
@@ -64,13 +49,11 @@ export function registerBrowserTabRoutes(
app.delete("/tabs/:targetId", async (req, res) => {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx)
return jsonError(res, profileCtx.status, profileCtx.error);
if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error);
const targetId = toStringOrEmpty(req.params.targetId);
if (!targetId) return jsonError(res, 400, "targetId is required");
try {
if (!(await profileCtx.isReachable(300)))
return jsonError(res, 409, "browser not running");
if (!(await profileCtx.isReachable(300))) return jsonError(res, 409, "browser not running");
await profileCtx.closeTab(targetId);
res.json({ ok: true });
} catch (err) {
@@ -82,8 +65,7 @@ export function registerBrowserTabRoutes(
app.post("/tabs/action", async (req, res) => {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx)
return jsonError(res, profileCtx.status, profileCtx.error);
if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error);
const action = toStringOrEmpty((req.body as { action?: unknown })?.action);
const index = toNumber((req.body as { index?: unknown })?.index);
try {
@@ -109,8 +91,7 @@ export function registerBrowserTabRoutes(
}
if (action === "select") {
if (typeof index !== "number")
return jsonError(res, 400, "index is required");
if (typeof index !== "number") return jsonError(res, 400, "index is required");
const tabs = await profileCtx.listTabs();
const target = tabs[index];
if (!target) return jsonError(res, 404, "tab not found");

View File

@@ -32,11 +32,7 @@ export function getProfileContext(
}
}
export function jsonError(
res: express.Response,
status: number,
message: string,
) {
export function jsonError(res: express.Response, status: number, message: string) {
res.status(status).json({ error: message });
}

View File

@@ -10,24 +10,15 @@ export async function normalizeBrowserScreenshot(
maxBytes?: number;
},
): Promise<{ buffer: Buffer; contentType?: "image/jpeg" }> {
const maxSide = Math.max(
1,
Math.round(opts?.maxSide ?? DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE),
);
const maxBytes = Math.max(
1,
Math.round(opts?.maxBytes ?? DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES),
);
const maxSide = Math.max(1, Math.round(opts?.maxSide ?? DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE));
const maxBytes = Math.max(1, Math.round(opts?.maxBytes ?? DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES));
const meta = await getImageMetadata(buffer);
const width = Number(meta?.width ?? 0);
const height = Number(meta?.height ?? 0);
const maxDim = Math.max(width, height);
if (
buffer.byteLength <= maxBytes &&
(maxDim === 0 || (width <= maxSide && height <= maxSide))
) {
if (buffer.byteLength <= maxBytes && (maxDim === 0 || (width <= maxSide && height <= maxSide))) {
return { buffer };
}

View File

@@ -33,10 +33,7 @@ export type {
/**
* Normalize a CDP WebSocket URL to use the correct base URL.
*/
function normalizeWsUrl(
raw: string | undefined,
cdpBaseUrl: string,
): string | undefined {
function normalizeWsUrl(raw: string | undefined, cdpBaseUrl: string): string | undefined {
if (!raw) return undefined;
try {
return normalizeCdpWsUrl(raw, cdpBaseUrl);
@@ -45,11 +42,7 @@ function normalizeWsUrl(
}
}
async function fetchJson<T>(
url: string,
timeoutMs = 1500,
init?: RequestInit,
): Promise<T> {
async function fetchJson<T>(url: string, timeoutMs = 1500, init?: RequestInit): Promise<T> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
@@ -61,11 +54,7 @@ async function fetchJson<T>(
}
}
async function fetchOk(
url: string,
timeoutMs = 1500,
init?: RequestInit,
): Promise<void> {
async function fetchOk(url: string, timeoutMs = 1500, init?: RequestInit): Promise<void> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
@@ -183,9 +172,7 @@ function createProfileContext(
return await isChromeReachable(profile.cdpUrl, timeoutMs);
};
const attachRunning = (
running: NonNullable<ProfileRuntimeState["running"]>,
) => {
const attachRunning = (running: NonNullable<ProfileRuntimeState["running"]>) => {
setProfileRunning(running);
running.proc.on("exit", () => {
// Guard against server teardown (e.g., SIGUSR1 restart)
@@ -204,10 +191,7 @@ function createProfileContext(
const httpReachable = await isHttpReachable();
if (!httpReachable) {
if (
(current.resolved.attachOnly || remoteCdp) &&
opts.onEnsureAttachTarget
) {
if ((current.resolved.attachOnly || remoteCdp) && opts.onEnsureAttachTarget) {
await opts.onEnsureAttachTarget(profile);
if (await isHttpReachable(1200)) return;
}
@@ -374,9 +358,7 @@ function createProfileContext(
};
}
export function createBrowserRouteContext(
opts: ContextOptions,
): BrowserRouteContext {
export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteContext {
const state = () => {
const current = opts.getState();
if (!current) throw new Error("Browser server not started");
@@ -389,9 +371,7 @@ export function createBrowserRouteContext(
const profile = resolveProfile(current.resolved, name);
if (!profile) {
const available = Object.keys(current.resolved.profiles).join(", ");
throw new Error(
`Profile "${name}" not found. Available profiles: ${available || "(none)"}`,
);
throw new Error(`Profile "${name}" not found. Available profiles: ${available || "(none)"}`);
}
return createProfileContext(opts, profile);
};
@@ -470,10 +450,8 @@ export function createBrowserRouteContext(
listProfiles,
// Legacy methods delegate to default profile
ensureBrowserAvailable: () => getDefaultContext().ensureBrowserAvailable(),
ensureTabAvailable: (targetId) =>
getDefaultContext().ensureTabAvailable(targetId),
isHttpReachable: (timeoutMs) =>
getDefaultContext().isHttpReachable(timeoutMs),
ensureTabAvailable: (targetId) => getDefaultContext().ensureTabAvailable(targetId),
isHttpReachable: (timeoutMs) => getDefaultContext().isHttpReachable(timeoutMs),
isReachable: (timeoutMs) => getDefaultContext().isReachable(timeoutMs),
listTabs: () => getDefaultContext().listTabs(),
openTab: (url) => getDefaultContext().openTab(url),

View File

@@ -2,10 +2,7 @@ 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";
import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
export type { BrowserTab };

View File

@@ -106,20 +106,18 @@ 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,
};
},
),
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;
@@ -390,9 +388,10 @@ describe("browser control server", () => {
});
expect(responseBody).toMatchObject({ ok: true });
const consoleRes = (await realFetch(`${base}/console?level=error`).then(
(r) => r.json(),
)) as { ok: boolean; messages?: unknown[] };
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);

View File

@@ -107,20 +107,18 @@ 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,
};
},
),
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;
@@ -269,9 +267,9 @@ describe("browser control server", () => {
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 };
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({
@@ -279,9 +277,10 @@ describe("browser control server", () => {
limit: 1,
});
const snapAi = (await realFetch(`${base}/snapshot?format=ai`).then((r) =>
r.json(),
)) as { ok: boolean; format?: string };
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({

View File

@@ -106,20 +106,18 @@ 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,
};
},
),
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;
@@ -253,9 +251,10 @@ describe("browser control server", () => {
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[] };
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);
@@ -280,9 +279,7 @@ describe("browser control server", () => {
});
expect(delAmbiguous.status).toBe(409);
const snapAmbiguous = await realFetch(
`${base}/snapshot?format=aria&targetId=abc`,
);
const snapAmbiguous = await realFetch(`${base}/snapshot?format=aria&targetId=abc`);
expect(snapAmbiguous.status).toBe(409);
});
});
@@ -357,9 +354,10 @@ describe("backward compatibility (profile parameter)", () => {
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 };
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");
});
@@ -371,9 +369,10 @@ describe("backward compatibility (profile parameter)", () => {
await realFetch(`${base}/start`, { method: "POST" });
const result = (await realFetch(`${base}/stop`, { method: "POST" }).then(
(r) => r.json(),
)) as { ok: boolean; profile?: string };
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");
});
@@ -413,9 +412,9 @@ describe("backward compatibility (profile parameter)", () => {
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 }> };
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);
@@ -428,9 +427,10 @@ describe("backward compatibility (profile parameter)", () => {
await realFetch(`${base}/start`, { method: "POST" });
const result = (await realFetch(`${base}/tabs?profile=clawd`).then((r) =>
r.json(),
)) as { running: boolean; tabs: unknown[] };
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);
});

View File

@@ -106,20 +106,18 @@ 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,
};
},
),
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;

View File

@@ -106,20 +106,18 @@ 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,
};
},
),
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;

View File

@@ -106,20 +106,18 @@ 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,
};
},
),
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;
@@ -254,9 +252,9 @@ describe("browser control server", () => {
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 };
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");
@@ -343,9 +341,10 @@ describe("browser control server", () => {
});
expect(dialogMissingAccept.status).toBe(400);
const snapDefault = (await realFetch(`${base}/snapshot?format=wat`).then(
(r) => r.json(),
)) as { ok: boolean; format?: string };
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");
@@ -412,9 +411,9 @@ describe("browser control server", () => {
}).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 };
const status = (await realFetch(`${bridge.baseUrl}/`).then((r) => r.json())) as {
running?: boolean;
};
expect(status.running).toBe(true);
expect(ensured).toHaveBeenCalledTimes(1);

View File

@@ -3,15 +3,9 @@ import express from "express";
import { loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging.js";
import {
resolveBrowserConfig,
shouldStartLocalBrowserServer,
} from "./config.js";
import { resolveBrowserConfig, shouldStartLocalBrowserServer } from "./config.js";
import { registerBrowserRoutes } from "./routes/index.js";
import {
type BrowserServerState,
createBrowserRouteContext,
} from "./server-context.js";
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
let state: BrowserServerState | null = null;
const log = createSubsystemLogger("browser");
@@ -44,9 +38,7 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
const s = app.listen(port, "127.0.0.1", () => resolve(s));
s.once("error", reject);
}).catch((err) => {
logServer.error(
`clawd browser server failed to bind 127.0.0.1:${port}: ${String(err)}`,
);
logServer.error(`clawd browser server failed to bind 127.0.0.1:${port}: ${String(err)}`);
return null;
});

View File

@@ -4,10 +4,7 @@ import { resolveTargetIdFromTabs } from "./target-id.js";
describe("browser target id resolution", () => {
it("resolves exact ids", () => {
const res = resolveTargetIdFromTabs("FULL", [
{ targetId: "AAA" },
{ targetId: "FULL" },
]);
const res = resolveTargetIdFromTabs("FULL", [{ targetId: "AAA" }, { targetId: "FULL" }]);
expect(res).toEqual({ ok: true, targetId: "FULL" });
});

View File

@@ -13,9 +13,7 @@ export function resolveTargetIdFromTabs(
if (exact) return { ok: true, targetId: exact.targetId };
const lower = needle.toLowerCase();
const matches = tabs
.map((t) => t.targetId)
.filter((id) => id.toLowerCase().startsWith(lower));
const matches = tabs.map((t) => t.targetId).filter((id) => id.toLowerCase().startsWith(lower));
const only = matches.length === 1 ? matches[0] : undefined;
if (only) return { ok: true, targetId: only };