fix(browser): improve CDP startup diagnostics

This commit is contained in:
Peter Steinberger
2026-04-18 23:42:20 +01:00
parent 0e9d63a417
commit 58da2f5897
9 changed files with 478 additions and 121 deletions

View File

@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
- Telegram/callbacks: treat permanent callback edit errors as completed updates so stale command pagination buttons no longer wedge the update watermark and block newer Telegram updates. (#68588) Thanks @Lucenx9.
- Browser/CDP: allow the selected remote CDP profile host for CDP health and control checks without widening browser navigation SSRF policy, so WSL-to-Windows Chrome endpoints no longer appear offline under strict defaults. Fixes #68108. (#68207) Thanks @Mlightsnow.
- Codex: stop cumulative app-server token totals from being treated as fresh context usage, so session status no longer reports inflated context percentages after long Codex threads. (#64669) Thanks @cyrusaf.
- Browser/CDP: add phase-specific CDP readiness diagnostics and normalize loopback WebSocket host aliases, so Windows browser startup failures surface whether HTTP discovery, WebSocket discovery, SSRF validation, or the `Browser.getVersion` health check failed.
## 2026.4.18

View File

@@ -404,6 +404,14 @@ describe("cdp", () => {
expect(normalized).toBe("wss://user:pass@example.com/devtools/browser/ABC?token=abc");
});
it("normalizes loopback websocket aliases to the configured CDP loopback host", () => {
const normalized = normalizeCdpWsUrl(
"ws://localhost.:18800/devtools/browser/ABC",
"http://127.0.0.1:18800",
);
expect(normalized).toBe("ws://127.0.0.1:18800/devtools/browser/ABC");
});
it("rewrites 0.0.0.0 wildcard bind address to remote CDP host", () => {
const normalized = normalizeCdpWsUrl(
"ws://0.0.0.0:3000/devtools/browser/ABC",

View File

@@ -31,6 +31,8 @@ export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
ws.port = cdpPort;
}
ws.protocol = cdp.protocol === "https:" ? "wss:" : "ws:";
} else if (isLoopbackHost(ws.hostname) && isLoopbackHost(cdp.hostname)) {
ws.hostname = cdp.hostname;
}
if (cdp.protocol === "https:" && ws.protocol === "ws:") {
ws.protocol = "wss:";

View File

@@ -0,0 +1,342 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { rawDataToString } from "../infra/ws.js";
import { redactSensitiveText } from "../logging/redact.js";
import { CHROME_REACHABILITY_TIMEOUT_MS, CHROME_WS_READY_TIMEOUT_MS } from "./cdp-timeouts.js";
import {
appendCdpPath,
assertCdpEndpointAllowed,
fetchCdpChecked,
isWebSocketUrl,
openCdpWebSocket,
redactCdpUrl,
} from "./cdp.helpers.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import { BrowserCdpEndpointBlockedError } from "./errors.js";
export type ChromeCdpDiagnosticCode =
| "ssrf_blocked"
| "http_unreachable"
| "http_status_failed"
| "invalid_json"
| "missing_websocket_debugger_url"
| "websocket_ssrf_blocked"
| "websocket_handshake_failed"
| "websocket_health_command_failed"
| "websocket_health_command_timeout";
export type ChromeCdpDiagnostic =
| {
ok: true;
cdpUrl: string;
wsUrl: string;
browser?: string;
userAgent?: string;
elapsedMs: number;
}
| {
ok: false;
code: ChromeCdpDiagnosticCode;
cdpUrl: string;
wsUrl?: string;
message: string;
elapsedMs: number;
};
export type ChromeVersion = {
webSocketDebuggerUrl?: string;
Browser?: string;
"User-Agent"?: string;
};
function elapsedSince(startedAt: number): number {
return Math.max(0, Date.now() - startedAt);
}
export function safeChromeCdpErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return redactSensitiveText(message || "unknown error");
}
function failureDiagnostic(params: {
cdpUrl: string;
code: ChromeCdpDiagnosticCode;
message: string;
startedAt: number;
wsUrl?: string;
}): ChromeCdpDiagnostic {
return {
ok: false,
cdpUrl: params.cdpUrl,
wsUrl: params.wsUrl,
code: params.code,
message: redactSensitiveText(params.message),
elapsedMs: elapsedSince(params.startedAt),
};
}
export async function readChromeVersion(
cdpUrl: string,
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
ssrfPolicy?: SsrFPolicy,
): Promise<ChromeVersion> {
const ctrl = new AbortController();
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
try {
const versionUrl = appendCdpPath(cdpUrl, "/json/version");
const { response, release } = await fetchCdpChecked(
versionUrl,
timeoutMs,
{ signal: ctrl.signal },
ssrfPolicy,
);
try {
const data = (await response.json()) as ChromeVersion;
if (!data || typeof data !== "object") {
throw new Error("CDP /json/version returned non-object JSON");
}
return data;
} finally {
await release();
}
} finally {
clearTimeout(t);
}
}
type CdpHealthDiagnostic =
| { ok: true }
| {
ok: false;
code:
| "websocket_handshake_failed"
| "websocket_health_command_failed"
| "websocket_health_command_timeout";
message: string;
};
async function diagnoseCdpHealthCommand(
wsUrl: string,
timeoutMs = CHROME_WS_READY_TIMEOUT_MS,
): Promise<CdpHealthDiagnostic> {
return await new Promise<CdpHealthDiagnostic>((resolve) => {
const ws = openCdpWebSocket(wsUrl, {
handshakeTimeoutMs: timeoutMs,
});
let settled = false;
let opened = false;
const onMessage = (raw: Parameters<typeof rawDataToString>[0]) => {
if (settled) {
return;
}
let parsed: { id?: unknown; result?: unknown } | null = null;
try {
parsed = JSON.parse(rawDataToString(raw)) as { id?: unknown; result?: unknown };
} catch {
return;
}
if (parsed?.id !== 1) {
return;
}
if (parsed.result && typeof parsed.result === "object") {
finish({ ok: true });
return;
}
finish({
ok: false,
code: "websocket_health_command_failed",
message: "Browser.getVersion returned no result object",
});
};
const finish = (value: CdpHealthDiagnostic) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
ws.off("message", onMessage);
try {
ws.close();
} catch {
// ignore
}
resolve(value);
};
const timer = setTimeout(
() => {
try {
ws.terminate();
} catch {
// ignore
}
finish({
ok: false,
code: opened ? "websocket_health_command_timeout" : "websocket_handshake_failed",
message: opened
? `Browser.getVersion did not respond within ${timeoutMs}ms`
: `WebSocket handshake did not complete within ${timeoutMs}ms`,
});
},
Math.max(50, timeoutMs + 25),
);
ws.once("open", () => {
opened = true;
try {
ws.send(
JSON.stringify({
id: 1,
method: "Browser.getVersion",
}),
);
} catch (err) {
finish({
ok: false,
code: "websocket_health_command_failed",
message: safeChromeCdpErrorMessage(err),
});
}
});
ws.on("message", onMessage);
ws.once("error", (err) => {
finish({
ok: false,
code: opened ? "websocket_health_command_failed" : "websocket_handshake_failed",
message: safeChromeCdpErrorMessage(err),
});
});
ws.once("close", () => {
finish({
ok: false,
code: opened ? "websocket_health_command_failed" : "websocket_handshake_failed",
message: opened
? "WebSocket closed before Browser.getVersion completed"
: "WebSocket closed before handshake completed",
});
});
});
}
function classifyChromeVersionError(error: unknown): {
code: ChromeCdpDiagnosticCode;
message: string;
} {
const message = safeChromeCdpErrorMessage(error);
if (error instanceof BrowserCdpEndpointBlockedError) {
return { code: "ssrf_blocked", message };
}
if (/^HTTP \d+/.test(message)) {
return { code: "http_status_failed", message };
}
if (error instanceof SyntaxError || message.includes("non-object JSON")) {
return { code: "invalid_json", message };
}
return { code: "http_unreachable", message };
}
export function formatChromeCdpDiagnostic(diagnostic: ChromeCdpDiagnostic): string {
const redactedCdpUrl = redactCdpUrl(diagnostic.cdpUrl) ?? diagnostic.cdpUrl;
const redactedWsUrl = redactCdpUrl(diagnostic.wsUrl) ?? diagnostic.wsUrl;
if (diagnostic.ok) {
const browser = diagnostic.browser ? ` browser=${diagnostic.browser}` : "";
return `CDP diagnostic: ready after ${diagnostic.elapsedMs}ms; cdp=${redactedCdpUrl}; websocket=${redactedWsUrl}.${browser}`;
}
const websocket = redactedWsUrl ? `; websocket=${redactedWsUrl}` : "";
return `CDP diagnostic: ${diagnostic.code} after ${diagnostic.elapsedMs}ms; cdp=${redactedCdpUrl}${websocket}; ${diagnostic.message}.`;
}
export async function diagnoseChromeCdp(
cdpUrl: string,
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
handshakeTimeoutMs = CHROME_WS_READY_TIMEOUT_MS,
ssrfPolicy?: SsrFPolicy,
): Promise<ChromeCdpDiagnostic> {
const startedAt = Date.now();
try {
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
} catch (err) {
return failureDiagnostic({
cdpUrl,
code: "ssrf_blocked",
message: safeChromeCdpErrorMessage(err),
startedAt,
});
}
if (isWebSocketUrl(cdpUrl)) {
const health = await diagnoseCdpHealthCommand(cdpUrl, handshakeTimeoutMs);
if (!health.ok) {
return failureDiagnostic({
cdpUrl,
wsUrl: cdpUrl,
code: health.code,
message: health.message,
startedAt,
});
}
return {
ok: true,
cdpUrl,
wsUrl: cdpUrl,
elapsedMs: elapsedSince(startedAt),
};
}
let version: ChromeVersion;
try {
version = await readChromeVersion(cdpUrl, timeoutMs, ssrfPolicy);
} catch (err) {
const classified = classifyChromeVersionError(err);
return failureDiagnostic({
cdpUrl,
code: classified.code,
message: classified.message,
startedAt,
});
}
const wsUrlRaw = normalizeOptionalString(version.webSocketDebuggerUrl) ?? "";
if (!wsUrlRaw) {
return failureDiagnostic({
cdpUrl,
code: "missing_websocket_debugger_url",
message: "CDP /json/version did not include webSocketDebuggerUrl",
startedAt,
});
}
const wsUrl = normalizeCdpWsUrl(wsUrlRaw, cdpUrl);
try {
await assertCdpEndpointAllowed(wsUrl, ssrfPolicy);
} catch (err) {
return failureDiagnostic({
cdpUrl,
wsUrl,
code: "websocket_ssrf_blocked",
message: safeChromeCdpErrorMessage(err),
startedAt,
});
}
const health = await diagnoseCdpHealthCommand(wsUrl, handshakeTimeoutMs);
if (!health.ok) {
return failureDiagnostic({
cdpUrl,
wsUrl,
code: health.code,
message: health.message,
startedAt,
});
}
return {
ok: true,
cdpUrl,
wsUrl,
browser: version.Browser,
userAgent: version["User-Agent"],
elapsedMs: elapsedSince(startedAt),
};
}

View File

@@ -8,9 +8,11 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
import { WebSocketServer } from "ws";
import {
decorateOpenClawProfile,
diagnoseChromeCdp,
ensureProfileCleanExit,
findChromeExecutableMac,
findChromeExecutableWindows,
formatChromeCdpDiagnostic,
getChromeWebSocketUrl,
isChromeCdpReady,
isChromeReachable,
@@ -312,6 +314,22 @@ describe("browser chrome helpers", () => {
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false);
});
it("diagnoses /json/version responses that omit the websocket URL", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ Browser: "Chrome/Mock" }),
} as unknown as Response),
);
await expect(diagnoseChromeCdp("http://127.0.0.1:12345", 50, 50)).resolves.toMatchObject({
ok: false,
code: "missing_websocket_debugger_url",
cdpUrl: "http://127.0.0.1:12345",
});
});
it("allows loopback CDP probes while still blocking non-loopback private targets in strict SSRF mode", async () => {
const fetchSpy = vi
.fn()
@@ -417,6 +435,39 @@ describe("browser chrome helpers", () => {
});
});
it("diagnoses stale websocket command channels with the discovered websocket URL", async () => {
await withMockChromeCdpServer({
wsPath: "/devtools/browser/stale-diagnostic",
onConnection: (wss) => wss.on("connection", (_ws) => {}),
run: async (baseUrl) => {
const diagnostic = await diagnoseChromeCdp(baseUrl, 300, 150);
expect(diagnostic).toMatchObject({
ok: false,
code: "websocket_health_command_timeout",
});
expect(diagnostic.wsUrl).toMatch(/\/devtools\/browser\/stale-diagnostic$/);
},
});
});
it("formats diagnostics with redacted CDP credentials", () => {
const formatted = formatChromeCdpDiagnostic({
ok: false,
code: "websocket_handshake_failed",
cdpUrl: "https://user:pass@browserless.example.com?token=supersecret123",
wsUrl: "wss://user:pass@browserless.example.com/devtools/browser/1?token=supersecret123",
message: "connect ECONNREFUSED browserless.example.com",
elapsedMs: 12,
});
expect(formatted).toContain("websocket_handshake_failed");
expect(formatted).toContain("https://browserless.example.com/?token=***");
expect(formatted).toContain("wss://browserless.example.com/devtools/browser/1?token=***");
expect(formatted).not.toContain("user");
expect(formatted).not.toContain("pass");
expect(formatted).not.toContain("supersecret123");
});
it("probes WebSocket URLs via handshake instead of HTTP", async () => {
// For ws:// URLs, isChromeReachable should NOT call fetch at all —
// it should attempt a WebSocket handshake instead.

View File

@@ -5,7 +5,6 @@ import path from "node:path";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { ensurePortAvailable } from "../infra/ports.js";
import { rawDataToString } from "../infra/ws.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { CONFIG_DIR } from "../utils.js";
import {
@@ -19,14 +18,15 @@ import {
CHROME_STOP_TIMEOUT_MS,
CHROME_WS_READY_TIMEOUT_MS,
} from "./cdp-timeouts.js";
import {
appendCdpPath,
assertCdpEndpointAllowed,
fetchCdpChecked,
isWebSocketUrl,
openCdpWebSocket,
} from "./cdp.helpers.js";
import { assertCdpEndpointAllowed, isWebSocketUrl, openCdpWebSocket } from "./cdp.helpers.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import {
diagnoseChromeCdp,
formatChromeCdpDiagnostic,
type ChromeVersion,
readChromeVersion,
safeChromeCdpErrorMessage,
} from "./chrome.diagnostics.js";
import {
type BrowserExecutable,
resolveBrowserExecutableForPlatform,
@@ -45,6 +45,12 @@ import {
const log = createSubsystemLogger("browser").child("chrome");
export type { BrowserExecutable } from "./chrome.executables.js";
export {
diagnoseChromeCdp,
formatChromeCdpDiagnostic,
type ChromeCdpDiagnostic,
type ChromeCdpDiagnosticCode,
} from "./chrome.diagnostics.js";
export {
findChromeExecutableLinux,
findChromeExecutableMac,
@@ -127,15 +133,24 @@ export function buildOpenClawChromeLaunchArgs(params: {
async function canOpenWebSocket(url: string, timeoutMs: number): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const ws = openCdpWebSocket(url, { handshakeTimeoutMs: timeoutMs });
let settled = false;
const finish = (value: boolean) => {
if (settled) {
return;
}
settled = true;
resolve(value);
};
ws.once("open", () => {
try {
ws.close();
} catch {
// ignore
}
resolve(true);
finish(true);
});
ws.once("error", () => resolve(false));
ws.once("error", () => finish(false));
ws.once("close", () => finish(false));
});
}
@@ -157,40 +172,15 @@ export async function isChromeReachable(
}
}
type ChromeVersion = {
webSocketDebuggerUrl?: string;
Browser?: string;
"User-Agent"?: string;
};
async function fetchChromeVersion(
cdpUrl: string,
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
ssrfPolicy?: SsrFPolicy,
): Promise<ChromeVersion | null> {
const ctrl = new AbortController();
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
try {
const versionUrl = appendCdpPath(cdpUrl, "/json/version");
const { response, release } = await fetchCdpChecked(
versionUrl,
timeoutMs,
{ signal: ctrl.signal },
ssrfPolicy,
);
try {
const data = (await response.json()) as ChromeVersion;
if (!data || typeof data !== "object") {
return null;
}
return data;
} finally {
await release();
}
return await readChromeVersion(cdpUrl, timeoutMs, ssrfPolicy);
} catch {
return null;
} finally {
clearTimeout(t);
}
}
@@ -214,92 +204,17 @@ export async function getChromeWebSocketUrl(
return normalizedWsUrl;
}
async function canRunCdpHealthCommand(
wsUrl: string,
timeoutMs = CHROME_WS_READY_TIMEOUT_MS,
): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const ws = openCdpWebSocket(wsUrl, {
handshakeTimeoutMs: timeoutMs,
});
let settled = false;
const onMessage = (raw: Parameters<typeof rawDataToString>[0]) => {
if (settled) {
return;
}
let parsed: { id?: unknown; result?: unknown } | null = null;
try {
parsed = JSON.parse(rawDataToString(raw)) as { id?: unknown; result?: unknown };
} catch {
return;
}
if (parsed?.id !== 1) {
return;
}
finish(Boolean(parsed.result && typeof parsed.result === "object"));
};
const finish = (value: boolean) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
ws.off("message", onMessage);
try {
ws.close();
} catch {
// ignore
}
resolve(value);
};
const timer = setTimeout(
() => {
try {
ws.terminate();
} catch {
// ignore
}
finish(false);
},
Math.max(50, timeoutMs + 25),
);
ws.once("open", () => {
try {
ws.send(
JSON.stringify({
id: 1,
method: "Browser.getVersion",
}),
);
} catch {
finish(false);
}
});
ws.on("message", onMessage);
ws.once("error", () => {
finish(false);
});
ws.once("close", () => {
finish(false);
});
});
}
export async function isChromeCdpReady(
cdpUrl: string,
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
handshakeTimeoutMs = CHROME_WS_READY_TIMEOUT_MS,
ssrfPolicy?: SsrFPolicy,
): Promise<boolean> {
const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs, ssrfPolicy).catch(() => null);
if (!wsUrl) {
return false;
const diagnostic = await diagnoseChromeCdp(cdpUrl, timeoutMs, handshakeTimeoutMs, ssrfPolicy);
if (!diagnostic.ok) {
log.debug(formatChromeCdpDiagnostic(diagnostic));
}
return await canRunCdpHealthCommand(wsUrl, handshakeTimeoutMs);
return diagnostic.ok;
}
export async function launchOpenClawChrome(
@@ -418,6 +333,9 @@ export async function launchOpenClawChrome(
}
if (!(await isChromeReachable(profile.cdpUrl))) {
const diagnosticText = await diagnoseChromeCdp(profile.cdpUrl)
.then(formatChromeCdpDiagnostic)
.catch((err) => `CDP diagnostic failed: ${safeChromeCdpErrorMessage(err)}.`);
const stderrOutput =
normalizeOptionalString(Buffer.concat(stderrChunks).toString("utf8")) ?? "";
const stderrHint = stderrOutput
@@ -433,7 +351,7 @@ export async function launchOpenClawChrome(
// ignore
}
throw new Error(
`Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}".${sandboxHint}${stderrHint}`,
`Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}". ${diagnosticText}${sandboxHint}${stderrHint}`,
);
}

View File

@@ -14,6 +14,8 @@ import {
listChromeMcpTabs,
} from "./chrome-mcp.js";
import {
diagnoseChromeCdp,
formatChromeCdpDiagnostic,
isChromeCdpReady,
isChromeReachable,
launchOpenClawChrome,
@@ -96,6 +98,17 @@ export function createProfileAvailability({
return await isChromeReachable(profile.cdpUrl, httpTimeoutMs, getCdpReachabilityPolicy());
};
const describeCdpFailure = async (timeoutMs?: number): Promise<string> => {
const { httpTimeoutMs, wsTimeoutMs } = resolveTimeouts(timeoutMs);
const diagnostic = await diagnoseChromeCdp(
profile.cdpUrl,
httpTimeoutMs,
wsTimeoutMs,
getCdpReachabilityPolicy(),
);
return formatChromeCdpDiagnostic(diagnostic);
};
const attachRunning = (running: NonNullable<ProfileRuntimeState["running"]>) => {
setProfileRunning(running);
running.proc.on("exit", () => {
@@ -150,7 +163,9 @@ export function createProfileAvailability({
await new Promise((r) => setTimeout(r, CDP_READY_AFTER_LAUNCH_POLL_MS));
}
throw new Error(
`Chrome CDP websocket for profile "${profile.name}" is not reachable after start.`,
`Chrome CDP websocket for profile "${profile.name}" is not reachable after start. ${await describeCdpFailure(
CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS,
)}`,
);
};
@@ -245,18 +260,20 @@ export function createProfileAvailability({
if (remoteCdp && (await isReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS))) {
return;
}
const detail = await describeCdpFailure(PROFILE_ATTACH_RETRY_TIMEOUT_MS);
throw new BrowserProfileUnavailableError(
remoteCdp
? `Remote CDP websocket for profile "${profile.name}" is not reachable.`
: `Browser attachOnly is enabled and CDP websocket for profile "${profile.name}" is not reachable.`,
? `Remote CDP websocket for profile "${profile.name}" is not reachable. ${detail}`
: `Browser attachOnly is enabled and CDP websocket for profile "${profile.name}" is not reachable. ${detail}`,
);
}
// HTTP responds but WebSocket fails - port in use by something else.
if (!profileState.running) {
const detail = await describeCdpFailure(PROFILE_ATTACH_RETRY_TIMEOUT_MS);
throw new BrowserProfileUnavailableError(
`Port ${profile.cdpPort} is in use for profile "${profile.name}" but not by openclaw. ` +
`Run action=reset-profile profile=${profile.name} to kill the process.`,
`Run action=reset-profile profile=${profile.name} to kill the process. ${detail}`,
);
}
@@ -268,7 +285,9 @@ export function createProfileAvailability({
if (!(await isReachable(PROFILE_POST_RESTART_WS_TIMEOUT_MS))) {
throw new Error(
`Chrome CDP websocket for profile "${profile.name}" is not reachable after restart.`,
`Chrome CDP websocket for profile "${profile.name}" is not reachable after restart. ${await describeCdpFailure(
PROFILE_POST_RESTART_WS_TIMEOUT_MS,
)}`,
);
}
};

View File

@@ -5,6 +5,18 @@ const chromeUserDataDir = { dir: "/tmp/openclaw" };
installChromeUserDataDirHooks(chromeUserDataDir);
vi.mock("./chrome.js", () => ({
diagnoseChromeCdp: vi.fn(async () => ({
ok: false,
code: "websocket_health_command_timeout",
cdpUrl: "http://127.0.0.1:18800",
message: "mock CDP diagnostic",
elapsedMs: 1,
})),
formatChromeCdpDiagnostic: vi.fn((diagnostic: { ok: boolean; code?: string; message?: string }) =>
diagnostic.ok
? "CDP diagnostic: ready."
: `CDP diagnostic: ${diagnostic.code}; ${diagnostic.message}.`,
),
isChromeCdpReady: vi.fn(async () => true),
isChromeReachable: vi.fn(async () => true),
launchOpenClawChrome: vi.fn(async () => {

View File

@@ -55,8 +55,12 @@ describe("browser server-context ensureBrowserAvailable", () => {
const promise = profile.ensureBrowserAvailable();
const rejected = expect(promise).rejects.toThrow("not reachable after start");
const diagnosticRejected = expect(promise).rejects.toThrow(
"CDP diagnostic: websocket_health_command_timeout; mock CDP diagnostic.",
);
await vi.advanceTimersByTimeAsync(8100);
await rejected;
await diagnosticRejected;
expect(launchOpenClawChrome).toHaveBeenCalledTimes(1);
expect(stopOpenClawChrome).toHaveBeenCalledTimes(1);