chore: Enable "curly" rule to avoid single-statement if confusion/errors.

This commit is contained in:
cpojer
2026-01-31 16:19:20 +09:00
parent 009b16fab8
commit 5ceff756e1
1266 changed files with 27871 additions and 9393 deletions

View File

@@ -35,7 +35,9 @@ export async function startBrowserBridgeServer(params: {
if (authToken) {
app.use((req, res, next) => {
const auth = String(req.headers.authorization ?? "").trim();
if (auth === `Bearer ${authToken}`) return next();
if (auth === `Bearer ${authToken}`) {
return next();
}
res.status(401).send("Unauthorized");
});
}

View File

@@ -32,7 +32,9 @@ export function getHeadersWithAuth(url: string, headers: Record<string, string>
try {
const parsed = new URL(url);
const hasAuthHeader = Object.keys(headers).some((key) => key.toLowerCase() === "authorization");
if (hasAuthHeader) return headers;
if (hasAuthHeader) {
return headers;
}
if (parsed.username || parsed.password) {
const auth = Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64");
return { ...headers, Authorization: `Basic ${auth}` };
@@ -65,7 +67,9 @@ function createCdpSender(ws: WebSocket) {
};
const closeWithError = (err: Error) => {
for (const [, p] of pending) p.reject(err);
for (const [, p] of pending) {
p.reject(err);
}
pending.clear();
try {
ws.close();
@@ -77,9 +81,13 @@ function createCdpSender(ws: WebSocket) {
ws.on("message", (data) => {
try {
const parsed = JSON.parse(rawDataToString(data)) as CdpResponse;
if (typeof parsed.id !== "number") return;
if (typeof parsed.id !== "number") {
return;
}
const p = pending.get(parsed.id);
if (!p) return;
if (!p) {
return;
}
pending.delete(parsed.id);
if (parsed.error?.message) {
p.reject(new Error(parsed.error.message));
@@ -104,7 +112,9 @@ export async function fetchJson<T>(url: string, timeoutMs = 1500, init?: Request
try {
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
const res = await fetch(url, { ...init, headers, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return (await res.json()) as T;
} finally {
clearTimeout(t);
@@ -117,7 +127,9 @@ export async function fetchOk(url: string, timeoutMs = 1500, init?: RequestInit)
try {
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
const res = await fetch(url, { ...init, headers, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
} finally {
clearTimeout(t);
}

View File

@@ -11,12 +11,16 @@ describe("cdp", () => {
afterEach(async () => {
await new Promise<void>((resolve) => {
if (!httpServer) return resolve();
if (!httpServer) {
return resolve();
}
httpServer.close(() => resolve());
httpServer = null;
});
await new Promise<void>((resolve) => {
if (!wsServer) return resolve();
if (!wsServer) {
return resolve();
}
wsServer.close(() => resolve());
wsServer = null;
});
@@ -34,7 +38,9 @@ describe("cdp", () => {
method?: string;
params?: { url?: string };
};
if (msg.method !== "Target.createTarget") return;
if (msg.method !== "Target.createTarget") {
return;
}
socket.send(
JSON.stringify({
id: msg.id,

View File

@@ -8,7 +8,9 @@ export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
if (isLoopbackHost(ws.hostname) && !isLoopbackHost(cdp.hostname)) {
ws.hostname = cdp.hostname;
const cdpPort = cdp.port || (cdp.protocol === "https:" ? "443" : "80");
if (cdpPort) ws.port = cdpPort;
if (cdpPort) {
ws.port = cdpPort;
}
ws.protocol = cdp.protocol === "https:" ? "wss:" : "ws:";
}
if (cdp.protocol === "https:" && ws.protocol === "ws:") {
@@ -19,7 +21,9 @@ export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
ws.password = cdp.password;
}
for (const [key, value] of cdp.searchParams.entries()) {
if (!ws.searchParams.has(key)) ws.searchParams.append(key, value);
if (!ws.searchParams.has(key)) {
ws.searchParams.append(key, value);
}
}
return ws.toString();
}
@@ -71,7 +75,9 @@ export async function captureScreenshot(opts: {
})) as { data?: string };
const base64 = result?.data;
if (!base64) throw new Error("Screenshot failed: missing data");
if (!base64) {
throw new Error("Screenshot failed: missing data");
}
return Buffer.from(base64, "base64");
});
}
@@ -86,14 +92,18 @@ export async function createTargetViaCdp(opts: {
);
const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();
const wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
if (!wsUrl) throw new Error("CDP /json/version missing webSocketDebuggerUrl");
if (!wsUrl) {
throw new Error("CDP /json/version missing webSocketDebuggerUrl");
}
return await withCdpSocket(wsUrl, async (send) => {
const created = (await send("Target.createTarget", { url: opts.url })) as {
targetId?: string;
};
const targetId = String(created?.targetId ?? "").trim();
if (!targetId) throw new Error("CDP Target.createTarget returned no targetId");
if (!targetId) {
throw new Error("CDP Target.createTarget returned no targetId");
}
return { targetId };
});
}
@@ -138,7 +148,9 @@ export async function evaluateJavaScript(opts: {
};
const result = evaluated?.result;
if (!result) throw new Error("CDP Runtime.evaluate returned no result");
if (!result) {
throw new Error("CDP Runtime.evaluate returned no result");
}
return { result, exceptionDetails: evaluated.exceptionDetails };
});
}
@@ -164,9 +176,13 @@ export type RawAXNode = {
};
function axValue(v: unknown): string {
if (!v || typeof v !== "object") return "";
if (!v || typeof v !== "object") {
return "";
}
const value = (v as { value?: unknown }).value;
if (typeof value === "string") return value;
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
@@ -176,25 +192,35 @@ function axValue(v: unknown): string {
export function formatAriaSnapshot(nodes: RawAXNode[], limit: number): AriaSnapshotNode[] {
const byId = new Map<string, RawAXNode>();
for (const n of nodes) {
if (n.nodeId) byId.set(n.nodeId, n);
if (n.nodeId) {
byId.set(n.nodeId, n);
}
}
// Heuristic: pick a root-ish node (one that is not referenced as a child), else first.
const referenced = new Set<string>();
for (const n of nodes) {
for (const c of n.childIds ?? []) referenced.add(c);
for (const c of n.childIds ?? []) {
referenced.add(c);
}
}
const root = nodes.find((n) => n.nodeId && !referenced.has(n.nodeId)) ?? nodes[0];
if (!root?.nodeId) return [];
if (!root?.nodeId) {
return [];
}
const out: AriaSnapshotNode[] = [];
const stack: Array<{ id: string; depth: number }> = [{ id: root.nodeId, depth: 0 }];
while (stack.length && out.length < limit) {
const popped = stack.pop();
if (!popped) break;
if (!popped) {
break;
}
const { id, depth } = popped;
const n = byId.get(id);
if (!n) continue;
if (!n) {
continue;
}
const role = axValue(n.role);
const name = axValue(n.name);
const value = axValue(n.value);
@@ -213,7 +239,9 @@ export function formatAriaSnapshot(nodes: RawAXNode[], limit: number): AriaSnaps
const children = (n.childIds ?? []).filter((c) => byId.has(c));
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i];
if (child) stack.push({ id: child, depth: depth + 1 });
if (child) {
stack.push({ id: child, depth: depth + 1 });
}
}
}
@@ -297,7 +325,9 @@ export async function snapshotDom(opts: {
returnByValue: true,
});
const value = evaluated.result?.value;
if (!value || typeof value !== "object") return { nodes: [] };
if (!value || typeof value !== "object") {
return { nodes: [] };
}
const nodes = (value as { nodes?: unknown }).nodes;
return { nodes: Array.isArray(nodes) ? (nodes as DomSnapshotNode[]) : [] };
}

View File

@@ -39,7 +39,9 @@ describe("browser default executable detection", () => {
});
vi.mocked(fs.existsSync).mockImplementation((p) => {
const value = String(p);
if (value.includes("com.apple.launchservices.secure.plist")) return true;
if (value.includes("com.apple.launchservices.secure.plist")) {
return true;
}
return value.includes("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
});
@@ -65,7 +67,9 @@ describe("browser default executable detection", () => {
});
vi.mocked(fs.existsSync).mockImplementation((p) => {
const value = String(p);
if (value.includes("com.apple.launchservices.secure.plist")) return true;
if (value.includes("com.apple.launchservices.secure.plist")) {
return true;
}
return value.includes("Google Chrome.app/Contents/MacOS/Google Chrome");
});

View File

@@ -115,10 +115,18 @@ function execText(
function inferKindFromIdentifier(identifier: string): BrowserExecutable["kind"] {
const id = identifier.toLowerCase();
if (id.includes("brave")) return "brave";
if (id.includes("edge")) return "edge";
if (id.includes("chromium")) return "chromium";
if (id.includes("canary")) return "canary";
if (id.includes("brave")) {
return "brave";
}
if (id.includes("edge")) {
return "edge";
}
if (id.includes("chromium")) {
return "chromium";
}
if (id.includes("canary")) {
return "canary";
}
if (
id.includes("opera") ||
id.includes("vivaldi") ||
@@ -132,40 +140,63 @@ function inferKindFromIdentifier(identifier: string): BrowserExecutable["kind"]
function inferKindFromExecutableName(name: string): BrowserExecutable["kind"] {
const lower = name.toLowerCase();
if (lower.includes("brave")) return "brave";
if (lower.includes("edge") || lower.includes("msedge")) return "edge";
if (lower.includes("chromium")) return "chromium";
if (lower.includes("canary") || lower.includes("sxs")) return "canary";
if (lower.includes("opera") || lower.includes("vivaldi") || lower.includes("yandex"))
if (lower.includes("brave")) {
return "brave";
}
if (lower.includes("edge") || lower.includes("msedge")) {
return "edge";
}
if (lower.includes("chromium")) {
return "chromium";
}
if (lower.includes("canary") || lower.includes("sxs")) {
return "canary";
}
if (lower.includes("opera") || lower.includes("vivaldi") || lower.includes("yandex")) {
return "chromium";
}
return "chrome";
}
function detectDefaultChromiumExecutable(platform: NodeJS.Platform): BrowserExecutable | null {
if (platform === "darwin") return detectDefaultChromiumExecutableMac();
if (platform === "linux") return detectDefaultChromiumExecutableLinux();
if (platform === "win32") return detectDefaultChromiumExecutableWindows();
if (platform === "darwin") {
return detectDefaultChromiumExecutableMac();
}
if (platform === "linux") {
return detectDefaultChromiumExecutableLinux();
}
if (platform === "win32") {
return detectDefaultChromiumExecutableWindows();
}
return null;
}
function detectDefaultChromiumExecutableMac(): BrowserExecutable | null {
const bundleId = detectDefaultBrowserBundleIdMac();
if (!bundleId || !CHROMIUM_BUNDLE_IDS.has(bundleId)) return null;
if (!bundleId || !CHROMIUM_BUNDLE_IDS.has(bundleId)) {
return null;
}
const appPathRaw = execText("/usr/bin/osascript", [
"-e",
`POSIX path of (path to application id "${bundleId}")`,
]);
if (!appPathRaw) return null;
if (!appPathRaw) {
return null;
}
const appPath = appPathRaw.trim().replace(/\/$/, "");
const exeName = execText("/usr/bin/defaults", [
"read",
path.join(appPath, "Contents", "Info"),
"CFBundleExecutable",
]);
if (!exeName) return null;
if (!exeName) {
return null;
}
const exePath = path.join(appPath, "Contents", "MacOS", exeName.trim());
if (!exists(exePath)) return null;
if (!exists(exePath)) {
return null;
}
return { kind: inferKindFromIdentifier(bundleId), path: exePath };
}
@@ -174,33 +205,45 @@ function detectDefaultBrowserBundleIdMac(): string | null {
os.homedir(),
"Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist",
);
if (!exists(plistPath)) return null;
if (!exists(plistPath)) {
return null;
}
const handlersRaw = execText(
"/usr/bin/plutil",
["-extract", "LSHandlers", "json", "-o", "-", "--", plistPath],
2000,
5 * 1024 * 1024,
);
if (!handlersRaw) return null;
if (!handlersRaw) {
return null;
}
let handlers: unknown;
try {
handlers = JSON.parse(handlersRaw);
} catch {
return null;
}
if (!Array.isArray(handlers)) return null;
if (!Array.isArray(handlers)) {
return null;
}
const resolveScheme = (scheme: string) => {
let candidate: string | null = null;
for (const entry of handlers) {
if (!entry || typeof entry !== "object") continue;
if (!entry || typeof entry !== "object") {
continue;
}
const record = entry as Record<string, unknown>;
if (record.LSHandlerURLScheme !== scheme) continue;
if (record.LSHandlerURLScheme !== scheme) {
continue;
}
const role =
(typeof record.LSHandlerRoleAll === "string" && record.LSHandlerRoleAll) ||
(typeof record.LSHandlerRoleViewer === "string" && record.LSHandlerRoleViewer) ||
null;
if (role) candidate = role;
if (role) {
candidate = role;
}
}
return candidate;
};
@@ -212,19 +255,33 @@ function detectDefaultChromiumExecutableLinux(): BrowserExecutable | null {
const desktopId =
execText("xdg-settings", ["get", "default-web-browser"]) ||
execText("xdg-mime", ["query", "default", "x-scheme-handler/http"]);
if (!desktopId) return null;
if (!desktopId) {
return null;
}
const trimmed = desktopId.trim();
if (!CHROMIUM_DESKTOP_IDS.has(trimmed)) return null;
if (!CHROMIUM_DESKTOP_IDS.has(trimmed)) {
return null;
}
const desktopPath = findDesktopFilePath(trimmed);
if (!desktopPath) return null;
if (!desktopPath) {
return null;
}
const execLine = readDesktopExecLine(desktopPath);
if (!execLine) return null;
if (!execLine) {
return null;
}
const command = extractExecutableFromExecLine(execLine);
if (!command) return null;
if (!command) {
return null;
}
const resolved = resolveLinuxExecutablePath(command);
if (!resolved) return null;
if (!resolved) {
return null;
}
const exeName = path.posix.basename(resolved).toLowerCase();
if (!CHROMIUM_EXE_NAMES.has(exeName)) return null;
if (!CHROMIUM_EXE_NAMES.has(exeName)) {
return null;
}
return { kind: inferKindFromExecutableName(exeName), path: resolved };
}
@@ -232,13 +289,21 @@ function detectDefaultChromiumExecutableWindows(): BrowserExecutable | null {
const progId = readWindowsProgId();
const command =
(progId ? readWindowsCommandForProgId(progId) : null) || readWindowsCommandForProgId("http");
if (!command) return null;
if (!command) {
return null;
}
const expanded = expandWindowsEnvVars(command);
const exePath = extractWindowsExecutablePath(expanded);
if (!exePath) return null;
if (!exists(exePath)) return null;
if (!exePath) {
return null;
}
if (!exists(exePath)) {
return null;
}
const exeName = path.win32.basename(exePath).toLowerCase();
if (!CHROMIUM_EXE_NAMES.has(exeName)) return null;
if (!CHROMIUM_EXE_NAMES.has(exeName)) {
return null;
}
return { kind: inferKindFromExecutableName(exeName), path: exePath };
}
@@ -250,7 +315,9 @@ function findDesktopFilePath(desktopId: string): string | null {
path.join("/var/lib/snapd/desktop/applications", desktopId),
];
for (const candidate of candidates) {
if (exists(candidate)) return candidate;
if (exists(candidate)) {
return candidate;
}
}
return null;
}
@@ -273,9 +340,15 @@ function readDesktopExecLine(desktopPath: string): string | null {
function extractExecutableFromExecLine(execLine: string): string | null {
const tokens = splitExecLine(execLine);
for (const token of tokens) {
if (!token) continue;
if (token === "env") continue;
if (token.includes("=") && !token.startsWith("/") && !token.includes("\\")) continue;
if (!token) {
continue;
}
if (token === "env") {
continue;
}
if (token.includes("=") && !token.startsWith("/") && !token.includes("\\")) {
continue;
}
return token.replace(/^["']|["']$/g, "");
}
return null;
@@ -307,14 +380,20 @@ function splitExecLine(line: string): string[] {
}
current += ch;
}
if (current) tokens.push(current);
if (current) {
tokens.push(current);
}
return tokens;
}
function resolveLinuxExecutablePath(command: string): string | null {
const cleaned = command.trim().replace(/%[a-zA-Z]/g, "");
if (!cleaned) return null;
if (cleaned.startsWith("/")) return cleaned;
if (!cleaned) {
return null;
}
if (cleaned.startsWith("/")) {
return cleaned;
}
const resolved = execText("which", [cleaned], 800);
return resolved ? resolved.trim() : null;
}
@@ -326,7 +405,9 @@ function readWindowsProgId(): string | null {
"/v",
"ProgId",
]);
if (!output) return null;
if (!output) {
return null;
}
const match = output.match(/ProgId\s+REG_\w+\s+(.+)$/im);
return match?.[1]?.trim() || null;
}
@@ -337,7 +418,9 @@ function readWindowsCommandForProgId(progId: string): string | null {
? "HKCR\\http\\shell\\open\\command"
: `HKCR\\${progId}\\shell\\open\\command`;
const output = execText("reg", ["query", key, "/ve"]);
if (!output) return null;
if (!output) {
return null;
}
const match = output.match(/REG_\w+\s+(.+)$/im);
return match?.[1]?.trim() || null;
}
@@ -351,15 +434,21 @@ function expandWindowsEnvVars(value: string): string {
function extractWindowsExecutablePath(command: string): string | null {
const quoted = command.match(/"([^"]+\\.exe)"/i);
if (quoted?.[1]) return quoted[1];
if (quoted?.[1]) {
return quoted[1];
}
const unquoted = command.match(/([^\\s]+\\.exe)/i);
if (unquoted?.[1]) return unquoted[1];
if (unquoted?.[1]) {
return unquoted[1];
}
return null;
}
function findFirstExecutable(candidates: Array<BrowserExecutable>): BrowserExecutable | null {
for (const candidate of candidates) {
if (exists(candidate.path)) return candidate;
if (exists(candidate.path)) {
return candidate;
}
}
return null;
@@ -520,10 +609,18 @@ export function resolveBrowserExecutableForPlatform(
}
const detected = detectDefaultChromiumExecutable(platform);
if (detected) return detected;
if (detected) {
return detected;
}
if (platform === "darwin") return findChromeExecutableMac();
if (platform === "linux") return findChromeExecutableLinux();
if (platform === "win32") return findChromeExecutableWindows();
if (platform === "darwin") {
return findChromeExecutableMac();
}
if (platform === "linux") {
return findChromeExecutableLinux();
}
if (platform === "win32") {
return findChromeExecutableWindows();
}
return null;
}

View File

@@ -12,10 +12,14 @@ function decoratedMarkerPath(userDataDir: string) {
function safeReadJson(filePath: string): Record<string, unknown> | null {
try {
if (!fs.existsSync(filePath)) return null;
if (!fs.existsSync(filePath)) {
return null;
}
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return null;
}
return parsed as Record<string, unknown>;
} catch {
return null;
@@ -41,7 +45,9 @@ function setDeep(obj: Record<string, unknown>, keys: string[], value: unknown) {
function parseHexRgbToSignedArgbInt(hex: string): number | null {
const cleaned = hex.trim().replace(/^#/, "");
if (!/^[0-9a-fA-F]{6}$/.test(cleaned)) return null;
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).

View File

@@ -88,9 +88,13 @@ async function fetchChromeVersion(cdpUrl: string, timeoutMs = 500): Promise<Chro
signal: ctrl.signal,
headers: getHeadersWithAuth(versionUrl),
});
if (!res.ok) return null;
if (!res.ok) {
return null;
}
const data = (await res.json()) as ChromeVersion;
if (!data || typeof data !== "object") return null;
if (!data || typeof data !== "object") {
return null;
}
return data;
} catch {
return null;
@@ -105,7 +109,9 @@ export async function getChromeWebSocketUrl(
): Promise<string | null> {
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
if (!wsUrl) return null;
if (!wsUrl) {
return null;
}
return normalizeCdpWsUrl(wsUrl, cdpUrl);
}
@@ -149,7 +155,9 @@ export async function isChromeCdpReady(
handshakeTimeoutMs = 800,
): Promise<boolean> {
const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs);
if (!wsUrl) return false;
if (!wsUrl) {
return false;
}
return await canOpenWebSocket(wsUrl, handshakeTimeoutMs);
}
@@ -232,7 +240,9 @@ export async function launchOpenClawChrome(
const bootstrap = spawnOnce();
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
if (exists(localStatePath) && exists(preferencesPath)) break;
if (exists(localStatePath) && exists(preferencesPath)) {
break;
}
await new Promise((r) => setTimeout(r, 100));
}
try {
@@ -242,7 +252,9 @@ export async function launchOpenClawChrome(
}
const exitDeadline = Date.now() + 5000;
while (Date.now() < exitDeadline) {
if (bootstrap.exitCode != null) break;
if (bootstrap.exitCode != null) {
break;
}
await new Promise((r) => setTimeout(r, 50));
}
}
@@ -269,7 +281,9 @@ export async function launchOpenClawChrome(
// Wait for CDP to come up.
const readyDeadline = Date.now() + 15_000;
while (Date.now() < readyDeadline) {
if (await isChromeReachable(profile.cdpUrl, 500)) break;
if (await isChromeReachable(profile.cdpUrl, 500)) {
break;
}
await new Promise((r) => setTimeout(r, 200));
}
@@ -301,7 +315,9 @@ export async function launchOpenClawChrome(
export async function stopOpenClawChrome(running: RunningChrome, timeoutMs = 2500) {
const proc = running.proc;
if (proc.killed) return;
if (proc.killed) {
return;
}
try {
proc.kill("SIGTERM");
} catch {
@@ -310,8 +326,12 @@ export async function stopOpenClawChrome(running: RunningChrome, timeoutMs = 250
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (!proc.exitCode && proc.killed) break;
if (!(await isChromeReachable(cdpUrlForPort(running.cdpPort), 200))) return;
if (!proc.exitCode && proc.killed) {
break;
}
if (!(await isChromeReachable(cdpUrlForPort(running.cdpPort), 200))) {
return;
}
await new Promise((r) => setTimeout(r, 100));
}

View File

@@ -11,7 +11,9 @@ function buildProfileQuery(profile?: string): string {
function withBaseUrl(baseUrl: string | undefined, path: string): string {
const trimmed = baseUrl?.trim();
if (!trimmed) return path;
if (!trimmed) {
return path;
}
return `${trimmed.replace(/\/$/, "")}${path}`;
}

View File

@@ -12,7 +12,9 @@ function buildProfileQuery(profile?: string): string {
function withBaseUrl(baseUrl: string | undefined, path: string): string {
const trimmed = baseUrl?.trim();
if (!trimmed) return path;
if (!trimmed) {
return path;
}
return `${trimmed.replace(/\/$/, "")}${path}`;
}
@@ -21,9 +23,15 @@ export async function browserConsoleMessages(
opts: { level?: string; targetId?: string; profile?: string } = {},
): Promise<{ ok: true; messages: BrowserConsoleMessage[]; targetId: string }> {
const q = new URLSearchParams();
if (opts.level) q.set("level", opts.level);
if (opts.targetId) q.set("targetId", opts.targetId);
if (opts.profile) q.set("profile", opts.profile);
if (opts.level) {
q.set("level", opts.level);
}
if (opts.targetId) {
q.set("targetId", opts.targetId);
}
if (opts.profile) {
q.set("profile", opts.profile);
}
const suffix = q.toString() ? `?${q.toString()}` : "";
return await fetchBrowserJson<{
ok: true;
@@ -50,9 +58,15 @@ export async function browserPageErrors(
opts: { targetId?: string; clear?: boolean; profile?: string } = {},
): Promise<{ ok: true; targetId: string; errors: BrowserPageError[] }> {
const q = new URLSearchParams();
if (opts.targetId) q.set("targetId", opts.targetId);
if (typeof opts.clear === "boolean") q.set("clear", String(opts.clear));
if (opts.profile) q.set("profile", opts.profile);
if (opts.targetId) {
q.set("targetId", opts.targetId);
}
if (typeof opts.clear === "boolean") {
q.set("clear", String(opts.clear));
}
if (opts.profile) {
q.set("profile", opts.profile);
}
const suffix = q.toString() ? `?${q.toString()}` : "";
return await fetchBrowserJson<{
ok: true;
@@ -71,10 +85,18 @@ export async function browserRequests(
} = {},
): Promise<{ ok: true; targetId: string; requests: BrowserNetworkRequest[] }> {
const q = new URLSearchParams();
if (opts.targetId) q.set("targetId", opts.targetId);
if (opts.filter) q.set("filter", opts.filter);
if (typeof opts.clear === "boolean") q.set("clear", String(opts.clear));
if (opts.profile) q.set("profile", opts.profile);
if (opts.targetId) {
q.set("targetId", opts.targetId);
}
if (opts.filter) {
q.set("filter", opts.filter);
}
if (typeof opts.clear === "boolean") {
q.set("clear", String(opts.clear));
}
if (opts.profile) {
q.set("profile", opts.profile);
}
const suffix = q.toString() ? `?${q.toString()}` : "";
return await fetchBrowserJson<{
ok: true;

View File

@@ -7,7 +7,9 @@ function buildProfileQuery(profile?: string): string {
function withBaseUrl(baseUrl: string | undefined, path: string): string {
const trimmed = baseUrl?.trim();
if (!trimmed) return path;
if (!trimmed) {
return path;
}
return `${trimmed.replace(/\/$/, "")}${path}`;
}
@@ -16,8 +18,12 @@ export async function browserCookies(
opts: { targetId?: string; profile?: string } = {},
): Promise<{ ok: true; targetId: string; cookies: unknown[] }> {
const q = new URLSearchParams();
if (opts.targetId) q.set("targetId", opts.targetId);
if (opts.profile) q.set("profile", opts.profile);
if (opts.targetId) {
q.set("targetId", opts.targetId);
}
if (opts.profile) {
q.set("profile", opts.profile);
}
const suffix = q.toString() ? `?${q.toString()}` : "";
return await fetchBrowserJson<{
ok: true;
@@ -66,9 +72,15 @@ export async function browserStorageGet(
},
): Promise<{ ok: true; targetId: string; values: Record<string, string> }> {
const q = new URLSearchParams();
if (opts.targetId) q.set("targetId", opts.targetId);
if (opts.key) q.set("key", opts.key);
if (opts.profile) q.set("profile", opts.profile);
if (opts.targetId) {
q.set("targetId", opts.targetId);
}
if (opts.key) {
q.set("key", opts.key);
}
if (opts.profile) {
q.set("profile", opts.profile);
}
const suffix = q.toString() ? `?${q.toString()}` : "";
return await fetchBrowserJson<{
ok: true;

View File

@@ -92,7 +92,9 @@ function buildProfileQuery(profile?: string): string {
function withBaseUrl(baseUrl: string | undefined, path: string): string {
const trimmed = baseUrl?.trim();
if (!trimmed) return path;
if (!trimmed) {
return path;
}
return `${trimmed.replace(/\/$/, "")}${path}`;
}
@@ -291,21 +293,42 @@ export async function browserSnapshot(
): Promise<SnapshotResult> {
const q = new URLSearchParams();
q.set("format", opts.format);
if (opts.targetId) q.set("targetId", opts.targetId);
if (typeof opts.limit === "number") q.set("limit", String(opts.limit));
if (opts.targetId) {
q.set("targetId", opts.targetId);
}
if (typeof opts.limit === "number") {
q.set("limit", String(opts.limit));
}
if (typeof opts.maxChars === "number" && Number.isFinite(opts.maxChars)) {
q.set("maxChars", String(opts.maxChars));
}
if (opts.refs === "aria" || opts.refs === "role") q.set("refs", opts.refs);
if (typeof opts.interactive === "boolean") q.set("interactive", String(opts.interactive));
if (typeof opts.compact === "boolean") q.set("compact", String(opts.compact));
if (typeof opts.depth === "number" && Number.isFinite(opts.depth))
if (opts.refs === "aria" || opts.refs === "role") {
q.set("refs", opts.refs);
}
if (typeof opts.interactive === "boolean") {
q.set("interactive", String(opts.interactive));
}
if (typeof opts.compact === "boolean") {
q.set("compact", String(opts.compact));
}
if (typeof opts.depth === "number" && Number.isFinite(opts.depth)) {
q.set("depth", String(opts.depth));
if (opts.selector?.trim()) q.set("selector", opts.selector.trim());
if (opts.frame?.trim()) q.set("frame", opts.frame.trim());
if (opts.labels === true) q.set("labels", "1");
if (opts.mode) q.set("mode", opts.mode);
if (opts.profile) q.set("profile", opts.profile);
}
if (opts.selector?.trim()) {
q.set("selector", opts.selector.trim());
}
if (opts.frame?.trim()) {
q.set("frame", opts.frame.trim());
}
if (opts.labels === true) {
q.set("labels", "1");
}
if (opts.mode) {
q.set("mode", opts.mode);
}
if (opts.profile) {
q.set("profile", opts.profile);
}
return await fetchBrowserJson<SnapshotResult>(withBaseUrl(baseUrl, `/snapshot?${q.toString()}`), {
timeoutMs: 20000,
});

View File

@@ -57,9 +57,13 @@ function isLoopbackHost(host: string) {
function normalizeHexColor(raw: string | undefined) {
const value = (raw ?? "").trim();
if (!value) return DEFAULT_OPENCLAW_BROWSER_COLOR;
if (!value) {
return DEFAULT_OPENCLAW_BROWSER_COLOR;
}
const normalized = value.startsWith("#") ? value : `#${value}`;
if (!/^#[0-9a-fA-F]{6}$/.test(normalized)) return DEFAULT_OPENCLAW_BROWSER_COLOR;
if (!/^#[0-9a-fA-F]{6}$/.test(normalized)) {
return DEFAULT_OPENCLAW_BROWSER_COLOR;
}
return normalized.toUpperCase();
}
@@ -124,12 +128,18 @@ function ensureDefaultChromeExtensionProfile(
controlPort: number,
): Record<string, BrowserProfileConfig> {
const result = { ...profiles };
if (result.chrome) return result;
if (result.chrome) {
return result;
}
const relayPort = controlPort + 1;
if (!Number.isFinite(relayPort) || relayPort <= 0 || relayPort > 65535) return result;
if (!Number.isFinite(relayPort) || relayPort <= 0 || relayPort > 65535) {
return result;
}
// Avoid adding the built-in profile if the derived relay port is already used by another profile
// (legacy single-profile configs may use controlPort+1 for openclaw/openclaw CDP).
if (getUsedPorts(result).has(relayPort)) return result;
if (getUsedPorts(result).has(relayPort)) {
return result;
}
result.chrome = {
driver: "extension",
cdpUrl: `http://127.0.0.1:${relayPort}`,
@@ -226,7 +236,9 @@ export function resolveProfile(
profileName: string,
): ResolvedBrowserProfile | null {
const profile = resolved.profiles[profileName];
if (!profile) return null;
if (!profile) {
return null;
}
const rawProfileUrl = profile.cdpUrl?.trim() ?? "";
let cdpHost = resolved.cdpHost;

View File

@@ -19,11 +19,15 @@ export function createBrowserControlContext() {
}
export async function startBrowserControlServiceFromConfig(): Promise<BrowserServerState | null> {
if (state) return state;
if (state) {
return state;
}
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
if (!resolved.enabled) return null;
if (!resolved.enabled) {
return null;
}
state = {
server: null,
@@ -36,7 +40,9 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
// so the extension can connect before the first browser action.
for (const name of Object.keys(resolved.profiles)) {
const profile = resolveProfile(resolved, name);
if (!profile || profile.driver !== "extension") continue;
if (!profile || profile.driver !== "extension") {
continue;
}
await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch((err) => {
logService.warn(`Chrome extension relay init failed for profile "${name}": ${String(err)}`);
});
@@ -50,7 +56,9 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
export async function stopBrowserControlService(): Promise<void> {
const current = state;
if (!current) return;
if (!current) {
return;
}
const ctx = createBrowserRouteContext({
getState: () => state,

View File

@@ -18,7 +18,9 @@ async function getFreePort(): Promise<number> {
s.close((err) => (err ? reject(err) : resolve(assigned)));
});
});
if (port < 65535) return port;
if (port < 65535) {
return port;
}
}
}
@@ -36,12 +38,16 @@ function createMessageQueue(ws: WebSocket) {
let waiterTimer: NodeJS.Timeout | null = null;
const flushWaiter = (value: string) => {
if (!waiter) return false;
if (!waiter) {
return false;
}
const resolve = waiter;
waiter = null;
const reject = waiterReject;
waiterReject = null;
if (waiterTimer) clearTimeout(waiterTimer);
if (waiterTimer) {
clearTimeout(waiterTimer);
}
waiterTimer = null;
if (reject) {
// no-op (kept for symmetry)
@@ -59,16 +65,22 @@ function createMessageQueue(ws: WebSocket) {
: Array.isArray(data)
? Buffer.concat(data).toString("utf8")
: Buffer.from(data).toString("utf8");
if (flushWaiter(text)) return;
if (flushWaiter(text)) {
return;
}
queue.push(text);
});
ws.on("error", (err) => {
if (!waiterReject) return;
if (!waiterReject) {
return;
}
const reject = waiterReject;
waiterReject = null;
waiter = null;
if (waiterTimer) clearTimeout(waiterTimer);
if (waiterTimer) {
clearTimeout(waiterTimer);
}
waiterTimer = null;
reject(err instanceof Error ? err : new Error(String(err)));
});
@@ -76,7 +88,9 @@ function createMessageQueue(ws: WebSocket) {
const next = (timeoutMs = 5000) =>
new Promise<string>((resolve, reject) => {
const existing = queue.shift();
if (existing !== undefined) return resolve(existing);
if (existing !== undefined) {
return resolve(existing);
}
waiter = resolve;
waiterReject = reject;
waiterTimer = setTimeout(() => {
@@ -99,7 +113,9 @@ async function waitForListMatch<T>(
const deadline = Date.now() + timeoutMs;
while (true) {
const value = await fetchList();
if (predicate(value)) return value;
if (predicate(value)) {
return value;
}
if (Date.now() >= deadline) {
throw new Error("timeout waiting for list update");
}

View File

@@ -99,11 +99,21 @@ function isLoopbackHost(host: string) {
}
function isLoopbackAddress(ip: string | undefined): boolean {
if (!ip) return false;
if (ip === "127.0.0.1") return true;
if (ip.startsWith("127.")) return true;
if (ip === "::1") return true;
if (ip.startsWith("::ffff:127.")) return true;
if (!ip) {
return false;
}
if (ip === "127.0.0.1") {
return true;
}
if (ip.startsWith("127.")) {
return true;
}
if (ip === "::1") {
return true;
}
if (ip.startsWith("::ffff:127.")) {
return true;
}
return false;
}
@@ -158,7 +168,9 @@ export async function ensureChromeExtensionRelayServer(opts: {
}
const existing = serversByPort.get(info.port);
if (existing) return existing;
if (existing) {
return existing;
}
let extensionWs: WebSocket | null = null;
const cdpClients = new Set<WebSocket>();
@@ -192,13 +204,17 @@ export async function ensureChromeExtensionRelayServer(opts: {
const broadcastToCdpClients = (evt: CdpEvent) => {
const msg = JSON.stringify(evt);
for (const ws of cdpClients) {
if (ws.readyState !== WebSocket.OPEN) continue;
if (ws.readyState !== WebSocket.OPEN) {
continue;
}
ws.send(msg);
}
};
const sendResponseToCdp = (ws: WebSocket, res: CdpResponse) => {
if (ws.readyState !== WebSocket.OPEN) return;
if (ws.readyState !== WebSocket.OPEN) {
return;
}
ws.send(JSON.stringify(res));
};
@@ -253,12 +269,16 @@ export async function ensureChromeExtensionRelayServer(opts: {
const targetId = typeof params.targetId === "string" ? params.targetId : undefined;
if (targetId) {
for (const t of connectedTargets.values()) {
if (t.targetId === targetId) return { targetInfo: t.targetInfo };
if (t.targetId === targetId) {
return { targetInfo: t.targetInfo };
}
}
}
if (cmd.sessionId && connectedTargets.has(cmd.sessionId)) {
const t = connectedTargets.get(cmd.sessionId);
if (t) return { targetInfo: t.targetInfo };
if (t) {
return { targetInfo: t.targetInfo };
}
}
const first = Array.from(connectedTargets.values())[0];
return { targetInfo: first?.targetInfo };
@@ -266,9 +286,13 @@ export async function ensureChromeExtensionRelayServer(opts: {
case "Target.attachToTarget": {
const params = (cmd.params ?? {}) as { targetId?: string };
const targetId = typeof params.targetId === "string" ? params.targetId : undefined;
if (!targetId) throw new Error("targetId required");
if (!targetId) {
throw new Error("targetId required");
}
for (const t of connectedTargets.values()) {
if (t.targetId === targetId) return { sessionId: t.sessionId };
if (t.targetId === targetId) {
return { sessionId: t.sessionId };
}
}
throw new Error("target not found");
}
@@ -322,7 +346,9 @@ export async function ensureChromeExtensionRelayServer(opts: {
"Protocol-Version": "1.3",
};
// Only advertise the WS URL if a real extension is connected.
if (extensionWs) payload.webSocketDebuggerUrl = cdpWsUrl;
if (extensionWs) {
payload.webSocketDebuggerUrl = cdpWsUrl;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(payload));
return;
@@ -438,7 +464,9 @@ export async function ensureChromeExtensionRelayServer(opts: {
extensionWs = ws;
const ping = setInterval(() => {
if (ws.readyState !== WebSocket.OPEN) return;
if (ws.readyState !== WebSocket.OPEN) {
return;
}
ws.send(JSON.stringify({ method: "ping" } satisfies ExtensionPingMessage));
}, 5000);
@@ -452,7 +480,9 @@ export async function ensureChromeExtensionRelayServer(opts: {
if (parsed && typeof parsed === "object" && "id" in parsed && typeof parsed.id === "number") {
const pending = pendingExtension.get(parsed.id);
if (!pending) return;
if (!pending) {
return;
}
pendingExtension.delete(parsed.id);
clearTimeout(pending.timer);
if ("error" in parsed && typeof parsed.error === "string" && parsed.error.trim()) {
@@ -464,18 +494,26 @@ export async function ensureChromeExtensionRelayServer(opts: {
}
if (parsed && typeof parsed === "object" && "method" in parsed) {
if ((parsed as ExtensionPongMessage).method === "pong") return;
if ((parsed as ExtensionForwardEventMessage).method !== "forwardCDPEvent") return;
if ((parsed as ExtensionPongMessage).method === "pong") {
return;
}
if ((parsed as ExtensionForwardEventMessage).method !== "forwardCDPEvent") {
return;
}
const evt = parsed as ExtensionForwardEventMessage;
const method = evt.params?.method;
const params = evt.params?.params;
const sessionId = evt.params?.sessionId;
if (!method || typeof method !== "string") return;
if (!method || typeof method !== "string") {
return;
}
if (method === "Target.attachedToTarget") {
const attached = (params ?? {}) as AttachedToTargetEvent;
const targetType = attached?.targetInfo?.type ?? "page";
if (targetType !== "page") return;
if (targetType !== "page") {
return;
}
if (attached?.sessionId && attached?.targetInfo?.targetId) {
const prev = connectedTargets.get(attached.sessionId);
const nextTargetId = attached.targetInfo.targetId;
@@ -502,7 +540,9 @@ export async function ensureChromeExtensionRelayServer(opts: {
if (method === "Target.detachedFromTarget") {
const detached = (params ?? {}) as DetachedFromTargetEvent;
if (detached?.sessionId) connectedTargets.delete(detached.sessionId);
if (detached?.sessionId) {
connectedTargets.delete(detached.sessionId);
}
broadcastToCdpClients({ method, params, sessionId });
return;
}
@@ -515,7 +555,9 @@ export async function ensureChromeExtensionRelayServer(opts: {
const targetId = targetInfo?.targetId;
if (targetId && (targetInfo?.type ?? "page") === "page") {
for (const [sid, target] of connectedTargets) {
if (target.targetId !== targetId) continue;
if (target.targetId !== targetId) {
continue;
}
connectedTargets.set(sid, {
...target,
targetInfo: { ...target.targetInfo, ...(targetInfo as object) },
@@ -559,8 +601,12 @@ export async function ensureChromeExtensionRelayServer(opts: {
} catch {
return;
}
if (!cmd || typeof cmd !== "object") return;
if (typeof cmd.id !== "number" || typeof cmd.method !== "string") return;
if (!cmd || typeof cmd !== "object") {
return;
}
if (typeof cmd.id !== "number" || typeof cmd.method !== "string") {
return;
}
if (!extensionWs) {
sendResponseToCdp(ws, {
@@ -665,7 +711,9 @@ export async function ensureChromeExtensionRelayServer(opts: {
export async function stopChromeExtensionRelayServer(opts: { cdpUrl: string }): Promise<boolean> {
const info = parseBaseUrl(opts.cdpUrl);
const existing = serversByPort.get(info.port);
if (!existing) return false;
if (!existing) {
return false;
}
await existing.stop();
return true;
}

View File

@@ -124,7 +124,9 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
const deleteProfile = async (nameRaw: string): Promise<DeleteProfileResult> => {
const name = nameRaw.trim();
if (!name) throw new Error("profile name is required");
if (!name) {
throw new Error("profile name is required");
}
if (!isValidProfileName(name)) {
throw new Error("invalid profile name");
}

View File

@@ -18,7 +18,9 @@ export const CDP_PORT_RANGE_END = 18899;
export const PROFILE_NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/;
export function isValidProfileName(name: string): boolean {
if (!name || name.length > 64) return false;
if (!name || name.length > 64) {
return false;
}
return PROFILE_NAME_REGEX.test(name);
}
@@ -31,9 +33,13 @@ export function allocateCdpPort(
if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end <= 0) {
return null;
}
if (start > end) return null;
if (start > end) {
return null;
}
for (let port = start; port <= end; port++) {
if (!usedPorts.has(port)) return port;
if (!usedPorts.has(port)) {
return port;
}
}
return null;
}
@@ -41,7 +47,9 @@ export function allocateCdpPort(
export function getUsedPorts(
profiles: Record<string, { cdpPort?: number; cdpUrl?: string }> | undefined,
): Set<number> {
if (!profiles) return new Set();
if (!profiles) {
return new Set();
}
const used = new Set<number>();
for (const profile of Object.values(profiles)) {
if (typeof profile.cdpPort === "number") {
@@ -49,7 +57,9 @@ export function getUsedPorts(
continue;
}
const rawUrl = profile.cdpUrl?.trim();
if (!rawUrl) continue;
if (!rawUrl) {
continue;
}
try {
const parsed = new URL(rawUrl);
const port =
@@ -97,6 +107,8 @@ export function allocateColor(usedColors: Set<string>): string {
export function getUsedColors(
profiles: Record<string, { color: string }> | undefined,
): Set<string> {
if (!profiles) return new Set();
if (!profiles) {
return new Set();
}
return new Set(Object.values(profiles).map((p) => p.color.toUpperCase()));
}

View File

@@ -9,7 +9,9 @@ let pwAiModuleStrict: Promise<PwAiModule | null> | null = null;
function isModuleNotFoundError(err: unknown): boolean {
const code = extractErrorCode(err);
if (code === "ERR_MODULE_NOT_FOUND") return true;
if (code === "ERR_MODULE_NOT_FOUND") {
return true;
}
const msg = formatErrorMessage(err);
return (
msg.includes("Cannot find module") ||
@@ -24,8 +26,12 @@ async function loadPwAiModule(mode: PwAiLoadMode): Promise<PwAiModule | null> {
try {
return await import("./pw-ai.js");
} catch (err) {
if (mode === "soft") return null;
if (isModuleNotFoundError(err)) return null;
if (mode === "soft") {
return null;
}
if (isModuleNotFoundError(err)) {
return null;
}
throw err;
}
}
@@ -33,9 +39,13 @@ async function loadPwAiModule(mode: PwAiLoadMode): Promise<PwAiModule | null> {
export async function getPwAiModule(opts?: { mode?: PwAiLoadMode }): Promise<PwAiModule | null> {
const mode: PwAiLoadMode = opts?.mode ?? "soft";
if (mode === "soft") {
if (!pwAiModuleSoft) pwAiModuleSoft = loadPwAiModule("soft");
if (!pwAiModuleSoft) {
pwAiModuleSoft = loadPwAiModule("soft");
}
return await pwAiModuleSoft;
}
if (!pwAiModuleStrict) pwAiModuleStrict = loadPwAiModule("strict");
if (!pwAiModuleStrict) {
pwAiModuleStrict = loadPwAiModule("strict");
}
return await pwAiModuleStrict;
}

View File

@@ -125,7 +125,9 @@ function createRoleNameTracker(): RoleNameTracker {
getDuplicateKeys() {
const out = new Set<string>();
for (const [key, refs] of refsByKey) {
if (refs.length > 1) out.add(key);
if (refs.length > 1) {
out.add(key);
}
}
return out;
},
@@ -136,7 +138,9 @@ function removeNthFromNonDuplicates(refs: RoleRefMap, tracker: RoleNameTracker)
const duplicates = tracker.getDuplicateKeys();
for (const [ref, data] of Object.entries(refs)) {
const key = tracker.getKey(data.role, data.name);
if (!duplicates.has(key)) delete refs[ref]?.nth;
if (!duplicates.has(key)) {
delete refs[ref]?.nth;
}
}
}
@@ -159,13 +163,17 @@ function compactTree(tree: string) {
let hasRelevantChildren = false;
for (let j = i + 1; j < lines.length; j += 1) {
const childIndent = getIndentLevel(lines[j]);
if (childIndent <= currentIndent) break;
if (childIndent <= currentIndent) {
break;
}
if (lines[j]?.includes("[ref=")) {
hasRelevantChildren = true;
break;
}
}
if (hasRelevantChildren) result.push(line);
if (hasRelevantChildren) {
result.push(line);
}
}
return result.join("\n");
@@ -179,24 +187,36 @@ function processLine(
nextRef: () => string,
): string | null {
const depth = getIndentLevel(line);
if (options.maxDepth !== undefined && depth > options.maxDepth) return null;
if (options.maxDepth !== undefined && depth > options.maxDepth) {
return null;
}
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
if (!match) return options.interactive ? null : line;
if (!match) {
return options.interactive ? null : line;
}
const [, prefix, roleRaw, name, suffix] = match;
if (roleRaw.startsWith("/")) return options.interactive ? null : line;
if (roleRaw.startsWith("/")) {
return options.interactive ? null : line;
}
const role = roleRaw.toLowerCase();
const isInteractive = INTERACTIVE_ROLES.has(role);
const isContent = CONTENT_ROLES.has(role);
const isStructural = STRUCTURAL_ROLES.has(role);
if (options.interactive && !isInteractive) return null;
if (options.compact && isStructural && !name) return null;
if (options.interactive && !isInteractive) {
return null;
}
if (options.compact && isStructural && !name) {
return null;
}
const shouldHaveRef = isInteractive || (isContent && name);
if (!shouldHaveRef) return line;
if (!shouldHaveRef) {
return line;
}
const ref = nextRef();
const nth = tracker.getNextIndex(role, name);
@@ -208,16 +228,24 @@ function processLine(
};
let enhanced = `${prefix}${roleRaw}`;
if (name) enhanced += ` "${name}"`;
if (name) {
enhanced += ` "${name}"`;
}
enhanced += ` [ref=${ref}]`;
if (nth > 0) enhanced += ` [nth=${nth}]`;
if (suffix) enhanced += suffix;
if (nth > 0) {
enhanced += ` [nth=${nth}]`;
}
if (suffix) {
enhanced += suffix;
}
return enhanced;
}
export function parseRoleRef(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;
if (!trimmed) {
return null;
}
const normalized = trimmed.startsWith("@")
? trimmed.slice(1)
: trimmed.startsWith("ref=")
@@ -244,15 +272,23 @@ export function buildRoleSnapshotFromAriaSnapshot(
const result: string[] = [];
for (const line of lines) {
const depth = getIndentLevel(line);
if (options.maxDepth !== undefined && depth > options.maxDepth) continue;
if (options.maxDepth !== undefined && depth > options.maxDepth) {
continue;
}
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
if (!match) continue;
if (!match) {
continue;
}
const [, , roleRaw, name, suffix] = match;
if (roleRaw.startsWith("/")) continue;
if (roleRaw.startsWith("/")) {
continue;
}
const role = roleRaw.toLowerCase();
if (!INTERACTIVE_ROLES.has(role)) continue;
if (!INTERACTIVE_ROLES.has(role)) {
continue;
}
const ref = nextRef();
const nth = tracker.getNextIndex(role, name);
@@ -264,10 +300,16 @@ export function buildRoleSnapshotFromAriaSnapshot(
};
let enhanced = `- ${roleRaw}`;
if (name) enhanced += ` "${name}"`;
if (name) {
enhanced += ` "${name}"`;
}
enhanced += ` [ref=${ref}]`;
if (nth > 0) enhanced += ` [nth=${nth}]`;
if (suffix.includes("[")) enhanced += suffix;
if (nth > 0) {
enhanced += ` [nth=${nth}]`;
}
if (suffix.includes("[")) {
enhanced += suffix;
}
result.push(enhanced);
}
@@ -282,7 +324,9 @@ export function buildRoleSnapshotFromAriaSnapshot(
const result: string[] = [];
for (const line of lines) {
const processed = processLine(line, refs, options, tracker, nextRef);
if (processed !== null) result.push(processed);
if (processed !== null) {
result.push(processed);
}
}
removeNthFromNonDuplicates(refs, tracker);
@@ -314,15 +358,25 @@ export function buildRoleSnapshotFromAiSnapshot(
const out: string[] = [];
for (const line of lines) {
const depth = getIndentLevel(line);
if (options.maxDepth !== undefined && depth > options.maxDepth) continue;
if (options.maxDepth !== undefined && depth > options.maxDepth) {
continue;
}
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
if (!match) continue;
if (!match) {
continue;
}
const [, , roleRaw, name, suffix] = match;
if (roleRaw.startsWith("/")) continue;
if (roleRaw.startsWith("/")) {
continue;
}
const role = roleRaw.toLowerCase();
if (!INTERACTIVE_ROLES.has(role)) continue;
if (!INTERACTIVE_ROLES.has(role)) {
continue;
}
const ref = parseAiSnapshotRef(suffix);
if (!ref) continue;
if (!ref) {
continue;
}
refs[ref] = { role, ...(name ? { name } : {}) };
out.push(`- ${roleRaw}${name ? ` "${name}"` : ""}${suffix}`);
}
@@ -335,7 +389,9 @@ export function buildRoleSnapshotFromAiSnapshot(
const out: string[] = [];
for (const line of lines) {
const depth = getIndentLevel(line);
if (options.maxDepth !== undefined && depth > options.maxDepth) continue;
if (options.maxDepth !== undefined && depth > options.maxDepth) {
continue;
}
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
if (!match) {
@@ -351,10 +407,14 @@ export function buildRoleSnapshotFromAiSnapshot(
const role = roleRaw.toLowerCase();
const isStructural = STRUCTURAL_ROLES.has(role);
if (options.compact && isStructural && !name) continue;
if (options.compact && isStructural && !name) {
continue;
}
const ref = parseAiSnapshotRef(suffix);
if (ref) refs[ref] = { role, ...(name ? { name } : {}) };
if (ref) {
refs[ref] = { role, ...(name ? { name } : {}) };
}
out.push(line);
}

View File

@@ -11,7 +11,9 @@ async function waitFor(
): Promise<void> {
const deadline = Date.now() + opts.timeoutMs;
while (Date.now() < deadline) {
if (await fn()) return;
if (await fn()) {
return;
}
await new Promise((r) => setTimeout(r, opts.intervalMs));
}
throw new Error("timed out");

View File

@@ -117,7 +117,9 @@ export function rememberRoleRefsForTarget(opts: {
mode?: NonNullable<PageState["roleRefsMode"]>;
}): void {
const targetId = opts.targetId.trim();
if (!targetId) return;
if (!targetId) {
return;
}
roleRefsByTarget.set(roleRefsKey(opts.cdpUrl, targetId), {
refs: opts.refs,
...(opts.frameSelector ? { frameSelector: opts.frameSelector } : {}),
@@ -125,7 +127,9 @@ export function rememberRoleRefsForTarget(opts: {
});
while (roleRefsByTarget.size > MAX_ROLE_REFS_CACHE) {
const first = roleRefsByTarget.keys().next();
if (first.done) break;
if (first.done) {
break;
}
roleRefsByTarget.delete(first.value);
}
}
@@ -142,7 +146,9 @@ export function storeRoleRefsForTarget(opts: {
state.roleRefs = opts.refs;
state.roleRefsFrameSelector = opts.frameSelector;
state.roleRefsMode = opts.mode;
if (!opts.targetId?.trim()) return;
if (!opts.targetId?.trim()) {
return;
}
rememberRoleRefsForTarget({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
@@ -158,11 +164,17 @@ export function restoreRoleRefsForTarget(opts: {
page: Page;
}): void {
const targetId = opts.targetId?.trim() || "";
if (!targetId) return;
if (!targetId) {
return;
}
const cached = roleRefsByTarget.get(roleRefsKey(opts.cdpUrl, targetId));
if (!cached) return;
if (!cached) {
return;
}
const state = ensurePageState(opts.page);
if (state.roleRefs) return;
if (state.roleRefs) {
return;
}
state.roleRefs = cached.refs;
state.roleRefsFrameSelector = cached.frameSelector;
state.roleRefsMode = cached.mode;
@@ -170,7 +182,9 @@ export function restoreRoleRefsForTarget(opts: {
export function ensurePageState(page: Page): PageState {
const existing = pageStates.get(page);
if (existing) return existing;
if (existing) {
return existing;
}
const state: PageState = {
console: [],
@@ -194,7 +208,9 @@ export function ensurePageState(page: Page): PageState {
location: msg.location(),
};
state.console.push(entry);
if (state.console.length > MAX_CONSOLE_MESSAGES) state.console.shift();
if (state.console.length > MAX_CONSOLE_MESSAGES) {
state.console.shift();
}
});
page.on("pageerror", (err: Error) => {
state.errors.push({
@@ -203,7 +219,9 @@ export function ensurePageState(page: Page): PageState {
stack: err?.stack ? String(err.stack) : undefined,
timestamp: new Date().toISOString(),
});
if (state.errors.length > MAX_PAGE_ERRORS) state.errors.shift();
if (state.errors.length > MAX_PAGE_ERRORS) {
state.errors.shift();
}
});
page.on("request", (req: Request) => {
state.nextRequestId += 1;
@@ -216,12 +234,16 @@ export function ensurePageState(page: Page): PageState {
url: req.url(),
resourceType: req.resourceType(),
});
if (state.requests.length > MAX_NETWORK_REQUESTS) state.requests.shift();
if (state.requests.length > MAX_NETWORK_REQUESTS) {
state.requests.shift();
}
});
page.on("response", (resp: Response) => {
const req = resp.request();
const id = state.requestIds.get(req);
if (!id) return;
if (!id) {
return;
}
let rec: BrowserNetworkRequest | undefined;
for (let i = state.requests.length - 1; i >= 0; i -= 1) {
const candidate = state.requests[i];
@@ -230,13 +252,17 @@ export function ensurePageState(page: Page): PageState {
break;
}
}
if (!rec) return;
if (!rec) {
return;
}
rec.status = resp.status();
rec.ok = resp.ok();
});
page.on("requestfailed", (req: Request) => {
const id = state.requestIds.get(req);
if (!id) return;
if (!id) {
return;
}
let rec: BrowserNetworkRequest | undefined;
for (let i = state.requests.length - 1; i >= 0; i -= 1) {
const candidate = state.requests[i];
@@ -245,7 +271,9 @@ export function ensurePageState(page: Page): PageState {
break;
}
}
if (!rec) return;
if (!rec) {
return;
}
rec.failureText = req.failure()?.errorText;
rec.ok = false;
});
@@ -259,30 +287,42 @@ export function ensurePageState(page: Page): PageState {
}
function observeContext(context: BrowserContext) {
if (observedContexts.has(context)) return;
if (observedContexts.has(context)) {
return;
}
observedContexts.add(context);
ensureContextState(context);
for (const page of context.pages()) ensurePageState(page);
for (const page of context.pages()) {
ensurePageState(page);
}
context.on("page", (page) => ensurePageState(page));
}
export function ensureContextState(context: BrowserContext): ContextState {
const existing = contextStates.get(context);
if (existing) return existing;
if (existing) {
return existing;
}
const state: ContextState = { traceActive: false };
contextStates.set(context, state);
return state;
}
function observeBrowser(browser: Browser) {
for (const context of browser.contexts()) observeContext(context);
for (const context of browser.contexts()) {
observeContext(context);
}
}
async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
const normalized = normalizeCdpUrl(cdpUrl);
if (cached?.cdpUrl === normalized) return cached;
if (connecting) return await connecting;
if (cached?.cdpUrl === normalized) {
return cached;
}
if (connecting) {
return await connecting;
}
const connectWithRetry = async (): Promise<ConnectedBrowser> => {
let lastErr: unknown;
@@ -297,7 +337,9 @@ async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
cached = connected;
observeBrowser(browser);
browser.on("disconnected", () => {
if (cached?.browser === browser) cached = null;
if (cached?.browser === browser) {
cached = null;
}
});
return connected;
} catch (err) {
@@ -346,7 +388,9 @@ async function findPageByTargetId(
// First, try the standard CDP session approach
for (const page of pages) {
const tid = await pageTargetId(page).catch(() => null);
if (tid && tid === targetId) return page;
if (tid && tid === targetId) {
return page;
}
}
// If CDP sessions fail (e.g., extension relay blocks Target.attachToBrowserTarget),
// fall back to URL-based matching using the /json/list endpoint
@@ -396,15 +440,21 @@ export async function getPageForTargetId(opts: {
}): Promise<Page> {
const { browser } = await connectBrowser(opts.cdpUrl);
const pages = await getAllPages(browser);
if (!pages.length) throw new Error("No pages available in the connected browser.");
if (!pages.length) {
throw new Error("No pages available in the connected browser.");
}
const first = pages[0];
if (!opts.targetId) return first;
if (!opts.targetId) {
return first;
}
const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!found) {
// Extension relays can block CDP attachment APIs (e.g. Target.attachToBrowserTarget),
// which prevents us from resolving a page's targetId via newCDPSession(). If Playwright
// only exposes a single Page, use it as a best-effort fallback.
if (pages.length === 1) return first;
if (pages.length === 1) {
return first;
}
throw new Error("tab not found");
}
return found;
@@ -452,7 +502,9 @@ export function refLocator(page: Page, ref: string) {
export async function closePlaywrightBrowserConnection(): Promise<void> {
const cur = cached;
cached = null;
if (!cur) return;
if (!cur) {
return;
}
await cur.browser.close().catch(() => {});
}

View File

@@ -13,7 +13,9 @@ export async function getPageErrorsViaPlaywright(opts: {
const page = await getPageForTargetId(opts);
const state = ensurePageState(page);
const errors = [...state.errors];
if (opts.clear) state.errors = [];
if (opts.clear) {
state.errors = [];
}
return { errors };
}
@@ -58,7 +60,9 @@ export async function getConsoleMessagesViaPlaywright(opts: {
}): Promise<BrowserConsoleMessage[]> {
const page = await getPageForTargetId(opts);
const state = ensurePageState(page);
if (!opts.level) return [...state.console];
if (!opts.level) {
return [...state.console];
}
const min = consolePriority(opts.level);
return state.console.filter((msg) => consolePriority(msg.type) >= min);
}

View File

@@ -11,13 +11,17 @@ let pageState: {
const sessionMocks = vi.hoisted(() => ({
getPageForTargetId: vi.fn(async () => {
if (!currentPage) throw new Error("missing page");
if (!currentPage) {
throw new Error("missing page");
}
return currentPage;
}),
ensurePageState: vi.fn(() => pageState),
restoreRoleRefsForTarget: vi.fn(() => {}),
refLocator: vi.fn(() => {
if (!currentRefLocator) throw new Error("missing locator");
if (!currentRefLocator) {
throw new Error("missing locator");
}
return currentRefLocator;
}),
rememberRoleRefsForTarget: vi.fn(() => {}),
@@ -39,7 +43,9 @@ describe("pw-tools-core", () => {
armIdDialog: 0,
armIdDownload: 0,
};
for (const fn of Object.values(sessionMocks)) fn.mockClear();
for (const fn of Object.values(sessionMocks)) {
fn.mockClear();
}
});
it("clamps timeoutMs for scrollIntoView", async () => {

View File

@@ -31,7 +31,9 @@ function createPageDownloadWaiter(page: Page, timeoutMs: number) {
let handler: ((download: unknown) => void) | undefined;
const cleanup = () => {
if (timer) clearTimeout(timer);
if (timer) {
clearTimeout(timer);
}
timer = undefined;
if (handler) {
page.off("download", handler as never);
@@ -41,7 +43,9 @@ function createPageDownloadWaiter(page: Page, timeoutMs: number) {
const promise = new Promise<unknown>((resolve, reject) => {
handler = (download: unknown) => {
if (done) return;
if (done) {
return;
}
done = true;
cleanup();
resolve(download);
@@ -49,7 +53,9 @@ function createPageDownloadWaiter(page: Page, timeoutMs: number) {
page.on("download", handler as never);
timer = setTimeout(() => {
if (done) return;
if (done) {
return;
}
done = true;
cleanup();
reject(new Error("Timeout waiting for download"));
@@ -59,7 +65,9 @@ function createPageDownloadWaiter(page: Page, timeoutMs: number) {
return {
promise,
cancel: () => {
if (done) return;
if (done) {
return;
}
done = true;
cleanup();
},
@@ -82,7 +90,9 @@ export async function armFileUploadViaPlaywright(opts: {
void page
.waitForEvent("filechooser", { timeout })
.then(async (fileChooser) => {
if (state.armIdUpload !== armId) return;
if (state.armIdUpload !== armId) {
return;
}
if (!opts.paths?.length) {
// Playwright removed `FileChooser.cancel()`; best-effort close the chooser instead.
try {
@@ -130,9 +140,14 @@ export async function armDialogViaPlaywright(opts: {
void page
.waitForEvent("dialog", { timeout })
.then(async (dialog) => {
if (state.armIdDialog !== armId) return;
if (opts.accept) await dialog.accept(opts.promptText);
else await dialog.dismiss();
if (state.armIdDialog !== armId) {
return;
}
if (opts.accept) {
await dialog.accept(opts.promptText);
} else {
await dialog.dismiss();
}
})
.catch(() => {
// Ignore timeouts; the dialog may never appear.
@@ -199,7 +214,9 @@ export async function downloadViaPlaywright(opts: {
const ref = requireRef(opts.ref);
const outPath = String(opts.path ?? "").trim();
if (!outPath) throw new Error("path is required");
if (!outPath) {
throw new Error("path is required");
}
state.armIdDownload = bumpDownloadArmId();
const armId = state.armIdDownload;

View File

@@ -88,7 +88,9 @@ export async function dragViaPlaywright(opts: {
}): Promise<void> {
const startRef = requireRef(opts.startRef);
const endRef = requireRef(opts.endRef);
if (!startRef || !endRef) throw new Error("startRef and endRef are required");
if (!startRef || !endRef) {
throw new Error("startRef and endRef are required");
}
const page = await getPageForTargetId(opts);
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
@@ -109,7 +111,9 @@ export async function selectOptionViaPlaywright(opts: {
timeoutMs?: number;
}): Promise<void> {
const ref = requireRef(opts.ref);
if (!opts.values?.length) throw new Error("values are required");
if (!opts.values?.length) {
throw new Error("values are required");
}
const page = await getPageForTargetId(opts);
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
@@ -129,7 +133,9 @@ export async function pressKeyViaPlaywright(opts: {
delayMs?: number;
}): Promise<void> {
const key = String(opts.key ?? "").trim();
if (!key) throw new Error("key is required");
if (!key) {
throw new Error("key is required");
}
const page = await getPageForTargetId(opts);
ensurePageState(page);
await page.keyboard.press(key, {
@@ -188,7 +194,9 @@ export async function fillFormViaPlaywright(opts: {
: typeof rawValue === "number" || typeof rawValue === "boolean"
? String(rawValue)
: "";
if (!ref || !type) continue;
if (!ref || !type) {
continue;
}
const locator = refLocator(page, ref);
if (type === "checkbox" || type === "radio") {
const checked =
@@ -215,7 +223,9 @@ export async function evaluateViaPlaywright(opts: {
ref?: string;
}): Promise<unknown> {
const fnText = String(opts.fn ?? "").trim();
if (!fnText) throw new Error("function is required");
if (!fnText) {
throw new Error("function is required");
}
const page = await getPageForTargetId(opts);
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
@@ -344,13 +354,17 @@ export async function takeScreenshotViaPlaywright(opts: {
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
const type = opts.type ?? "png";
if (opts.ref) {
if (opts.fullPage) throw new Error("fullPage is not supported for element screenshots");
if (opts.fullPage) {
throw new Error("fullPage is not supported for element screenshots");
}
const locator = refLocator(page, opts.ref);
const buffer = await locator.screenshot({ type });
return { buffer };
}
if (opts.element) {
if (opts.fullPage) throw new Error("fullPage is not supported for element screenshots");
if (opts.fullPage) {
throw new Error("fullPage is not supported for element screenshots");
}
const locator = page.locator(opts.element).first();
const buffer = await locator.screenshot({ type });
return { buffer };
@@ -499,7 +513,9 @@ export async function setInputFilesViaPlaywright(opts: {
const page = await getPageForTargetId(opts);
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
if (!opts.paths.length) throw new Error("paths are required");
if (!opts.paths.length) {
throw new Error("paths are required");
}
const inputRef = typeof opts.inputRef === "string" ? opts.inputRef.trim() : "";
const element = typeof opts.element === "string" ? opts.element.trim() : "";
if (inputRef && element) {

View File

@@ -11,13 +11,17 @@ let pageState: {
const sessionMocks = vi.hoisted(() => ({
getPageForTargetId: vi.fn(async () => {
if (!currentPage) throw new Error("missing page");
if (!currentPage) {
throw new Error("missing page");
}
return currentPage;
}),
ensurePageState: vi.fn(() => pageState),
restoreRoleRefsForTarget: vi.fn(() => {}),
refLocator: vi.fn(() => {
if (!currentRefLocator) throw new Error("missing locator");
if (!currentRefLocator) {
throw new Error("missing locator");
}
return currentRefLocator;
}),
rememberRoleRefsForTarget: vi.fn(() => {}),
@@ -39,7 +43,9 @@ describe("pw-tools-core", () => {
armIdDialog: 0,
armIdDownload: 0,
};
for (const fn of Object.values(sessionMocks)) fn.mockClear();
for (const fn of Object.values(sessionMocks)) {
fn.mockClear();
}
});
it("last file-chooser arm wins", async () => {

View File

@@ -4,8 +4,12 @@ import { normalizeTimeoutMs } from "./pw-tools-core.shared.js";
function matchUrlPattern(pattern: string, url: string): boolean {
const p = pattern.trim();
if (!p) return false;
if (p === url) return true;
if (!p) {
return false;
}
if (p === url) {
return true;
}
if (p.includes("*")) {
const escaped = p.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
const regex = new RegExp(`^${escaped.replace(/\*\*/g, ".*").replace(/\*/g, ".*")}$`);
@@ -28,7 +32,9 @@ export async function responseBodyViaPlaywright(opts: {
truncated?: boolean;
}> {
const pattern = String(opts.url ?? "").trim();
if (!pattern) throw new Error("url is required");
if (!pattern) {
throw new Error("url is required");
}
const maxChars =
typeof opts.maxChars === "number" && Number.isFinite(opts.maxChars)
? Math.max(1, Math.min(5_000_000, Math.floor(opts.maxChars)))
@@ -44,16 +50,24 @@ export async function responseBodyViaPlaywright(opts: {
let handler: ((resp: unknown) => void) | undefined;
const cleanup = () => {
if (timer) clearTimeout(timer);
if (timer) {
clearTimeout(timer);
}
timer = undefined;
if (handler) page.off("response", handler as never);
if (handler) {
page.off("response", handler as never);
}
};
handler = (resp: unknown) => {
if (done) return;
if (done) {
return;
}
const r = resp as { url?: () => string };
const u = r.url?.() || "";
if (!matchUrlPattern(pattern, u)) return;
if (!matchUrlPattern(pattern, u)) {
return;
}
done = true;
cleanup();
resolve(resp);
@@ -61,7 +75,9 @@ export async function responseBodyViaPlaywright(opts: {
page.on("response", handler as never);
timer = setTimeout(() => {
if (done) return;
if (done) {
return;
}
done = true;
cleanup();
reject(

View File

@@ -11,13 +11,17 @@ let pageState: {
const sessionMocks = vi.hoisted(() => ({
getPageForTargetId: vi.fn(async () => {
if (!currentPage) throw new Error("missing page");
if (!currentPage) {
throw new Error("missing page");
}
return currentPage;
}),
ensurePageState: vi.fn(() => pageState),
restoreRoleRefsForTarget: vi.fn(() => {}),
refLocator: vi.fn(() => {
if (!currentRefLocator) throw new Error("missing locator");
if (!currentRefLocator) {
throw new Error("missing locator");
}
return currentRefLocator;
}),
rememberRoleRefsForTarget: vi.fn(() => {}),
@@ -39,7 +43,9 @@ describe("pw-tools-core", () => {
armIdDialog: 0,
armIdDownload: 0,
};
for (const fn of Object.values(sessionMocks)) fn.mockClear();
for (const fn of Object.values(sessionMocks)) {
fn.mockClear();
}
});
it("screenshots an element selector", async () => {

View File

@@ -23,7 +23,9 @@ export function requireRef(value: unknown): string {
const raw = typeof value === "string" ? value.trim() : "";
const roleRef = raw ? parseRoleRef(raw) : null;
const ref = roleRef ?? (raw.startsWith("@") ? raw.slice(1) : raw);
if (!ref) throw new Error("ref is required");
if (!ref) {
throw new Error("ref is required");
}
return ref;
}

View File

@@ -160,7 +160,9 @@ export async function navigateViaPlaywright(opts: {
timeoutMs?: number;
}): Promise<{ url: string }> {
const url = String(opts.url ?? "").trim();
if (!url) throw new Error("url is required");
if (!url) {
throw new Error("url is required");
}
const page = await getPageForTargetId(opts);
ensurePageState(page);
await page.goto(url, {

View File

@@ -47,7 +47,9 @@ export async function setHttpCredentialsViaPlaywright(opts: {
}
const username = String(opts.username ?? "");
const password = String(opts.password ?? "");
if (!username) throw new Error("username is required (or set clear=true)");
if (!username) {
throw new Error("username is required (or set clear=true)");
}
await page.context().setHTTPCredentials({ username, password });
}
@@ -108,7 +110,9 @@ export async function setLocaleViaPlaywright(opts: {
const page = await getPageForTargetId(opts);
ensurePageState(page);
const locale = String(opts.locale ?? "").trim();
if (!locale) throw new Error("locale is required");
if (!locale) {
throw new Error("locale is required");
}
await withCdpSession(page, async (session) => {
try {
await session.send("Emulation.setLocaleOverride", { locale });
@@ -129,15 +133,20 @@ export async function setTimezoneViaPlaywright(opts: {
const page = await getPageForTargetId(opts);
ensurePageState(page);
const timezoneId = String(opts.timezoneId ?? "").trim();
if (!timezoneId) throw new Error("timezoneId is required");
if (!timezoneId) {
throw new Error("timezoneId is required");
}
await withCdpSession(page, async (session) => {
try {
await session.send("Emulation.setTimezoneOverride", { timezoneId });
} catch (err) {
const msg = String(err);
if (msg.includes("Timezone override is already in effect")) return;
if (msg.includes("Invalid timezone"))
if (msg.includes("Timezone override is already in effect")) {
return;
}
if (msg.includes("Invalid timezone")) {
throw new Error(`Invalid timezone ID: ${timezoneId}`, { cause: err });
}
throw err;
}
});
@@ -151,7 +160,9 @@ export async function setDeviceViaPlaywright(opts: {
const page = await getPageForTargetId(opts);
ensurePageState(page);
const name = String(opts.name ?? "").trim();
if (!name) throw new Error("device name is required");
if (!name) {
throw new Error("device name is required");
}
const descriptor = (playwrightDevices as Record<string, unknown>)[name] as
| {
userAgent?: string;

View File

@@ -74,9 +74,13 @@ export async function storageGetViaPlaywright(opts: {
const out: Record<string, string> = {};
for (let i = 0; i < store.length; i += 1) {
const k = store.key(i);
if (!k) continue;
if (!k) {
continue;
}
const v = store.getItem(k);
if (v !== null) out[k] = v;
if (v !== null) {
out[k] = v;
}
}
return out;
},
@@ -95,7 +99,9 @@ export async function storageSetViaPlaywright(opts: {
const page = await getPageForTargetId(opts);
ensurePageState(page);
const key = String(opts.key ?? "");
if (!key) throw new Error("key is required");
if (!key) {
throw new Error("key is required");
}
await page.evaluate(
({ kind, key: k, value }) => {
const store = kind === "session" ? window.sessionStorage : window.localStorage;

View File

@@ -12,13 +12,17 @@ let pageState: {
const sessionMocks = vi.hoisted(() => ({
getPageForTargetId: vi.fn(async () => {
if (!currentPage) throw new Error("missing page");
if (!currentPage) {
throw new Error("missing page");
}
return currentPage;
}),
ensurePageState: vi.fn(() => pageState),
restoreRoleRefsForTarget: vi.fn(() => {}),
refLocator: vi.fn(() => {
if (!currentRefLocator) throw new Error("missing locator");
if (!currentRefLocator) {
throw new Error("missing locator");
}
return currentRefLocator;
}),
rememberRoleRefsForTarget: vi.fn(() => {}),
@@ -40,13 +44,17 @@ describe("pw-tools-core", () => {
armIdDialog: 0,
armIdDownload: 0,
};
for (const fn of Object.values(sessionMocks)) fn.mockClear();
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;
if (event === "download") {
downloadHandler = handler;
}
});
const off = vi.fn();
@@ -79,7 +87,9 @@ describe("pw-tools-core", () => {
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;
if (event === "download") {
downloadHandler = handler;
}
});
const off = vi.fn();
@@ -118,7 +128,9 @@ describe("pw-tools-core", () => {
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;
if (event === "response") {
responseHandler = handler;
}
});
const off = vi.fn();
currentPage = { on, off };

View File

@@ -16,7 +16,9 @@ export const ACT_KINDS = [
export type ActKind = (typeof ACT_KINDS)[number];
export function isActKind(value: unknown): value is ActKind {
if (typeof value !== "string") return false;
if (typeof value !== "string") {
return false;
}
return (ACT_KINDS as readonly string[]).includes(value);
}
@@ -32,7 +34,9 @@ const ALLOWED_CLICK_MODIFIERS = new Set<ClickModifier>([
]);
export function parseClickButton(raw: string): ClickButton | undefined {
if (raw === "left" || raw === "right" || raw === "middle") return raw;
if (raw === "left" || raw === "right" || raw === "middle") {
return raw;
}
return undefined;
}

View File

@@ -22,7 +22,9 @@ export function registerBrowserAgentActRoutes(
) {
app.post("/act", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const kindRaw = toStringOrEmpty(body.kind);
if (!isActKind(kindRaw)) {
@@ -38,18 +40,24 @@ export function registerBrowserAgentActRoutes(
const tab = await profileCtx.ensureTabAvailable(targetId);
const cdpUrl = profileCtx.profile.cdpUrl;
const pw = await requirePwAi(res, `act:${kind}`);
if (!pw) return;
if (!pw) {
return;
}
const evaluateEnabled = ctx.state().resolved.evaluateEnabled;
switch (kind) {
case "click": {
const ref = toStringOrEmpty(body.ref);
if (!ref) return jsonError(res, 400, "ref is required");
if (!ref) {
return jsonError(res, 400, "ref is required");
}
const doubleClick = toBoolean(body.doubleClick) ?? false;
const timeoutMs = toNumber(body.timeoutMs);
const buttonRaw = toStringOrEmpty(body.button) || "";
const button = buttonRaw ? parseClickButton(buttonRaw) : undefined;
if (buttonRaw && !button) return jsonError(res, 400, "button must be left|right|middle");
if (buttonRaw && !button) {
return jsonError(res, 400, "button must be left|right|middle");
}
const modifiersRaw = toStringArray(body.modifiers) ?? [];
const parsedModifiers = parseClickModifiers(modifiersRaw);
@@ -63,16 +71,26 @@ export function registerBrowserAgentActRoutes(
ref,
doubleClick,
};
if (button) clickRequest.button = button;
if (modifiers) clickRequest.modifiers = modifiers;
if (timeoutMs) clickRequest.timeoutMs = timeoutMs;
if (button) {
clickRequest.button = button;
}
if (modifiers) {
clickRequest.modifiers = modifiers;
}
if (timeoutMs) {
clickRequest.timeoutMs = timeoutMs;
}
await pw.clickViaPlaywright(clickRequest);
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
}
case "type": {
const ref = toStringOrEmpty(body.ref);
if (!ref) return jsonError(res, 400, "ref is required");
if (typeof body.text !== "string") return jsonError(res, 400, "text is required");
if (!ref) {
return jsonError(res, 400, "ref is required");
}
if (typeof body.text !== "string") {
return jsonError(res, 400, "text is required");
}
const text = body.text;
const submit = toBoolean(body.submit) ?? false;
const slowly = toBoolean(body.slowly) ?? false;
@@ -85,13 +103,17 @@ export function registerBrowserAgentActRoutes(
submit,
slowly,
};
if (timeoutMs) typeRequest.timeoutMs = timeoutMs;
if (timeoutMs) {
typeRequest.timeoutMs = timeoutMs;
}
await pw.typeViaPlaywright(typeRequest);
return res.json({ ok: true, targetId: tab.targetId });
}
case "press": {
const key = toStringOrEmpty(body.key);
if (!key) return jsonError(res, 400, "key is required");
if (!key) {
return jsonError(res, 400, "key is required");
}
const delayMs = toNumber(body.delayMs);
await pw.pressKeyViaPlaywright({
cdpUrl,
@@ -103,7 +125,9 @@ export function registerBrowserAgentActRoutes(
}
case "hover": {
const ref = toStringOrEmpty(body.ref);
if (!ref) return jsonError(res, 400, "ref is required");
if (!ref) {
return jsonError(res, 400, "ref is required");
}
const timeoutMs = toNumber(body.timeoutMs);
await pw.hoverViaPlaywright({
cdpUrl,
@@ -115,21 +139,27 @@ export function registerBrowserAgentActRoutes(
}
case "scrollIntoView": {
const ref = toStringOrEmpty(body.ref);
if (!ref) return jsonError(res, 400, "ref is required");
if (!ref) {
return jsonError(res, 400, "ref is required");
}
const timeoutMs = toNumber(body.timeoutMs);
const scrollRequest: Parameters<typeof pw.scrollIntoViewViaPlaywright>[0] = {
cdpUrl,
targetId: tab.targetId,
ref,
};
if (timeoutMs) scrollRequest.timeoutMs = timeoutMs;
if (timeoutMs) {
scrollRequest.timeoutMs = timeoutMs;
}
await pw.scrollIntoViewViaPlaywright(scrollRequest);
return res.json({ ok: true, targetId: tab.targetId });
}
case "drag": {
const startRef = toStringOrEmpty(body.startRef);
const endRef = toStringOrEmpty(body.endRef);
if (!startRef || !endRef) return jsonError(res, 400, "startRef and endRef are required");
if (!startRef || !endRef) {
return jsonError(res, 400, "startRef and endRef are required");
}
const timeoutMs = toNumber(body.timeoutMs);
await pw.dragViaPlaywright({
cdpUrl,
@@ -143,7 +173,9 @@ export function registerBrowserAgentActRoutes(
case "select": {
const ref = toStringOrEmpty(body.ref);
const values = toStringArray(body.values);
if (!ref || !values?.length) return jsonError(res, 400, "ref and values are required");
if (!ref || !values?.length) {
return jsonError(res, 400, "ref and values are required");
}
const timeoutMs = toNumber(body.timeoutMs);
await pw.selectOptionViaPlaywright({
cdpUrl,
@@ -158,11 +190,15 @@ export function registerBrowserAgentActRoutes(
const rawFields = Array.isArray(body.fields) ? body.fields : [];
const fields = rawFields
.map((field) => {
if (!field || typeof field !== "object") return null;
if (!field || typeof field !== "object") {
return null;
}
const rec = field as Record<string, unknown>;
const ref = toStringOrEmpty(rec.ref);
const type = toStringOrEmpty(rec.type);
if (!ref || !type) return null;
if (!ref || !type) {
return null;
}
const value =
typeof rec.value === "string" ||
typeof rec.value === "number" ||
@@ -174,7 +210,9 @@ export function registerBrowserAgentActRoutes(
return parsed;
})
.filter((field): field is BrowserFormField => field !== null);
if (!fields.length) return jsonError(res, 400, "fields are required");
if (!fields.length) {
return jsonError(res, 400, "fields are required");
}
const timeoutMs = toNumber(body.timeoutMs);
await pw.fillFormViaPlaywright({
cdpUrl,
@@ -187,7 +225,9 @@ export function registerBrowserAgentActRoutes(
case "resize": {
const width = toNumber(body.width);
const height = toNumber(body.height);
if (!width || !height) return jsonError(res, 400, "width and height are required");
if (!width || !height) {
return jsonError(res, 400, "width and height are required");
}
await pw.resizeViewportViaPlaywright({
cdpUrl,
targetId: tab.targetId,
@@ -262,7 +302,9 @@ export function registerBrowserAgentActRoutes(
);
}
const fn = toStringOrEmpty(body.fn);
if (!fn) return jsonError(res, 400, "fn is required");
if (!fn) {
return jsonError(res, 400, "fn is required");
}
const ref = toStringOrEmpty(body.ref) || undefined;
const result = await pw.evaluateViaPlaywright({
cdpUrl,
@@ -292,7 +334,9 @@ export function registerBrowserAgentActRoutes(
app.post("/hooks/file-chooser", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const ref = toStringOrEmpty(body.ref) || undefined;
@@ -300,11 +344,15 @@ export function registerBrowserAgentActRoutes(
const element = toStringOrEmpty(body.element) || undefined;
const paths = toStringArray(body.paths) ?? [];
const timeoutMs = toNumber(body.timeoutMs);
if (!paths.length) return jsonError(res, 400, "paths are required");
if (!paths.length) {
return jsonError(res, 400, "paths are required");
}
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "file chooser hook");
if (!pw) return;
if (!pw) {
return;
}
if (inputRef || element) {
if (ref) {
return jsonError(res, 400, "ref cannot be combined with inputRef/element");
@@ -339,17 +387,23 @@ export function registerBrowserAgentActRoutes(
app.post("/hooks/dialog", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const accept = toBoolean(body.accept);
const promptText = toStringOrEmpty(body.promptText) || undefined;
const timeoutMs = toNumber(body.timeoutMs);
if (accept === undefined) return jsonError(res, 400, "accept is required");
if (accept === undefined) {
return jsonError(res, 400, "accept is required");
}
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "dialog hook");
if (!pw) return;
if (!pw) {
return;
}
await pw.armDialogViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -365,7 +419,9 @@ export function registerBrowserAgentActRoutes(
app.post("/wait/download", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const out = toStringOrEmpty(body.path) || undefined;
@@ -373,7 +429,9 @@ export function registerBrowserAgentActRoutes(
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "wait for download");
if (!pw) return;
if (!pw) {
return;
}
const result = await pw.waitForDownloadViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -388,18 +446,26 @@ export function registerBrowserAgentActRoutes(
app.post("/download", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const ref = toStringOrEmpty(body.ref);
const out = toStringOrEmpty(body.path);
const timeoutMs = toNumber(body.timeoutMs);
if (!ref) return jsonError(res, 400, "ref is required");
if (!out) return jsonError(res, 400, "path is required");
if (!ref) {
return jsonError(res, 400, "ref is required");
}
if (!out) {
return jsonError(res, 400, "path is required");
}
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "download");
if (!pw) return;
if (!pw) {
return;
}
const result = await pw.downloadViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -415,17 +481,23 @@ export function registerBrowserAgentActRoutes(
app.post("/response/body", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const url = toStringOrEmpty(body.url);
const timeoutMs = toNumber(body.timeoutMs);
const maxChars = toNumber(body.maxChars);
if (!url) return jsonError(res, 400, "url is required");
if (!url) {
return jsonError(res, 400, "url is required");
}
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "response body");
if (!pw) return;
if (!pw) {
return;
}
const result = await pw.responseBodyViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -441,15 +513,21 @@ export function registerBrowserAgentActRoutes(
app.post("/highlight", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const ref = toStringOrEmpty(body.ref);
if (!ref) return jsonError(res, 400, "ref is required");
if (!ref) {
return jsonError(res, 400, "ref is required");
}
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "highlight");
if (!pw) return;
if (!pw) {
return;
}
await pw.highlightViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,

View File

@@ -13,14 +13,18 @@ export function registerBrowserAgentDebugRoutes(
) {
app.get("/console", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const level = typeof req.query.level === "string" ? req.query.level : "";
try {
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
const pw = await requirePwAi(res, "console messages");
if (!pw) return;
if (!pw) {
return;
}
const messages = await pw.getConsoleMessagesViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -34,14 +38,18 @@ export function registerBrowserAgentDebugRoutes(
app.get("/errors", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const clear = toBoolean(req.query.clear) ?? false;
try {
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
const pw = await requirePwAi(res, "page errors");
if (!pw) return;
if (!pw) {
return;
}
const result = await pw.getPageErrorsViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -55,7 +63,9 @@ export function registerBrowserAgentDebugRoutes(
app.get("/requests", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const filter = typeof req.query.filter === "string" ? req.query.filter : "";
const clear = toBoolean(req.query.clear) ?? false;
@@ -63,7 +73,9 @@ export function registerBrowserAgentDebugRoutes(
try {
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
const pw = await requirePwAi(res, "network requests");
if (!pw) return;
if (!pw) {
return;
}
const result = await pw.getNetworkRequestsViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -78,7 +90,9 @@ export function registerBrowserAgentDebugRoutes(
app.post("/trace/start", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const screenshots = toBoolean(body.screenshots) ?? undefined;
@@ -87,7 +101,9 @@ export function registerBrowserAgentDebugRoutes(
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "trace start");
if (!pw) return;
if (!pw) {
return;
}
await pw.traceStartViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -103,14 +119,18 @@ export function registerBrowserAgentDebugRoutes(
app.post("/trace/stop", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const out = toStringOrEmpty(body.path) || "";
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "trace stop");
if (!pw) return;
if (!pw) {
return;
}
const id = crypto.randomUUID();
const dir = "/tmp/openclaw";
await fs.mkdir(dir, { recursive: true });

View File

@@ -16,13 +16,17 @@ export const SELECTOR_UNSUPPORTED_MESSAGE = [
export function readBody(req: BrowserRequest): Record<string, unknown> {
const body = req.body as Record<string, unknown> | undefined;
if (!body || typeof body !== "object" || Array.isArray(body)) return {};
if (!body || typeof body !== "object" || Array.isArray(body)) {
return {};
}
return body;
}
export function handleRouteError(ctx: BrowserRouteContext, res: BrowserResponse, err: unknown) {
const mapped = ctx.mapTabError(err);
if (mapped) return jsonError(res, mapped.status, mapped.message);
if (mapped) {
return jsonError(res, mapped.status, mapped.message);
}
jsonError(res, 500, String(err));
}
@@ -48,7 +52,9 @@ export async function requirePwAi(
feature: string,
): Promise<PwAiModule | null> {
const mod = await getPwAiModule();
if (mod) return mod;
if (mod) {
return mod;
}
jsonError(
res,
501,

View File

@@ -29,15 +29,21 @@ export function registerBrowserAgentSnapshotRoutes(
) {
app.post("/navigate", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const url = toStringOrEmpty(body.url);
const targetId = toStringOrEmpty(body.targetId) || undefined;
if (!url) return jsonError(res, 400, "url is required");
if (!url) {
return jsonError(res, 400, "url is required");
}
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "navigate");
if (!pw) return;
if (!pw) {
return;
}
const result = await pw.navigateViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -51,13 +57,17 @@ export function registerBrowserAgentSnapshotRoutes(
app.post("/pdf", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "pdf");
if (!pw) return;
if (!pw) {
return;
}
const pdf = await pw.pdfViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -82,7 +92,9 @@ export function registerBrowserAgentSnapshotRoutes(
app.post("/screenshot", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const fullPage = toBoolean(body.fullPage) ?? false;
@@ -101,7 +113,9 @@ export function registerBrowserAgentSnapshotRoutes(
profileCtx.profile.driver === "extension" || !tab.wsUrl || Boolean(ref) || Boolean(element);
if (shouldUsePlaywright) {
const pw = await requirePwAi(res, "screenshot");
if (!pw) return;
if (!pw) {
return;
}
const snap = await pw.takeScreenshotViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -144,7 +158,9 @@ export function registerBrowserAgentSnapshotRoutes(
app.get("/snapshot", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const mode = req.query.mode === "efficient" ? "efficient" : undefined;
const labels = toBoolean(req.query.labels) ?? undefined;
@@ -187,7 +203,9 @@ export function registerBrowserAgentSnapshotRoutes(
}
if (format === "ai") {
const pw = await requirePwAi(res, "ai snapshot");
if (!pw) return;
if (!pw) {
return;
}
const wantsRoleSnapshot =
labels === true ||
mode === "efficient" ||
@@ -282,7 +300,9 @@ export function registerBrowserAgentSnapshotRoutes(
// Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session.
// Also covers cases where wsUrl is missing/unusable.
return requirePwAi(res, "aria snapshot").then(async (pw) => {
if (!pw) return null;
if (!pw) {
return null;
}
return await pw.snapshotAriaViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -293,7 +313,9 @@ export function registerBrowserAgentSnapshotRoutes(
: snapshotAria({ wsUrl: tab.wsUrl ?? "", limit });
const resolved = await Promise.resolve(snap);
if (!resolved) return;
if (!resolved) {
return;
}
return res.json({
ok: true,
format,

View File

@@ -9,12 +9,16 @@ export function registerBrowserAgentStorageRoutes(
) {
app.get("/cookies", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
try {
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
const pw = await requirePwAi(res, "cookies");
if (!pw) return;
if (!pw) {
return;
}
const result = await pw.cookiesGetViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -27,18 +31,24 @@ export function registerBrowserAgentStorageRoutes(
app.post("/cookies/set", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const cookie =
body.cookie && typeof body.cookie === "object" && !Array.isArray(body.cookie)
? (body.cookie as Record<string, unknown>)
: null;
if (!cookie) return jsonError(res, 400, "cookie is required");
if (!cookie) {
return jsonError(res, 400, "cookie is required");
}
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "cookies set");
if (!pw) return;
if (!pw) {
return;
}
await pw.cookiesSetViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -65,13 +75,17 @@ export function registerBrowserAgentStorageRoutes(
app.post("/cookies/clear", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "cookies clear");
if (!pw) return;
if (!pw) {
return;
}
await pw.cookiesClearViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -84,16 +98,21 @@ export function registerBrowserAgentStorageRoutes(
app.get("/storage/:kind", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const kind = toStringOrEmpty(req.params.kind);
if (kind !== "local" && kind !== "session")
if (kind !== "local" && kind !== "session") {
return jsonError(res, 400, "kind must be local|session");
}
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const key = typeof req.query.key === "string" ? req.query.key : "";
try {
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
const pw = await requirePwAi(res, "storage get");
if (!pw) return;
if (!pw) {
return;
}
const result = await pw.storageGetViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -108,19 +127,26 @@ export function registerBrowserAgentStorageRoutes(
app.post("/storage/:kind/set", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const kind = toStringOrEmpty(req.params.kind);
if (kind !== "local" && kind !== "session")
if (kind !== "local" && kind !== "session") {
return jsonError(res, 400, "kind must be local|session");
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const key = toStringOrEmpty(body.key);
if (!key) return jsonError(res, 400, "key is required");
if (!key) {
return jsonError(res, 400, "key is required");
}
const value = typeof body.value === "string" ? body.value : "";
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "storage set");
if (!pw) return;
if (!pw) {
return;
}
await pw.storageSetViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -136,16 +162,21 @@ export function registerBrowserAgentStorageRoutes(
app.post("/storage/:kind/clear", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const kind = toStringOrEmpty(req.params.kind);
if (kind !== "local" && kind !== "session")
if (kind !== "local" && kind !== "session") {
return jsonError(res, 400, "kind must be local|session");
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "storage clear");
if (!pw) return;
if (!pw) {
return;
}
await pw.storageClearViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -159,15 +190,21 @@ export function registerBrowserAgentStorageRoutes(
app.post("/set/offline", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const offline = toBoolean(body.offline);
if (offline === undefined) return jsonError(res, 400, "offline is required");
if (offline === undefined) {
return jsonError(res, 400, "offline is required");
}
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "offline");
if (!pw) return;
if (!pw) {
return;
}
await pw.setOfflineViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -181,22 +218,30 @@ export function registerBrowserAgentStorageRoutes(
app.post("/set/headers", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const headers =
body.headers && typeof body.headers === "object" && !Array.isArray(body.headers)
? (body.headers as Record<string, unknown>)
: null;
if (!headers) return jsonError(res, 400, "headers is required");
if (!headers) {
return jsonError(res, 400, "headers is required");
}
const parsed: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) {
if (typeof v === "string") parsed[k] = v;
if (typeof v === "string") {
parsed[k] = v;
}
}
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "headers");
if (!pw) return;
if (!pw) {
return;
}
await pw.setExtraHTTPHeadersViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -210,7 +255,9 @@ export function registerBrowserAgentStorageRoutes(
app.post("/set/credentials", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const clear = toBoolean(body.clear) ?? false;
@@ -219,7 +266,9 @@ export function registerBrowserAgentStorageRoutes(
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "http credentials");
if (!pw) return;
if (!pw) {
return;
}
await pw.setHttpCredentialsViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -235,7 +284,9 @@ export function registerBrowserAgentStorageRoutes(
app.post("/set/geolocation", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const clear = toBoolean(body.clear) ?? false;
@@ -246,7 +297,9 @@ export function registerBrowserAgentStorageRoutes(
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "geolocation");
if (!pw) return;
if (!pw) {
return;
}
await pw.setGeolocationViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -264,7 +317,9 @@ export function registerBrowserAgentStorageRoutes(
app.post("/set/media", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const schemeRaw = toStringOrEmpty(body.colorScheme);
@@ -274,12 +329,15 @@ export function registerBrowserAgentStorageRoutes(
: schemeRaw === "none"
? null
: undefined;
if (colorScheme === undefined)
if (colorScheme === undefined) {
return jsonError(res, 400, "colorScheme must be dark|light|no-preference|none");
}
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "media emulation");
if (!pw) return;
if (!pw) {
return;
}
await pw.emulateMediaViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -293,15 +351,21 @@ export function registerBrowserAgentStorageRoutes(
app.post("/set/timezone", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const timezoneId = toStringOrEmpty(body.timezoneId);
if (!timezoneId) return jsonError(res, 400, "timezoneId is required");
if (!timezoneId) {
return jsonError(res, 400, "timezoneId is required");
}
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "timezone");
if (!pw) return;
if (!pw) {
return;
}
await pw.setTimezoneViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -315,15 +379,21 @@ export function registerBrowserAgentStorageRoutes(
app.post("/set/locale", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const locale = toStringOrEmpty(body.locale);
if (!locale) return jsonError(res, 400, "locale is required");
if (!locale) {
return jsonError(res, 400, "locale is required");
}
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "locale");
if (!pw) return;
if (!pw) {
return;
}
await pw.setLocaleViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -337,15 +407,21 @@ export function registerBrowserAgentStorageRoutes(
app.post("/set/device", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
if (!profileCtx) {
return;
}
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const name = toStringOrEmpty(body.name);
if (!name) return jsonError(res, 400, "name is required");
if (!name) {
return jsonError(res, 400, "name is required");
}
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "device emulation");
if (!pw) return;
if (!pw) {
return;
}
await pw.setDeviceViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,

View File

@@ -131,7 +131,9 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
| "extension"
| "";
if (!name) return jsonError(res, 400, "name is required");
if (!name) {
return jsonError(res, 400, "name is required");
}
try {
const service = createBrowserProfilesService(ctx);
@@ -163,7 +165,9 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
// Delete a profile
app.delete("/profiles/:name", async (req, res) => {
const name = toStringOrEmpty(req.params.name);
if (!name) return jsonError(res, 400, "profile name is required");
if (!name) {
return jsonError(res, 400, "profile name is required");
}
try {
const service = createBrowserProfilesService(ctx);

View File

@@ -55,7 +55,9 @@ function createRegistry() {
}
function normalizePath(path: string) {
if (!path) return "/";
if (!path) {
return "/";
}
return path.startsWith("/") ? path : `/${path}`;
}
@@ -71,7 +73,9 @@ export function createBrowserRouteDispatcher(ctx: BrowserRouteContext) {
const body = req.body;
const match = registry.routes.find((route) => {
if (route.method !== method) return false;
if (route.method !== method) {
return false;
}
return route.regex.test(path);
});
if (!match) {

View File

@@ -5,10 +5,14 @@ import type { BrowserRouteRegistrar } from "./types.js";
export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
app.get("/tabs", async (req, res) => {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error);
if ("error" in profileCtx) {
return jsonError(res, profileCtx.status, profileCtx.error);
}
try {
const reachable = await profileCtx.isReachable(300);
if (!reachable) return res.json({ running: false, tabs: [] as unknown[] });
if (!reachable) {
return res.json({ running: false, tabs: [] as unknown[] });
}
const tabs = await profileCtx.listTabs();
res.json({ running: true, tabs });
} catch (err) {
@@ -18,9 +22,13 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
app.post("/tabs/open", async (req, res) => {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error);
if ("error" in profileCtx) {
return jsonError(res, profileCtx.status, profileCtx.error);
}
const url = toStringOrEmpty((req.body as { url?: unknown })?.url);
if (!url) return jsonError(res, 400, "url is required");
if (!url) {
return jsonError(res, 400, "url is required");
}
try {
await profileCtx.ensureBrowserAvailable();
const tab = await profileCtx.openTab(url);
@@ -32,45 +40,65 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
app.post("/tabs/focus", async (req, res) => {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error);
if ("error" in profileCtx) {
return jsonError(res, profileCtx.status, profileCtx.error);
}
const targetId = toStringOrEmpty((req.body as { targetId?: unknown })?.targetId);
if (!targetId) return jsonError(res, 400, "targetId is required");
if (!targetId) {
return jsonError(res, 400, "targetId is required");
}
try {
if (!(await profileCtx.isReachable(300))) return jsonError(res, 409, "browser not running");
if (!(await profileCtx.isReachable(300))) {
return jsonError(res, 409, "browser not running");
}
await profileCtx.focusTab(targetId);
res.json({ ok: true });
} catch (err) {
const mapped = ctx.mapTabError(err);
if (mapped) return jsonError(res, mapped.status, mapped.message);
if (mapped) {
return jsonError(res, mapped.status, mapped.message);
}
jsonError(res, 500, String(err));
}
});
app.delete("/tabs/:targetId", async (req, res) => {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error);
if ("error" in profileCtx) {
return jsonError(res, profileCtx.status, profileCtx.error);
}
const targetId = toStringOrEmpty(req.params.targetId);
if (!targetId) return jsonError(res, 400, "targetId is required");
if (!targetId) {
return jsonError(res, 400, "targetId is required");
}
try {
if (!(await profileCtx.isReachable(300))) return jsonError(res, 409, "browser not running");
if (!(await profileCtx.isReachable(300))) {
return jsonError(res, 409, "browser not running");
}
await profileCtx.closeTab(targetId);
res.json({ ok: true });
} catch (err) {
const mapped = ctx.mapTabError(err);
if (mapped) return jsonError(res, mapped.status, mapped.message);
if (mapped) {
return jsonError(res, mapped.status, mapped.message);
}
jsonError(res, 500, String(err));
}
});
app.post("/tabs/action", async (req, res) => {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error);
if ("error" in profileCtx) {
return jsonError(res, profileCtx.status, profileCtx.error);
}
const action = toStringOrEmpty((req.body as { action?: unknown })?.action);
const index = toNumber((req.body as { index?: unknown })?.index);
try {
if (action === "list") {
const reachable = await profileCtx.isReachable(300);
if (!reachable) return res.json({ ok: true, tabs: [] as unknown[] });
if (!reachable) {
return res.json({ ok: true, tabs: [] as unknown[] });
}
const tabs = await profileCtx.listTabs();
return res.json({ ok: true, tabs });
}
@@ -84,16 +112,22 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
if (action === "close") {
const tabs = await profileCtx.listTabs();
const target = typeof index === "number" ? tabs[index] : tabs.at(0);
if (!target) return jsonError(res, 404, "tab not found");
if (!target) {
return jsonError(res, 404, "tab not found");
}
await profileCtx.closeTab(target.targetId);
return res.json({ ok: true, targetId: target.targetId });
}
if (action === "select") {
if (typeof index !== "number") return jsonError(res, 400, "index is required");
if (typeof index !== "number") {
return jsonError(res, 400, "index is required");
}
const tabs = await profileCtx.listTabs();
const target = tabs[index];
if (!target) return jsonError(res, 404, "tab not found");
if (!target) {
return jsonError(res, 404, "tab not found");
}
await profileCtx.focusTab(target.targetId);
return res.json({ ok: true, targetId: target.targetId });
}
@@ -101,7 +135,9 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
return jsonError(res, 400, "unknown tab action");
} catch (err) {
const mapped = ctx.mapTabError(err);
if (mapped) return jsonError(res, mapped.status, mapped.message);
if (mapped) {
return jsonError(res, mapped.status, mapped.message);
}
jsonError(res, 500, String(err));
}
});

View File

@@ -37,7 +37,9 @@ export function jsonError(res: BrowserResponse, status: number, message: string)
}
export function toStringOrEmpty(value: unknown) {
if (typeof value === "string") return value.trim();
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value).trim();
}
@@ -45,7 +47,9 @@ export function toStringOrEmpty(value: unknown) {
}
export function toNumber(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
@@ -61,7 +65,9 @@ export function toBoolean(value: unknown) {
}
export function toStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
if (!Array.isArray(value)) {
return undefined;
}
const strings = value.map((v) => toStringOrEmpty(v)).filter(Boolean);
return strings.length ? strings : undefined;
}

View File

@@ -104,9 +104,13 @@ describe("browser server-context ensureTabAvailable", () => {
fetchMock.mockImplementation(async (url: unknown) => {
const u = String(url);
if (!u.includes("/json/list")) throw new Error(`unexpected fetch: ${u}`);
if (!u.includes("/json/list")) {
throw new Error(`unexpected fetch: ${u}`);
}
const next = responses.shift();
if (!next) throw new Error("no more responses");
if (!next) {
throw new Error("no more responses");
}
return { ok: true, json: async () => next } as unknown as Response;
});
@@ -152,9 +156,13 @@ describe("browser server-context ensureTabAvailable", () => {
const responses = [[]];
fetchMock.mockImplementation(async (url: unknown) => {
const u = String(url);
if (!u.includes("/json/list")) throw new Error(`unexpected fetch: ${u}`);
if (!u.includes("/json/list")) {
throw new Error(`unexpected fetch: ${u}`);
}
const next = responses.shift();
if (!next) throw new Error("no more responses");
if (!next) {
throw new Error("no more responses");
}
return { ok: true, json: async () => next } as unknown as Response;
});
// @ts-expect-error test override

View File

@@ -116,7 +116,9 @@ describe("browser server-context remote profile tab operations", () => {
const listPagesViaPlaywright = vi.fn(async () => {
const next = responses.shift();
if (!next) throw new Error("no more responses");
if (!next) {
throw new Error("no more responses");
}
return next;
});
@@ -212,7 +214,9 @@ describe("browser server-context remote profile tab operations", () => {
const fetchMock = vi.fn(async (url: unknown) => {
const u = String(url);
if (!u.includes("/json/list")) throw new Error(`unexpected fetch: ${u}`);
if (!u.includes("/json/list")) {
throw new Error(`unexpected fetch: ${u}`);
}
return {
ok: true,
json: async () => [
@@ -254,7 +258,9 @@ describe("browser server-context tab selection state", () => {
const fetchMock = vi.fn(async (url: unknown) => {
const u = String(url);
if (!u.includes("/json/list")) throw new Error(`unexpected fetch: ${u}`);
if (!u.includes("/json/list")) {
throw new Error(`unexpected fetch: ${u}`);
}
return {
ok: true,
json: async () => [

View File

@@ -40,7 +40,9 @@ export type {
* Normalize a CDP WebSocket URL to use the correct base URL.
*/
function normalizeWsUrl(raw: string | undefined, cdpBaseUrl: string): string | undefined {
if (!raw) return undefined;
if (!raw) {
return undefined;
}
try {
return normalizeCdpWsUrl(raw, cdpBaseUrl);
} catch {
@@ -54,7 +56,9 @@ async function fetchJson<T>(url: string, timeoutMs = 1500, init?: RequestInit):
try {
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
const res = await fetch(url, { ...init, headers, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return (await res.json()) as T;
} finally {
clearTimeout(t);
@@ -67,7 +71,9 @@ async function fetchOk(url: string, timeoutMs = 1500, init?: RequestInit): Promi
try {
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
const res = await fetch(url, { ...init, headers, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
} finally {
clearTimeout(t);
}
@@ -82,7 +88,9 @@ function createProfileContext(
): ProfileContext {
const state = () => {
const current = opts.getState();
if (!current) throw new Error("Browser server not started");
if (!current) {
throw new Error("Browser server not started");
}
return current;
};
@@ -170,7 +178,9 @@ function createProfileContext(
while (Date.now() < deadline) {
const tabs = await listTabs().catch(() => [] as BrowserTab[]);
const found = tabs.find((t) => t.targetId === createdViaCdp);
if (found) return found;
if (found) {
return found;
}
await new Promise((r) => setTimeout(r, 100));
}
return { targetId: createdViaCdp, title: "", url, type: "page" };
@@ -201,7 +211,9 @@ function createProfileContext(
throw err;
});
if (!created.id) throw new Error("Failed to open tab (missing id)");
if (!created.id) {
throw new Error("Failed to open tab (missing id)");
}
const profileState = getProfileState();
profileState.lastTargetId = created.id;
return {
@@ -214,7 +226,9 @@ function createProfileContext(
};
const resolveRemoteHttpTimeout = (timeoutMs: number | undefined) => {
if (profile.cdpIsLoopback) return timeoutMs ?? 300;
if (profile.cdpIsLoopback) {
return timeoutMs ?? 300;
}
const resolved = state().resolved;
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) {
return Math.max(Math.floor(timeoutMs), resolved.remoteCdpTimeoutMs);
@@ -249,7 +263,9 @@ function createProfileContext(
setProfileRunning(running);
running.proc.on("exit", () => {
// Guard against server teardown (e.g., SIGUSR1 restart)
if (!opts.getState()) return;
if (!opts.getState()) {
return;
}
const profileState = getProfileState();
if (profileState.running?.pid === running.pid) {
setProfileRunning(null);
@@ -282,7 +298,9 @@ function createProfileContext(
}
}
if (await isReachable(600)) return;
if (await isReachable(600)) {
return;
}
// Relay server is up, but no attached tab yet. Prompt user to attach.
throw new Error(
`Chrome extension relay is running, but no tab is connected. Click the OpenClaw Chrome extension icon on a tab to attach it (profile "${profile.name}").`,
@@ -292,7 +310,9 @@ function createProfileContext(
if (!httpReachable) {
if ((current.resolved.attachOnly || remoteCdp) && opts.onEnsureAttachTarget) {
await opts.onEnsureAttachTarget(profile);
if (await isHttpReachable(1200)) return;
if (await isHttpReachable(1200)) {
return;
}
}
if (current.resolved.attachOnly || remoteCdp) {
throw new Error(
@@ -307,7 +327,9 @@ function createProfileContext(
}
// Port is reachable - check if we own it
if (await isReachable()) return;
if (await isReachable()) {
return;
}
// HTTP responds but WebSocket fails - port in use by something else
if (!profileState.running) {
@@ -321,7 +343,9 @@ function createProfileContext(
if (current.resolved.attachOnly || remoteCdp) {
if (opts.onEnsureAttachTarget) {
await opts.onEnsureAttachTarget(profile);
if (await isReachable(1200)) return;
if (await isReachable(1200)) {
return;
}
}
throw new Error(
remoteCdp
@@ -368,7 +392,9 @@ function createProfileContext(
const resolveById = (raw: string) => {
const resolved = resolveTargetIdFromTabs(raw, candidates);
if (!resolved.ok) {
if (resolved.reason === "ambiguous") return "AMBIGUOUS" as const;
if (resolved.reason === "ambiguous") {
return "AMBIGUOUS" as const;
}
return null;
}
return candidates.find((t) => t.targetId === resolved.targetId) ?? null;
@@ -377,7 +403,9 @@ function createProfileContext(
const pickDefault = () => {
const last = profileState.lastTargetId?.trim() || "";
const lastResolved = last ? resolveById(last) : null;
if (lastResolved && lastResolved !== "AMBIGUOUS") return lastResolved;
if (lastResolved && lastResolved !== "AMBIGUOUS") {
return lastResolved;
}
// Prefer a real page tab first (avoid service workers/background targets).
const page = candidates.find((t) => (t.type ?? "page") === "page");
return page ?? candidates.at(0) ?? null;
@@ -393,7 +421,9 @@ function createProfileContext(
if (chosen === "AMBIGUOUS") {
throw new Error("ambiguous target id prefix");
}
if (!chosen) throw new Error("tab not found");
if (!chosen) {
throw new Error("tab not found");
}
profileState.lastTargetId = chosen.targetId;
return chosen;
};
@@ -463,7 +493,9 @@ function createProfileContext(
return { stopped };
}
const profileState = getProfileState();
if (!profileState.running) return { stopped: false };
if (!profileState.running) {
return { stopped: false };
}
await stopOpenClawChrome(profileState.running);
setProfileRunning(null);
return { stopped: true };
@@ -530,7 +562,9 @@ function createProfileContext(
export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteContext {
const state = () => {
const current = opts.getState();
if (!current) throw new Error("Browser server not started");
if (!current) {
throw new Error("Browser server not started");
}
return current;
};
@@ -552,7 +586,9 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
for (const name of Object.keys(current.resolved.profiles)) {
const profileState = current.profiles.get(name);
const profile = resolveProfile(current.resolved, name);
if (!profile) continue;
if (!profile) {
continue;
}
let tabCount = 0;
let running = false;

View File

@@ -73,7 +73,9 @@ function makeProc(pid = 123) {
return undefined;
},
emitExit: () => {
for (const cb of handlers.get("exit") ?? []) cb(0);
for (const cb of handlers.get("exit") ?? []) {
cb(0);
}
},
kill: () => {
return true;
@@ -164,7 +166,9 @@ async function getFreePort(): Promise<number> {
s.close((err) => (err ? reject(err) : resolve(assigned)));
});
});
if (port < 65535) return port;
if (port < 65535) {
return port;
}
}
}
@@ -191,12 +195,18 @@ describe("browser control server", () => {
createTargetId = null;
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
if (createTargetId) return { targetId: createTargetId };
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();
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}`;
@@ -210,7 +220,9 @@ describe("browser control server", () => {
vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) return makeResponse([]);
if (!reachable) {
return makeResponse([]);
}
return makeResponse([
{
id: "abcd1234",
@@ -243,8 +255,12 @@ describe("browser control server", () => {
type: "page",
});
}
if (u.includes("/json/activate/")) return makeResponse("ok");
if (u.includes("/json/close/")) return makeResponse("ok");
if (u.includes("/json/activate/")) {
return makeResponse("ok");
}
if (u.includes("/json/close/")) {
return makeResponse("ok");
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);

View File

@@ -73,7 +73,9 @@ function makeProc(pid = 123) {
return undefined;
},
emitExit: () => {
for (const cb of handlers.get("exit") ?? []) cb(0);
for (const cb of handlers.get("exit") ?? []) {
cb(0);
}
},
kill: () => {
return true;
@@ -163,7 +165,9 @@ async function getFreePort(): Promise<number> {
s.close((err) => (err ? reject(err) : resolve(assigned)));
});
});
if (port < 65535) return port;
if (port < 65535) {
return port;
}
}
}
@@ -189,12 +193,18 @@ describe("browser control server", () => {
createTargetId = null;
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
if (createTargetId) return { targetId: createTargetId };
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();
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}`;
@@ -208,7 +218,9 @@ describe("browser control server", () => {
vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) return makeResponse([]);
if (!reachable) {
return makeResponse([]);
}
return makeResponse([
{
id: "abcd1234",
@@ -241,8 +253,12 @@ describe("browser control server", () => {
type: "page",
});
}
if (u.includes("/json/activate/")) return makeResponse("ok");
if (u.includes("/json/close/")) return makeResponse("ok");
if (u.includes("/json/activate/")) {
return makeResponse("ok");
}
if (u.includes("/json/close/")) {
return makeResponse("ok");
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);

View File

@@ -72,7 +72,9 @@ function makeProc(pid = 123) {
return undefined;
},
emitExit: () => {
for (const cb of handlers.get("exit") ?? []) cb(0);
for (const cb of handlers.get("exit") ?? []) {
cb(0);
}
},
kill: () => {
return true;
@@ -162,7 +164,9 @@ async function getFreePort(): Promise<number> {
s.close((err) => (err ? reject(err) : resolve(assigned)));
});
});
if (port < 65535) return port;
if (port < 65535) {
return port;
}
}
}
@@ -188,12 +192,18 @@ describe("browser control server", () => {
createTargetId = null;
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
if (createTargetId) return { targetId: createTargetId };
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();
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}`;
@@ -207,7 +217,9 @@ describe("browser control server", () => {
vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) return makeResponse([]);
if (!reachable) {
return makeResponse([]);
}
return makeResponse([
{
id: "abcd1234",
@@ -240,8 +252,12 @@ describe("browser control server", () => {
type: "page",
});
}
if (u.includes("/json/activate/")) return makeResponse("ok");
if (u.includes("/json/close/")) return makeResponse("ok");
if (u.includes("/json/activate/")) {
return makeResponse("ok");
}
if (u.includes("/json/close/")) {
return makeResponse("ok");
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
@@ -303,8 +319,12 @@ describe("backward compatibility (profile parameter)", () => {
cfgAttachOnly = false;
createTargetId = null;
for (const fn of Object.values(pwMocks)) fn.mockClear();
for (const fn of Object.values(cdpMocks)) fn.mockClear();
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}`;
@@ -319,7 +339,9 @@ describe("backward compatibility (profile parameter)", () => {
vi.fn(async (url: string) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) return makeResponse([]);
if (!reachable) {
return makeResponse([]);
}
return makeResponse([
{
id: "abcd1234",
@@ -339,8 +361,12 @@ describe("backward compatibility (profile parameter)", () => {
type: "page",
});
}
if (u.includes("/json/activate/")) return makeResponse("ok");
if (u.includes("/json/close/")) return makeResponse("ok");
if (u.includes("/json/activate/")) {
return makeResponse("ok");
}
if (u.includes("/json/close/")) {
return makeResponse("ok");
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);

View File

@@ -72,7 +72,9 @@ function makeProc(pid = 123) {
return undefined;
},
emitExit: () => {
for (const cb of handlers.get("exit") ?? []) cb(0);
for (const cb of handlers.get("exit") ?? []) {
cb(0);
}
},
kill: () => {
return true;
@@ -162,7 +164,9 @@ async function getFreePort(): Promise<number> {
s.close((err) => (err ? reject(err) : resolve(assigned)));
});
});
if (port < 65535) return port;
if (port < 65535) {
return port;
}
}
}
@@ -188,12 +192,18 @@ describe("browser control server", () => {
createTargetId = null;
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
if (createTargetId) return { targetId: createTargetId };
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();
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}`;
@@ -207,7 +217,9 @@ describe("browser control server", () => {
vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) return makeResponse([]);
if (!reachable) {
return makeResponse([]);
}
return makeResponse([
{
id: "abcd1234",
@@ -240,8 +252,12 @@ describe("browser control server", () => {
type: "page",
});
}
if (u.includes("/json/activate/")) return makeResponse("ok");
if (u.includes("/json/close/")) return makeResponse("ok");
if (u.includes("/json/activate/")) {
return makeResponse("ok");
}
if (u.includes("/json/close/")) {
return makeResponse("ok");
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
@@ -280,8 +296,12 @@ describe("profile CRUD endpoints", () => {
reachable = false;
cfgAttachOnly = false;
for (const fn of Object.values(pwMocks)) fn.mockClear();
for (const fn of Object.values(cdpMocks)) fn.mockClear();
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}`;
@@ -295,7 +315,9 @@ describe("profile CRUD endpoints", () => {
"fetch",
vi.fn(async (url: string) => {
const u = String(url);
if (u.includes("/json/list")) return makeResponse([]);
if (u.includes("/json/list")) {
return makeResponse([]);
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);

View File

@@ -72,7 +72,9 @@ function makeProc(pid = 123) {
return undefined;
},
emitExit: () => {
for (const cb of handlers.get("exit") ?? []) cb(0);
for (const cb of handlers.get("exit") ?? []) {
cb(0);
}
},
kill: () => {
return true;
@@ -162,7 +164,9 @@ async function getFreePort(): Promise<number> {
s.close((err) => (err ? reject(err) : resolve(assigned)));
});
});
if (port < 65535) return port;
if (port < 65535) {
return port;
}
}
}
@@ -188,12 +192,18 @@ describe("browser control server", () => {
createTargetId = null;
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
if (createTargetId) return { targetId: createTargetId };
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();
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}`;
@@ -207,7 +217,9 @@ describe("browser control server", () => {
vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) return makeResponse([]);
if (!reachable) {
return makeResponse([]);
}
return makeResponse([
{
id: "abcd1234",
@@ -240,8 +252,12 @@ describe("browser control server", () => {
type: "page",
});
}
if (u.includes("/json/activate/")) return makeResponse("ok");
if (u.includes("/json/close/")) return makeResponse("ok");
if (u.includes("/json/activate/")) {
return makeResponse("ok");
}
if (u.includes("/json/close/")) {
return makeResponse("ok");
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);

View File

@@ -72,7 +72,9 @@ function makeProc(pid = 123) {
return undefined;
},
emitExit: () => {
for (const cb of handlers.get("exit") ?? []) cb(0);
for (const cb of handlers.get("exit") ?? []) {
cb(0);
}
},
kill: () => {
return true;
@@ -162,7 +164,9 @@ async function getFreePort(): Promise<number> {
s.close((err) => (err ? reject(err) : resolve(assigned)));
});
});
if (port < 65535) return port;
if (port < 65535) {
return port;
}
}
}
@@ -188,12 +192,18 @@ describe("browser control server", () => {
createTargetId = null;
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
if (createTargetId) return { targetId: createTargetId };
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();
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}`;
@@ -207,7 +217,9 @@ describe("browser control server", () => {
vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) return makeResponse([]);
if (!reachable) {
return makeResponse([]);
}
return makeResponse([
{
id: "abcd1234",
@@ -240,8 +252,12 @@ describe("browser control server", () => {
type: "page",
});
}
if (u.includes("/json/activate/")) return makeResponse("ok");
if (u.includes("/json/close/")) return makeResponse("ok");
if (u.includes("/json/activate/")) {
return makeResponse("ok");
}
if (u.includes("/json/close/")) {
return makeResponse("ok");
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);

View File

@@ -14,11 +14,15 @@ const log = createSubsystemLogger("browser");
const logServer = log.child("server");
export async function startBrowserControlServerFromConfig(): Promise<BrowserServerState | null> {
if (state) return state;
if (state) {
return state;
}
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
if (!resolved.enabled) return null;
if (!resolved.enabled) {
return null;
}
const app = express();
app.use(express.json({ limit: "1mb" }));
@@ -37,7 +41,9 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
return null;
});
if (!server) return null;
if (!server) {
return null;
}
state = {
server,
@@ -50,7 +56,9 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
// so the extension can connect before the first browser action.
for (const name of Object.keys(resolved.profiles)) {
const profile = resolveProfile(resolved, name);
if (!profile || profile.driver !== "extension") continue;
if (!profile || profile.driver !== "extension") {
continue;
}
await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch((err) => {
logServer.warn(`Chrome extension relay init failed for profile "${name}": ${String(err)}`);
});
@@ -62,7 +70,9 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
export async function stopBrowserControlServer(): Promise<void> {
const current = state;
if (!current) return;
if (!current) {
return;
}
const ctx = createBrowserRouteContext({
getState: () => state,

View File

@@ -7,16 +7,24 @@ export function resolveTargetIdFromTabs(
tabs: Array<{ targetId: string }>,
): TargetIdResolution {
const needle = input.trim();
if (!needle) return { ok: false, reason: "not_found" };
if (!needle) {
return { ok: false, reason: "not_found" };
}
const exact = tabs.find((t) => t.targetId === needle);
if (exact) return { ok: true, targetId: exact.targetId };
if (exact) {
return { ok: true, targetId: exact.targetId };
}
const lower = needle.toLowerCase();
const matches = tabs.map((t) => t.targetId).filter((id) => id.toLowerCase().startsWith(lower));
const only = matches.length === 1 ? matches[0] : undefined;
if (only) return { ok: true, targetId: only };
if (matches.length === 0) return { ok: false, reason: "not_found" };
if (only) {
return { ok: true, targetId: only };
}
if (matches.length === 0) {
return { ok: false, reason: "not_found" };
}
return { ok: false, reason: "ambiguous", matches };
}