chore: migrate to oxlint and oxfmt
Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")>();
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {}) },
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user