mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 03:03:24 -04:00
chore: Enable "curly" rule to avoid single-statement if confusion/errors.
This commit is contained in:
@@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]) : [] };
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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" });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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" });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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" });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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" });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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" });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user