refactor(src): split oversized modules
This commit is contained in:
BIN
src/browser/.DS_Store
vendored
Normal file
BIN
src/browser/.DS_Store
vendored
Normal file
Binary file not shown.
122
src/browser/cdp.helpers.ts
Normal file
122
src/browser/cdp.helpers.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import WebSocket from "ws";
|
||||
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
|
||||
type CdpResponse = {
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: { message?: string };
|
||||
};
|
||||
|
||||
type Pending = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (err: Error) => void;
|
||||
};
|
||||
|
||||
export type CdpSendFn = (
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
|
||||
export function isLoopbackHost(host: string) {
|
||||
const h = host.trim().toLowerCase();
|
||||
return (
|
||||
h === "localhost" ||
|
||||
h === "127.0.0.1" ||
|
||||
h === "0.0.0.0" ||
|
||||
h === "[::1]" ||
|
||||
h === "::1" ||
|
||||
h === "[::]" ||
|
||||
h === "::"
|
||||
);
|
||||
}
|
||||
|
||||
function createCdpSender(ws: WebSocket) {
|
||||
let nextId = 1;
|
||||
const pending = new Map<number, Pending>();
|
||||
|
||||
const send: CdpSendFn = (
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
) => {
|
||||
const id = nextId++;
|
||||
const msg = { id, method, params };
|
||||
ws.send(JSON.stringify(msg));
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
});
|
||||
};
|
||||
|
||||
const closeWithError = (err: Error) => {
|
||||
for (const [, p] of pending) p.reject(err);
|
||||
pending.clear();
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
ws.on("message", (data) => {
|
||||
try {
|
||||
const parsed = JSON.parse(rawDataToString(data)) as CdpResponse;
|
||||
if (typeof parsed.id !== "number") return;
|
||||
const p = pending.get(parsed.id);
|
||||
if (!p) return;
|
||||
pending.delete(parsed.id);
|
||||
if (parsed.error?.message) {
|
||||
p.reject(new Error(parsed.error.message));
|
||||
return;
|
||||
}
|
||||
p.resolve(parsed.result);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
closeWithError(new Error("CDP socket closed"));
|
||||
});
|
||||
|
||||
return { send, closeWithError };
|
||||
}
|
||||
|
||||
export async function fetchJson<T>(url: string, timeoutMs = 1500): Promise<T> {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, { signal: ctrl.signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()) as T;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
export async function withCdpSocket<T>(
|
||||
wsUrl: string,
|
||||
fn: (send: CdpSendFn) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const ws = new WebSocket(wsUrl, { handshakeTimeout: 5000 });
|
||||
const { send, closeWithError } = createCdpSender(ws);
|
||||
|
||||
const openPromise = new Promise<void>((resolve, reject) => {
|
||||
ws.once("open", () => resolve());
|
||||
ws.once("error", (err) => reject(err));
|
||||
});
|
||||
|
||||
await openPromise;
|
||||
|
||||
try {
|
||||
return await fn(send);
|
||||
} catch (err) {
|
||||
closeWithError(err instanceof Error ? err : new Error(String(err)));
|
||||
throw err;
|
||||
} finally {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,4 @@
|
||||
import WebSocket from "ws";
|
||||
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
|
||||
type CdpResponse = {
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: { message?: string };
|
||||
};
|
||||
|
||||
type Pending = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (err: Error) => void;
|
||||
};
|
||||
|
||||
type CdpSendFn = (
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
|
||||
function isLoopbackHost(host: string) {
|
||||
const h = host.trim().toLowerCase();
|
||||
return (
|
||||
h === "localhost" ||
|
||||
h === "127.0.0.1" ||
|
||||
h === "0.0.0.0" ||
|
||||
h === "[::1]" ||
|
||||
h === "::1" ||
|
||||
h === "[::]" ||
|
||||
h === "::"
|
||||
);
|
||||
}
|
||||
import { fetchJson, isLoopbackHost, withCdpSocket } from "./cdp.helpers.js";
|
||||
|
||||
export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
|
||||
const ws = new URL(wsUrl);
|
||||
@@ -43,96 +12,6 @@ export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
|
||||
return ws.toString();
|
||||
}
|
||||
|
||||
function createCdpSender(ws: WebSocket) {
|
||||
let nextId = 1;
|
||||
const pending = new Map<number, Pending>();
|
||||
|
||||
const send: CdpSendFn = (
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
) => {
|
||||
const id = nextId++;
|
||||
const msg = { id, method, params };
|
||||
ws.send(JSON.stringify(msg));
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
});
|
||||
};
|
||||
|
||||
const closeWithError = (err: Error) => {
|
||||
for (const [, p] of pending) p.reject(err);
|
||||
pending.clear();
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
ws.on("message", (data) => {
|
||||
try {
|
||||
const parsed = JSON.parse(rawDataToString(data)) as CdpResponse;
|
||||
if (typeof parsed.id !== "number") return;
|
||||
const p = pending.get(parsed.id);
|
||||
if (!p) return;
|
||||
pending.delete(parsed.id);
|
||||
if (parsed.error?.message) {
|
||||
p.reject(new Error(parsed.error.message));
|
||||
return;
|
||||
}
|
||||
p.resolve(parsed.result);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
closeWithError(new Error("CDP socket closed"));
|
||||
});
|
||||
|
||||
return { send, closeWithError };
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, timeoutMs = 1500): Promise<T> {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, { signal: ctrl.signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()) as T;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
async function withCdpSocket<T>(
|
||||
wsUrl: string,
|
||||
fn: (send: CdpSendFn) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const ws = new WebSocket(wsUrl, { handshakeTimeout: 5000 });
|
||||
const { send, closeWithError } = createCdpSender(ws);
|
||||
|
||||
const openPromise = new Promise<void>((resolve, reject) => {
|
||||
ws.once("open", () => resolve());
|
||||
ws.once("error", (err) => reject(err));
|
||||
});
|
||||
|
||||
await openPromise;
|
||||
|
||||
try {
|
||||
return await fn(send);
|
||||
} catch (err) {
|
||||
closeWithError(err instanceof Error ? err : new Error(String(err)));
|
||||
throw err;
|
||||
} finally {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function captureScreenshotPng(opts: {
|
||||
wsUrl: string;
|
||||
fullPage?: boolean;
|
||||
|
||||
166
src/browser/chrome.executables.ts
Normal file
166
src/browser/chrome.executables.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ResolvedBrowserConfig } from "./config.js";
|
||||
|
||||
export type BrowserExecutable = {
|
||||
kind: "canary" | "chromium" | "chrome" | "custom";
|
||||
path: string;
|
||||
};
|
||||
|
||||
function exists(filePath: string) {
|
||||
try {
|
||||
return fs.existsSync(filePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findFirstExecutable(
|
||||
candidates: Array<BrowserExecutable>,
|
||||
): BrowserExecutable | null {
|
||||
for (const candidate of candidates) {
|
||||
if (exists(candidate.path)) return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findChromeExecutableMac(): BrowserExecutable | null {
|
||||
const candidates: Array<BrowserExecutable> = [
|
||||
{
|
||||
kind: "canary",
|
||||
path: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||||
},
|
||||
{
|
||||
kind: "canary",
|
||||
path: path.join(
|
||||
os.homedir(),
|
||||
"Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: "chromium",
|
||||
path: "/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
},
|
||||
{
|
||||
kind: "chromium",
|
||||
path: path.join(
|
||||
os.homedir(),
|
||||
"Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: "chrome",
|
||||
path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
},
|
||||
{
|
||||
kind: "chrome",
|
||||
path: path.join(
|
||||
os.homedir(),
|
||||
"Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return findFirstExecutable(candidates);
|
||||
}
|
||||
|
||||
export function findChromeExecutableLinux(): BrowserExecutable | null {
|
||||
const candidates: Array<BrowserExecutable> = [
|
||||
{ kind: "chrome", path: "/usr/bin/google-chrome" },
|
||||
{ kind: "chrome", path: "/usr/bin/google-chrome-stable" },
|
||||
{ kind: "chromium", path: "/usr/bin/chromium" },
|
||||
{ kind: "chromium", path: "/usr/bin/chromium-browser" },
|
||||
{ kind: "chromium", path: "/snap/bin/chromium" },
|
||||
{ kind: "chrome", path: "/usr/bin/chrome" },
|
||||
];
|
||||
|
||||
return findFirstExecutable(candidates);
|
||||
}
|
||||
|
||||
export function findChromeExecutableWindows(): BrowserExecutable | null {
|
||||
const localAppData = process.env.LOCALAPPDATA ?? "";
|
||||
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
|
||||
// Must use bracket notation: variable name contains parentheses
|
||||
const programFilesX86 =
|
||||
process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
|
||||
|
||||
const joinWin = path.win32.join;
|
||||
const candidates: Array<BrowserExecutable> = [];
|
||||
|
||||
if (localAppData) {
|
||||
// Chrome Canary (user install)
|
||||
candidates.push({
|
||||
kind: "canary",
|
||||
path: joinWin(
|
||||
localAppData,
|
||||
"Google",
|
||||
"Chrome SxS",
|
||||
"Application",
|
||||
"chrome.exe",
|
||||
),
|
||||
});
|
||||
// Chromium (user install)
|
||||
candidates.push({
|
||||
kind: "chromium",
|
||||
path: joinWin(localAppData, "Chromium", "Application", "chrome.exe"),
|
||||
});
|
||||
// Chrome (user install)
|
||||
candidates.push({
|
||||
kind: "chrome",
|
||||
path: joinWin(
|
||||
localAppData,
|
||||
"Google",
|
||||
"Chrome",
|
||||
"Application",
|
||||
"chrome.exe",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Chrome (system install, 64-bit)
|
||||
candidates.push({
|
||||
kind: "chrome",
|
||||
path: joinWin(
|
||||
programFiles,
|
||||
"Google",
|
||||
"Chrome",
|
||||
"Application",
|
||||
"chrome.exe",
|
||||
),
|
||||
});
|
||||
// Chrome (system install, 32-bit on 64-bit Windows)
|
||||
candidates.push({
|
||||
kind: "chrome",
|
||||
path: joinWin(
|
||||
programFilesX86,
|
||||
"Google",
|
||||
"Chrome",
|
||||
"Application",
|
||||
"chrome.exe",
|
||||
),
|
||||
});
|
||||
|
||||
return findFirstExecutable(candidates);
|
||||
}
|
||||
|
||||
export function resolveBrowserExecutableForPlatform(
|
||||
resolved: ResolvedBrowserConfig,
|
||||
platform: NodeJS.Platform,
|
||||
): BrowserExecutable | null {
|
||||
if (resolved.executablePath) {
|
||||
if (!exists(resolved.executablePath)) {
|
||||
throw new Error(
|
||||
`browser.executablePath not found: ${resolved.executablePath}`,
|
||||
);
|
||||
}
|
||||
return { kind: "custom", path: resolved.executablePath };
|
||||
}
|
||||
|
||||
if (platform === "darwin") return findChromeExecutableMac();
|
||||
if (platform === "linux") return findChromeExecutableLinux();
|
||||
if (platform === "win32") return findChromeExecutableWindows();
|
||||
return null;
|
||||
}
|
||||
221
src/browser/chrome.profile-decoration.ts
Normal file
221
src/browser/chrome.profile-decoration.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
DEFAULT_CLAWD_BROWSER_COLOR,
|
||||
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
|
||||
} from "./constants.js";
|
||||
|
||||
function decoratedMarkerPath(userDataDir: string) {
|
||||
return path.join(userDataDir, ".clawd-profile-decorated");
|
||||
}
|
||||
|
||||
function safeReadJson(filePath: string): Record<string, unknown> | null {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
||||
return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeWriteJson(filePath: string, data: Record<string, unknown>) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
function setDeep(obj: Record<string, unknown>, keys: string[], value: unknown) {
|
||||
let node: Record<string, unknown> = obj;
|
||||
for (const key of keys.slice(0, -1)) {
|
||||
const next = node[key];
|
||||
if (typeof next !== "object" || next === null || Array.isArray(next)) {
|
||||
node[key] = {};
|
||||
}
|
||||
node = node[key] as Record<string, unknown>;
|
||||
}
|
||||
node[keys[keys.length - 1] ?? ""] = value;
|
||||
}
|
||||
|
||||
function parseHexRgbToSignedArgbInt(hex: string): number | null {
|
||||
const cleaned = hex.trim().replace(/^#/, "");
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(cleaned)) return null;
|
||||
const rgb = Number.parseInt(cleaned, 16);
|
||||
const argbUnsigned = (0xff << 24) | rgb;
|
||||
// Chrome stores colors as signed 32-bit ints (SkColor).
|
||||
return argbUnsigned > 0x7fffffff
|
||||
? argbUnsigned - 0x1_0000_0000
|
||||
: argbUnsigned;
|
||||
}
|
||||
|
||||
export function isProfileDecorated(
|
||||
userDataDir: string,
|
||||
desiredName: string,
|
||||
desiredColorHex: string,
|
||||
): boolean {
|
||||
const desiredColorInt = parseHexRgbToSignedArgbInt(desiredColorHex);
|
||||
|
||||
const localStatePath = path.join(userDataDir, "Local State");
|
||||
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
|
||||
|
||||
const localState = safeReadJson(localStatePath);
|
||||
const profile = localState?.profile;
|
||||
const infoCache =
|
||||
typeof profile === "object" && profile !== null && !Array.isArray(profile)
|
||||
? (profile as Record<string, unknown>).info_cache
|
||||
: null;
|
||||
const info =
|
||||
typeof infoCache === "object" &&
|
||||
infoCache !== null &&
|
||||
!Array.isArray(infoCache) &&
|
||||
typeof (infoCache as Record<string, unknown>).Default === "object" &&
|
||||
(infoCache as Record<string, unknown>).Default !== null &&
|
||||
!Array.isArray((infoCache as Record<string, unknown>).Default)
|
||||
? ((infoCache as Record<string, unknown>).Default as Record<
|
||||
string,
|
||||
unknown
|
||||
>)
|
||||
: null;
|
||||
|
||||
const prefs = safeReadJson(preferencesPath);
|
||||
const browserTheme = (() => {
|
||||
const browser = prefs?.browser;
|
||||
const theme =
|
||||
typeof browser === "object" && browser !== null && !Array.isArray(browser)
|
||||
? (browser as Record<string, unknown>).theme
|
||||
: null;
|
||||
return typeof theme === "object" && theme !== null && !Array.isArray(theme)
|
||||
? (theme as Record<string, unknown>)
|
||||
: null;
|
||||
})();
|
||||
|
||||
const autogeneratedTheme = (() => {
|
||||
const autogenerated = prefs?.autogenerated;
|
||||
const theme =
|
||||
typeof autogenerated === "object" &&
|
||||
autogenerated !== null &&
|
||||
!Array.isArray(autogenerated)
|
||||
? (autogenerated as Record<string, unknown>).theme
|
||||
: null;
|
||||
return typeof theme === "object" && theme !== null && !Array.isArray(theme)
|
||||
? (theme as Record<string, unknown>)
|
||||
: null;
|
||||
})();
|
||||
|
||||
const nameOk =
|
||||
typeof info?.name === "string" ? info.name === desiredName : true;
|
||||
|
||||
if (desiredColorInt == null) {
|
||||
// If the user provided a non-#RRGGBB value, we can only do best-effort.
|
||||
return nameOk;
|
||||
}
|
||||
|
||||
const localSeedOk =
|
||||
typeof info?.profile_color_seed === "number"
|
||||
? info.profile_color_seed === desiredColorInt
|
||||
: false;
|
||||
|
||||
const prefOk =
|
||||
(typeof browserTheme?.user_color2 === "number" &&
|
||||
browserTheme.user_color2 === desiredColorInt) ||
|
||||
(typeof autogeneratedTheme?.color === "number" &&
|
||||
autogeneratedTheme.color === desiredColorInt);
|
||||
|
||||
return nameOk && localSeedOk && prefOk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort profile decoration (name + lobster-orange). Chrome preference keys
|
||||
* vary by version; we keep this conservative and idempotent.
|
||||
*/
|
||||
export function decorateClawdProfile(
|
||||
userDataDir: string,
|
||||
opts?: { name?: string; color?: string },
|
||||
) {
|
||||
const desiredName = opts?.name ?? DEFAULT_CLAWD_BROWSER_PROFILE_NAME;
|
||||
const desiredColor = (
|
||||
opts?.color ?? DEFAULT_CLAWD_BROWSER_COLOR
|
||||
).toUpperCase();
|
||||
const desiredColorInt = parseHexRgbToSignedArgbInt(desiredColor);
|
||||
|
||||
const localStatePath = path.join(userDataDir, "Local State");
|
||||
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
|
||||
|
||||
const localState = safeReadJson(localStatePath) ?? {};
|
||||
// Common-ish shape: profile.info_cache.Default
|
||||
setDeep(
|
||||
localState,
|
||||
["profile", "info_cache", "Default", "name"],
|
||||
desiredName,
|
||||
);
|
||||
setDeep(
|
||||
localState,
|
||||
["profile", "info_cache", "Default", "shortcut_name"],
|
||||
desiredName,
|
||||
);
|
||||
setDeep(
|
||||
localState,
|
||||
["profile", "info_cache", "Default", "user_name"],
|
||||
desiredName,
|
||||
);
|
||||
// Color keys are best-effort (Chrome changes these frequently).
|
||||
setDeep(
|
||||
localState,
|
||||
["profile", "info_cache", "Default", "profile_color"],
|
||||
desiredColor,
|
||||
);
|
||||
setDeep(
|
||||
localState,
|
||||
["profile", "info_cache", "Default", "user_color"],
|
||||
desiredColor,
|
||||
);
|
||||
if (desiredColorInt != null) {
|
||||
// These are the fields Chrome actually uses for profile/avatar tinting.
|
||||
setDeep(
|
||||
localState,
|
||||
["profile", "info_cache", "Default", "profile_color_seed"],
|
||||
desiredColorInt,
|
||||
);
|
||||
setDeep(
|
||||
localState,
|
||||
["profile", "info_cache", "Default", "profile_highlight_color"],
|
||||
desiredColorInt,
|
||||
);
|
||||
setDeep(
|
||||
localState,
|
||||
["profile", "info_cache", "Default", "default_avatar_fill_color"],
|
||||
desiredColorInt,
|
||||
);
|
||||
setDeep(
|
||||
localState,
|
||||
["profile", "info_cache", "Default", "default_avatar_stroke_color"],
|
||||
desiredColorInt,
|
||||
);
|
||||
}
|
||||
safeWriteJson(localStatePath, localState);
|
||||
|
||||
const prefs = safeReadJson(preferencesPath) ?? {};
|
||||
setDeep(prefs, ["profile", "name"], desiredName);
|
||||
setDeep(prefs, ["profile", "profile_color"], desiredColor);
|
||||
setDeep(prefs, ["profile", "user_color"], desiredColor);
|
||||
if (desiredColorInt != null) {
|
||||
// Chrome refresh stores the autogenerated theme in these prefs (SkColor ints).
|
||||
setDeep(prefs, ["autogenerated", "theme", "color"], desiredColorInt);
|
||||
// User-selected browser theme color (pref name: browser.theme.user_color2).
|
||||
setDeep(prefs, ["browser", "theme", "user_color2"], desiredColorInt);
|
||||
}
|
||||
safeWriteJson(preferencesPath, prefs);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
decoratedMarkerPath(userDataDir),
|
||||
`${Date.now()}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,14 @@ import { ensurePortAvailable } from "../infra/ports.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { CONFIG_DIR } from "../utils.js";
|
||||
import { normalizeCdpWsUrl } from "./cdp.js";
|
||||
import {
|
||||
type BrowserExecutable,
|
||||
resolveBrowserExecutableForPlatform,
|
||||
} from "./chrome.executables.js";
|
||||
import {
|
||||
decorateClawdProfile,
|
||||
isProfileDecorated,
|
||||
} from "./chrome.profile-decoration.js";
|
||||
import type {
|
||||
ResolvedBrowserConfig,
|
||||
ResolvedBrowserProfile,
|
||||
@@ -19,19 +27,17 @@ import {
|
||||
|
||||
const log = createSubsystemLogger("browser").child("chrome");
|
||||
|
||||
export type BrowserExecutable = {
|
||||
kind: "canary" | "chromium" | "chrome" | "custom";
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type RunningChrome = {
|
||||
pid: number;
|
||||
exe: BrowserExecutable;
|
||||
userDataDir: string;
|
||||
cdpPort: number;
|
||||
startedAt: number;
|
||||
proc: ChildProcessWithoutNullStreams;
|
||||
};
|
||||
export type { BrowserExecutable } from "./chrome.executables.js";
|
||||
export {
|
||||
findChromeExecutableLinux,
|
||||
findChromeExecutableMac,
|
||||
findChromeExecutableWindows,
|
||||
resolveBrowserExecutableForPlatform,
|
||||
} from "./chrome.executables.js";
|
||||
export {
|
||||
decorateClawdProfile,
|
||||
isProfileDecorated,
|
||||
} from "./chrome.profile-decoration.js";
|
||||
|
||||
function exists(filePath: string) {
|
||||
try {
|
||||
@@ -41,153 +47,14 @@ function exists(filePath: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function findFirstExecutable(
|
||||
candidates: Array<BrowserExecutable>,
|
||||
): BrowserExecutable | null {
|
||||
for (const candidate of candidates) {
|
||||
if (exists(candidate.path)) return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findChromeExecutableMac(): BrowserExecutable | null {
|
||||
const candidates: Array<BrowserExecutable> = [
|
||||
{
|
||||
kind: "canary",
|
||||
path: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||||
},
|
||||
{
|
||||
kind: "canary",
|
||||
path: path.join(
|
||||
os.homedir(),
|
||||
"Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: "chromium",
|
||||
path: "/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
},
|
||||
{
|
||||
kind: "chromium",
|
||||
path: path.join(
|
||||
os.homedir(),
|
||||
"Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: "chrome",
|
||||
path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
},
|
||||
{
|
||||
kind: "chrome",
|
||||
path: path.join(
|
||||
os.homedir(),
|
||||
"Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return findFirstExecutable(candidates);
|
||||
}
|
||||
|
||||
export function findChromeExecutableLinux(): BrowserExecutable | null {
|
||||
const candidates: Array<BrowserExecutable> = [
|
||||
{ kind: "chrome", path: "/usr/bin/google-chrome" },
|
||||
{ kind: "chrome", path: "/usr/bin/google-chrome-stable" },
|
||||
{ kind: "chromium", path: "/usr/bin/chromium" },
|
||||
{ kind: "chromium", path: "/usr/bin/chromium-browser" },
|
||||
{ kind: "chromium", path: "/snap/bin/chromium" },
|
||||
{ kind: "chrome", path: "/usr/bin/chrome" },
|
||||
];
|
||||
|
||||
return findFirstExecutable(candidates);
|
||||
}
|
||||
|
||||
export function findChromeExecutableWindows(): BrowserExecutable | null {
|
||||
const localAppData = process.env.LOCALAPPDATA ?? "";
|
||||
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
|
||||
// Must use bracket notation: variable name contains parentheses
|
||||
const programFilesX86 =
|
||||
process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
|
||||
|
||||
const joinWin = path.win32.join;
|
||||
const candidates: Array<BrowserExecutable> = [];
|
||||
|
||||
if (localAppData) {
|
||||
// Chrome Canary (user install)
|
||||
candidates.push({
|
||||
kind: "canary",
|
||||
path: joinWin(
|
||||
localAppData,
|
||||
"Google",
|
||||
"Chrome SxS",
|
||||
"Application",
|
||||
"chrome.exe",
|
||||
),
|
||||
});
|
||||
// Chromium (user install)
|
||||
candidates.push({
|
||||
kind: "chromium",
|
||||
path: joinWin(localAppData, "Chromium", "Application", "chrome.exe"),
|
||||
});
|
||||
// Chrome (user install)
|
||||
candidates.push({
|
||||
kind: "chrome",
|
||||
path: joinWin(
|
||||
localAppData,
|
||||
"Google",
|
||||
"Chrome",
|
||||
"Application",
|
||||
"chrome.exe",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Chrome (system install, 64-bit)
|
||||
candidates.push({
|
||||
kind: "chrome",
|
||||
path: joinWin(
|
||||
programFiles,
|
||||
"Google",
|
||||
"Chrome",
|
||||
"Application",
|
||||
"chrome.exe",
|
||||
),
|
||||
});
|
||||
// Chrome (system install, 32-bit on 64-bit Windows)
|
||||
candidates.push({
|
||||
kind: "chrome",
|
||||
path: joinWin(
|
||||
programFilesX86,
|
||||
"Google",
|
||||
"Chrome",
|
||||
"Application",
|
||||
"chrome.exe",
|
||||
),
|
||||
});
|
||||
|
||||
return findFirstExecutable(candidates);
|
||||
}
|
||||
|
||||
export function resolveBrowserExecutableForPlatform(
|
||||
resolved: ResolvedBrowserConfig,
|
||||
platform: NodeJS.Platform,
|
||||
): BrowserExecutable | null {
|
||||
if (resolved.executablePath) {
|
||||
if (!exists(resolved.executablePath)) {
|
||||
throw new Error(
|
||||
`browser.executablePath not found: ${resolved.executablePath}`,
|
||||
);
|
||||
}
|
||||
return { kind: "custom", path: resolved.executablePath };
|
||||
}
|
||||
|
||||
if (platform === "darwin") return findChromeExecutableMac();
|
||||
if (platform === "linux") return findChromeExecutableLinux();
|
||||
if (platform === "win32") return findChromeExecutableWindows();
|
||||
return null;
|
||||
}
|
||||
export type RunningChrome = {
|
||||
pid: number;
|
||||
exe: BrowserExecutable;
|
||||
userDataDir: string;
|
||||
cdpPort: number;
|
||||
startedAt: number;
|
||||
proc: ChildProcessWithoutNullStreams;
|
||||
};
|
||||
|
||||
function resolveBrowserExecutable(
|
||||
resolved: ResolvedBrowserConfig,
|
||||
@@ -201,223 +68,10 @@ export function resolveClawdUserDataDir(
|
||||
return path.join(CONFIG_DIR, "browser", profileName, "user-data");
|
||||
}
|
||||
|
||||
function decoratedMarkerPath(userDataDir: string) {
|
||||
return path.join(userDataDir, ".clawd-profile-decorated");
|
||||
}
|
||||
|
||||
function safeReadJson(filePath: string): Record<string, unknown> | null {
|
||||
try {
|
||||
if (!exists(filePath)) return null;
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
||||
return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeWriteJson(filePath: string, data: Record<string, unknown>) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
function cdpUrlForPort(cdpPort: number) {
|
||||
return `http://127.0.0.1:${cdpPort}`;
|
||||
}
|
||||
|
||||
function setDeep(obj: Record<string, unknown>, keys: string[], value: unknown) {
|
||||
let node: Record<string, unknown> = obj;
|
||||
for (const key of keys.slice(0, -1)) {
|
||||
const next = node[key];
|
||||
if (typeof next !== "object" || next === null || Array.isArray(next)) {
|
||||
node[key] = {};
|
||||
}
|
||||
node = node[key] as Record<string, unknown>;
|
||||
}
|
||||
node[keys[keys.length - 1] ?? ""] = value;
|
||||
}
|
||||
|
||||
function parseHexRgbToSignedArgbInt(hex: string): number | null {
|
||||
const cleaned = hex.trim().replace(/^#/, "");
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(cleaned)) return null;
|
||||
const rgb = Number.parseInt(cleaned, 16);
|
||||
const argbUnsigned = (0xff << 24) | rgb;
|
||||
// Chrome stores colors as signed 32-bit ints (SkColor).
|
||||
return argbUnsigned > 0x7fffffff
|
||||
? argbUnsigned - 0x1_0000_0000
|
||||
: argbUnsigned;
|
||||
}
|
||||
|
||||
function isProfileDecorated(
|
||||
userDataDir: string,
|
||||
desiredName: string,
|
||||
desiredColorHex: string,
|
||||
): boolean {
|
||||
const desiredColorInt = parseHexRgbToSignedArgbInt(desiredColorHex);
|
||||
|
||||
const localStatePath = path.join(userDataDir, "Local State");
|
||||
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
|
||||
|
||||
const localState = safeReadJson(localStatePath);
|
||||
const profile = localState?.profile;
|
||||
const infoCache =
|
||||
typeof profile === "object" && profile !== null && !Array.isArray(profile)
|
||||
? (profile as Record<string, unknown>).info_cache
|
||||
: null;
|
||||
const info =
|
||||
typeof infoCache === "object" &&
|
||||
infoCache !== null &&
|
||||
!Array.isArray(infoCache) &&
|
||||
typeof (infoCache as Record<string, unknown>).Default === "object" &&
|
||||
(infoCache as Record<string, unknown>).Default !== null &&
|
||||
!Array.isArray((infoCache as Record<string, unknown>).Default)
|
||||
? ((infoCache as Record<string, unknown>).Default as Record<
|
||||
string,
|
||||
unknown
|
||||
>)
|
||||
: null;
|
||||
|
||||
const prefs = safeReadJson(preferencesPath);
|
||||
const browserTheme = (() => {
|
||||
const browser = prefs?.browser;
|
||||
const theme =
|
||||
typeof browser === "object" && browser !== null && !Array.isArray(browser)
|
||||
? (browser as Record<string, unknown>).theme
|
||||
: null;
|
||||
return typeof theme === "object" && theme !== null && !Array.isArray(theme)
|
||||
? (theme as Record<string, unknown>)
|
||||
: null;
|
||||
})();
|
||||
|
||||
const autogeneratedTheme = (() => {
|
||||
const autogenerated = prefs?.autogenerated;
|
||||
const theme =
|
||||
typeof autogenerated === "object" &&
|
||||
autogenerated !== null &&
|
||||
!Array.isArray(autogenerated)
|
||||
? (autogenerated as Record<string, unknown>).theme
|
||||
: null;
|
||||
return typeof theme === "object" && theme !== null && !Array.isArray(theme)
|
||||
? (theme as Record<string, unknown>)
|
||||
: null;
|
||||
})();
|
||||
|
||||
const nameOk =
|
||||
typeof info?.name === "string" ? info.name === desiredName : true;
|
||||
|
||||
if (desiredColorInt == null) {
|
||||
// If the user provided a non-#RRGGBB value, we can only do best-effort.
|
||||
return nameOk;
|
||||
}
|
||||
|
||||
const localSeedOk =
|
||||
typeof info?.profile_color_seed === "number"
|
||||
? info.profile_color_seed === desiredColorInt
|
||||
: false;
|
||||
|
||||
const prefOk =
|
||||
(typeof browserTheme?.user_color2 === "number" &&
|
||||
browserTheme.user_color2 === desiredColorInt) ||
|
||||
(typeof autogeneratedTheme?.color === "number" &&
|
||||
autogeneratedTheme.color === desiredColorInt);
|
||||
|
||||
return nameOk && localSeedOk && prefOk;
|
||||
}
|
||||
/**
|
||||
* Best-effort profile decoration (name + lobster-orange). Chrome preference keys
|
||||
* vary by version; we keep this conservative and idempotent.
|
||||
*/
|
||||
export function decorateClawdProfile(
|
||||
userDataDir: string,
|
||||
opts?: { name?: string; color?: string },
|
||||
) {
|
||||
const desiredName = opts?.name ?? DEFAULT_CLAWD_BROWSER_PROFILE_NAME;
|
||||
const desiredColor = (
|
||||
opts?.color ?? DEFAULT_CLAWD_BROWSER_COLOR
|
||||
).toUpperCase();
|
||||
const desiredColorInt = parseHexRgbToSignedArgbInt(desiredColor);
|
||||
|
||||
const localStatePath = path.join(userDataDir, "Local State");
|
||||
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
|
||||
|
||||
const localState = safeReadJson(localStatePath) ?? {};
|
||||
// Common-ish shape: profile.info_cache.Default
|
||||
setDeep(
|
||||
localState,
|
||||
["profile", "info_cache", "Default", "name"],
|
||||
desiredName,
|
||||
);
|
||||
setDeep(
|
||||
localState,
|
||||
["profile", "info_cache", "Default", "shortcut_name"],
|
||||
desiredName,
|
||||
);
|
||||
setDeep(
|
||||
localState,
|
||||
["profile", "info_cache", "Default", "user_name"],
|
||||
desiredName,
|
||||
);
|
||||
// Color keys are best-effort (Chrome changes these frequently).
|
||||
setDeep(
|
||||
localState,
|
||||
["profile", "info_cache", "Default", "profile_color"],
|
||||
desiredColor,
|
||||
);
|
||||
setDeep(
|
||||
localState,
|
||||
["profile", "info_cache", "Default", "user_color"],
|
||||
desiredColor,
|
||||
);
|
||||
if (desiredColorInt != null) {
|
||||
// These are the fields Chrome actually uses for profile/avatar tinting.
|
||||
setDeep(
|
||||
localState,
|
||||
["profile", "info_cache", "Default", "profile_color_seed"],
|
||||
desiredColorInt,
|
||||
);
|
||||
setDeep(
|
||||
localState,
|
||||
["profile", "info_cache", "Default", "profile_highlight_color"],
|
||||
desiredColorInt,
|
||||
);
|
||||
setDeep(
|
||||
localState,
|
||||
["profile", "info_cache", "Default", "default_avatar_fill_color"],
|
||||
desiredColorInt,
|
||||
);
|
||||
setDeep(
|
||||
localState,
|
||||
["profile", "info_cache", "Default", "default_avatar_stroke_color"],
|
||||
desiredColorInt,
|
||||
);
|
||||
}
|
||||
safeWriteJson(localStatePath, localState);
|
||||
|
||||
const prefs = safeReadJson(preferencesPath) ?? {};
|
||||
setDeep(prefs, ["profile", "name"], desiredName);
|
||||
setDeep(prefs, ["profile", "profile_color"], desiredColor);
|
||||
setDeep(prefs, ["profile", "user_color"], desiredColor);
|
||||
if (desiredColorInt != null) {
|
||||
// Chrome refresh stores the autogenerated theme in these prefs (SkColor ints).
|
||||
setDeep(prefs, ["autogenerated", "theme", "color"], desiredColorInt);
|
||||
// User-selected browser theme color (pref name: browser.theme.user_color2).
|
||||
setDeep(prefs, ["browser", "theme", "user_color2"], desiredColorInt);
|
||||
}
|
||||
safeWriteJson(preferencesPath, prefs);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
decoratedMarkerPath(userDataDir),
|
||||
`${Date.now()}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export async function isChromeReachable(
|
||||
cdpUrl: string,
|
||||
timeoutMs = 500,
|
||||
|
||||
160
src/browser/pw-tools-core.part-1.test.ts
Normal file
160
src/browser/pw-tools-core.part-1.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let currentPage: Record<string, unknown> | null = null;
|
||||
let currentRefLocator: Record<string, unknown> | null = null;
|
||||
let pageState: {
|
||||
console: unknown[];
|
||||
armIdUpload: number;
|
||||
armIdDialog: number;
|
||||
armIdDownload: number;
|
||||
};
|
||||
|
||||
const sessionMocks = vi.hoisted(() => ({
|
||||
getPageForTargetId: vi.fn(async () => {
|
||||
if (!currentPage) throw new Error("missing page");
|
||||
return currentPage;
|
||||
}),
|
||||
ensurePageState: vi.fn(() => pageState),
|
||||
refLocator: vi.fn(() => {
|
||||
if (!currentRefLocator) throw new Error("missing locator");
|
||||
return currentRefLocator;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./pw-session.js", () => sessionMocks);
|
||||
|
||||
async function importModule() {
|
||||
return await import("./pw-tools-core.js");
|
||||
}
|
||||
|
||||
describe("pw-tools-core", () => {
|
||||
beforeEach(() => {
|
||||
currentPage = null;
|
||||
currentRefLocator = null;
|
||||
pageState = {
|
||||
console: [],
|
||||
armIdUpload: 0,
|
||||
armIdDialog: 0,
|
||||
armIdDownload: 0,
|
||||
};
|
||||
for (const fn of Object.values(sessionMocks)) fn.mockClear();
|
||||
});
|
||||
|
||||
it("screenshots an element selector", async () => {
|
||||
const elementScreenshot = vi.fn(async () => Buffer.from("E"));
|
||||
currentPage = {
|
||||
locator: vi.fn(() => ({
|
||||
first: () => ({ screenshot: elementScreenshot }),
|
||||
})),
|
||||
screenshot: vi.fn(async () => Buffer.from("P")),
|
||||
};
|
||||
|
||||
const mod = await importModule();
|
||||
const res = await mod.takeScreenshotViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
element: "#main",
|
||||
type: "png",
|
||||
});
|
||||
|
||||
expect(res.buffer.toString()).toBe("E");
|
||||
expect(sessionMocks.getPageForTargetId).toHaveBeenCalled();
|
||||
expect(
|
||||
currentPage.locator as ReturnType<typeof vi.fn>,
|
||||
).toHaveBeenCalledWith("#main");
|
||||
expect(elementScreenshot).toHaveBeenCalledWith({ type: "png" });
|
||||
});
|
||||
it("screenshots a ref locator", async () => {
|
||||
const refScreenshot = vi.fn(async () => Buffer.from("R"));
|
||||
currentRefLocator = { screenshot: refScreenshot };
|
||||
currentPage = {
|
||||
locator: vi.fn(),
|
||||
screenshot: vi.fn(async () => Buffer.from("P")),
|
||||
};
|
||||
|
||||
const mod = await importModule();
|
||||
const res = await mod.takeScreenshotViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: "76",
|
||||
type: "jpeg",
|
||||
});
|
||||
|
||||
expect(res.buffer.toString()).toBe("R");
|
||||
expect(sessionMocks.refLocator).toHaveBeenCalledWith(currentPage, "76");
|
||||
expect(refScreenshot).toHaveBeenCalledWith({ type: "jpeg" });
|
||||
});
|
||||
it("rejects fullPage for element or ref screenshots", async () => {
|
||||
currentRefLocator = { screenshot: vi.fn(async () => Buffer.from("R")) };
|
||||
currentPage = {
|
||||
locator: vi.fn(() => ({
|
||||
first: () => ({ screenshot: vi.fn(async () => Buffer.from("E")) }),
|
||||
})),
|
||||
screenshot: vi.fn(async () => Buffer.from("P")),
|
||||
};
|
||||
|
||||
const mod = await importModule();
|
||||
|
||||
await expect(
|
||||
mod.takeScreenshotViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
element: "#x",
|
||||
fullPage: true,
|
||||
}),
|
||||
).rejects.toThrow(/fullPage is not supported/i);
|
||||
|
||||
await expect(
|
||||
mod.takeScreenshotViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: "1",
|
||||
fullPage: true,
|
||||
}),
|
||||
).rejects.toThrow(/fullPage is not supported/i);
|
||||
});
|
||||
it("arms the next file chooser and sets files (default timeout)", async () => {
|
||||
const fileChooser = { setFiles: vi.fn(async () => {}) };
|
||||
const waitForEvent = vi.fn(
|
||||
async (_event: string, _opts: unknown) => fileChooser,
|
||||
);
|
||||
currentPage = {
|
||||
waitForEvent,
|
||||
keyboard: { press: vi.fn(async () => {}) },
|
||||
};
|
||||
|
||||
const mod = await importModule();
|
||||
await mod.armFileUploadViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
paths: ["/tmp/a.txt"],
|
||||
});
|
||||
|
||||
// waitForEvent is awaited immediately; handler continues async.
|
||||
await Promise.resolve();
|
||||
|
||||
expect(waitForEvent).toHaveBeenCalledWith("filechooser", {
|
||||
timeout: 120_000,
|
||||
});
|
||||
expect(fileChooser.setFiles).toHaveBeenCalledWith(["/tmp/a.txt"]);
|
||||
});
|
||||
it("arms the next file chooser and escapes if no paths provided", async () => {
|
||||
const fileChooser = { setFiles: vi.fn(async () => {}) };
|
||||
const press = vi.fn(async () => {});
|
||||
const waitForEvent = vi.fn(async () => fileChooser);
|
||||
currentPage = {
|
||||
waitForEvent,
|
||||
keyboard: { press },
|
||||
};
|
||||
|
||||
const mod = await importModule();
|
||||
await mod.armFileUploadViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
paths: [],
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fileChooser.setFiles).not.toHaveBeenCalled();
|
||||
expect(press).toHaveBeenCalledWith("Escape");
|
||||
});
|
||||
});
|
||||
167
src/browser/pw-tools-core.part-2.test.ts
Normal file
167
src/browser/pw-tools-core.part-2.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let currentPage: Record<string, unknown> | null = null;
|
||||
let currentRefLocator: Record<string, unknown> | null = null;
|
||||
let pageState: {
|
||||
console: unknown[];
|
||||
armIdUpload: number;
|
||||
armIdDialog: number;
|
||||
armIdDownload: number;
|
||||
};
|
||||
|
||||
const sessionMocks = vi.hoisted(() => ({
|
||||
getPageForTargetId: vi.fn(async () => {
|
||||
if (!currentPage) throw new Error("missing page");
|
||||
return currentPage;
|
||||
}),
|
||||
ensurePageState: vi.fn(() => pageState),
|
||||
refLocator: vi.fn(() => {
|
||||
if (!currentRefLocator) throw new Error("missing locator");
|
||||
return currentRefLocator;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./pw-session.js", () => sessionMocks);
|
||||
|
||||
async function importModule() {
|
||||
return await import("./pw-tools-core.js");
|
||||
}
|
||||
|
||||
describe("pw-tools-core", () => {
|
||||
beforeEach(() => {
|
||||
currentPage = null;
|
||||
currentRefLocator = null;
|
||||
pageState = {
|
||||
console: [],
|
||||
armIdUpload: 0,
|
||||
armIdDialog: 0,
|
||||
armIdDownload: 0,
|
||||
};
|
||||
for (const fn of Object.values(sessionMocks)) fn.mockClear();
|
||||
});
|
||||
|
||||
it("last file-chooser arm wins", async () => {
|
||||
let resolve1: ((value: unknown) => void) | null = null;
|
||||
let resolve2: ((value: unknown) => void) | null = null;
|
||||
|
||||
const fc1 = { setFiles: vi.fn(async () => {}) };
|
||||
const fc2 = { setFiles: vi.fn(async () => {}) };
|
||||
|
||||
const waitForEvent = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolve1 = r;
|
||||
}) as Promise<unknown>,
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolve2 = r;
|
||||
}) as Promise<unknown>,
|
||||
);
|
||||
|
||||
currentPage = {
|
||||
waitForEvent,
|
||||
keyboard: { press: vi.fn(async () => {}) },
|
||||
};
|
||||
|
||||
const mod = await importModule();
|
||||
await mod.armFileUploadViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
paths: ["/tmp/1"],
|
||||
});
|
||||
await mod.armFileUploadViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
paths: ["/tmp/2"],
|
||||
});
|
||||
|
||||
resolve1?.(fc1);
|
||||
resolve2?.(fc2);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fc1.setFiles).not.toHaveBeenCalled();
|
||||
expect(fc2.setFiles).toHaveBeenCalledWith(["/tmp/2"]);
|
||||
});
|
||||
it("arms the next dialog and accepts/dismisses (default timeout)", async () => {
|
||||
const accept = vi.fn(async () => {});
|
||||
const dismiss = vi.fn(async () => {});
|
||||
const dialog = { accept, dismiss };
|
||||
const waitForEvent = vi.fn(async () => dialog);
|
||||
currentPage = {
|
||||
waitForEvent,
|
||||
};
|
||||
|
||||
const mod = await importModule();
|
||||
await mod.armDialogViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
accept: true,
|
||||
promptText: "x",
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(waitForEvent).toHaveBeenCalledWith("dialog", { timeout: 120_000 });
|
||||
expect(accept).toHaveBeenCalledWith("x");
|
||||
expect(dismiss).not.toHaveBeenCalled();
|
||||
|
||||
accept.mockClear();
|
||||
dismiss.mockClear();
|
||||
waitForEvent.mockClear();
|
||||
|
||||
await mod.armDialogViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
accept: false,
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(waitForEvent).toHaveBeenCalledWith("dialog", { timeout: 120_000 });
|
||||
expect(dismiss).toHaveBeenCalled();
|
||||
expect(accept).not.toHaveBeenCalled();
|
||||
});
|
||||
it("waits for selector, url, load state, and function", async () => {
|
||||
const waitForSelector = vi.fn(async () => {});
|
||||
const waitForURL = vi.fn(async () => {});
|
||||
const waitForLoadState = vi.fn(async () => {});
|
||||
const waitForFunction = vi.fn(async () => {});
|
||||
const waitForTimeout = vi.fn(async () => {});
|
||||
|
||||
currentPage = {
|
||||
locator: vi.fn(() => ({
|
||||
first: () => ({ waitFor: waitForSelector }),
|
||||
})),
|
||||
waitForURL,
|
||||
waitForLoadState,
|
||||
waitForFunction,
|
||||
waitForTimeout,
|
||||
getByText: vi.fn(() => ({ first: () => ({ waitFor: vi.fn() }) })),
|
||||
};
|
||||
|
||||
const mod = await importModule();
|
||||
await mod.waitForViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
selector: "#main",
|
||||
url: "**/dash",
|
||||
loadState: "networkidle",
|
||||
fn: "window.ready===true",
|
||||
timeoutMs: 1234,
|
||||
timeMs: 50,
|
||||
});
|
||||
|
||||
expect(waitForTimeout).toHaveBeenCalledWith(50);
|
||||
expect(
|
||||
currentPage.locator as ReturnType<typeof vi.fn>,
|
||||
).toHaveBeenCalledWith("#main");
|
||||
expect(waitForSelector).toHaveBeenCalledWith({
|
||||
state: "visible",
|
||||
timeout: 1234,
|
||||
});
|
||||
expect(waitForURL).toHaveBeenCalledWith("**/dash", { timeout: 1234 });
|
||||
expect(waitForLoadState).toHaveBeenCalledWith("networkidle", {
|
||||
timeout: 1234,
|
||||
});
|
||||
expect(waitForFunction).toHaveBeenCalledWith("window.ready===true", {
|
||||
timeout: 1234,
|
||||
});
|
||||
});
|
||||
});
|
||||
178
src/browser/pw-tools-core.part-3.test.ts
Normal file
178
src/browser/pw-tools-core.part-3.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let currentPage: Record<string, unknown> | null = null;
|
||||
let currentRefLocator: Record<string, unknown> | null = null;
|
||||
let pageState: {
|
||||
console: unknown[];
|
||||
armIdUpload: number;
|
||||
armIdDialog: number;
|
||||
armIdDownload: number;
|
||||
};
|
||||
|
||||
const sessionMocks = vi.hoisted(() => ({
|
||||
getPageForTargetId: vi.fn(async () => {
|
||||
if (!currentPage) throw new Error("missing page");
|
||||
return currentPage;
|
||||
}),
|
||||
ensurePageState: vi.fn(() => pageState),
|
||||
refLocator: vi.fn(() => {
|
||||
if (!currentRefLocator) throw new Error("missing locator");
|
||||
return currentRefLocator;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./pw-session.js", () => sessionMocks);
|
||||
|
||||
async function importModule() {
|
||||
return await import("./pw-tools-core.js");
|
||||
}
|
||||
|
||||
describe("pw-tools-core", () => {
|
||||
beforeEach(() => {
|
||||
currentPage = null;
|
||||
currentRefLocator = null;
|
||||
pageState = {
|
||||
console: [],
|
||||
armIdUpload: 0,
|
||||
armIdDialog: 0,
|
||||
armIdDownload: 0,
|
||||
};
|
||||
for (const fn of Object.values(sessionMocks)) fn.mockClear();
|
||||
});
|
||||
|
||||
it("waits for the next download and saves it", async () => {
|
||||
let downloadHandler: ((download: unknown) => void) | undefined;
|
||||
const on = vi.fn((event: string, handler: (download: unknown) => void) => {
|
||||
if (event === "download") downloadHandler = handler;
|
||||
});
|
||||
const off = vi.fn();
|
||||
|
||||
const saveAs = vi.fn(async () => {});
|
||||
const download = {
|
||||
url: () => "https://example.com/file.bin",
|
||||
suggestedFilename: () => "file.bin",
|
||||
saveAs,
|
||||
};
|
||||
|
||||
currentPage = { on, off };
|
||||
|
||||
const mod = await importModule();
|
||||
const targetPath = path.resolve("/tmp/file.bin");
|
||||
const p = mod.waitForDownloadViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
path: targetPath,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(downloadHandler).toBeDefined();
|
||||
downloadHandler?.(download);
|
||||
|
||||
const res = await p;
|
||||
expect(saveAs).toHaveBeenCalledWith(targetPath);
|
||||
expect(res.path).toBe(targetPath);
|
||||
});
|
||||
it("clicks a ref and saves the resulting download", async () => {
|
||||
let downloadHandler: ((download: unknown) => void) | undefined;
|
||||
const on = vi.fn((event: string, handler: (download: unknown) => void) => {
|
||||
if (event === "download") downloadHandler = handler;
|
||||
});
|
||||
const off = vi.fn();
|
||||
|
||||
const click = vi.fn(async () => {});
|
||||
currentRefLocator = { click };
|
||||
|
||||
const saveAs = vi.fn(async () => {});
|
||||
const download = {
|
||||
url: () => "https://example.com/report.pdf",
|
||||
suggestedFilename: () => "report.pdf",
|
||||
saveAs,
|
||||
};
|
||||
|
||||
currentPage = { on, off };
|
||||
|
||||
const mod = await importModule();
|
||||
const targetPath = path.resolve("/tmp/report.pdf");
|
||||
const p = mod.downloadViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: "e12",
|
||||
path: targetPath,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(downloadHandler).toBeDefined();
|
||||
expect(click).toHaveBeenCalledWith({ timeout: 1000 });
|
||||
|
||||
downloadHandler?.(download);
|
||||
|
||||
const res = await p;
|
||||
expect(saveAs).toHaveBeenCalledWith(targetPath);
|
||||
expect(res.path).toBe(targetPath);
|
||||
});
|
||||
it("waits for a matching response and returns its body", async () => {
|
||||
let responseHandler: ((resp: unknown) => void) | undefined;
|
||||
const on = vi.fn((event: string, handler: (resp: unknown) => void) => {
|
||||
if (event === "response") responseHandler = handler;
|
||||
});
|
||||
const off = vi.fn();
|
||||
currentPage = { on, off };
|
||||
|
||||
const resp = {
|
||||
url: () => "https://example.com/api/data",
|
||||
status: () => 200,
|
||||
headers: () => ({ "content-type": "application/json" }),
|
||||
text: async () => '{"ok":true,"value":123}',
|
||||
};
|
||||
|
||||
const mod = await importModule();
|
||||
const p = mod.responseBodyViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
url: "**/api/data",
|
||||
timeoutMs: 1000,
|
||||
maxChars: 10,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(responseHandler).toBeDefined();
|
||||
responseHandler?.(resp);
|
||||
|
||||
const res = await p;
|
||||
expect(res.url).toBe("https://example.com/api/data");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toBe('{"ok":true');
|
||||
expect(res.truncated).toBe(true);
|
||||
});
|
||||
it("scrolls a ref into view (default timeout)", async () => {
|
||||
const scrollIntoViewIfNeeded = vi.fn(async () => {});
|
||||
currentRefLocator = { scrollIntoViewIfNeeded };
|
||||
currentPage = {};
|
||||
|
||||
const mod = await importModule();
|
||||
await mod.scrollIntoViewViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: "1",
|
||||
});
|
||||
|
||||
expect(sessionMocks.refLocator).toHaveBeenCalledWith(currentPage, "1");
|
||||
expect(scrollIntoViewIfNeeded).toHaveBeenCalledWith({ timeout: 20_000 });
|
||||
});
|
||||
it("requires a ref for scrollIntoView", async () => {
|
||||
currentRefLocator = { scrollIntoViewIfNeeded: vi.fn(async () => {}) };
|
||||
currentPage = {};
|
||||
|
||||
const mod = await importModule();
|
||||
await expect(
|
||||
mod.scrollIntoViewViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: " ",
|
||||
}),
|
||||
).rejects.toThrow(/ref is required/i);
|
||||
});
|
||||
});
|
||||
148
src/browser/pw-tools-core.part-4.test.ts
Normal file
148
src/browser/pw-tools-core.part-4.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let currentPage: Record<string, unknown> | null = null;
|
||||
let currentRefLocator: Record<string, unknown> | null = null;
|
||||
let pageState: {
|
||||
console: unknown[];
|
||||
armIdUpload: number;
|
||||
armIdDialog: number;
|
||||
armIdDownload: number;
|
||||
};
|
||||
|
||||
const sessionMocks = vi.hoisted(() => ({
|
||||
getPageForTargetId: vi.fn(async () => {
|
||||
if (!currentPage) throw new Error("missing page");
|
||||
return currentPage;
|
||||
}),
|
||||
ensurePageState: vi.fn(() => pageState),
|
||||
refLocator: vi.fn(() => {
|
||||
if (!currentRefLocator) throw new Error("missing locator");
|
||||
return currentRefLocator;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./pw-session.js", () => sessionMocks);
|
||||
|
||||
async function importModule() {
|
||||
return await import("./pw-tools-core.js");
|
||||
}
|
||||
|
||||
describe("pw-tools-core", () => {
|
||||
beforeEach(() => {
|
||||
currentPage = null;
|
||||
currentRefLocator = null;
|
||||
pageState = {
|
||||
console: [],
|
||||
armIdUpload: 0,
|
||||
armIdDialog: 0,
|
||||
armIdDownload: 0,
|
||||
};
|
||||
for (const fn of Object.values(sessionMocks)) fn.mockClear();
|
||||
});
|
||||
|
||||
it("clamps timeoutMs for scrollIntoView", async () => {
|
||||
const scrollIntoViewIfNeeded = vi.fn(async () => {});
|
||||
currentRefLocator = { scrollIntoViewIfNeeded };
|
||||
currentPage = {};
|
||||
|
||||
const mod = await importModule();
|
||||
await mod.scrollIntoViewViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: "1",
|
||||
timeoutMs: 50,
|
||||
});
|
||||
|
||||
expect(scrollIntoViewIfNeeded).toHaveBeenCalledWith({ timeout: 500 });
|
||||
});
|
||||
it("rewrites strict mode violations for scrollIntoView", async () => {
|
||||
const scrollIntoViewIfNeeded = vi.fn(async () => {
|
||||
throw new Error(
|
||||
'Error: strict mode violation: locator("aria-ref=1") resolved to 2 elements',
|
||||
);
|
||||
});
|
||||
currentRefLocator = { scrollIntoViewIfNeeded };
|
||||
currentPage = {};
|
||||
|
||||
const mod = await importModule();
|
||||
await expect(
|
||||
mod.scrollIntoViewViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: "1",
|
||||
}),
|
||||
).rejects.toThrow(/Run a new snapshot/i);
|
||||
});
|
||||
it("rewrites not-visible timeouts for scrollIntoView", async () => {
|
||||
const scrollIntoViewIfNeeded = vi.fn(async () => {
|
||||
throw new Error(
|
||||
'Timeout 5000ms exceeded. waiting for locator("aria-ref=1") to be visible',
|
||||
);
|
||||
});
|
||||
currentRefLocator = { scrollIntoViewIfNeeded };
|
||||
currentPage = {};
|
||||
|
||||
const mod = await importModule();
|
||||
await expect(
|
||||
mod.scrollIntoViewViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: "1",
|
||||
}),
|
||||
).rejects.toThrow(/not found or not visible/i);
|
||||
});
|
||||
it("rewrites strict mode violations into snapshot hints", async () => {
|
||||
const click = vi.fn(async () => {
|
||||
throw new Error(
|
||||
'Error: strict mode violation: locator("aria-ref=1") resolved to 2 elements',
|
||||
);
|
||||
});
|
||||
currentRefLocator = { click };
|
||||
currentPage = {};
|
||||
|
||||
const mod = await importModule();
|
||||
await expect(
|
||||
mod.clickViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: "1",
|
||||
}),
|
||||
).rejects.toThrow(/Run a new snapshot/i);
|
||||
});
|
||||
it("rewrites not-visible timeouts into snapshot hints", async () => {
|
||||
const click = vi.fn(async () => {
|
||||
throw new Error(
|
||||
'Timeout 5000ms exceeded. waiting for locator("aria-ref=1") to be visible',
|
||||
);
|
||||
});
|
||||
currentRefLocator = { click };
|
||||
currentPage = {};
|
||||
|
||||
const mod = await importModule();
|
||||
await expect(
|
||||
mod.clickViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: "1",
|
||||
}),
|
||||
).rejects.toThrow(/not found or not visible/i);
|
||||
});
|
||||
it("rewrites covered/hidden errors into interactable hints", async () => {
|
||||
const click = vi.fn(async () => {
|
||||
throw new Error(
|
||||
"Element is not receiving pointer events because another element intercepts pointer events",
|
||||
);
|
||||
});
|
||||
currentRefLocator = { click };
|
||||
currentPage = {};
|
||||
|
||||
const mod = await importModule();
|
||||
await expect(
|
||||
mod.clickViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: "1",
|
||||
}),
|
||||
).rejects.toThrow(/not interactable/i);
|
||||
});
|
||||
});
|
||||
@@ -1,542 +0,0 @@
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let currentPage: Record<string, unknown> | null = null;
|
||||
let currentRefLocator: Record<string, unknown> | null = null;
|
||||
let pageState: {
|
||||
console: unknown[];
|
||||
armIdUpload: number;
|
||||
armIdDialog: number;
|
||||
armIdDownload: number;
|
||||
};
|
||||
|
||||
const sessionMocks = vi.hoisted(() => ({
|
||||
getPageForTargetId: vi.fn(async () => {
|
||||
if (!currentPage) throw new Error("missing page");
|
||||
return currentPage;
|
||||
}),
|
||||
ensurePageState: vi.fn(() => pageState),
|
||||
refLocator: vi.fn(() => {
|
||||
if (!currentRefLocator) throw new Error("missing locator");
|
||||
return currentRefLocator;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./pw-session.js", () => sessionMocks);
|
||||
|
||||
async function importModule() {
|
||||
return await import("./pw-tools-core.js");
|
||||
}
|
||||
|
||||
describe("pw-tools-core", () => {
|
||||
beforeEach(() => {
|
||||
currentPage = null;
|
||||
currentRefLocator = null;
|
||||
pageState = {
|
||||
console: [],
|
||||
armIdUpload: 0,
|
||||
armIdDialog: 0,
|
||||
armIdDownload: 0,
|
||||
};
|
||||
for (const fn of Object.values(sessionMocks)) fn.mockClear();
|
||||
});
|
||||
|
||||
it("screenshots an element selector", async () => {
|
||||
const elementScreenshot = vi.fn(async () => Buffer.from("E"));
|
||||
currentPage = {
|
||||
locator: vi.fn(() => ({
|
||||
first: () => ({ screenshot: elementScreenshot }),
|
||||
})),
|
||||
screenshot: vi.fn(async () => Buffer.from("P")),
|
||||
};
|
||||
|
||||
const mod = await importModule();
|
||||
const res = await mod.takeScreenshotViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
element: "#main",
|
||||
type: "png",
|
||||
});
|
||||
|
||||
expect(res.buffer.toString()).toBe("E");
|
||||
expect(sessionMocks.getPageForTargetId).toHaveBeenCalled();
|
||||
expect(
|
||||
currentPage.locator as ReturnType<typeof vi.fn>,
|
||||
).toHaveBeenCalledWith("#main");
|
||||
expect(elementScreenshot).toHaveBeenCalledWith({ type: "png" });
|
||||
});
|
||||
|
||||
it("screenshots a ref locator", async () => {
|
||||
const refScreenshot = vi.fn(async () => Buffer.from("R"));
|
||||
currentRefLocator = { screenshot: refScreenshot };
|
||||
currentPage = {
|
||||
locator: vi.fn(),
|
||||
screenshot: vi.fn(async () => Buffer.from("P")),
|
||||
};
|
||||
|
||||
const mod = await importModule();
|
||||
const res = await mod.takeScreenshotViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: "76",
|
||||
type: "jpeg",
|
||||
});
|
||||
|
||||
expect(res.buffer.toString()).toBe("R");
|
||||
expect(sessionMocks.refLocator).toHaveBeenCalledWith(currentPage, "76");
|
||||
expect(refScreenshot).toHaveBeenCalledWith({ type: "jpeg" });
|
||||
});
|
||||
|
||||
it("rejects fullPage for element or ref screenshots", async () => {
|
||||
currentRefLocator = { screenshot: vi.fn(async () => Buffer.from("R")) };
|
||||
currentPage = {
|
||||
locator: vi.fn(() => ({
|
||||
first: () => ({ screenshot: vi.fn(async () => Buffer.from("E")) }),
|
||||
})),
|
||||
screenshot: vi.fn(async () => Buffer.from("P")),
|
||||
};
|
||||
|
||||
const mod = await importModule();
|
||||
|
||||
await expect(
|
||||
mod.takeScreenshotViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
element: "#x",
|
||||
fullPage: true,
|
||||
}),
|
||||
).rejects.toThrow(/fullPage is not supported/i);
|
||||
|
||||
await expect(
|
||||
mod.takeScreenshotViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: "1",
|
||||
fullPage: true,
|
||||
}),
|
||||
).rejects.toThrow(/fullPage is not supported/i);
|
||||
});
|
||||
|
||||
it("arms the next file chooser and sets files (default timeout)", async () => {
|
||||
const fileChooser = { setFiles: vi.fn(async () => {}) };
|
||||
const waitForEvent = vi.fn(
|
||||
async (_event: string, _opts: unknown) => fileChooser,
|
||||
);
|
||||
currentPage = {
|
||||
waitForEvent,
|
||||
keyboard: { press: vi.fn(async () => {}) },
|
||||
};
|
||||
|
||||
const mod = await importModule();
|
||||
await mod.armFileUploadViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
paths: ["/tmp/a.txt"],
|
||||
});
|
||||
|
||||
// waitForEvent is awaited immediately; handler continues async.
|
||||
await Promise.resolve();
|
||||
|
||||
expect(waitForEvent).toHaveBeenCalledWith("filechooser", {
|
||||
timeout: 120_000,
|
||||
});
|
||||
expect(fileChooser.setFiles).toHaveBeenCalledWith(["/tmp/a.txt"]);
|
||||
});
|
||||
|
||||
it("arms the next file chooser and escapes if no paths provided", async () => {
|
||||
const fileChooser = { setFiles: vi.fn(async () => {}) };
|
||||
const press = vi.fn(async () => {});
|
||||
const waitForEvent = vi.fn(async () => fileChooser);
|
||||
currentPage = {
|
||||
waitForEvent,
|
||||
keyboard: { press },
|
||||
};
|
||||
|
||||
const mod = await importModule();
|
||||
await mod.armFileUploadViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
paths: [],
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fileChooser.setFiles).not.toHaveBeenCalled();
|
||||
expect(press).toHaveBeenCalledWith("Escape");
|
||||
});
|
||||
|
||||
it("last file-chooser arm wins", async () => {
|
||||
let resolve1: ((value: unknown) => void) | null = null;
|
||||
let resolve2: ((value: unknown) => void) | null = null;
|
||||
|
||||
const fc1 = { setFiles: vi.fn(async () => {}) };
|
||||
const fc2 = { setFiles: vi.fn(async () => {}) };
|
||||
|
||||
const waitForEvent = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolve1 = r;
|
||||
}) as Promise<unknown>,
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolve2 = r;
|
||||
}) as Promise<unknown>,
|
||||
);
|
||||
|
||||
currentPage = {
|
||||
waitForEvent,
|
||||
keyboard: { press: vi.fn(async () => {}) },
|
||||
};
|
||||
|
||||
const mod = await importModule();
|
||||
await mod.armFileUploadViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
paths: ["/tmp/1"],
|
||||
});
|
||||
await mod.armFileUploadViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
paths: ["/tmp/2"],
|
||||
});
|
||||
|
||||
resolve1?.(fc1);
|
||||
resolve2?.(fc2);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fc1.setFiles).not.toHaveBeenCalled();
|
||||
expect(fc2.setFiles).toHaveBeenCalledWith(["/tmp/2"]);
|
||||
});
|
||||
|
||||
it("arms the next dialog and accepts/dismisses (default timeout)", async () => {
|
||||
const accept = vi.fn(async () => {});
|
||||
const dismiss = vi.fn(async () => {});
|
||||
const dialog = { accept, dismiss };
|
||||
const waitForEvent = vi.fn(async () => dialog);
|
||||
currentPage = {
|
||||
waitForEvent,
|
||||
};
|
||||
|
||||
const mod = await importModule();
|
||||
await mod.armDialogViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
accept: true,
|
||||
promptText: "x",
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(waitForEvent).toHaveBeenCalledWith("dialog", { timeout: 120_000 });
|
||||
expect(accept).toHaveBeenCalledWith("x");
|
||||
expect(dismiss).not.toHaveBeenCalled();
|
||||
|
||||
accept.mockClear();
|
||||
dismiss.mockClear();
|
||||
waitForEvent.mockClear();
|
||||
|
||||
await mod.armDialogViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
accept: false,
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(waitForEvent).toHaveBeenCalledWith("dialog", { timeout: 120_000 });
|
||||
expect(dismiss).toHaveBeenCalled();
|
||||
expect(accept).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for selector, url, load state, and function", async () => {
|
||||
const waitForSelector = vi.fn(async () => {});
|
||||
const waitForURL = vi.fn(async () => {});
|
||||
const waitForLoadState = vi.fn(async () => {});
|
||||
const waitForFunction = vi.fn(async () => {});
|
||||
const waitForTimeout = vi.fn(async () => {});
|
||||
|
||||
currentPage = {
|
||||
locator: vi.fn(() => ({
|
||||
first: () => ({ waitFor: waitForSelector }),
|
||||
})),
|
||||
waitForURL,
|
||||
waitForLoadState,
|
||||
waitForFunction,
|
||||
waitForTimeout,
|
||||
getByText: vi.fn(() => ({ first: () => ({ waitFor: vi.fn() }) })),
|
||||
};
|
||||
|
||||
const mod = await importModule();
|
||||
await mod.waitForViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
selector: "#main",
|
||||
url: "**/dash",
|
||||
loadState: "networkidle",
|
||||
fn: "window.ready===true",
|
||||
timeoutMs: 1234,
|
||||
timeMs: 50,
|
||||
});
|
||||
|
||||
expect(waitForTimeout).toHaveBeenCalledWith(50);
|
||||
expect(
|
||||
currentPage.locator as ReturnType<typeof vi.fn>,
|
||||
).toHaveBeenCalledWith("#main");
|
||||
expect(waitForSelector).toHaveBeenCalledWith({
|
||||
state: "visible",
|
||||
timeout: 1234,
|
||||
});
|
||||
expect(waitForURL).toHaveBeenCalledWith("**/dash", { timeout: 1234 });
|
||||
expect(waitForLoadState).toHaveBeenCalledWith("networkidle", {
|
||||
timeout: 1234,
|
||||
});
|
||||
expect(waitForFunction).toHaveBeenCalledWith("window.ready===true", {
|
||||
timeout: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for the next download and saves it", async () => {
|
||||
let downloadHandler: ((download: unknown) => void) | undefined;
|
||||
const on = vi.fn((event: string, handler: (download: unknown) => void) => {
|
||||
if (event === "download") downloadHandler = handler;
|
||||
});
|
||||
const off = vi.fn();
|
||||
|
||||
const saveAs = vi.fn(async () => {});
|
||||
const download = {
|
||||
url: () => "https://example.com/file.bin",
|
||||
suggestedFilename: () => "file.bin",
|
||||
saveAs,
|
||||
};
|
||||
|
||||
currentPage = { on, off };
|
||||
|
||||
const mod = await importModule();
|
||||
const targetPath = path.resolve("/tmp/file.bin");
|
||||
const p = mod.waitForDownloadViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
path: targetPath,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(downloadHandler).toBeDefined();
|
||||
downloadHandler?.(download);
|
||||
|
||||
const res = await p;
|
||||
expect(saveAs).toHaveBeenCalledWith(targetPath);
|
||||
expect(res.path).toBe(targetPath);
|
||||
});
|
||||
|
||||
it("clicks a ref and saves the resulting download", async () => {
|
||||
let downloadHandler: ((download: unknown) => void) | undefined;
|
||||
const on = vi.fn((event: string, handler: (download: unknown) => void) => {
|
||||
if (event === "download") downloadHandler = handler;
|
||||
});
|
||||
const off = vi.fn();
|
||||
|
||||
const click = vi.fn(async () => {});
|
||||
currentRefLocator = { click };
|
||||
|
||||
const saveAs = vi.fn(async () => {});
|
||||
const download = {
|
||||
url: () => "https://example.com/report.pdf",
|
||||
suggestedFilename: () => "report.pdf",
|
||||
saveAs,
|
||||
};
|
||||
|
||||
currentPage = { on, off };
|
||||
|
||||
const mod = await importModule();
|
||||
const targetPath = path.resolve("/tmp/report.pdf");
|
||||
const p = mod.downloadViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: "e12",
|
||||
path: targetPath,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(downloadHandler).toBeDefined();
|
||||
expect(click).toHaveBeenCalledWith({ timeout: 1000 });
|
||||
|
||||
downloadHandler?.(download);
|
||||
|
||||
const res = await p;
|
||||
expect(saveAs).toHaveBeenCalledWith(targetPath);
|
||||
expect(res.path).toBe(targetPath);
|
||||
});
|
||||
|
||||
it("waits for a matching response and returns its body", async () => {
|
||||
let responseHandler: ((resp: unknown) => void) | undefined;
|
||||
const on = vi.fn((event: string, handler: (resp: unknown) => void) => {
|
||||
if (event === "response") responseHandler = handler;
|
||||
});
|
||||
const off = vi.fn();
|
||||
currentPage = { on, off };
|
||||
|
||||
const resp = {
|
||||
url: () => "https://example.com/api/data",
|
||||
status: () => 200,
|
||||
headers: () => ({ "content-type": "application/json" }),
|
||||
text: async () => '{"ok":true,"value":123}',
|
||||
};
|
||||
|
||||
const mod = await importModule();
|
||||
const p = mod.responseBodyViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
url: "**/api/data",
|
||||
timeoutMs: 1000,
|
||||
maxChars: 10,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(responseHandler).toBeDefined();
|
||||
responseHandler?.(resp);
|
||||
|
||||
const res = await p;
|
||||
expect(res.url).toBe("https://example.com/api/data");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toBe('{"ok":true');
|
||||
expect(res.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("scrolls a ref into view (default timeout)", async () => {
|
||||
const scrollIntoViewIfNeeded = vi.fn(async () => {});
|
||||
currentRefLocator = { scrollIntoViewIfNeeded };
|
||||
currentPage = {};
|
||||
|
||||
const mod = await importModule();
|
||||
await mod.scrollIntoViewViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: "1",
|
||||
});
|
||||
|
||||
expect(sessionMocks.refLocator).toHaveBeenCalledWith(currentPage, "1");
|
||||
expect(scrollIntoViewIfNeeded).toHaveBeenCalledWith({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
it("requires a ref for scrollIntoView", async () => {
|
||||
currentRefLocator = { scrollIntoViewIfNeeded: vi.fn(async () => {}) };
|
||||
currentPage = {};
|
||||
|
||||
const mod = await importModule();
|
||||
await expect(
|
||||
mod.scrollIntoViewViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: " ",
|
||||
}),
|
||||
).rejects.toThrow(/ref is required/i);
|
||||
});
|
||||
|
||||
it("clamps timeoutMs for scrollIntoView", async () => {
|
||||
const scrollIntoViewIfNeeded = vi.fn(async () => {});
|
||||
currentRefLocator = { scrollIntoViewIfNeeded };
|
||||
currentPage = {};
|
||||
|
||||
const mod = await importModule();
|
||||
await mod.scrollIntoViewViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: "1",
|
||||
timeoutMs: 50,
|
||||
});
|
||||
|
||||
expect(scrollIntoViewIfNeeded).toHaveBeenCalledWith({ timeout: 500 });
|
||||
});
|
||||
|
||||
it("rewrites strict mode violations for scrollIntoView", async () => {
|
||||
const scrollIntoViewIfNeeded = vi.fn(async () => {
|
||||
throw new Error(
|
||||
'Error: strict mode violation: locator("aria-ref=1") resolved to 2 elements',
|
||||
);
|
||||
});
|
||||
currentRefLocator = { scrollIntoViewIfNeeded };
|
||||
currentPage = {};
|
||||
|
||||
const mod = await importModule();
|
||||
await expect(
|
||||
mod.scrollIntoViewViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: "1",
|
||||
}),
|
||||
).rejects.toThrow(/Run a new snapshot/i);
|
||||
});
|
||||
|
||||
it("rewrites not-visible timeouts for scrollIntoView", async () => {
|
||||
const scrollIntoViewIfNeeded = vi.fn(async () => {
|
||||
throw new Error(
|
||||
'Timeout 5000ms exceeded. waiting for locator("aria-ref=1") to be visible',
|
||||
);
|
||||
});
|
||||
currentRefLocator = { scrollIntoViewIfNeeded };
|
||||
currentPage = {};
|
||||
|
||||
const mod = await importModule();
|
||||
await expect(
|
||||
mod.scrollIntoViewViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: "1",
|
||||
}),
|
||||
).rejects.toThrow(/not found or not visible/i);
|
||||
});
|
||||
|
||||
it("rewrites strict mode violations into snapshot hints", async () => {
|
||||
const click = vi.fn(async () => {
|
||||
throw new Error(
|
||||
'Error: strict mode violation: locator("aria-ref=1") resolved to 2 elements',
|
||||
);
|
||||
});
|
||||
currentRefLocator = { click };
|
||||
currentPage = {};
|
||||
|
||||
const mod = await importModule();
|
||||
await expect(
|
||||
mod.clickViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: "1",
|
||||
}),
|
||||
).rejects.toThrow(/Run a new snapshot/i);
|
||||
});
|
||||
|
||||
it("rewrites not-visible timeouts into snapshot hints", async () => {
|
||||
const click = vi.fn(async () => {
|
||||
throw new Error(
|
||||
'Timeout 5000ms exceeded. waiting for locator("aria-ref=1") to be visible',
|
||||
);
|
||||
});
|
||||
currentRefLocator = { click };
|
||||
currentPage = {};
|
||||
|
||||
const mod = await importModule();
|
||||
await expect(
|
||||
mod.clickViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: "1",
|
||||
}),
|
||||
).rejects.toThrow(/not found or not visible/i);
|
||||
});
|
||||
|
||||
it("rewrites covered/hidden errors into interactable hints", async () => {
|
||||
const click = vi.fn(async () => {
|
||||
throw new Error(
|
||||
"Element is not receiving pointer events because another element intercepts pointer events",
|
||||
);
|
||||
});
|
||||
currentRefLocator = { click };
|
||||
currentPage = {};
|
||||
|
||||
const mod = await importModule();
|
||||
await expect(
|
||||
mod.clickViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "T1",
|
||||
ref: "1",
|
||||
}),
|
||||
).rejects.toThrow(/not interactable/i);
|
||||
});
|
||||
});
|
||||
@@ -1,92 +1,34 @@
|
||||
import fs from "node:fs";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
import { createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
|
||||
import {
|
||||
isChromeCdpReady,
|
||||
isChromeReachable,
|
||||
launchClawdChrome,
|
||||
type RunningChrome,
|
||||
resolveClawdUserDataDir,
|
||||
stopClawdChrome,
|
||||
} from "./chrome.js";
|
||||
import type { BrowserTab } from "./client.js";
|
||||
import type {
|
||||
ResolvedBrowserConfig,
|
||||
ResolvedBrowserProfile,
|
||||
} from "./config.js";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import { resolveProfile } from "./config.js";
|
||||
import type {
|
||||
BrowserRouteContext,
|
||||
BrowserTab,
|
||||
ContextOptions,
|
||||
ProfileContext,
|
||||
ProfileRuntimeState,
|
||||
ProfileStatus,
|
||||
} from "./server-context.types.js";
|
||||
import { resolveTargetIdFromTabs } from "./target-id.js";
|
||||
import { movePathToTrash } from "./trash.js";
|
||||
|
||||
export type { BrowserTab };
|
||||
|
||||
/**
|
||||
* Runtime state for a single profile's Chrome instance.
|
||||
*/
|
||||
export type ProfileRuntimeState = {
|
||||
profile: ResolvedBrowserProfile;
|
||||
running: RunningChrome | null;
|
||||
};
|
||||
|
||||
export type BrowserServerState = {
|
||||
server: Server;
|
||||
port: number;
|
||||
resolved: ResolvedBrowserConfig;
|
||||
profiles: Map<string, ProfileRuntimeState>;
|
||||
};
|
||||
|
||||
export type BrowserRouteContext = {
|
||||
state: () => BrowserServerState;
|
||||
forProfile: (profileName?: string) => ProfileContext;
|
||||
listProfiles: () => Promise<ProfileStatus[]>;
|
||||
// Legacy methods delegate to default profile for backward compatibility
|
||||
ensureBrowserAvailable: () => Promise<void>;
|
||||
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
|
||||
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
isReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
listTabs: () => Promise<BrowserTab[]>;
|
||||
openTab: (url: string) => Promise<BrowserTab>;
|
||||
focusTab: (targetId: string) => Promise<void>;
|
||||
closeTab: (targetId: string) => Promise<void>;
|
||||
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
|
||||
resetProfile: () => Promise<{
|
||||
moved: boolean;
|
||||
from: string;
|
||||
to?: string;
|
||||
}>;
|
||||
mapTabError: (err: unknown) => { status: number; message: string } | null;
|
||||
};
|
||||
|
||||
export type ProfileContext = {
|
||||
profile: ResolvedBrowserProfile;
|
||||
ensureBrowserAvailable: () => Promise<void>;
|
||||
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
|
||||
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
isReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
listTabs: () => Promise<BrowserTab[]>;
|
||||
openTab: (url: string) => Promise<BrowserTab>;
|
||||
focusTab: (targetId: string) => Promise<void>;
|
||||
closeTab: (targetId: string) => Promise<void>;
|
||||
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
|
||||
resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>;
|
||||
};
|
||||
|
||||
export type ProfileStatus = {
|
||||
name: string;
|
||||
cdpPort: number;
|
||||
cdpUrl: string;
|
||||
color: string;
|
||||
running: boolean;
|
||||
tabCount: number;
|
||||
isDefault: boolean;
|
||||
isRemote: boolean;
|
||||
};
|
||||
|
||||
type ContextOptions = {
|
||||
getState: () => BrowserServerState | null;
|
||||
onEnsureAttachTarget?: (profile: ResolvedBrowserProfile) => Promise<void>;
|
||||
};
|
||||
export type {
|
||||
BrowserRouteContext,
|
||||
BrowserServerState,
|
||||
BrowserTab,
|
||||
ProfileContext,
|
||||
ProfileRuntimeState,
|
||||
ProfileStatus,
|
||||
} from "./server-context.types.js";
|
||||
|
||||
/**
|
||||
* Normalize a CDP WebSocket URL to use the correct base URL.
|
||||
@@ -157,7 +99,7 @@ function createProfileContext(
|
||||
return profileState;
|
||||
};
|
||||
|
||||
const setProfileRunning = (running: RunningChrome | null) => {
|
||||
const setProfileRunning = (running: ProfileRuntimeState["running"]) => {
|
||||
const profileState = getProfileState();
|
||||
profileState.running = running;
|
||||
};
|
||||
@@ -241,7 +183,9 @@ function createProfileContext(
|
||||
return await isChromeReachable(profile.cdpUrl, timeoutMs);
|
||||
};
|
||||
|
||||
const attachRunning = (running: RunningChrome) => {
|
||||
const attachRunning = (
|
||||
running: NonNullable<ProfileRuntimeState["running"]>,
|
||||
) => {
|
||||
setProfileRunning(running);
|
||||
running.proc.on("exit", () => {
|
||||
// Guard against server teardown (e.g., SIGUSR1 restart)
|
||||
|
||||
77
src/browser/server-context.types.ts
Normal file
77
src/browser/server-context.types.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Server } from "node:http";
|
||||
|
||||
import type { RunningChrome } from "./chrome.js";
|
||||
import type { BrowserTab } from "./client.js";
|
||||
import type {
|
||||
ResolvedBrowserConfig,
|
||||
ResolvedBrowserProfile,
|
||||
} from "./config.js";
|
||||
|
||||
export type { BrowserTab };
|
||||
|
||||
/**
|
||||
* Runtime state for a single profile's Chrome instance.
|
||||
*/
|
||||
export type ProfileRuntimeState = {
|
||||
profile: ResolvedBrowserProfile;
|
||||
running: RunningChrome | null;
|
||||
};
|
||||
|
||||
export type BrowserServerState = {
|
||||
server: Server;
|
||||
port: number;
|
||||
resolved: ResolvedBrowserConfig;
|
||||
profiles: Map<string, ProfileRuntimeState>;
|
||||
};
|
||||
|
||||
export type BrowserRouteContext = {
|
||||
state: () => BrowserServerState;
|
||||
forProfile: (profileName?: string) => ProfileContext;
|
||||
listProfiles: () => Promise<ProfileStatus[]>;
|
||||
// Legacy methods delegate to default profile for backward compatibility
|
||||
ensureBrowserAvailable: () => Promise<void>;
|
||||
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
|
||||
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
isReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
listTabs: () => Promise<BrowserTab[]>;
|
||||
openTab: (url: string) => Promise<BrowserTab>;
|
||||
focusTab: (targetId: string) => Promise<void>;
|
||||
closeTab: (targetId: string) => Promise<void>;
|
||||
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
|
||||
resetProfile: () => Promise<{
|
||||
moved: boolean;
|
||||
from: string;
|
||||
to?: string;
|
||||
}>;
|
||||
mapTabError: (err: unknown) => { status: number; message: string } | null;
|
||||
};
|
||||
|
||||
export type ProfileContext = {
|
||||
profile: ResolvedBrowserProfile;
|
||||
ensureBrowserAvailable: () => Promise<void>;
|
||||
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
|
||||
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
isReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
listTabs: () => Promise<BrowserTab[]>;
|
||||
openTab: (url: string) => Promise<BrowserTab>;
|
||||
focusTab: (targetId: string) => Promise<void>;
|
||||
closeTab: (targetId: string) => Promise<void>;
|
||||
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
|
||||
resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>;
|
||||
};
|
||||
|
||||
export type ProfileStatus = {
|
||||
name: string;
|
||||
cdpPort: number;
|
||||
cdpUrl: string;
|
||||
color: string;
|
||||
running: boolean;
|
||||
tabCount: number;
|
||||
isDefault: boolean;
|
||||
isRemote: boolean;
|
||||
};
|
||||
|
||||
export type ContextOptions = {
|
||||
getState: () => BrowserServerState | null;
|
||||
onEnsureAttachTarget?: (profile: ResolvedBrowserProfile) => Promise<void>;
|
||||
};
|
||||
303
src/browser/server.part-1.test.ts
Normal file
303
src/browser/server.part-1.test.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { type AddressInfo, createServer } from "node:net";
|
||||
import { fetch as realFetch } from "undici";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let testPort = 0;
|
||||
let _cdpBaseUrl = "";
|
||||
let reachable = false;
|
||||
let cfgAttachOnly = false;
|
||||
let createTargetId: string | null = null;
|
||||
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
createTargetViaCdp: vi.fn(async () => {
|
||||
throw new Error("cdp disabled");
|
||||
}),
|
||||
snapshotAria: vi.fn(async () => ({
|
||||
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
|
||||
})),
|
||||
}));
|
||||
|
||||
const pwMocks = vi.hoisted(() => ({
|
||||
armDialogViaPlaywright: vi.fn(async () => {}),
|
||||
armFileUploadViaPlaywright: vi.fn(async () => {}),
|
||||
clickViaPlaywright: vi.fn(async () => {}),
|
||||
closePageViaPlaywright: vi.fn(async () => {}),
|
||||
closePlaywrightBrowserConnection: vi.fn(async () => {}),
|
||||
downloadViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/report.pdf",
|
||||
suggestedFilename: "report.pdf",
|
||||
path: "/tmp/report.pdf",
|
||||
})),
|
||||
dragViaPlaywright: vi.fn(async () => {}),
|
||||
evaluateViaPlaywright: vi.fn(async () => "ok"),
|
||||
fillFormViaPlaywright: vi.fn(async () => {}),
|
||||
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
|
||||
hoverViaPlaywright: vi.fn(async () => {}),
|
||||
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
|
||||
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
|
||||
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
|
||||
pressKeyViaPlaywright: vi.fn(async () => {}),
|
||||
responseBodyViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/api/data",
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: '{"ok":true}',
|
||||
})),
|
||||
resizeViewportViaPlaywright: vi.fn(async () => {}),
|
||||
selectOptionViaPlaywright: vi.fn(async () => {}),
|
||||
setInputFilesViaPlaywright: vi.fn(async () => {}),
|
||||
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
|
||||
takeScreenshotViaPlaywright: vi.fn(async () => ({
|
||||
buffer: Buffer.from("png"),
|
||||
})),
|
||||
typeViaPlaywright: vi.fn(async () => {}),
|
||||
waitForDownloadViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/report.pdf",
|
||||
suggestedFilename: "report.pdf",
|
||||
path: "/tmp/report.pdf",
|
||||
})),
|
||||
waitForViaPlaywright: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
function makeProc(pid = 123) {
|
||||
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
return {
|
||||
pid,
|
||||
killed: false,
|
||||
exitCode: null as number | null,
|
||||
on: (event: string, cb: (...args: unknown[]) => void) => {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
|
||||
return undefined;
|
||||
},
|
||||
emitExit: () => {
|
||||
for (const cb of handlers.get("exit") ?? []) cb(0);
|
||||
},
|
||||
kill: () => {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const proc = makeProc();
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||
color: "#FF4500",
|
||||
attachOnly: cfgAttachOnly,
|
||||
headless: true,
|
||||
defaultProfile: "clawd",
|
||||
profiles: {
|
||||
clawd: { cdpPort: testPort + 1, color: "#FF4500" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
writeConfigFile: vi.fn(async () => {}),
|
||||
};
|
||||
});
|
||||
|
||||
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
|
||||
vi.mock("./chrome.js", () => ({
|
||||
isChromeCdpReady: vi.fn(async () => reachable),
|
||||
isChromeReachable: vi.fn(async () => reachable),
|
||||
launchClawdChrome: vi.fn(
|
||||
async (_resolved: unknown, profile: { cdpPort: number }) => {
|
||||
launchCalls.push({ port: profile.cdpPort });
|
||||
reachable = true;
|
||||
return {
|
||||
pid: 123,
|
||||
exe: { kind: "chrome", path: "/fake/chrome" },
|
||||
userDataDir: "/tmp/clawd",
|
||||
cdpPort: profile.cdpPort,
|
||||
startedAt: Date.now(),
|
||||
proc,
|
||||
};
|
||||
},
|
||||
),
|
||||
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd"),
|
||||
stopClawdChrome: vi.fn(async () => {
|
||||
reachable = false;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./cdp.js", () => ({
|
||||
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
||||
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
||||
snapshotAria: cdpMocks.snapshotAria,
|
||||
}));
|
||||
|
||||
vi.mock("./pw-ai.js", () => pwMocks);
|
||||
|
||||
vi.mock("../media/store.js", () => ({
|
||||
ensureMediaDir: vi.fn(async () => {}),
|
||||
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
|
||||
}));
|
||||
|
||||
vi.mock("./screenshot.js", () => ({
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
|
||||
normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({
|
||||
buffer: buf,
|
||||
contentType: "image/png",
|
||||
})),
|
||||
}));
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
while (true) {
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
const s = createServer();
|
||||
s.once("error", reject);
|
||||
s.listen(0, "127.0.0.1", () => {
|
||||
const assigned = (s.address() as AddressInfo).port;
|
||||
s.close((err) => (err ? reject(err) : resolve(assigned)));
|
||||
});
|
||||
});
|
||||
if (port < 65535) return port;
|
||||
}
|
||||
}
|
||||
|
||||
function makeResponse(
|
||||
body: unknown,
|
||||
init?: { ok?: boolean; status?: number; text?: string },
|
||||
): Response {
|
||||
const ok = init?.ok ?? true;
|
||||
const status = init?.status ?? 200;
|
||||
const text = init?.text ?? "";
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
text: async () => text,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe("browser control server", () => {
|
||||
beforeEach(async () => {
|
||||
reachable = false;
|
||||
cfgAttachOnly = false;
|
||||
createTargetId = null;
|
||||
|
||||
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
|
||||
if (createTargetId) return { targetId: createTargetId };
|
||||
throw new Error("cdp disabled");
|
||||
});
|
||||
|
||||
for (const fn of Object.values(pwMocks)) fn.mockClear();
|
||||
for (const fn of Object.values(cdpMocks)) fn.mockClear();
|
||||
|
||||
testPort = await getFreePort();
|
||||
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
|
||||
// Minimal CDP JSON endpoints used by the server.
|
||||
let putNewCalls = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/json/list")) {
|
||||
if (!reachable) return makeResponse([]);
|
||||
return makeResponse([
|
||||
{
|
||||
id: "abcd1234",
|
||||
title: "Tab",
|
||||
url: "https://example.com",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
|
||||
type: "page",
|
||||
},
|
||||
{
|
||||
id: "abce9999",
|
||||
title: "Other",
|
||||
url: "https://other",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999",
|
||||
type: "page",
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (u.includes("/json/new?")) {
|
||||
if (init?.method === "PUT") {
|
||||
putNewCalls += 1;
|
||||
if (putNewCalls === 1) {
|
||||
return makeResponse({}, { ok: false, status: 405, text: "" });
|
||||
}
|
||||
}
|
||||
return makeResponse({
|
||||
id: "newtab1",
|
||||
title: "",
|
||||
url: "about:blank",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
|
||||
type: "page",
|
||||
});
|
||||
}
|
||||
if (u.includes("/json/activate/")) return makeResponse("ok");
|
||||
if (u.includes("/json/close/")) return makeResponse("ok");
|
||||
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
|
||||
it("serves status + starts browser when requested", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
const started = await startBrowserControlServerFromConfig();
|
||||
expect(started?.port).toBe(testPort);
|
||||
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
const s1 = (await realFetch(`${base}/`).then((r) => r.json())) as {
|
||||
running: boolean;
|
||||
pid: number | null;
|
||||
};
|
||||
expect(s1.running).toBe(false);
|
||||
expect(s1.pid).toBe(null);
|
||||
|
||||
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
|
||||
const s2 = (await realFetch(`${base}/`).then((r) => r.json())) as {
|
||||
running: boolean;
|
||||
pid: number | null;
|
||||
chosenBrowser: string | null;
|
||||
};
|
||||
expect(s2.running).toBe(true);
|
||||
expect(s2.pid).toBe(123);
|
||||
expect(s2.chosenBrowser).toBe("chrome");
|
||||
expect(launchCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("handles tabs: list, open, focus conflict on ambiguous prefix", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
|
||||
const tabs = (await realFetch(`${base}/tabs`).then((r) => r.json())) as {
|
||||
running: boolean;
|
||||
tabs: Array<{ targetId: string }>;
|
||||
};
|
||||
expect(tabs.running).toBe(true);
|
||||
expect(tabs.tabs.length).toBeGreaterThan(0);
|
||||
|
||||
const opened = await realFetch(`${base}/tabs/open`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: "https://example.com" }),
|
||||
}).then((r) => r.json());
|
||||
expect(opened).toMatchObject({ targetId: "newtab1" });
|
||||
|
||||
const focus = await realFetch(`${base}/tabs/focus`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: "abc" }),
|
||||
});
|
||||
expect(focus.status).toBe(409);
|
||||
});
|
||||
});
|
||||
395
src/browser/server.part-2.test.ts
Normal file
395
src/browser/server.part-2.test.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import { type AddressInfo, createServer } from "node:net";
|
||||
import { fetch as realFetch } from "undici";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./constants.js";
|
||||
|
||||
let testPort = 0;
|
||||
let cdpBaseUrl = "";
|
||||
let reachable = false;
|
||||
let cfgAttachOnly = false;
|
||||
let createTargetId: string | null = null;
|
||||
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
createTargetViaCdp: vi.fn(async () => {
|
||||
throw new Error("cdp disabled");
|
||||
}),
|
||||
snapshotAria: vi.fn(async () => ({
|
||||
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
|
||||
})),
|
||||
}));
|
||||
|
||||
const pwMocks = vi.hoisted(() => ({
|
||||
armDialogViaPlaywright: vi.fn(async () => {}),
|
||||
armFileUploadViaPlaywright: vi.fn(async () => {}),
|
||||
clickViaPlaywright: vi.fn(async () => {}),
|
||||
closePageViaPlaywright: vi.fn(async () => {}),
|
||||
closePlaywrightBrowserConnection: vi.fn(async () => {}),
|
||||
downloadViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/report.pdf",
|
||||
suggestedFilename: "report.pdf",
|
||||
path: "/tmp/report.pdf",
|
||||
})),
|
||||
dragViaPlaywright: vi.fn(async () => {}),
|
||||
evaluateViaPlaywright: vi.fn(async () => "ok"),
|
||||
fillFormViaPlaywright: vi.fn(async () => {}),
|
||||
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
|
||||
hoverViaPlaywright: vi.fn(async () => {}),
|
||||
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
|
||||
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
|
||||
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
|
||||
pressKeyViaPlaywright: vi.fn(async () => {}),
|
||||
responseBodyViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/api/data",
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: '{"ok":true}',
|
||||
})),
|
||||
resizeViewportViaPlaywright: vi.fn(async () => {}),
|
||||
selectOptionViaPlaywright: vi.fn(async () => {}),
|
||||
setInputFilesViaPlaywright: vi.fn(async () => {}),
|
||||
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
|
||||
takeScreenshotViaPlaywright: vi.fn(async () => ({
|
||||
buffer: Buffer.from("png"),
|
||||
})),
|
||||
typeViaPlaywright: vi.fn(async () => {}),
|
||||
waitForDownloadViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/report.pdf",
|
||||
suggestedFilename: "report.pdf",
|
||||
path: "/tmp/report.pdf",
|
||||
})),
|
||||
waitForViaPlaywright: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
function makeProc(pid = 123) {
|
||||
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
return {
|
||||
pid,
|
||||
killed: false,
|
||||
exitCode: null as number | null,
|
||||
on: (event: string, cb: (...args: unknown[]) => void) => {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
|
||||
return undefined;
|
||||
},
|
||||
emitExit: () => {
|
||||
for (const cb of handlers.get("exit") ?? []) cb(0);
|
||||
},
|
||||
kill: () => {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const proc = makeProc();
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||
color: "#FF4500",
|
||||
attachOnly: cfgAttachOnly,
|
||||
headless: true,
|
||||
defaultProfile: "clawd",
|
||||
profiles: {
|
||||
clawd: { cdpPort: testPort + 1, color: "#FF4500" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
writeConfigFile: vi.fn(async () => {}),
|
||||
};
|
||||
});
|
||||
|
||||
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
|
||||
vi.mock("./chrome.js", () => ({
|
||||
isChromeCdpReady: vi.fn(async () => reachable),
|
||||
isChromeReachable: vi.fn(async () => reachable),
|
||||
launchClawdChrome: vi.fn(
|
||||
async (_resolved: unknown, profile: { cdpPort: number }) => {
|
||||
launchCalls.push({ port: profile.cdpPort });
|
||||
reachable = true;
|
||||
return {
|
||||
pid: 123,
|
||||
exe: { kind: "chrome", path: "/fake/chrome" },
|
||||
userDataDir: "/tmp/clawd",
|
||||
cdpPort: profile.cdpPort,
|
||||
startedAt: Date.now(),
|
||||
proc,
|
||||
};
|
||||
},
|
||||
),
|
||||
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd"),
|
||||
stopClawdChrome: vi.fn(async () => {
|
||||
reachable = false;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./cdp.js", () => ({
|
||||
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
||||
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
||||
snapshotAria: cdpMocks.snapshotAria,
|
||||
}));
|
||||
|
||||
vi.mock("./pw-ai.js", () => pwMocks);
|
||||
|
||||
vi.mock("../media/store.js", () => ({
|
||||
ensureMediaDir: vi.fn(async () => {}),
|
||||
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
|
||||
}));
|
||||
|
||||
vi.mock("./screenshot.js", () => ({
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
|
||||
normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({
|
||||
buffer: buf,
|
||||
contentType: "image/png",
|
||||
})),
|
||||
}));
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
while (true) {
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
const s = createServer();
|
||||
s.once("error", reject);
|
||||
s.listen(0, "127.0.0.1", () => {
|
||||
const assigned = (s.address() as AddressInfo).port;
|
||||
s.close((err) => (err ? reject(err) : resolve(assigned)));
|
||||
});
|
||||
});
|
||||
if (port < 65535) return port;
|
||||
}
|
||||
}
|
||||
|
||||
function makeResponse(
|
||||
body: unknown,
|
||||
init?: { ok?: boolean; status?: number; text?: string },
|
||||
): Response {
|
||||
const ok = init?.ok ?? true;
|
||||
const status = init?.status ?? 200;
|
||||
const text = init?.text ?? "";
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
text: async () => text,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe("browser control server", () => {
|
||||
beforeEach(async () => {
|
||||
reachable = false;
|
||||
cfgAttachOnly = false;
|
||||
createTargetId = null;
|
||||
|
||||
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
|
||||
if (createTargetId) return { targetId: createTargetId };
|
||||
throw new Error("cdp disabled");
|
||||
});
|
||||
|
||||
for (const fn of Object.values(pwMocks)) fn.mockClear();
|
||||
for (const fn of Object.values(cdpMocks)) fn.mockClear();
|
||||
|
||||
testPort = await getFreePort();
|
||||
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
|
||||
// Minimal CDP JSON endpoints used by the server.
|
||||
let putNewCalls = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/json/list")) {
|
||||
if (!reachable) return makeResponse([]);
|
||||
return makeResponse([
|
||||
{
|
||||
id: "abcd1234",
|
||||
title: "Tab",
|
||||
url: "https://example.com",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
|
||||
type: "page",
|
||||
},
|
||||
{
|
||||
id: "abce9999",
|
||||
title: "Other",
|
||||
url: "https://other",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999",
|
||||
type: "page",
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (u.includes("/json/new?")) {
|
||||
if (init?.method === "PUT") {
|
||||
putNewCalls += 1;
|
||||
if (putNewCalls === 1) {
|
||||
return makeResponse({}, { ok: false, status: 405, text: "" });
|
||||
}
|
||||
}
|
||||
return makeResponse({
|
||||
id: "newtab1",
|
||||
title: "",
|
||||
url: "about:blank",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
|
||||
type: "page",
|
||||
});
|
||||
}
|
||||
if (u.includes("/json/activate/")) return makeResponse("ok");
|
||||
if (u.includes("/json/close/")) return makeResponse("ok");
|
||||
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
|
||||
const startServerAndBase = async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
|
||||
return base;
|
||||
};
|
||||
|
||||
const postJson = async <T>(url: string, body?: unknown): Promise<T> => {
|
||||
const res = await realFetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return (await res.json()) as T;
|
||||
};
|
||||
|
||||
it("agent contract: snapshot endpoints", async () => {
|
||||
const base = await startServerAndBase();
|
||||
|
||||
const snapAria = (await realFetch(
|
||||
`${base}/snapshot?format=aria&limit=1`,
|
||||
).then((r) => r.json())) as { ok: boolean; format?: string };
|
||||
expect(snapAria.ok).toBe(true);
|
||||
expect(snapAria.format).toBe("aria");
|
||||
expect(cdpMocks.snapshotAria).toHaveBeenCalledWith({
|
||||
wsUrl: "ws://127.0.0.1/devtools/page/abcd1234",
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const snapAi = (await realFetch(`${base}/snapshot?format=ai`).then((r) =>
|
||||
r.json(),
|
||||
)) as { ok: boolean; format?: string };
|
||||
expect(snapAi.ok).toBe(true);
|
||||
expect(snapAi.format).toBe("ai");
|
||||
expect(pwMocks.snapshotAiViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
|
||||
});
|
||||
});
|
||||
|
||||
it("agent contract: navigation + common act commands", async () => {
|
||||
const base = await startServerAndBase();
|
||||
|
||||
const nav = (await postJson(`${base}/navigate`, {
|
||||
url: "https://example.com",
|
||||
})) as { ok: boolean; targetId?: string };
|
||||
expect(nav.ok).toBe(true);
|
||||
expect(typeof nav.targetId).toBe("string");
|
||||
expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
const click = (await postJson(`${base}/act`, {
|
||||
kind: "click",
|
||||
ref: "1",
|
||||
button: "left",
|
||||
modifiers: ["Shift"],
|
||||
})) as { ok: boolean };
|
||||
expect(click.ok).toBe(true);
|
||||
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(1, {
|
||||
cdpUrl: cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
ref: "1",
|
||||
doubleClick: false,
|
||||
button: "left",
|
||||
modifiers: ["Shift"],
|
||||
});
|
||||
|
||||
const clickSelector = await realFetch(`${base}/act`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind: "click", selector: "button.save" }),
|
||||
});
|
||||
expect(clickSelector.status).toBe(400);
|
||||
expect(((await clickSelector.json()) as { error?: string }).error).toMatch(
|
||||
/'selector' is not supported/i,
|
||||
);
|
||||
|
||||
const type = (await postJson(`${base}/act`, {
|
||||
kind: "type",
|
||||
ref: "1",
|
||||
text: "",
|
||||
})) as { ok: boolean };
|
||||
expect(type.ok).toBe(true);
|
||||
expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith(1, {
|
||||
cdpUrl: cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
ref: "1",
|
||||
text: "",
|
||||
submit: false,
|
||||
slowly: false,
|
||||
});
|
||||
|
||||
const press = (await postJson(`${base}/act`, {
|
||||
kind: "press",
|
||||
key: "Enter",
|
||||
})) as { ok: boolean };
|
||||
expect(press.ok).toBe(true);
|
||||
expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
key: "Enter",
|
||||
});
|
||||
|
||||
const hover = (await postJson(`${base}/act`, {
|
||||
kind: "hover",
|
||||
ref: "2",
|
||||
})) as { ok: boolean };
|
||||
expect(hover.ok).toBe(true);
|
||||
expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
ref: "2",
|
||||
});
|
||||
|
||||
const scroll = (await postJson(`${base}/act`, {
|
||||
kind: "scrollIntoView",
|
||||
ref: "2",
|
||||
})) as { ok: boolean };
|
||||
expect(scroll.ok).toBe(true);
|
||||
expect(pwMocks.scrollIntoViewViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
ref: "2",
|
||||
});
|
||||
|
||||
const drag = (await postJson(`${base}/act`, {
|
||||
kind: "drag",
|
||||
startRef: "3",
|
||||
endRef: "4",
|
||||
})) as { ok: boolean };
|
||||
expect(drag.ok).toBe(true);
|
||||
expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
startRef: "3",
|
||||
endRef: "4",
|
||||
});
|
||||
});
|
||||
});
|
||||
438
src/browser/server.part-3.test.ts
Normal file
438
src/browser/server.part-3.test.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import { type AddressInfo, createServer } from "node:net";
|
||||
import { fetch as realFetch } from "undici";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let testPort = 0;
|
||||
let cdpBaseUrl = "";
|
||||
let reachable = false;
|
||||
let cfgAttachOnly = false;
|
||||
let createTargetId: string | null = null;
|
||||
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
createTargetViaCdp: vi.fn(async () => {
|
||||
throw new Error("cdp disabled");
|
||||
}),
|
||||
snapshotAria: vi.fn(async () => ({
|
||||
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
|
||||
})),
|
||||
}));
|
||||
|
||||
const pwMocks = vi.hoisted(() => ({
|
||||
armDialogViaPlaywright: vi.fn(async () => {}),
|
||||
armFileUploadViaPlaywright: vi.fn(async () => {}),
|
||||
clickViaPlaywright: vi.fn(async () => {}),
|
||||
closePageViaPlaywright: vi.fn(async () => {}),
|
||||
closePlaywrightBrowserConnection: vi.fn(async () => {}),
|
||||
downloadViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/report.pdf",
|
||||
suggestedFilename: "report.pdf",
|
||||
path: "/tmp/report.pdf",
|
||||
})),
|
||||
dragViaPlaywright: vi.fn(async () => {}),
|
||||
evaluateViaPlaywright: vi.fn(async () => "ok"),
|
||||
fillFormViaPlaywright: vi.fn(async () => {}),
|
||||
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
|
||||
hoverViaPlaywright: vi.fn(async () => {}),
|
||||
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
|
||||
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
|
||||
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
|
||||
pressKeyViaPlaywright: vi.fn(async () => {}),
|
||||
responseBodyViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/api/data",
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: '{"ok":true}',
|
||||
})),
|
||||
resizeViewportViaPlaywright: vi.fn(async () => {}),
|
||||
selectOptionViaPlaywright: vi.fn(async () => {}),
|
||||
setInputFilesViaPlaywright: vi.fn(async () => {}),
|
||||
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
|
||||
takeScreenshotViaPlaywright: vi.fn(async () => ({
|
||||
buffer: Buffer.from("png"),
|
||||
})),
|
||||
typeViaPlaywright: vi.fn(async () => {}),
|
||||
waitForDownloadViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/report.pdf",
|
||||
suggestedFilename: "report.pdf",
|
||||
path: "/tmp/report.pdf",
|
||||
})),
|
||||
waitForViaPlaywright: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
function makeProc(pid = 123) {
|
||||
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
return {
|
||||
pid,
|
||||
killed: false,
|
||||
exitCode: null as number | null,
|
||||
on: (event: string, cb: (...args: unknown[]) => void) => {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
|
||||
return undefined;
|
||||
},
|
||||
emitExit: () => {
|
||||
for (const cb of handlers.get("exit") ?? []) cb(0);
|
||||
},
|
||||
kill: () => {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const proc = makeProc();
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||
color: "#FF4500",
|
||||
attachOnly: cfgAttachOnly,
|
||||
headless: true,
|
||||
defaultProfile: "clawd",
|
||||
profiles: {
|
||||
clawd: { cdpPort: testPort + 1, color: "#FF4500" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
writeConfigFile: vi.fn(async () => {}),
|
||||
};
|
||||
});
|
||||
|
||||
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
|
||||
vi.mock("./chrome.js", () => ({
|
||||
isChromeCdpReady: vi.fn(async () => reachable),
|
||||
isChromeReachable: vi.fn(async () => reachable),
|
||||
launchClawdChrome: vi.fn(
|
||||
async (_resolved: unknown, profile: { cdpPort: number }) => {
|
||||
launchCalls.push({ port: profile.cdpPort });
|
||||
reachable = true;
|
||||
return {
|
||||
pid: 123,
|
||||
exe: { kind: "chrome", path: "/fake/chrome" },
|
||||
userDataDir: "/tmp/clawd",
|
||||
cdpPort: profile.cdpPort,
|
||||
startedAt: Date.now(),
|
||||
proc,
|
||||
};
|
||||
},
|
||||
),
|
||||
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd"),
|
||||
stopClawdChrome: vi.fn(async () => {
|
||||
reachable = false;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./cdp.js", () => ({
|
||||
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
||||
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
||||
snapshotAria: cdpMocks.snapshotAria,
|
||||
}));
|
||||
|
||||
vi.mock("./pw-ai.js", () => pwMocks);
|
||||
|
||||
vi.mock("../media/store.js", () => ({
|
||||
ensureMediaDir: vi.fn(async () => {}),
|
||||
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
|
||||
}));
|
||||
|
||||
vi.mock("./screenshot.js", () => ({
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
|
||||
normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({
|
||||
buffer: buf,
|
||||
contentType: "image/png",
|
||||
})),
|
||||
}));
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
while (true) {
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
const s = createServer();
|
||||
s.once("error", reject);
|
||||
s.listen(0, "127.0.0.1", () => {
|
||||
const assigned = (s.address() as AddressInfo).port;
|
||||
s.close((err) => (err ? reject(err) : resolve(assigned)));
|
||||
});
|
||||
});
|
||||
if (port < 65535) return port;
|
||||
}
|
||||
}
|
||||
|
||||
function makeResponse(
|
||||
body: unknown,
|
||||
init?: { ok?: boolean; status?: number; text?: string },
|
||||
): Response {
|
||||
const ok = init?.ok ?? true;
|
||||
const status = init?.status ?? 200;
|
||||
const text = init?.text ?? "";
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
text: async () => text,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe("browser control server", () => {
|
||||
beforeEach(async () => {
|
||||
reachable = false;
|
||||
cfgAttachOnly = false;
|
||||
createTargetId = null;
|
||||
|
||||
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
|
||||
if (createTargetId) return { targetId: createTargetId };
|
||||
throw new Error("cdp disabled");
|
||||
});
|
||||
|
||||
for (const fn of Object.values(pwMocks)) fn.mockClear();
|
||||
for (const fn of Object.values(cdpMocks)) fn.mockClear();
|
||||
|
||||
testPort = await getFreePort();
|
||||
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
|
||||
// Minimal CDP JSON endpoints used by the server.
|
||||
let putNewCalls = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/json/list")) {
|
||||
if (!reachable) return makeResponse([]);
|
||||
return makeResponse([
|
||||
{
|
||||
id: "abcd1234",
|
||||
title: "Tab",
|
||||
url: "https://example.com",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
|
||||
type: "page",
|
||||
},
|
||||
{
|
||||
id: "abce9999",
|
||||
title: "Other",
|
||||
url: "https://other",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999",
|
||||
type: "page",
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (u.includes("/json/new?")) {
|
||||
if (init?.method === "PUT") {
|
||||
putNewCalls += 1;
|
||||
if (putNewCalls === 1) {
|
||||
return makeResponse({}, { ok: false, status: 405, text: "" });
|
||||
}
|
||||
}
|
||||
return makeResponse({
|
||||
id: "newtab1",
|
||||
title: "",
|
||||
url: "about:blank",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
|
||||
type: "page",
|
||||
});
|
||||
}
|
||||
if (u.includes("/json/activate/")) return makeResponse("ok");
|
||||
if (u.includes("/json/close/")) return makeResponse("ok");
|
||||
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
|
||||
it("skips default maxChars when explicitly set to zero", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
|
||||
|
||||
const snapAi = (await realFetch(
|
||||
`${base}/snapshot?format=ai&maxChars=0`,
|
||||
).then((r) => r.json())) as { ok: boolean; format?: string };
|
||||
expect(snapAi.ok).toBe(true);
|
||||
expect(snapAi.format).toBe("ai");
|
||||
|
||||
const [call] = pwMocks.snapshotAiViaPlaywright.mock.calls.at(-1) ?? [];
|
||||
expect(call).toEqual({
|
||||
cdpUrl: cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
});
|
||||
});
|
||||
|
||||
it("validates agent inputs (agent routes)", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
|
||||
|
||||
const navMissing = await realFetch(`${base}/navigate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(navMissing.status).toBe(400);
|
||||
|
||||
const actMissing = await realFetch(`${base}/act`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(actMissing.status).toBe(400);
|
||||
|
||||
const clickMissingRef = await realFetch(`${base}/act`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind: "click" }),
|
||||
});
|
||||
expect(clickMissingRef.status).toBe(400);
|
||||
|
||||
const scrollMissingRef = await realFetch(`${base}/act`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind: "scrollIntoView" }),
|
||||
});
|
||||
expect(scrollMissingRef.status).toBe(400);
|
||||
|
||||
const scrollSelectorUnsupported = await realFetch(`${base}/act`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind: "scrollIntoView", selector: "button.save" }),
|
||||
});
|
||||
expect(scrollSelectorUnsupported.status).toBe(400);
|
||||
|
||||
const clickBadButton = await realFetch(`${base}/act`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind: "click", ref: "1", button: "nope" }),
|
||||
});
|
||||
expect(clickBadButton.status).toBe(400);
|
||||
|
||||
const clickBadModifiers = await realFetch(`${base}/act`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind: "click", ref: "1", modifiers: ["Nope"] }),
|
||||
});
|
||||
expect(clickBadModifiers.status).toBe(400);
|
||||
|
||||
const typeBadText = await realFetch(`${base}/act`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind: "type", ref: "1", text: 123 }),
|
||||
});
|
||||
expect(typeBadText.status).toBe(400);
|
||||
|
||||
const uploadMissingPaths = await realFetch(`${base}/hooks/file-chooser`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(uploadMissingPaths.status).toBe(400);
|
||||
|
||||
const dialogMissingAccept = await realFetch(`${base}/hooks/dialog`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(dialogMissingAccept.status).toBe(400);
|
||||
|
||||
const snapDefault = (await realFetch(`${base}/snapshot?format=wat`).then(
|
||||
(r) => r.json(),
|
||||
)) as { ok: boolean; format?: string };
|
||||
expect(snapDefault.ok).toBe(true);
|
||||
expect(snapDefault.format).toBe("ai");
|
||||
|
||||
const screenshotBadCombo = await realFetch(`${base}/screenshot`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fullPage: true, element: "body" }),
|
||||
});
|
||||
expect(screenshotBadCombo.status).toBe(400);
|
||||
});
|
||||
|
||||
it("covers common error branches", async () => {
|
||||
cfgAttachOnly = true;
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
const missing = await realFetch(`${base}/tabs/open`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(missing.status).toBe(400);
|
||||
|
||||
reachable = false;
|
||||
const started = (await realFetch(`${base}/start`, {
|
||||
method: "POST",
|
||||
}).then((r) => r.json())) as { error?: string };
|
||||
expect(started.error ?? "").toMatch(/attachOnly/i);
|
||||
});
|
||||
|
||||
it("allows attachOnly servers to ensure reachability via callback", async () => {
|
||||
cfgAttachOnly = true;
|
||||
reachable = false;
|
||||
const { startBrowserBridgeServer } = await import("./bridge-server.js");
|
||||
|
||||
const ensured = vi.fn(async () => {
|
||||
reachable = true;
|
||||
});
|
||||
|
||||
const bridge = await startBrowserBridgeServer({
|
||||
resolved: {
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:0",
|
||||
controlHost: "127.0.0.1",
|
||||
controlPort: 0,
|
||||
cdpProtocol: "http",
|
||||
cdpHost: "127.0.0.1",
|
||||
cdpIsLoopback: true,
|
||||
color: "#FF4500",
|
||||
headless: true,
|
||||
noSandbox: false,
|
||||
attachOnly: true,
|
||||
defaultProfile: "clawd",
|
||||
profiles: {
|
||||
clawd: { cdpPort: testPort + 1, color: "#FF4500" },
|
||||
},
|
||||
},
|
||||
onEnsureAttachTarget: ensured,
|
||||
});
|
||||
|
||||
const started = (await realFetch(`${bridge.baseUrl}/start`, {
|
||||
method: "POST",
|
||||
}).then((r) => r.json())) as { ok?: boolean; error?: string };
|
||||
expect(started.error).toBeUndefined();
|
||||
expect(started.ok).toBe(true);
|
||||
const status = (await realFetch(`${bridge.baseUrl}/`).then((r) =>
|
||||
r.json(),
|
||||
)) as { running?: boolean };
|
||||
expect(status.running).toBe(true);
|
||||
expect(ensured).toHaveBeenCalledTimes(1);
|
||||
|
||||
await new Promise<void>((resolve) => bridge.server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it("opens tabs via CDP createTarget path", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
|
||||
|
||||
createTargetId = "abcd1234";
|
||||
const opened = (await realFetch(`${base}/tabs/open`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: "https://example.com" }),
|
||||
}).then((r) => r.json())) as { targetId?: string };
|
||||
expect(opened.targetId).toBe("abcd1234");
|
||||
});
|
||||
});
|
||||
463
src/browser/server.part-4.test.ts
Normal file
463
src/browser/server.part-4.test.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
import { type AddressInfo, createServer } from "node:net";
|
||||
import { fetch as realFetch } from "undici";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let testPort = 0;
|
||||
let _cdpBaseUrl = "";
|
||||
let reachable = false;
|
||||
let cfgAttachOnly = false;
|
||||
let createTargetId: string | null = null;
|
||||
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
createTargetViaCdp: vi.fn(async () => {
|
||||
throw new Error("cdp disabled");
|
||||
}),
|
||||
snapshotAria: vi.fn(async () => ({
|
||||
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
|
||||
})),
|
||||
}));
|
||||
|
||||
const pwMocks = vi.hoisted(() => ({
|
||||
armDialogViaPlaywright: vi.fn(async () => {}),
|
||||
armFileUploadViaPlaywright: vi.fn(async () => {}),
|
||||
clickViaPlaywright: vi.fn(async () => {}),
|
||||
closePageViaPlaywright: vi.fn(async () => {}),
|
||||
closePlaywrightBrowserConnection: vi.fn(async () => {}),
|
||||
downloadViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/report.pdf",
|
||||
suggestedFilename: "report.pdf",
|
||||
path: "/tmp/report.pdf",
|
||||
})),
|
||||
dragViaPlaywright: vi.fn(async () => {}),
|
||||
evaluateViaPlaywright: vi.fn(async () => "ok"),
|
||||
fillFormViaPlaywright: vi.fn(async () => {}),
|
||||
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
|
||||
hoverViaPlaywright: vi.fn(async () => {}),
|
||||
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
|
||||
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
|
||||
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
|
||||
pressKeyViaPlaywright: vi.fn(async () => {}),
|
||||
responseBodyViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/api/data",
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: '{"ok":true}',
|
||||
})),
|
||||
resizeViewportViaPlaywright: vi.fn(async () => {}),
|
||||
selectOptionViaPlaywright: vi.fn(async () => {}),
|
||||
setInputFilesViaPlaywright: vi.fn(async () => {}),
|
||||
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
|
||||
takeScreenshotViaPlaywright: vi.fn(async () => ({
|
||||
buffer: Buffer.from("png"),
|
||||
})),
|
||||
typeViaPlaywright: vi.fn(async () => {}),
|
||||
waitForDownloadViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/report.pdf",
|
||||
suggestedFilename: "report.pdf",
|
||||
path: "/tmp/report.pdf",
|
||||
})),
|
||||
waitForViaPlaywright: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
function makeProc(pid = 123) {
|
||||
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
return {
|
||||
pid,
|
||||
killed: false,
|
||||
exitCode: null as number | null,
|
||||
on: (event: string, cb: (...args: unknown[]) => void) => {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
|
||||
return undefined;
|
||||
},
|
||||
emitExit: () => {
|
||||
for (const cb of handlers.get("exit") ?? []) cb(0);
|
||||
},
|
||||
kill: () => {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const proc = makeProc();
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||
color: "#FF4500",
|
||||
attachOnly: cfgAttachOnly,
|
||||
headless: true,
|
||||
defaultProfile: "clawd",
|
||||
profiles: {
|
||||
clawd: { cdpPort: testPort + 1, color: "#FF4500" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
writeConfigFile: vi.fn(async () => {}),
|
||||
};
|
||||
});
|
||||
|
||||
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
|
||||
vi.mock("./chrome.js", () => ({
|
||||
isChromeCdpReady: vi.fn(async () => reachable),
|
||||
isChromeReachable: vi.fn(async () => reachable),
|
||||
launchClawdChrome: vi.fn(
|
||||
async (_resolved: unknown, profile: { cdpPort: number }) => {
|
||||
launchCalls.push({ port: profile.cdpPort });
|
||||
reachable = true;
|
||||
return {
|
||||
pid: 123,
|
||||
exe: { kind: "chrome", path: "/fake/chrome" },
|
||||
userDataDir: "/tmp/clawd",
|
||||
cdpPort: profile.cdpPort,
|
||||
startedAt: Date.now(),
|
||||
proc,
|
||||
};
|
||||
},
|
||||
),
|
||||
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd"),
|
||||
stopClawdChrome: vi.fn(async () => {
|
||||
reachable = false;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./cdp.js", () => ({
|
||||
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
||||
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
||||
snapshotAria: cdpMocks.snapshotAria,
|
||||
}));
|
||||
|
||||
vi.mock("./pw-ai.js", () => pwMocks);
|
||||
|
||||
vi.mock("../media/store.js", () => ({
|
||||
ensureMediaDir: vi.fn(async () => {}),
|
||||
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
|
||||
}));
|
||||
|
||||
vi.mock("./screenshot.js", () => ({
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
|
||||
normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({
|
||||
buffer: buf,
|
||||
contentType: "image/png",
|
||||
})),
|
||||
}));
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
while (true) {
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
const s = createServer();
|
||||
s.once("error", reject);
|
||||
s.listen(0, "127.0.0.1", () => {
|
||||
const assigned = (s.address() as AddressInfo).port;
|
||||
s.close((err) => (err ? reject(err) : resolve(assigned)));
|
||||
});
|
||||
});
|
||||
if (port < 65535) return port;
|
||||
}
|
||||
}
|
||||
|
||||
function makeResponse(
|
||||
body: unknown,
|
||||
init?: { ok?: boolean; status?: number; text?: string },
|
||||
): Response {
|
||||
const ok = init?.ok ?? true;
|
||||
const status = init?.status ?? 200;
|
||||
const text = init?.text ?? "";
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
text: async () => text,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe("browser control server", () => {
|
||||
beforeEach(async () => {
|
||||
reachable = false;
|
||||
cfgAttachOnly = false;
|
||||
createTargetId = null;
|
||||
|
||||
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
|
||||
if (createTargetId) return { targetId: createTargetId };
|
||||
throw new Error("cdp disabled");
|
||||
});
|
||||
|
||||
for (const fn of Object.values(pwMocks)) fn.mockClear();
|
||||
for (const fn of Object.values(cdpMocks)) fn.mockClear();
|
||||
|
||||
testPort = await getFreePort();
|
||||
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
|
||||
// Minimal CDP JSON endpoints used by the server.
|
||||
let putNewCalls = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/json/list")) {
|
||||
if (!reachable) return makeResponse([]);
|
||||
return makeResponse([
|
||||
{
|
||||
id: "abcd1234",
|
||||
title: "Tab",
|
||||
url: "https://example.com",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
|
||||
type: "page",
|
||||
},
|
||||
{
|
||||
id: "abce9999",
|
||||
title: "Other",
|
||||
url: "https://other",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999",
|
||||
type: "page",
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (u.includes("/json/new?")) {
|
||||
if (init?.method === "PUT") {
|
||||
putNewCalls += 1;
|
||||
if (putNewCalls === 1) {
|
||||
return makeResponse({}, { ok: false, status: 405, text: "" });
|
||||
}
|
||||
}
|
||||
return makeResponse({
|
||||
id: "newtab1",
|
||||
title: "",
|
||||
url: "about:blank",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
|
||||
type: "page",
|
||||
});
|
||||
}
|
||||
if (u.includes("/json/activate/")) return makeResponse("ok");
|
||||
if (u.includes("/json/close/")) return makeResponse("ok");
|
||||
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
|
||||
it("covers additional endpoint branches", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
const tabsWhenStopped = (await realFetch(`${base}/tabs`).then((r) =>
|
||||
r.json(),
|
||||
)) as { running: boolean; tabs: unknown[] };
|
||||
expect(tabsWhenStopped.running).toBe(false);
|
||||
expect(Array.isArray(tabsWhenStopped.tabs)).toBe(true);
|
||||
|
||||
const focusStopped = await realFetch(`${base}/tabs/focus`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: "abcd" }),
|
||||
});
|
||||
expect(focusStopped.status).toBe(409);
|
||||
|
||||
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
|
||||
|
||||
const focusMissing = await realFetch(`${base}/tabs/focus`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetId: "zzz" }),
|
||||
});
|
||||
expect(focusMissing.status).toBe(404);
|
||||
|
||||
const delAmbiguous = await realFetch(`${base}/tabs/abc`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(delAmbiguous.status).toBe(409);
|
||||
|
||||
const snapAmbiguous = await realFetch(
|
||||
`${base}/snapshot?format=aria&targetId=abc`,
|
||||
);
|
||||
expect(snapAmbiguous.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe("backward compatibility (profile parameter)", () => {
|
||||
beforeEach(async () => {
|
||||
reachable = false;
|
||||
cfgAttachOnly = false;
|
||||
createTargetId = null;
|
||||
|
||||
for (const fn of Object.values(pwMocks)) fn.mockClear();
|
||||
for (const fn of Object.values(cdpMocks)) fn.mockClear();
|
||||
|
||||
testPort = await getFreePort();
|
||||
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/json/list")) {
|
||||
if (!reachable) return makeResponse([]);
|
||||
return makeResponse([
|
||||
{
|
||||
id: "abcd1234",
|
||||
title: "Tab",
|
||||
url: "https://example.com",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
|
||||
type: "page",
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (u.includes("/json/new?")) {
|
||||
return makeResponse({
|
||||
id: "newtab1",
|
||||
title: "",
|
||||
url: "about:blank",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
|
||||
type: "page",
|
||||
});
|
||||
}
|
||||
if (u.includes("/json/activate/")) return makeResponse("ok");
|
||||
if (u.includes("/json/close/")) return makeResponse("ok");
|
||||
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
|
||||
it("GET / without profile uses default profile", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
const status = (await realFetch(`${base}/`).then((r) => r.json())) as {
|
||||
running: boolean;
|
||||
profile?: string;
|
||||
};
|
||||
expect(status.running).toBe(false);
|
||||
// Should use default profile (clawd)
|
||||
expect(status.profile).toBe("clawd");
|
||||
});
|
||||
|
||||
it("POST /start without profile uses default profile", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
const result = (await realFetch(`${base}/start`, { method: "POST" }).then(
|
||||
(r) => r.json(),
|
||||
)) as { ok: boolean; profile?: string };
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.profile).toBe("clawd");
|
||||
});
|
||||
|
||||
it("POST /stop without profile uses default profile", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
await realFetch(`${base}/start`, { method: "POST" });
|
||||
|
||||
const result = (await realFetch(`${base}/stop`, { method: "POST" }).then(
|
||||
(r) => r.json(),
|
||||
)) as { ok: boolean; profile?: string };
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.profile).toBe("clawd");
|
||||
});
|
||||
|
||||
it("GET /tabs without profile uses default profile", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
await realFetch(`${base}/start`, { method: "POST" });
|
||||
|
||||
const result = (await realFetch(`${base}/tabs`).then((r) => r.json())) as {
|
||||
running: boolean;
|
||||
tabs: unknown[];
|
||||
};
|
||||
expect(result.running).toBe(true);
|
||||
expect(Array.isArray(result.tabs)).toBe(true);
|
||||
});
|
||||
|
||||
it("POST /tabs/open without profile uses default profile", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
await realFetch(`${base}/start`, { method: "POST" });
|
||||
|
||||
const result = (await realFetch(`${base}/tabs/open`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: "https://example.com" }),
|
||||
}).then((r) => r.json())) as { targetId?: string };
|
||||
expect(result.targetId).toBe("newtab1");
|
||||
});
|
||||
|
||||
it("GET /profiles returns list of profiles", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
const result = (await realFetch(`${base}/profiles`).then((r) =>
|
||||
r.json(),
|
||||
)) as { profiles: Array<{ name: string }> };
|
||||
expect(Array.isArray(result.profiles)).toBe(true);
|
||||
// Should at least have the default clawd profile
|
||||
expect(result.profiles.some((p) => p.name === "clawd")).toBe(true);
|
||||
});
|
||||
|
||||
it("GET /tabs?profile=clawd returns tabs for specified profile", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
await realFetch(`${base}/start`, { method: "POST" });
|
||||
|
||||
const result = (await realFetch(`${base}/tabs?profile=clawd`).then((r) =>
|
||||
r.json(),
|
||||
)) as { running: boolean; tabs: unknown[] };
|
||||
expect(result.running).toBe(true);
|
||||
expect(Array.isArray(result.tabs)).toBe(true);
|
||||
});
|
||||
|
||||
it("POST /tabs/open?profile=clawd opens tab in specified profile", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
await realFetch(`${base}/start`, { method: "POST" });
|
||||
|
||||
const result = (await realFetch(`${base}/tabs/open?profile=clawd`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: "https://example.com" }),
|
||||
}).then((r) => r.json())) as { targetId?: string };
|
||||
expect(result.targetId).toBe("newtab1");
|
||||
});
|
||||
|
||||
it("GET /tabs?profile=unknown returns 404", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
const result = await realFetch(`${base}/tabs?profile=unknown`);
|
||||
expect(result.status).toBe(404);
|
||||
const body = (await result.json()) as { error: string };
|
||||
expect(body.error).toContain("not found");
|
||||
});
|
||||
});
|
||||
416
src/browser/server.part-5.test.ts
Normal file
416
src/browser/server.part-5.test.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { type AddressInfo, createServer } from "node:net";
|
||||
import { fetch as realFetch } from "undici";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let testPort = 0;
|
||||
let _cdpBaseUrl = "";
|
||||
let reachable = false;
|
||||
let cfgAttachOnly = false;
|
||||
let createTargetId: string | null = null;
|
||||
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
createTargetViaCdp: vi.fn(async () => {
|
||||
throw new Error("cdp disabled");
|
||||
}),
|
||||
snapshotAria: vi.fn(async () => ({
|
||||
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
|
||||
})),
|
||||
}));
|
||||
|
||||
const pwMocks = vi.hoisted(() => ({
|
||||
armDialogViaPlaywright: vi.fn(async () => {}),
|
||||
armFileUploadViaPlaywright: vi.fn(async () => {}),
|
||||
clickViaPlaywright: vi.fn(async () => {}),
|
||||
closePageViaPlaywright: vi.fn(async () => {}),
|
||||
closePlaywrightBrowserConnection: vi.fn(async () => {}),
|
||||
downloadViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/report.pdf",
|
||||
suggestedFilename: "report.pdf",
|
||||
path: "/tmp/report.pdf",
|
||||
})),
|
||||
dragViaPlaywright: vi.fn(async () => {}),
|
||||
evaluateViaPlaywright: vi.fn(async () => "ok"),
|
||||
fillFormViaPlaywright: vi.fn(async () => {}),
|
||||
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
|
||||
hoverViaPlaywright: vi.fn(async () => {}),
|
||||
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
|
||||
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
|
||||
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
|
||||
pressKeyViaPlaywright: vi.fn(async () => {}),
|
||||
responseBodyViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/api/data",
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: '{"ok":true}',
|
||||
})),
|
||||
resizeViewportViaPlaywright: vi.fn(async () => {}),
|
||||
selectOptionViaPlaywright: vi.fn(async () => {}),
|
||||
setInputFilesViaPlaywright: vi.fn(async () => {}),
|
||||
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
|
||||
takeScreenshotViaPlaywright: vi.fn(async () => ({
|
||||
buffer: Buffer.from("png"),
|
||||
})),
|
||||
typeViaPlaywright: vi.fn(async () => {}),
|
||||
waitForDownloadViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/report.pdf",
|
||||
suggestedFilename: "report.pdf",
|
||||
path: "/tmp/report.pdf",
|
||||
})),
|
||||
waitForViaPlaywright: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
function makeProc(pid = 123) {
|
||||
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
return {
|
||||
pid,
|
||||
killed: false,
|
||||
exitCode: null as number | null,
|
||||
on: (event: string, cb: (...args: unknown[]) => void) => {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
|
||||
return undefined;
|
||||
},
|
||||
emitExit: () => {
|
||||
for (const cb of handlers.get("exit") ?? []) cb(0);
|
||||
},
|
||||
kill: () => {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const proc = makeProc();
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||
color: "#FF4500",
|
||||
attachOnly: cfgAttachOnly,
|
||||
headless: true,
|
||||
defaultProfile: "clawd",
|
||||
profiles: {
|
||||
clawd: { cdpPort: testPort + 1, color: "#FF4500" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
writeConfigFile: vi.fn(async () => {}),
|
||||
};
|
||||
});
|
||||
|
||||
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
|
||||
vi.mock("./chrome.js", () => ({
|
||||
isChromeCdpReady: vi.fn(async () => reachable),
|
||||
isChromeReachable: vi.fn(async () => reachable),
|
||||
launchClawdChrome: vi.fn(
|
||||
async (_resolved: unknown, profile: { cdpPort: number }) => {
|
||||
launchCalls.push({ port: profile.cdpPort });
|
||||
reachable = true;
|
||||
return {
|
||||
pid: 123,
|
||||
exe: { kind: "chrome", path: "/fake/chrome" },
|
||||
userDataDir: "/tmp/clawd",
|
||||
cdpPort: profile.cdpPort,
|
||||
startedAt: Date.now(),
|
||||
proc,
|
||||
};
|
||||
},
|
||||
),
|
||||
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd"),
|
||||
stopClawdChrome: vi.fn(async () => {
|
||||
reachable = false;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./cdp.js", () => ({
|
||||
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
||||
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
||||
snapshotAria: cdpMocks.snapshotAria,
|
||||
}));
|
||||
|
||||
vi.mock("./pw-ai.js", () => pwMocks);
|
||||
|
||||
vi.mock("../media/store.js", () => ({
|
||||
ensureMediaDir: vi.fn(async () => {}),
|
||||
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
|
||||
}));
|
||||
|
||||
vi.mock("./screenshot.js", () => ({
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
|
||||
normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({
|
||||
buffer: buf,
|
||||
contentType: "image/png",
|
||||
})),
|
||||
}));
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
while (true) {
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
const s = createServer();
|
||||
s.once("error", reject);
|
||||
s.listen(0, "127.0.0.1", () => {
|
||||
const assigned = (s.address() as AddressInfo).port;
|
||||
s.close((err) => (err ? reject(err) : resolve(assigned)));
|
||||
});
|
||||
});
|
||||
if (port < 65535) return port;
|
||||
}
|
||||
}
|
||||
|
||||
function makeResponse(
|
||||
body: unknown,
|
||||
init?: { ok?: boolean; status?: number; text?: string },
|
||||
): Response {
|
||||
const ok = init?.ok ?? true;
|
||||
const status = init?.status ?? 200;
|
||||
const text = init?.text ?? "";
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
text: async () => text,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe("browser control server", () => {
|
||||
beforeEach(async () => {
|
||||
reachable = false;
|
||||
cfgAttachOnly = false;
|
||||
createTargetId = null;
|
||||
|
||||
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
|
||||
if (createTargetId) return { targetId: createTargetId };
|
||||
throw new Error("cdp disabled");
|
||||
});
|
||||
|
||||
for (const fn of Object.values(pwMocks)) fn.mockClear();
|
||||
for (const fn of Object.values(cdpMocks)) fn.mockClear();
|
||||
|
||||
testPort = await getFreePort();
|
||||
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
|
||||
// Minimal CDP JSON endpoints used by the server.
|
||||
let putNewCalls = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/json/list")) {
|
||||
if (!reachable) return makeResponse([]);
|
||||
return makeResponse([
|
||||
{
|
||||
id: "abcd1234",
|
||||
title: "Tab",
|
||||
url: "https://example.com",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
|
||||
type: "page",
|
||||
},
|
||||
{
|
||||
id: "abce9999",
|
||||
title: "Other",
|
||||
url: "https://other",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999",
|
||||
type: "page",
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (u.includes("/json/new?")) {
|
||||
if (init?.method === "PUT") {
|
||||
putNewCalls += 1;
|
||||
if (putNewCalls === 1) {
|
||||
return makeResponse({}, { ok: false, status: 405, text: "" });
|
||||
}
|
||||
}
|
||||
return makeResponse({
|
||||
id: "newtab1",
|
||||
title: "",
|
||||
url: "about:blank",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
|
||||
type: "page",
|
||||
});
|
||||
}
|
||||
if (u.includes("/json/activate/")) return makeResponse("ok");
|
||||
if (u.includes("/json/close/")) return makeResponse("ok");
|
||||
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
|
||||
it("POST /tabs/open?profile=unknown returns 404", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
const result = await realFetch(`${base}/tabs/open?profile=unknown`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: "https://example.com" }),
|
||||
});
|
||||
expect(result.status).toBe(404);
|
||||
const body = (await result.json()) as { error: string };
|
||||
expect(body.error).toContain("not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("profile CRUD endpoints", () => {
|
||||
beforeEach(async () => {
|
||||
reachable = false;
|
||||
cfgAttachOnly = false;
|
||||
|
||||
for (const fn of Object.values(pwMocks)) fn.mockClear();
|
||||
for (const fn of Object.values(cdpMocks)) fn.mockClear();
|
||||
|
||||
testPort = await getFreePort();
|
||||
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/json/list")) return makeResponse([]);
|
||||
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
|
||||
it("POST /profiles/create returns 400 for missing name", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
const result = await realFetch(`${base}/profiles/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(result.status).toBe(400);
|
||||
const body = (await result.json()) as { error: string };
|
||||
expect(body.error).toContain("name is required");
|
||||
});
|
||||
|
||||
it("POST /profiles/create returns 400 for invalid name format", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
const result = await realFetch(`${base}/profiles/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "Invalid Name!" }),
|
||||
});
|
||||
expect(result.status).toBe(400);
|
||||
const body = (await result.json()) as { error: string };
|
||||
expect(body.error).toContain("invalid profile name");
|
||||
});
|
||||
|
||||
it("POST /profiles/create returns 409 for duplicate name", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
// "clawd" already exists as the default profile
|
||||
const result = await realFetch(`${base}/profiles/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "clawd" }),
|
||||
});
|
||||
expect(result.status).toBe(409);
|
||||
const body = (await result.json()) as { error: string };
|
||||
expect(body.error).toContain("already exists");
|
||||
});
|
||||
|
||||
it("POST /profiles/create accepts cdpUrl for remote profiles", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
const result = await realFetch(`${base}/profiles/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "remote", cdpUrl: "http://10.0.0.42:9222" }),
|
||||
});
|
||||
expect(result.status).toBe(200);
|
||||
const body = (await result.json()) as {
|
||||
profile?: string;
|
||||
cdpUrl?: string;
|
||||
isRemote?: boolean;
|
||||
};
|
||||
expect(body.profile).toBe("remote");
|
||||
expect(body.cdpUrl).toBe("http://10.0.0.42:9222");
|
||||
expect(body.isRemote).toBe(true);
|
||||
});
|
||||
|
||||
it("POST /profiles/create returns 400 for invalid cdpUrl", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
const result = await realFetch(`${base}/profiles/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "badremote", cdpUrl: "ws://bad" }),
|
||||
});
|
||||
expect(result.status).toBe(400);
|
||||
const body = (await result.json()) as { error: string };
|
||||
expect(body.error).toContain("cdpUrl");
|
||||
});
|
||||
|
||||
it("DELETE /profiles/:name returns 404 for non-existent profile", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
const result = await realFetch(`${base}/profiles/nonexistent`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(result.status).toBe(404);
|
||||
const body = (await result.json()) as { error: string };
|
||||
expect(body.error).toContain("not found");
|
||||
});
|
||||
|
||||
it("DELETE /profiles/:name returns 400 for default profile deletion", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
// clawd is the default profile
|
||||
const result = await realFetch(`${base}/profiles/clawd`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(result.status).toBe(400);
|
||||
const body = (await result.json()) as { error: string };
|
||||
expect(body.error).toContain("cannot delete the default profile");
|
||||
});
|
||||
|
||||
it("DELETE /profiles/:name returns 400 for invalid name format", async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
|
||||
const result = await realFetch(`${base}/profiles/Invalid-Name!`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(result.status).toBe(400);
|
||||
const body = (await result.json()) as { error: string };
|
||||
expect(body.error).toContain("invalid profile name");
|
||||
});
|
||||
});
|
||||
423
src/browser/server.part-6.test.ts
Normal file
423
src/browser/server.part-6.test.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import { type AddressInfo, createServer } from "node:net";
|
||||
import { fetch as realFetch } from "undici";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let testPort = 0;
|
||||
let cdpBaseUrl = "";
|
||||
let reachable = false;
|
||||
let cfgAttachOnly = false;
|
||||
let createTargetId: string | null = null;
|
||||
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
createTargetViaCdp: vi.fn(async () => {
|
||||
throw new Error("cdp disabled");
|
||||
}),
|
||||
snapshotAria: vi.fn(async () => ({
|
||||
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
|
||||
})),
|
||||
}));
|
||||
|
||||
const pwMocks = vi.hoisted(() => ({
|
||||
armDialogViaPlaywright: vi.fn(async () => {}),
|
||||
armFileUploadViaPlaywright: vi.fn(async () => {}),
|
||||
clickViaPlaywright: vi.fn(async () => {}),
|
||||
closePageViaPlaywright: vi.fn(async () => {}),
|
||||
closePlaywrightBrowserConnection: vi.fn(async () => {}),
|
||||
downloadViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/report.pdf",
|
||||
suggestedFilename: "report.pdf",
|
||||
path: "/tmp/report.pdf",
|
||||
})),
|
||||
dragViaPlaywright: vi.fn(async () => {}),
|
||||
evaluateViaPlaywright: vi.fn(async () => "ok"),
|
||||
fillFormViaPlaywright: vi.fn(async () => {}),
|
||||
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
|
||||
hoverViaPlaywright: vi.fn(async () => {}),
|
||||
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
|
||||
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
|
||||
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
|
||||
pressKeyViaPlaywright: vi.fn(async () => {}),
|
||||
responseBodyViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/api/data",
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: '{"ok":true}',
|
||||
})),
|
||||
resizeViewportViaPlaywright: vi.fn(async () => {}),
|
||||
selectOptionViaPlaywright: vi.fn(async () => {}),
|
||||
setInputFilesViaPlaywright: vi.fn(async () => {}),
|
||||
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
|
||||
takeScreenshotViaPlaywright: vi.fn(async () => ({
|
||||
buffer: Buffer.from("png"),
|
||||
})),
|
||||
typeViaPlaywright: vi.fn(async () => {}),
|
||||
waitForDownloadViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/report.pdf",
|
||||
suggestedFilename: "report.pdf",
|
||||
path: "/tmp/report.pdf",
|
||||
})),
|
||||
waitForViaPlaywright: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
function makeProc(pid = 123) {
|
||||
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
return {
|
||||
pid,
|
||||
killed: false,
|
||||
exitCode: null as number | null,
|
||||
on: (event: string, cb: (...args: unknown[]) => void) => {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
|
||||
return undefined;
|
||||
},
|
||||
emitExit: () => {
|
||||
for (const cb of handlers.get("exit") ?? []) cb(0);
|
||||
},
|
||||
kill: () => {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const proc = makeProc();
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||
color: "#FF4500",
|
||||
attachOnly: cfgAttachOnly,
|
||||
headless: true,
|
||||
defaultProfile: "clawd",
|
||||
profiles: {
|
||||
clawd: { cdpPort: testPort + 1, color: "#FF4500" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
writeConfigFile: vi.fn(async () => {}),
|
||||
};
|
||||
});
|
||||
|
||||
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
|
||||
vi.mock("./chrome.js", () => ({
|
||||
isChromeCdpReady: vi.fn(async () => reachable),
|
||||
isChromeReachable: vi.fn(async () => reachable),
|
||||
launchClawdChrome: vi.fn(
|
||||
async (_resolved: unknown, profile: { cdpPort: number }) => {
|
||||
launchCalls.push({ port: profile.cdpPort });
|
||||
reachable = true;
|
||||
return {
|
||||
pid: 123,
|
||||
exe: { kind: "chrome", path: "/fake/chrome" },
|
||||
userDataDir: "/tmp/clawd",
|
||||
cdpPort: profile.cdpPort,
|
||||
startedAt: Date.now(),
|
||||
proc,
|
||||
};
|
||||
},
|
||||
),
|
||||
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd"),
|
||||
stopClawdChrome: vi.fn(async () => {
|
||||
reachable = false;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./cdp.js", () => ({
|
||||
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
||||
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
||||
snapshotAria: cdpMocks.snapshotAria,
|
||||
}));
|
||||
|
||||
vi.mock("./pw-ai.js", () => pwMocks);
|
||||
|
||||
vi.mock("../media/store.js", () => ({
|
||||
ensureMediaDir: vi.fn(async () => {}),
|
||||
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
|
||||
}));
|
||||
|
||||
vi.mock("./screenshot.js", () => ({
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
|
||||
normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({
|
||||
buffer: buf,
|
||||
contentType: "image/png",
|
||||
})),
|
||||
}));
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
while (true) {
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
const s = createServer();
|
||||
s.once("error", reject);
|
||||
s.listen(0, "127.0.0.1", () => {
|
||||
const assigned = (s.address() as AddressInfo).port;
|
||||
s.close((err) => (err ? reject(err) : resolve(assigned)));
|
||||
});
|
||||
});
|
||||
if (port < 65535) return port;
|
||||
}
|
||||
}
|
||||
|
||||
function makeResponse(
|
||||
body: unknown,
|
||||
init?: { ok?: boolean; status?: number; text?: string },
|
||||
): Response {
|
||||
const ok = init?.ok ?? true;
|
||||
const status = init?.status ?? 200;
|
||||
const text = init?.text ?? "";
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
text: async () => text,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe("browser control server", () => {
|
||||
beforeEach(async () => {
|
||||
reachable = false;
|
||||
cfgAttachOnly = false;
|
||||
createTargetId = null;
|
||||
|
||||
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
|
||||
if (createTargetId) return { targetId: createTargetId };
|
||||
throw new Error("cdp disabled");
|
||||
});
|
||||
|
||||
for (const fn of Object.values(pwMocks)) fn.mockClear();
|
||||
for (const fn of Object.values(cdpMocks)) fn.mockClear();
|
||||
|
||||
testPort = await getFreePort();
|
||||
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
|
||||
// Minimal CDP JSON endpoints used by the server.
|
||||
let putNewCalls = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/json/list")) {
|
||||
if (!reachable) return makeResponse([]);
|
||||
return makeResponse([
|
||||
{
|
||||
id: "abcd1234",
|
||||
title: "Tab",
|
||||
url: "https://example.com",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
|
||||
type: "page",
|
||||
},
|
||||
{
|
||||
id: "abce9999",
|
||||
title: "Other",
|
||||
url: "https://other",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999",
|
||||
type: "page",
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (u.includes("/json/new?")) {
|
||||
if (init?.method === "PUT") {
|
||||
putNewCalls += 1;
|
||||
if (putNewCalls === 1) {
|
||||
return makeResponse({}, { ok: false, status: 405, text: "" });
|
||||
}
|
||||
}
|
||||
return makeResponse({
|
||||
id: "newtab1",
|
||||
title: "",
|
||||
url: "about:blank",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
|
||||
type: "page",
|
||||
});
|
||||
}
|
||||
if (u.includes("/json/activate/")) return makeResponse("ok");
|
||||
if (u.includes("/json/close/")) return makeResponse("ok");
|
||||
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
|
||||
const startServerAndBase = async () => {
|
||||
const { startBrowserControlServerFromConfig } = await import("./server.js");
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = `http://127.0.0.1:${testPort}`;
|
||||
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
|
||||
return base;
|
||||
};
|
||||
|
||||
const postJson = async <T>(url: string, body?: unknown): Promise<T> => {
|
||||
const res = await realFetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return (await res.json()) as T;
|
||||
};
|
||||
|
||||
it("agent contract: form + layout act commands", async () => {
|
||||
const base = await startServerAndBase();
|
||||
|
||||
const select = (await postJson(`${base}/act`, {
|
||||
kind: "select",
|
||||
ref: "5",
|
||||
values: ["a", "b"],
|
||||
})) as { ok: boolean };
|
||||
expect(select.ok).toBe(true);
|
||||
expect(pwMocks.selectOptionViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
ref: "5",
|
||||
values: ["a", "b"],
|
||||
});
|
||||
|
||||
const fill = (await postJson(`${base}/act`, {
|
||||
kind: "fill",
|
||||
fields: [{ ref: "6", type: "textbox", value: "hello" }],
|
||||
})) as { ok: boolean };
|
||||
expect(fill.ok).toBe(true);
|
||||
expect(pwMocks.fillFormViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
fields: [{ ref: "6", type: "textbox", value: "hello" }],
|
||||
});
|
||||
|
||||
const resize = (await postJson(`${base}/act`, {
|
||||
kind: "resize",
|
||||
width: 800,
|
||||
height: 600,
|
||||
})) as { ok: boolean };
|
||||
expect(resize.ok).toBe(true);
|
||||
expect(pwMocks.resizeViewportViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
|
||||
const wait = (await postJson(`${base}/act`, {
|
||||
kind: "wait",
|
||||
timeMs: 5,
|
||||
})) as { ok: boolean };
|
||||
expect(wait.ok).toBe(true);
|
||||
expect(pwMocks.waitForViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
timeMs: 5,
|
||||
text: undefined,
|
||||
textGone: undefined,
|
||||
});
|
||||
|
||||
const evalRes = (await postJson(`${base}/act`, {
|
||||
kind: "evaluate",
|
||||
fn: "() => 1",
|
||||
})) as { ok: boolean; result?: unknown };
|
||||
expect(evalRes.ok).toBe(true);
|
||||
expect(evalRes.result).toBe("ok");
|
||||
expect(pwMocks.evaluateViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
fn: "() => 1",
|
||||
ref: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("agent contract: hooks + response + downloads + screenshot", async () => {
|
||||
const base = await startServerAndBase();
|
||||
|
||||
const upload = await postJson(`${base}/hooks/file-chooser`, {
|
||||
paths: ["/tmp/a.txt"],
|
||||
timeoutMs: 1234,
|
||||
});
|
||||
expect(upload).toMatchObject({ ok: true });
|
||||
expect(pwMocks.armFileUploadViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
paths: ["/tmp/a.txt"],
|
||||
timeoutMs: 1234,
|
||||
});
|
||||
|
||||
const uploadWithRef = await postJson(`${base}/hooks/file-chooser`, {
|
||||
paths: ["/tmp/b.txt"],
|
||||
ref: "e12",
|
||||
});
|
||||
expect(uploadWithRef).toMatchObject({ ok: true });
|
||||
|
||||
const uploadWithInputRef = await postJson(`${base}/hooks/file-chooser`, {
|
||||
paths: ["/tmp/c.txt"],
|
||||
inputRef: "e99",
|
||||
});
|
||||
expect(uploadWithInputRef).toMatchObject({ ok: true });
|
||||
|
||||
const uploadWithElement = await postJson(`${base}/hooks/file-chooser`, {
|
||||
paths: ["/tmp/d.txt"],
|
||||
element: "input[type=file]",
|
||||
});
|
||||
expect(uploadWithElement).toMatchObject({ ok: true });
|
||||
|
||||
const dialog = await postJson(`${base}/hooks/dialog`, {
|
||||
accept: true,
|
||||
timeoutMs: 5678,
|
||||
});
|
||||
expect(dialog).toMatchObject({ ok: true });
|
||||
|
||||
const waitDownload = await postJson(`${base}/wait/download`, {
|
||||
path: "/tmp/report.pdf",
|
||||
timeoutMs: 1111,
|
||||
});
|
||||
expect(waitDownload).toMatchObject({ ok: true });
|
||||
|
||||
const download = await postJson(`${base}/download`, {
|
||||
ref: "e12",
|
||||
path: "/tmp/report.pdf",
|
||||
});
|
||||
expect(download).toMatchObject({ ok: true });
|
||||
|
||||
const responseBody = await postJson(`${base}/response/body`, {
|
||||
url: "**/api/data",
|
||||
timeoutMs: 2222,
|
||||
maxChars: 10,
|
||||
});
|
||||
expect(responseBody).toMatchObject({ ok: true });
|
||||
|
||||
const consoleRes = (await realFetch(`${base}/console?level=error`).then(
|
||||
(r) => r.json(),
|
||||
)) as { ok: boolean; messages?: unknown[] };
|
||||
expect(consoleRes.ok).toBe(true);
|
||||
expect(Array.isArray(consoleRes.messages)).toBe(true);
|
||||
|
||||
const pdf = (await postJson(`${base}/pdf`, {})) as {
|
||||
ok: boolean;
|
||||
path?: string;
|
||||
};
|
||||
expect(pdf.ok).toBe(true);
|
||||
expect(typeof pdf.path).toBe("string");
|
||||
|
||||
const shot = (await postJson(`${base}/screenshot`, {
|
||||
element: "body",
|
||||
type: "jpeg",
|
||||
})) as { ok: boolean; path?: string };
|
||||
expect(shot.ok).toBe(true);
|
||||
expect(typeof shot.path).toBe("string");
|
||||
});
|
||||
|
||||
it("agent contract: stop endpoint", async () => {
|
||||
const base = await startServerAndBase();
|
||||
|
||||
const stopped = (await realFetch(`${base}/stop`, {
|
||||
method: "POST",
|
||||
}).then((r) => r.json())) as { ok: boolean; stopped?: boolean };
|
||||
expect(stopped.ok).toBe(true);
|
||||
expect(stopped.stopped).toBe(true);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user