fix(browser): discover CDP websocket from bare ws:// URL before attach (#68715)

* fix(browser): discover CDP websocket from bare ws:// URL before attach

When browser.cdpUrl is set to a bare ws://host:port (no /devtools/ path), ensureBrowserAvailable would call isChromeReachable -> canOpenWebSocket against the URL verbatim. Chrome only accepts WebSocket upgrades at the specific path returned by /json/version, so the handshake failed immediately with HTTP 400. With attachOnly: true, that surfaced as:

  Browser attachOnly is enabled and profile "openclaw" is not running.

even though the CDP endpoint was reachable and the profile was healthy. Reproduced by the new tests in chrome.test.ts and cdp.test.ts (#68027).

Fix: introduce isDirectCdpWebSocketEndpoint(url) — true only when a ws/wss URL has a /devtools/<kind>/<id> handshake path. Route any other ws/wss cdpUrl (including the bare ws://host:port shape) through HTTP /json/version discovery by normalising the scheme via the existing normalizeCdpHttpBaseForJsonEndpoints helper. Apply this in isChromeReachable, getChromeWebSocketUrl, and createTargetViaCdp. Direct WS endpoints with a /devtools/ path are still opened without an extra discovery round-trip.

Fixes #68027

* test(browser): add seeded fuzz coverage for CDP URL helpers

Adds property-based / seeded-fuzz tests for the URL helpers the
attachOnly CDP fix depends on (#68027):

  - isWebSocketUrl
  - isDirectCdpWebSocketEndpoint
  - normalizeCdpHttpBaseForJsonEndpoints
  - parseBrowserHttpUrl
  - redactCdpUrl
  - appendCdpPath
  - getHeadersWithAuth

Follows the existing repo convention (see
src/gateway/http-common.fuzz.test.ts): no fast-check dep, small
mulberry32 PRNG + hand-rolled generators, deterministic per-describe
seeds so failures are reproducible.

Lifts cdp.helpers.ts coverage from 77.77% -> 89.54% statements,
67.9% -> 80.24% branches, 78% -> 90% lines. Remaining uncovered
lines are inside the WS sender internals (createCdpSender,
withCdpSocket, fetchCdpChecked rate-limit branch), which require
integration-style mocks and are unrelated to the attachOnly fix.

* test(browser): drive cdp.helpers/cdp/chrome to 100% coverage

Lifts the three files touched by the #68027 attachOnly fix to 100% statements/branches/functions/lines across the extensions test suite. Adds cdp.helpers.internal.test.ts, cdp.internal.test.ts, and chrome.internal.test.ts covering error paths, branch matrices, CDP session helpers, Chrome spawn/launch/stop flows, and canRunCdpHealthCommand. Defensively unreachable guards are annotated with c8 ignore + inline justifications.

* fix(browser): restore WS fallback for non-/devtools ws:// CDP URLs

When /json/version discovery is unavailable (or returns no
webSocketDebuggerUrl), fall back to treating the original bare ws/wss
URL as a direct WebSocket endpoint. This preserves the #68027 fix for
Chrome's debug port while restoring compatibility with Browserless/
Browserbase-style providers that expose a direct WebSocket root without
a /json/version endpoint.

Priority order for bare ws/wss cdpUrl inputs:
  1. /devtools/<kind>/<id> URL \u2192 direct handshake, no discovery (unchanged)
  2. bare ws/wss root \u2192 try HTTP discovery first; if discovery returns a
     webSocketDebuggerUrl use it; otherwise fall back to the original URL
     as a direct WS endpoint
  3. HTTP/HTTPS URL \u2192 HTTP discovery only, no fallback (unchanged)

Affected call sites: isChromeReachable, getChromeWebSocketUrl,
createTargetViaCdp.

Also renames a misleading test ('still enforces SSRF policy for direct
WebSocket URLs') to accurately describe what it tests: SSRF enforcement
on the navigation target URL, not on the CDP endpoint.

New tests added for all three fallback paths. Coverage remains 100% on
all three touched files (238 tests).

* fix: browser attachOnly bare ws CDP follow-ups (#68715) (thanks @visionik)
This commit is contained in:
Viz
2026-04-19 05:43:39 -04:00
committed by GitHub
parent 25e51bba52
commit 4cfc8cd5be
12 changed files with 3147 additions and 35 deletions

View File

@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
- 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.
- Browser/CDP: discover Chromes real DevTools websocket from bare `ws://host:port` attach-only roots before declaring the profile down, while still falling back to direct websocket providers that do not expose `/json/version`. Fixes #68027. (#68715) Thanks @visionik.
## 2026.4.18

View File

@@ -316,15 +316,29 @@ Notes:
## Direct WebSocket CDP providers
Some hosted browser services expose a **direct WebSocket** endpoint rather than
the standard HTTP-based CDP discovery (`/json/version`). OpenClaw supports both:
the standard HTTP-based CDP discovery (`/json/version`). OpenClaw accepts three
CDP URL shapes and picks the right connection strategy automatically:
- **HTTP(S) endpoints** — OpenClaw calls `/json/version` to discover the
WebSocket debugger URL, then connects.
- **WebSocket endpoints** (`ws://` / `wss://`) — OpenClaw connects directly,
skipping `/json/version`. Use this for services like
[Browserless](https://browserless.io),
[Browserbase](https://www.browserbase.com), or any provider that hands you a
WebSocket URL.
- **HTTP(S) discovery** — `http://host[:port]` or `https://host[:port]`.
OpenClaw calls `/json/version` to discover the WebSocket debugger URL, then
connects. No WebSocket fallback.
- **Direct WebSocket endpoints** — `ws://host[:port]/devtools/<kind>/<id>` or
`wss://...` with a `/devtools/browser|page|worker|shared_worker|service_worker/<id>`
path. OpenClaw connects directly via a WebSocket handshake and skips
`/json/version` entirely.
- **Bare WebSocket roots** — `ws://host[:port]` or `wss://host[:port]` with no
`/devtools/...` path (e.g. [Browserless](https://browserless.io),
[Browserbase](https://www.browserbase.com)). OpenClaw tries HTTP
`/json/version` discovery first (normalising the scheme to `http`/`https`);
if discovery returns a `webSocketDebuggerUrl` it is used, otherwise OpenClaw
falls back to a direct WebSocket handshake at the bare root. This covers
both Chrome-style remote debug ports and WebSocket-only providers.
Plain `ws://host:port` / `wss://host:port` without a `/devtools/...` path
pointed at a local Chrome instance is supported via the discovery-first
fallback — Chrome only accepts WebSocket upgrades on the specific per-browser
or per-target path returned by `/json/version`, so a bare-root handshake alone
would fail.
### Browserbase

View File

@@ -0,0 +1,441 @@
import { describe, expect, it } from "vitest";
import {
appendCdpPath,
getHeadersWithAuth,
isDirectCdpWebSocketEndpoint,
isWebSocketUrl,
normalizeCdpHttpBaseForJsonEndpoints,
parseBrowserHttpUrl,
redactCdpUrl,
} from "./cdp.helpers.js";
/**
* Seeded property-based / fuzz coverage for the URL helpers in cdp.helpers.
*
* The repo intentionally does not pull in `fast-check` (see
* src/gateway/http-common.fuzz.test.ts); this file follows the same
* pattern: a small deterministic PRNG (mulberry32) + hand-rolled
* generators, with every property running N iterations. Failures are
* deterministic because each describe block seeds its own rng.
*
* Focus is on the URL parsing / normalisation primitives that the
* #68027 attachOnly fix depends on: distinguishing direct-WS CDP
* endpoints from bare ws roots, and normalising bare ws URLs to http
* for `/json/version` discovery.
*/
/** Deterministic 32-bit PRNG. */
function makeRng(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function randInt(rng: () => number, loInclusive: number, hiInclusive: number): number {
return Math.floor(rng() * (hiInclusive - loInclusive + 1)) + loInclusive;
}
function pick<T>(rng: () => number, arr: readonly T[]): T {
return arr[randInt(rng, 0, arr.length - 1)];
}
function randHost(rng: () => number): string {
return pick(rng, [
"127.0.0.1",
"localhost",
"[::1]",
"0.0.0.0",
"[::]",
"example.com",
"connect.example.com",
"browserless.example",
"host-1.example.internal",
"user.example.com",
"192.168.1.202",
"10.0.0.5",
]);
}
function randPort(rng: () => number): string {
const kind = randInt(rng, 0, 4);
if (kind === 0) {
return "";
}
if (kind === 1) {
return ":9222";
}
if (kind === 2) {
return `:${randInt(rng, 1, 65535)}`;
}
if (kind === 3) {
return ":3000";
}
return ":443";
}
function randWsScheme(rng: () => number): "ws://" | "wss://" {
return rng() < 0.5 ? "ws://" : "wss://";
}
function randHttpScheme(rng: () => number): "http://" | "https://" {
return rng() < 0.5 ? "http://" : "https://";
}
function randDirectDevtoolsPath(rng: () => number): string {
const kind = pick(rng, ["browser", "page", "worker", "shared_worker", "service_worker"] as const);
const id = `${randInt(rng, 0, 0xffffffff).toString(16)}-${randInt(rng, 0, 9999)}`;
return `/devtools/${kind}/${id}`;
}
function randNonDevtoolsPath(rng: () => number): string {
return pick(rng, [
"",
"/",
"/json/version",
"/devtools",
"/devtools/",
"/devtools/browser/", // trailing slash, no id
"/devtools/unknown/abc",
"/other/path",
"/cdp",
"/json/list",
]);
}
function randQuery(rng: () => number): string {
if (rng() < 0.5) {
return "";
}
return pick(rng, ["?token=abc", "?apiKey=xyz&other=1", "?session=1&token=ws-token", "?t="]);
}
function randUserInfo(rng: () => number): string {
if (rng() < 0.6) {
return "";
}
return pick(rng, ["user:pass@", "u:p@", "alice:s3cr3t@", "only-user@", ":only-pass@"]);
}
const ITERATIONS = 200;
describe("fuzz: isWebSocketUrl", () => {
it("returns true for any syntactically valid ws/wss URL", () => {
const rng = makeRng(0x1001);
for (let i = 0; i < ITERATIONS; i += 1) {
const url = `${randWsScheme(rng)}${randUserInfo(rng)}${randHost(rng)}${randPort(rng)}${
rng() < 0.5 ? randDirectDevtoolsPath(rng) : randNonDevtoolsPath(rng)
}${randQuery(rng)}`;
try {
// Only assert the property when the URL itself parses; assign
// the result to satisfy eslint's no-new rule.
const _parsed = new URL(url);
void _parsed;
} catch {
continue;
}
expect(isWebSocketUrl(url)).toBe(true);
}
});
it("returns false for http/https URLs and random non-URL garbage", () => {
const rng = makeRng(0x1002);
for (let i = 0; i < ITERATIONS; i += 1) {
const kind = randInt(rng, 0, 2);
if (kind === 0) {
const url = `${randHttpScheme(rng)}${randHost(rng)}${randPort(rng)}${randNonDevtoolsPath(
rng,
)}${randQuery(rng)}`;
expect(isWebSocketUrl(url)).toBe(false);
} else if (kind === 1) {
expect(isWebSocketUrl("")).toBe(false);
} else {
// Deliberately malformed: no scheme, or unsupported scheme.
const junk = pick(rng, [
"not-a-url",
"ftp://example.com",
"file:///etc/passwd",
"://foo",
"ws:",
"ws:/",
"ws//",
]);
expect(isWebSocketUrl(junk)).toBe(false);
}
}
});
});
describe("fuzz: isDirectCdpWebSocketEndpoint", () => {
it("returns true iff the URL is ws/wss AND path is /devtools/<kind>/<id>", () => {
const rng = makeRng(0x2001);
for (let i = 0; i < ITERATIONS; i += 1) {
const scheme = randWsScheme(rng);
const path = randDirectDevtoolsPath(rng);
const url = `${scheme}${randHost(rng)}${randPort(rng)}${path}${randQuery(rng)}`;
expect(isDirectCdpWebSocketEndpoint(url)).toBe(true);
}
});
it("returns false for bare ws roots and non-devtools ws paths (needs HTTP discovery)", () => {
const rng = makeRng(0x2002);
for (let i = 0; i < ITERATIONS; i += 1) {
const url = `${randWsScheme(rng)}${randHost(rng)}${randPort(rng)}${randNonDevtoolsPath(
rng,
)}${randQuery(rng)}`;
expect(isDirectCdpWebSocketEndpoint(url)).toBe(false);
}
});
it("returns false for any http/https URL regardless of path", () => {
const rng = makeRng(0x2003);
for (let i = 0; i < ITERATIONS; i += 1) {
const path = rng() < 0.5 ? randDirectDevtoolsPath(rng) : randNonDevtoolsPath(rng);
const url = `${randHttpScheme(rng)}${randHost(rng)}${randPort(rng)}${path}${randQuery(rng)}`;
expect(isDirectCdpWebSocketEndpoint(url)).toBe(false);
}
});
it("never throws on random input (including invalid URLs)", () => {
const rng = makeRng(0x2004);
const junkPool = [
"",
" ",
"not-a-url",
"http://",
"ws://",
"ws:///devtools/browser/abc",
"://x",
"\u0000",
"ws://[not-an-ip]/devtools/browser/abc",
];
for (let i = 0; i < ITERATIONS; i += 1) {
const input = rng() < 0.5 ? pick(rng, junkPool) : String.fromCharCode(randInt(rng, 0, 0x7f));
expect(() => isDirectCdpWebSocketEndpoint(input)).not.toThrow();
expect(typeof isDirectCdpWebSocketEndpoint(input)).toBe("boolean");
}
});
});
describe("fuzz: normalizeCdpHttpBaseForJsonEndpoints", () => {
it("ws -> http and wss -> https, drops trailing /devtools/browser/... and /cdp", () => {
const rng = makeRng(0x3001);
for (let i = 0; i < ITERATIONS; i += 1) {
const scheme = randWsScheme(rng);
const host = randHost(rng);
const port = randPort(rng);
const suffix = pick(rng, [
"",
"/",
"/cdp",
"/devtools/browser/abc",
"/devtools/browser/abc/path-fragment",
]);
const input = `${scheme}${host}${port}${suffix}`;
const out = normalizeCdpHttpBaseForJsonEndpoints(input);
// Scheme mapping
if (scheme === "ws://") {
expect(out.startsWith("http://")).toBe(true);
expect(out.startsWith("ws://")).toBe(false);
} else {
expect(out.startsWith("https://")).toBe(true);
expect(out.startsWith("wss://")).toBe(false);
}
// /devtools/browser/... and /cdp are stripped
expect(out.includes("/devtools/browser/")).toBe(false);
expect(out.endsWith("/cdp")).toBe(false);
// No trailing slash
expect(out.endsWith("/")).toBe(false);
}
});
it("preserves http/https inputs and strips a trailing /cdp when present", () => {
const rng = makeRng(0x3002);
for (let i = 0; i < ITERATIONS; i += 1) {
const scheme = randHttpScheme(rng);
const hasCdp = rng() < 0.5;
const hasTrailingSlash = rng() < 0.3;
// Only exercise the trailing-/cdp branch here (the regex only
// strips /cdp when it's the final path segment, not /cdp/ etc.).
const input = `${scheme}${randHost(rng)}${randPort(rng)}${hasCdp ? "/cdp" : ""}${
hasTrailingSlash && !hasCdp ? "/" : ""
}`;
const out = normalizeCdpHttpBaseForJsonEndpoints(input);
expect(out.startsWith(scheme)).toBe(true);
expect(out.endsWith("/cdp")).toBe(false);
expect(out.endsWith("/")).toBe(false);
}
});
it("falls back safely for non-URL-ish inputs (never throws)", () => {
const rng = makeRng(0x3003);
// These inputs either trigger the catch branch (empty / "garbage" /
// bare "ws://" / "wss://") or are accepted by WHATWG URL as
// special-scheme absolute URLs (e.g. "ws:host/path" becomes
// "ws://host/path"). Either way the helper must never throw.
const junk = [
"ws:/devtools/browser/abc",
"wss:/devtools/browser/abc",
"ws:no-host/cdp",
"wss:no-host/",
"garbage",
"",
"ws://",
"wss://",
];
for (let i = 0; i < ITERATIONS; i += 1) {
const input = pick(rng, junk);
expect(() => normalizeCdpHttpBaseForJsonEndpoints(input)).not.toThrow();
const out = normalizeCdpHttpBaseForJsonEndpoints(input);
expect(typeof out).toBe("string");
// Scheme swap invariant: whatever branch ran, ws:/wss: never
// appear as a scheme prefix in the normalized output.
expect(out.startsWith("ws:")).toBe(false);
expect(out.startsWith("wss:")).toBe(false);
}
});
it("fallback explicitly handles malformed ws:/wss: scheme-only strings", () => {
// Hand-crafted inputs that parse as URLs via WHATWG but the pattern
// still exercises the scheme swap + suffix strip in both branches.
expect(normalizeCdpHttpBaseForJsonEndpoints("ws://host:9222/cdp")).toBe("http://host:9222");
expect(normalizeCdpHttpBaseForJsonEndpoints("wss://host:9222/")).toBe("https://host:9222");
expect(normalizeCdpHttpBaseForJsonEndpoints("ws://host/devtools/browser/abc")).toBe(
"http://host",
);
// WHATWG URL preserves the root "/" on the path after stripping the
// /devtools/browser/... suffix, so the trailing-slash removal only
// trims the final character of the serialized form (which is "1",
// not "/").
expect(normalizeCdpHttpBaseForJsonEndpoints("wss://host/devtools/browser/abc?t=1")).toBe(
"https://host/?t=1",
);
// Fallback branch: inputs `new URL` genuinely rejects. The fallback
// performs a naive scheme swap and suffix strip on the raw string.
expect(normalizeCdpHttpBaseForJsonEndpoints("")).toBe("");
expect(normalizeCdpHttpBaseForJsonEndpoints("garbage")).toBe("garbage");
expect(normalizeCdpHttpBaseForJsonEndpoints("ws://").startsWith("http:")).toBe(true);
expect(normalizeCdpHttpBaseForJsonEndpoints("wss://").startsWith("https:")).toBe(true);
});
});
describe("fuzz: parseBrowserHttpUrl", () => {
it("accepts http/https/ws/wss and assigns sensible default ports", () => {
const rng = makeRng(0x4001);
for (let i = 0; i < ITERATIONS; i += 1) {
const scheme = pick(rng, ["http://", "https://", "ws://", "wss://"] as const);
const explicitPort = rng() < 0.5;
const portNum = randInt(rng, 1, 65535);
const url = `${scheme}${randHost(rng)}${explicitPort ? `:${portNum}` : ""}/path`;
const result = parseBrowserHttpUrl(url, "test");
expect(result.parsed.protocol).toBe(scheme.replace("//", ""));
if (explicitPort) {
expect(result.port).toBe(portNum);
} else {
const isSecure = scheme === "https://" || scheme === "wss://";
expect(result.port).toBe(isSecure ? 443 : 80);
}
expect(result.normalized.endsWith("/")).toBe(false);
}
});
it("rejects unsupported protocols", () => {
const rng = makeRng(0x4002);
for (let i = 0; i < ITERATIONS; i += 1) {
const scheme = pick(rng, ["ftp://", "file://", "gopher://", "data:"] as const);
const url = scheme === "data:" ? "data:text/plain,hello" : `${scheme}${randHost(rng)}`;
expect(() => parseBrowserHttpUrl(url, "test")).toThrow(/must be http\(s\) or ws\(s\)/);
}
});
});
describe("fuzz: redactCdpUrl", () => {
it("strips username/password from valid URLs and preserves host/path", () => {
const rng = makeRng(0x5001);
for (let i = 0; i < ITERATIONS; i += 1) {
const scheme = pick(rng, ["http://", "https://", "ws://", "wss://"] as const);
const host = randHost(rng);
const port = randPort(rng);
const path = rng() < 0.5 ? randDirectDevtoolsPath(rng) : randNonDevtoolsPath(rng);
const url = `${scheme}user:pass@${host}${port}${path}`;
const out = redactCdpUrl(url);
expect(typeof out).toBe("string");
expect(String(out)).not.toContain("user:pass@");
}
});
it("returns non-string inputs unchanged and short-circuits empty/whitespace strings", () => {
expect(redactCdpUrl(undefined)).toBeUndefined();
expect(redactCdpUrl(null)).toBeNull();
// Empty and whitespace-only inputs both short-circuit to the
// trimmed empty string before any URL parsing / redaction.
expect(redactCdpUrl("")).toBe("");
expect(redactCdpUrl(" ")).toBe("");
});
it("falls back to redactSensitiveText for non-URL-ish inputs (never throws)", () => {
const rng = makeRng(0x5002);
for (let i = 0; i < ITERATIONS; i += 1) {
const junk = pick(rng, ["not-a-url", "http://", "ws://", "::::", "Bearer ey.SECRET.xyz"]);
expect(() => redactCdpUrl(junk)).not.toThrow();
const out = redactCdpUrl(junk);
expect(typeof out).toBe("string");
}
});
});
describe("fuzz: appendCdpPath", () => {
it("produces a URL that ends with the appended path exactly once", () => {
const rng = makeRng(0x6001);
for (let i = 0; i < ITERATIONS; i += 1) {
const scheme = pick(rng, ["http://", "https://", "ws://", "wss://"] as const);
const base = `${scheme}${randHost(rng)}${randPort(rng)}${rng() < 0.5 ? "/" : ""}`;
const path = pick(rng, ["/json/version", "json/version", "/json/close/TARGET_1"]);
const out = appendCdpPath(base, path);
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
// Path segment should appear in output and not be doubled.
expect(out.endsWith(normalizedPath)).toBe(true);
expect(out.split(normalizedPath).length - 1).toBeGreaterThanOrEqual(1);
}
});
});
describe("fuzz: getHeadersWithAuth", () => {
it("never throws and always returns a mergedHeaders object", () => {
const rng = makeRng(0x7001);
for (let i = 0; i < ITERATIONS; i += 1) {
const withAuth = rng() < 0.3;
const url =
rng() < 0.5
? `${randHttpScheme(rng)}${withAuth ? "alice:s3cr3t@" : ""}${randHost(rng)}${randPort(rng)}`
: pick(rng, ["not-a-url", "", "ws://"]);
const headers: Record<string, string> = {};
if (rng() < 0.3) {
headers.Authorization = "Bearer preset";
}
const out = getHeadersWithAuth(url, headers);
expect(typeof out).toBe("object");
// Preset auth header must always be preserved verbatim.
if (headers.Authorization) {
expect(out.Authorization).toBe("Bearer preset");
}
}
});
it("injects Basic auth from URL userinfo when no Authorization header is present", () => {
const out = getHeadersWithAuth("https://alice:s3cr3t@example.com/path");
expect(out.Authorization).toBe(`Basic ${Buffer.from("alice:s3cr3t").toString("base64")}`);
});
it("preserves an existing Authorization header (case-insensitive) over URL userinfo", () => {
const out = getHeadersWithAuth("https://alice:s3cr3t@example.com/path", {
authorization: "Bearer preset",
});
expect(out.authorization).toBe("Bearer preset");
expect(out.Authorization).toBeUndefined();
});
});

View File

@@ -0,0 +1,394 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WebSocketServer } from "ws";
import { rawDataToString } from "../infra/ws.js";
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
return {
...actual,
fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args),
};
});
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import {
assertCdpEndpointAllowed,
fetchCdpChecked,
fetchJson,
openCdpWebSocket,
withCdpSocket,
} from "./cdp.helpers.js";
import { BrowserCdpEndpointBlockedError } from "./errors.js";
/**
* Targets the non-URL-helper code paths in cdp.helpers.ts:
* - assertCdpEndpointAllowed invalid-protocol throw
* - fetchCdpChecked 429 rate-limit + double-release guard
* - createCdpSender message routing (non-number id, unknown id, error body)
* - createCdpSender 'error' event + pending rejection
* - withCdpSocket open-error / fn-throw / close error-close paths
*/
async function startWsServer() {
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
const port = (wss.address() as { port: number }).port;
return { wss, port, url: `ws://127.0.0.1:${port}/devtools/browser/TEST` };
}
describe("cdp.helpers internal", () => {
let wss: WebSocketServer | null = null;
afterEach(async () => {
fetchWithSsrFGuardMock.mockReset();
if (wss) {
await new Promise<void>((resolve) => wss?.close(() => resolve()));
wss = null;
}
});
describe("assertCdpEndpointAllowed", () => {
it("throws on non-http/https/ws/wss protocols under any SSRF policy", async () => {
await expect(
assertCdpEndpointAllowed("ftp://example.com/cdp", {
dangerouslyAllowPrivateNetwork: false,
}),
).rejects.toThrow(/Invalid CDP URL protocol: ftp/);
});
it("no-ops when no policy is supplied, regardless of protocol", async () => {
await expect(assertCdpEndpointAllowed("ftp://example.com/cdp")).resolves.toBeUndefined();
});
it("uses the raw ssrfPolicy path for non-loopback hosts", async () => {
// Non-loopback public host: hits the else branch of the loopback
// ternary in assertCdpEndpointAllowed. Using a well-known public IP
// under a permissive policy so the SSRF pin resolves without a DNS
// mock.
await expect(
assertCdpEndpointAllowed("http://93.184.216.34:443/cdp", {
allowPrivateNetwork: true,
}),
).resolves.toBeUndefined();
});
});
describe("fetchCdpChecked", () => {
it("maps HTTP 429 responses into the browser rate-limit error", async () => {
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: { ok: false, status: 429 } as unknown as Response,
release: vi.fn(async () => {}),
});
await expect(
fetchCdpChecked("http://127.0.0.1:9222/json/version", 250, undefined, {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["127.0.0.1"],
}),
).rejects.toThrow(/rate[ -]?limit/i);
});
it("is idempotent when release() is awaited more than once", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: { ok: true, status: 200 } as unknown as Response,
release,
});
const { release: guardedRelease } = await fetchCdpChecked(
"http://127.0.0.1:9222/json/version",
250,
undefined,
{ dangerouslyAllowPrivateNetwork: false, allowedHostnames: ["127.0.0.1"] },
);
await guardedRelease();
await guardedRelease();
// The underlying release must be invoked exactly once.
expect(release).toHaveBeenCalledTimes(1);
});
it("converts SSRF-blocked errors from the underlying fetch into a browser-scoped error", async () => {
fetchWithSsrFGuardMock.mockRejectedValueOnce(new SsrFBlockedError("blocked by policy"));
await expect(
fetchCdpChecked("http://127.0.0.1:9222/json/version", 250, undefined, {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["127.0.0.1"],
}),
).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError);
});
it("maps non-429 HTTP failures into a generic HTTP error", async () => {
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: { ok: false, status: 503 } as unknown as Response,
release: vi.fn(async () => {}),
});
await expect(
fetchJson("http://127.0.0.1:9222/json/version", 250, undefined, {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["127.0.0.1"],
}),
).rejects.toThrow(/HTTP 503/);
});
it("uses the caller-supplied policy for non-loopback hosts", async () => {
// Hits the else branch of the isLoopbackHost ternary inside
// withNoProxyForCdpUrl plus the left-hand side of the
// `ssrfPolicy ?? { allowPrivateNetwork: true }` coalescing.
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: { ok: true, status: 200 } as unknown as Response,
release,
});
await fetchCdpChecked("http://93.184.216.34:9222/json/version", 250, undefined, {
allowPrivateNetwork: true,
});
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
policy: expect.objectContaining({ allowPrivateNetwork: true }),
}),
);
});
it("falls back to a permissive private-network policy when none is supplied on a non-loopback host", async () => {
// Hits the right-hand side of the `ssrfPolicy ?? { allowPrivateNetwork: true }` default.
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: { ok: true, status: 200 } as unknown as Response,
release,
});
await fetchCdpChecked("http://93.184.216.34:9222/json/version", 250);
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
policy: { allowPrivateNetwork: true },
}),
);
});
});
describe("createCdpSender (via withCdpSocket)", () => {
it("ignores messages with a non-numeric id", async () => {
const server = await startWsServer();
wss = server.wss;
let received = 0;
server.wss.on("connection", (socket) => {
socket.on("message", (raw) => {
received += 1;
const text = rawDataToString(raw);
const msg = JSON.parse(text) as { id?: number; method?: string };
// First emit a noise message with a non-number id (should be ignored),
// then a garbage-json payload (hits the outer catch), then the real
// response so the caller resolves.
socket.send(JSON.stringify({ id: "oops", method: "unrelated" }));
socket.send("not-json");
socket.send(JSON.stringify({ id: msg.id, result: { echoed: msg.method } }));
});
});
const result = await withCdpSocket<{ echoed: string | undefined }>(
server.url,
async (send) => (await send("Test.ping")) as { echoed: string | undefined },
);
expect(result.echoed).toBe("Test.ping");
expect(received).toBe(1);
});
it("ignores responses whose id does not match any pending call", async () => {
const server = await startWsServer();
wss = server.wss;
server.wss.on("connection", (socket) => {
socket.on("message", (raw) => {
const msg = JSON.parse(rawDataToString(raw)) as { id?: number; method?: string };
// Stranger id with no pending entry — must be silently dropped.
socket.send(JSON.stringify({ id: 99999, result: {} }));
socket.send(JSON.stringify({ id: msg.id, result: { ok: true } }));
});
});
const result = await withCdpSocket<{ ok: boolean }>(
server.url,
async (send) => (await send("Test.ping")) as { ok: boolean },
);
expect(result.ok).toBe(true);
});
it("propagates CDP error-body messages as rejections to the caller", async () => {
const server = await startWsServer();
wss = server.wss;
server.wss.on("connection", (socket) => {
socket.on("message", (raw) => {
const msg = JSON.parse(rawDataToString(raw)) as { id?: number };
socket.send(
JSON.stringify({
id: msg.id,
error: { message: "boom from cdp" },
}),
);
});
});
await expect(
withCdpSocket(server.url, async (send) => {
await send("Test.failing");
}),
).rejects.toThrow(/boom from cdp/);
});
it("rejects in-flight pending calls when the socket closes mid-call", async () => {
const server = await startWsServer();
wss = server.wss;
server.wss.on("connection", (socket) => {
socket.on("message", () => {
// Defer close so the pending entry is definitely registered.
setTimeout(() => socket.close(), 10);
});
});
await expect(
withCdpSocket(server.url, async (send) => {
await send("Test.willClose");
}),
).rejects.toThrow(/CDP socket closed/);
});
});
describe("withCdpSocket", () => {
it("rejects and rethrows when the WebSocket fails to open", async () => {
// Port 1 on 127.0.0.1 is reserved and will reliably refuse connections,
// triggering the open-error branch synchronously.
await expect(
withCdpSocket("ws://127.0.0.1:1/devtools/browser/NO", async () => {
return "unreachable";
}),
).rejects.toThrow();
});
it("wraps a non-Error callback throw before closing the socket", async () => {
// `fn` is user-supplied and may throw a non-Error. Exercise the
// `err instanceof Error ? err : new Error(String(err))` wrap in the
// fn-throw catch branch.
const server = await startWsServer();
wss = server.wss;
server.wss.on("connection", (socket) => {
socket.on("message", (raw) => {
const msg = JSON.parse(rawDataToString(raw)) as { id?: number };
socket.send(JSON.stringify({ id: msg.id, result: {} }));
});
});
await expect(
withCdpSocket(server.url, async (send) => {
await send("Test.ok");
// biome-ignore lint/style/useThrowOnlyError: exercising the non-Error guard on purpose.
throw "raw-string-from-callback";
}),
).rejects.toThrow(/raw-string-from-callback/);
});
it("rethrows callback errors and still closes the socket cleanly", async () => {
const server = await startWsServer();
wss = server.wss;
server.wss.on("connection", (socket) => {
socket.on("message", (raw) => {
const msg = JSON.parse(rawDataToString(raw)) as { id?: number };
socket.send(JSON.stringify({ id: msg.id, result: {} }));
});
});
await expect(
withCdpSocket(server.url, async (send) => {
await send("Test.ok");
throw new Error("callback boom");
}),
).rejects.toThrow(/callback boom/);
});
it("tolerates a ws.close() that throws in the cleanup finally", async () => {
// Force ws.close() to throw by wrapping withCdpSocket against a live
// server but monkey-patching the ws prototype momentarily. We do this
// via a callback that pre-empts close by calling terminate() first.
const server = await startWsServer();
wss = server.wss;
server.wss.on("connection", (socket) => {
socket.on("message", (raw) => {
const msg = JSON.parse(rawDataToString(raw)) as { id?: number };
socket.send(JSON.stringify({ id: msg.id, result: {} }));
});
});
// The fn throws AFTER sending so both the catch (closeWithError) and
// the finally ws.close() run. ws.close() on an already-closed socket
// is a no-op but exercises the try/catch in the finally.
await expect(
withCdpSocket(server.url, async (send) => {
await send("Test.ok");
throw new Error("fn post-send boom");
}),
).rejects.toThrow(/fn post-send boom/);
});
});
describe("createCdpSender error/close event forwarding", () => {
beforeEach(() => {
// Ensure a fresh mock registry each scenario.
});
it("rejects pending calls when the ws emits an error event", async () => {
const server = await startWsServer();
wss = server.wss;
server.wss.on("connection", (socket) => {
socket.on("message", () => {
// Emit a synthetic error event on the server-side socket. The
// client-side ws will see the abrupt close and surface an error.
socket.terminate();
});
});
await expect(
withCdpSocket(server.url, async (send) => {
await send("Test.boom");
}),
).rejects.toThrow();
});
// The non-Error branch of the `err instanceof Error ? ... : new Error(String(err))`
// guard is defensive: node's `ws` library always emits Error instances
// on the 'error' event. Triggering the non-Error branch in a test
// requires synthetically emitting on the client socket, which the
// library then treats as an unhandled error event and hangs the
// suite. The branch is c8-ignored in the source file with an
// accompanying justification.
});
});
describe("openCdpWebSocket option handling", () => {
it("clamps a non-finite handshakeTimeoutMs to the default", () => {
// Exercises the Number.isFinite false side of the handshake-timeout
// ternary in openCdpWebSocket.
const ws = openCdpWebSocket("ws://127.0.0.1:1/devtools/browser/X", {
handshakeTimeoutMs: Number.NaN,
});
// Ensure we don't leak the socket even though we never await it.
ws.once("error", () => {});
ws.close();
});
it("honours an explicit, finite handshakeTimeoutMs", () => {
// Exercises the truthy side of the handshake-timeout ternary: both
// typeof === "number" AND Number.isFinite must be true.
const ws = openCdpWebSocket("ws://127.0.0.1:1/devtools/browser/X", {
handshakeTimeoutMs: 500,
});
ws.once("error", () => {});
ws.close();
});
it("omits the direct-loopback agent for non-loopback targets", () => {
// Exercises the falsy side of `agent ? { agent } : {}` — the loopback
// agent helper returns undefined for non-loopback hosts.
const ws = openCdpWebSocket("ws://93.184.216.34:9222/devtools/browser/X");
ws.once("error", () => {});
ws.close();
});
it("injects custom headers when opts.headers is a non-empty object", () => {
// Exercises the truthy side of `Object.keys(headers).length ? ... : {}`.
const ws = openCdpWebSocket("ws://127.0.0.1:1/devtools/browser/X", {
headers: { "X-Custom": "abc" },
});
ws.once("error", () => {});
ws.close();
});
});

View File

@@ -33,6 +33,11 @@ export function parseBrowserHttpUrl(raw: string, label: string) {
? 443
: 80;
// WHATWG URL rejects invalid ports (non-numeric, negative, >65535), and
// the ternary above falls back to 80/443 for empty or zero parsed.port,
// so this defensive guard is unreachable at runtime. Kept as a
// belt-and-braces check against parser drift.
/* c8 ignore next 3 */
if (Number.isNaN(port) || port <= 0 || port > 65535) {
throw new Error(`${label} has invalid port: ${parsed.port}`);
}
@@ -58,6 +63,37 @@ export function isWebSocketUrl(url: string): boolean {
}
}
/**
* Returns true when `url` is a ws/wss URL with a `/devtools/<kind>/<id>`
* path segment — i.e. a handshake-ready per-browser or per-target CDP
* endpoint that can be opened directly without HTTP discovery.
*
* Bare ws roots (`ws://host:port`, `ws://host:port/`) and any other
* non-`/devtools/...` paths are NOT direct endpoints: Chrome's debug
* port only accepts WebSocket upgrades on the specific path returned
* by `GET /json/version`. Callers with a bare ws root must normalise
* it to http for discovery instead of attempting a root handshake that
* Chrome will reject with HTTP 400.
*/
export function isDirectCdpWebSocketEndpoint(url: string): boolean {
if (!isWebSocketUrl(url)) {
return false;
}
try {
const parsed = new URL(url);
return /\/devtools\/(?:browser|page|worker|shared_worker|service_worker)\/[^/]/i.test(
parsed.pathname,
);
// isWebSocketUrl above already parsed the same URL successfully, so
// new URL(url) cannot throw here. Kept for structural symmetry with
// the other try/catch URL helpers.
/* c8 ignore start */
} catch {
return false;
}
/* c8 ignore stop */
}
export async function assertCdpEndpointAllowed(
cdpUrl: string,
ssrfPolicy?: SsrFPolicy,
@@ -201,6 +237,11 @@ function createCdpSender(ws: WebSocket) {
};
ws.on("error", (err) => {
// The `err instanceof Error` guard is defensive: Node's `ws` library
// always emits Error instances on the 'error' event. Triggering the
// non-Error branch would require synthetically emitting on the socket,
// which the library treats as an unhandled error and hangs the test.
/* c8 ignore next */
closeWithError(err instanceof Error ? err : new Error(String(err)));
});
@@ -342,6 +383,11 @@ export async function withCdpSocket<T>(
try {
await openPromise;
} catch (err) {
// openPromise is only rejected via `ws.once('error', err => reject(err))`
// or the close event's `new Error(...)`; the former always carries an
// Error from Node's `ws` library, the latter is already an Error. The
// non-Error wrap is defensive and structurally unreachable.
/* c8 ignore next */
closeWithError(err instanceof Error ? err : new Error(String(err)));
throw err;
}

View File

@@ -0,0 +1,955 @@
import { afterEach, describe, expect, it } from "vitest";
import { type WebSocket, WebSocketServer } from "ws";
import { rawDataToString } from "../infra/ws.js";
import {
type AriaSnapshotNode,
captureScreenshot,
captureScreenshotPng,
createTargetViaCdp,
type DomSnapshotNode,
evaluateJavaScript,
formatAriaSnapshot,
getDomText,
normalizeCdpWsUrl,
type QueryMatch,
querySelector,
type RawAXNode,
snapshotAria,
snapshotDom,
} from "./cdp.js";
/**
* Exercises the CDP session-oriented exports of cdp.ts against a local
* `ws` server. A single `createCdpMockServer` helper echoes replies
* keyed on method, keeping individual tests short.
*/
type CdpReplyHandler = (
msg: { id?: number; method?: string; params?: Record<string, unknown> },
socket: WebSocket,
) => void;
async function startMockWsServer(handle: CdpReplyHandler) {
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
const port = (wss.address() as { port: number }).port;
wss.on("connection", (socket) => {
socket.on("message", (raw) => {
const msg = JSON.parse(rawDataToString(raw)) as {
id?: number;
method?: string;
params?: Record<string, unknown>;
};
handle(msg, socket);
});
});
return {
wss,
port,
wsUrl: `ws://127.0.0.1:${port}/devtools/browser/TEST`,
};
}
describe("cdp internal", () => {
let wss: WebSocketServer | null = null;
afterEach(async () => {
if (wss) {
await new Promise<void>((resolve) => wss?.close(() => resolve()));
wss = null;
}
});
describe("captureScreenshot", () => {
it("captures a PNG without fullPage", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Page.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.captureScreenshot") {
expect(msg.params).toMatchObject({ format: "png", captureBeyondViewport: true });
socket.send(
JSON.stringify({
id: msg.id,
result: { data: Buffer.from("PNGDATA").toString("base64") },
}),
);
}
});
wss = server.wss;
const buf = await captureScreenshot({ wsUrl: server.wsUrl });
expect(buf.toString("utf8")).toBe("PNGDATA");
});
it("captureScreenshotPng forwards to the png captureScreenshot flow", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Page.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.captureScreenshot") {
expect(msg.params?.format).toBe("png");
socket.send(
JSON.stringify({
id: msg.id,
result: { data: Buffer.from("WRAPPED").toString("base64") },
}),
);
}
});
wss = server.wss;
const buf = await captureScreenshotPng({ wsUrl: server.wsUrl });
expect(buf.toString("utf8")).toBe("WRAPPED");
});
it("clamps out-of-range JPEG quality values into [0, 100]", async () => {
const observed: Array<Record<string, unknown>> = [];
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Page.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.captureScreenshot") {
observed.push(msg.params ?? {});
socket.send(
JSON.stringify({
id: msg.id,
result: { data: Buffer.from("JPG").toString("base64") },
}),
);
}
});
wss = server.wss;
await captureScreenshot({ wsUrl: server.wsUrl, format: "jpeg", quality: 250 });
expect(observed[0]?.format).toBe("jpeg");
expect(observed[0]?.quality).toBe(100);
});
it("captures fullPage and restores viewport overrides", async () => {
const events: string[] = [];
const server = await startMockWsServer((msg, socket) => {
events.push(msg.method ?? "");
if (msg.method === "Page.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.getLayoutMetrics") {
socket.send(
JSON.stringify({
id: msg.id,
result: { cssContentSize: { width: 2000, height: 3000 } },
}),
);
return;
}
if (msg.method === "Runtime.evaluate") {
// Pre-capture viewport probe + post-capture probe.
const isPre = events.filter((m) => m === "Runtime.evaluate").length === 1;
socket.send(
JSON.stringify({
id: msg.id,
result: {
result: {
value: isPre
? { w: 800, h: 600, dpr: 2, sw: 1600, sh: 1200 }
: { w: 2000, h: 3000, dpr: 2 },
},
},
}),
);
return;
}
if (msg.method === "Emulation.setDeviceMetricsOverride") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Emulation.clearDeviceMetricsOverride") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.captureScreenshot") {
socket.send(
JSON.stringify({
id: msg.id,
result: { data: Buffer.from("FULL").toString("base64") },
}),
);
}
});
wss = server.wss;
const buf = await captureScreenshot({ wsUrl: server.wsUrl, fullPage: true });
expect(buf.toString("utf8")).toBe("FULL");
expect(events).toContain("Emulation.setDeviceMetricsOverride");
expect(events).toContain("Emulation.clearDeviceMetricsOverride");
});
it("restores viewport even when the post-capture probe mismatches", async () => {
// Post probe returns a different dpr than saved → helper reapplies.
const calls: Array<Record<string, unknown>> = [];
let evalCount = 0;
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Page.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.getLayoutMetrics") {
socket.send(
JSON.stringify({
id: msg.id,
result: { contentSize: { width: 1200, height: 800 } },
}),
);
return;
}
if (msg.method === "Runtime.evaluate") {
evalCount += 1;
socket.send(
JSON.stringify({
id: msg.id,
result: {
result: {
value:
evalCount === 1
? { w: 400, h: 300, dpr: 1, sw: 800, sh: 600 }
: { w: 9999, h: 9999, dpr: 9 },
},
},
}),
);
return;
}
if (msg.method === "Emulation.setDeviceMetricsOverride") {
calls.push(msg.params ?? {});
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Emulation.clearDeviceMetricsOverride") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.captureScreenshot") {
socket.send(
JSON.stringify({
id: msg.id,
result: { data: Buffer.from("PIC").toString("base64") },
}),
);
}
});
wss = server.wss;
await captureScreenshot({ wsUrl: server.wsUrl, fullPage: true });
// Two setDeviceMetricsOverride calls: expand then restore.
expect(calls.length).toBeGreaterThanOrEqual(2);
});
it("skips viewport expansion when content size is zero", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Page.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.getLayoutMetrics") {
socket.send(
JSON.stringify({
id: msg.id,
result: { cssContentSize: { width: 0, height: 0 } },
}),
);
return;
}
if (msg.method === "Page.captureScreenshot") {
socket.send(
JSON.stringify({
id: msg.id,
result: { data: Buffer.from("Z").toString("base64") },
}),
);
}
});
wss = server.wss;
const buf = await captureScreenshot({ wsUrl: server.wsUrl, fullPage: true });
expect(buf.toString("utf8")).toBe("Z");
});
it("throws when Page.captureScreenshot returns no data", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Page.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.captureScreenshot") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
}
});
wss = server.wss;
await expect(captureScreenshot({ wsUrl: server.wsUrl })).rejects.toThrow(
/Screenshot failed: missing data/,
);
});
});
describe("createTargetViaCdp", () => {
it("throws when Target.createTarget returns no targetId", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Target.createTarget") {
socket.send(JSON.stringify({ id: msg.id, result: { targetId: "" } }));
}
});
wss = server.wss;
await expect(
createTargetViaCdp({ cdpUrl: server.wsUrl, url: "https://example.com" }),
).rejects.toThrow(/Target\.createTarget returned no targetId/);
});
});
describe("evaluateJavaScript", () => {
it("throws when Runtime.evaluate returns no result", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
}
});
wss = server.wss;
await expect(evaluateJavaScript({ wsUrl: server.wsUrl, expression: "1" })).rejects.toThrow(
/Runtime\.evaluate returned no result/,
);
});
it("surfaces CDP exceptionDetails alongside result", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(
JSON.stringify({
id: msg.id,
result: {
result: { type: "undefined" },
exceptionDetails: { text: "ReferenceError", lineNumber: 1 },
},
}),
);
}
});
wss = server.wss;
const res = await evaluateJavaScript({ wsUrl: server.wsUrl, expression: "boom" });
expect(res.exceptionDetails?.text).toBe("ReferenceError");
});
});
describe("formatAriaSnapshot", () => {
it("returns an empty array when the AX tree is empty", () => {
expect(formatAriaSnapshot([], 100)).toEqual([]);
});
it("returns an empty array when no node has an id", () => {
const nodes = [{ role: { value: "Role" }, name: { value: "" } }] as unknown as RawAXNode[];
expect(formatAriaSnapshot(nodes, 100)).toEqual([]);
});
it("skips child references that are absent from the node map", () => {
const nodes: RawAXNode[] = [
{
nodeId: "1",
role: { value: "Root" },
name: { value: "" },
childIds: ["2", "missing"],
},
{
nodeId: "2",
role: { value: "Leaf" },
name: { value: "ok" },
childIds: [],
},
];
const out: AriaSnapshotNode[] = formatAriaSnapshot(nodes, 100);
// Only the root + the resolvable child — missing is dropped.
expect(out).toHaveLength(2);
expect(out[1]?.name).toBe("ok");
});
it("coerces AX values from strings, numbers, and booleans (with fallback to empty)", () => {
const nodes: RawAXNode[] = [
{
nodeId: "1",
role: { value: "Root" } as unknown as RawAXNode["role"],
name: { value: 42 } as unknown as RawAXNode["name"],
value: { value: true } as unknown as RawAXNode["value"],
description: { value: {} } as unknown as RawAXNode["description"],
childIds: [],
},
];
const out = formatAriaSnapshot(nodes, 100);
expect(out[0]?.role).toBe("Root");
expect(out[0]?.name).toBe("42");
expect(out[0]?.value).toBe("true");
// Unknown/object-shaped AX value → falls back to empty → omitted.
expect(out[0]?.description).toBeUndefined();
});
it("respects the limit argument", () => {
const nodes: RawAXNode[] = Array.from({ length: 10 }, (_, i) => ({
nodeId: String(i + 1),
role: { value: `Role${i + 1}` },
name: { value: "" },
childIds: i === 0 ? ["2", "3", "4", "5", "6", "7", "8", "9", "10"] : [],
}));
const out = formatAriaSnapshot(nodes, 3);
expect(out).toHaveLength(3);
});
});
describe("snapshotAria", () => {
it("forwards the happy-path tree to formatAriaSnapshot", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Accessibility.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Accessibility.getFullAXTree") {
socket.send(
JSON.stringify({
id: msg.id,
result: {
nodes: [
{ nodeId: "1", role: { value: "Root" }, name: { value: "" }, childIds: [] },
],
},
}),
);
}
});
wss = server.wss;
const snap = await snapshotAria({ wsUrl: server.wsUrl, limit: 50 });
expect(snap.nodes[0]?.role).toBe("Root");
});
it("returns an empty list when the server omits nodes", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Accessibility.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Accessibility.getFullAXTree") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
}
});
wss = server.wss;
const snap = await snapshotAria({ wsUrl: server.wsUrl });
expect(snap.nodes).toEqual([]);
});
});
describe("snapshotDom", () => {
it("returns the nodes array from the evaluated expression", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
const fake: DomSnapshotNode[] = [{ ref: "n1", parentRef: null, depth: 0, tag: "html" }];
socket.send(
JSON.stringify({
id: msg.id,
result: { result: { value: { nodes: fake } } },
}),
);
}
});
wss = server.wss;
const snap = await snapshotDom({ wsUrl: server.wsUrl, limit: 10, maxTextChars: 200 });
expect(snap.nodes[0]?.tag).toBe("html");
});
it("returns an empty nodes array when the value is not an object", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(
JSON.stringify({
id: msg.id,
result: { result: { value: null } },
}),
);
}
});
wss = server.wss;
const snap = await snapshotDom({ wsUrl: server.wsUrl });
expect(snap.nodes).toEqual([]);
});
it("returns an empty nodes array when nodes is not an array", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(
JSON.stringify({
id: msg.id,
result: { result: { value: { nodes: "not-an-array" } } },
}),
);
}
});
wss = server.wss;
const snap = await snapshotDom({ wsUrl: server.wsUrl });
expect(snap.nodes).toEqual([]);
});
});
describe("getDomText", () => {
it("returns the evaluated string for text format", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(
JSON.stringify({
id: msg.id,
result: { result: { value: "plain body text" } },
}),
);
}
});
wss = server.wss;
const res = await getDomText({ wsUrl: server.wsUrl, format: "text", maxChars: 100 });
expect(res.text).toBe("plain body text");
});
it("returns the html outerHTML for html format with a selector", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(
JSON.stringify({
id: msg.id,
result: { result: { value: "<div>html</div>" } },
}),
);
}
});
wss = server.wss;
const res = await getDomText({
wsUrl: server.wsUrl,
format: "html",
selector: "#foo",
});
expect(res.text).toBe("<div>html</div>");
});
it("coerces numeric/boolean values to strings and falls back to empty for objects", async () => {
const responses: unknown[] = [42, true, { shape: "object" }];
let i = 0;
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(
JSON.stringify({
id: msg.id,
result: { result: { value: responses[i++] } },
}),
);
}
});
wss = server.wss;
const num = await getDomText({ wsUrl: server.wsUrl, format: "text" });
expect(num.text).toBe("42");
const bool = await getDomText({ wsUrl: server.wsUrl, format: "text" });
expect(bool.text).toBe("true");
const obj = await getDomText({ wsUrl: server.wsUrl, format: "text" });
expect(obj.text).toBe("");
});
});
describe("querySelector", () => {
it("returns the matches array from the evaluated expression", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
const matches: QueryMatch[] = [{ index: 1, tag: "button", text: "OK" }];
socket.send(JSON.stringify({ id: msg.id, result: { result: { value: matches } } }));
}
});
wss = server.wss;
const out = await querySelector({
wsUrl: server.wsUrl,
selector: "button",
limit: 5,
maxTextChars: 100,
maxHtmlChars: 500,
});
expect(out.matches[0]?.tag).toBe("button");
});
it("returns an empty array when the value is not an array", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(JSON.stringify({ id: msg.id, result: { result: { value: "not-array" } } }));
}
});
wss = server.wss;
const out = await querySelector({ wsUrl: server.wsUrl, selector: "button" });
expect(out.matches).toEqual([]);
});
});
describe("normalizeCdpWsUrl fill-in", () => {
it("respects an already-non-loopback ws hostname (no-rewrite branch)", () => {
// Covers the else side of the loopback/wildcard-guard in normalizeCdpWsUrl.
const out = normalizeCdpWsUrl(
"ws://non-loopback.example:9222/devtools/browser/ABC",
"http://non-loopback.example:9222",
);
expect(out).toContain("non-loopback.example:9222");
});
it("falls back to protocol-default ports when the cdp URL omits a port", () => {
// Covers the right-hand side of `cdp.port || (cdp.protocol === 'https:' ? '443' : '80')`.
// WHATWG URL elides default ports (443 for wss, 80 for ws) in the
// serialized form, so we assert the scheme + host rather than port.
const secure = normalizeCdpWsUrl(
"ws://127.0.0.1:9222/devtools/browser/ABC",
"https://example.com/",
);
expect(secure).toBe("wss://example.com/devtools/browser/ABC");
const plain = normalizeCdpWsUrl(
"ws://127.0.0.1:9222/devtools/browser/ABC",
"http://example.com/",
);
expect(plain).toBe("ws://example.com/devtools/browser/ABC");
});
});
describe("captureScreenshot branch coverage", () => {
it("uses the default jpeg quality when opts.quality is omitted", async () => {
const observed: Array<Record<string, unknown>> = [];
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Page.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.captureScreenshot") {
observed.push(msg.params ?? {});
socket.send(
JSON.stringify({
id: msg.id,
result: { data: Buffer.from("J").toString("base64") },
}),
);
}
});
wss = server.wss;
await captureScreenshot({ wsUrl: server.wsUrl, format: "jpeg" });
expect(observed[0]?.quality).toBe(85);
});
it("defaults fullPage content/viewport fields to 0 when the page reports nothing", async () => {
// Covers the right-hand sides of `size?.width ?? 0`, `size?.height ?? 0`,
// `v?.w ?? 0`, `v?.h ?? 0`, `v?.dpr ?? 1`, `v?.sw ?? currentW`, `v?.sh ?? currentH`.
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Page.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.getLayoutMetrics") {
// Both cssContentSize and contentSize absent — forces the
// `?? 0` default on width/height.
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.captureScreenshot") {
socket.send(
JSON.stringify({
id: msg.id,
result: { data: Buffer.from("N").toString("base64") },
}),
);
}
});
wss = server.wss;
const buf = await captureScreenshot({ wsUrl: server.wsUrl, fullPage: true });
expect(buf.toString("utf8")).toBe("N");
});
it("falls back to the non-css contentSize when cssContentSize is absent", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Page.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.getLayoutMetrics") {
socket.send(
JSON.stringify({
id: msg.id,
result: { contentSize: { width: 100, height: 200 } },
}),
);
return;
}
if (msg.method === "Runtime.evaluate") {
// viewport probe with a completely empty value to exercise all
// `v?.X ?? default` branches.
socket.send(JSON.stringify({ id: msg.id, result: { result: { value: {} } } }));
return;
}
if (msg.method === "Emulation.setDeviceMetricsOverride") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Emulation.clearDeviceMetricsOverride") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.captureScreenshot") {
socket.send(
JSON.stringify({
id: msg.id,
result: { data: Buffer.from("C").toString("base64") },
}),
);
}
});
wss = server.wss;
const buf = await captureScreenshot({ wsUrl: server.wsUrl, fullPage: true });
expect(buf.toString("utf8")).toBe("C");
});
});
describe("createTargetViaCdp branch coverage", () => {
it("normalises a bare ws:// CDP URL to http for /json/version discovery", async () => {
// Covers the truthy side of `isWebSocketUrl(opts.cdpUrl) ? normalize... : opts.cdpUrl`
// in createTargetViaCdp — the bare-ws root triggers discovery.
const http = await import("node:http");
const wsServer = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => wsServer.once("listening", () => resolve()));
const wsPort = (wsServer.address() as { port: number }).port;
wsServer.on("connection", (socket) => {
socket.on("message", (raw) => {
const msg = JSON.parse(rawDataToString(raw)) as { id?: number; method?: string };
if (msg.method === "Target.createTarget") {
socket.send(JSON.stringify({ id: msg.id, result: { targetId: "T_BARE_WS" } }));
}
});
});
const httpServer = http.createServer((req, res) => {
if (req.url === "/json/version") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
webSocketDebuggerUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/BARE_WS`,
}),
);
return;
}
res.writeHead(404).end();
});
await new Promise<void>((resolve) => httpServer.listen(0, "127.0.0.1", () => resolve()));
const httpPort = (httpServer.address() as { port: number }).port;
try {
const out = await createTargetViaCdp({
cdpUrl: `ws://127.0.0.1:${httpPort}`, // bare ws root → forces discovery
url: "https://example.com",
});
expect(out.targetId).toBe("T_BARE_WS");
} finally {
await new Promise<void>((resolve) => wsServer.close(() => resolve()));
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
}
});
it("throws when Target.createTarget returns a missing (undefined) targetId", async () => {
// Covers the right-hand side of `created?.targetId?.trim() ?? ""` (?? "").
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Target.createTarget") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
}
});
wss = server.wss;
await expect(
createTargetViaCdp({ cdpUrl: server.wsUrl, url: "https://example.com" }),
).rejects.toThrow(/Target\.createTarget returned no targetId/);
});
});
describe("formatAriaSnapshot branch coverage", () => {
it("falls back to 'unknown' role and omits empty value/description", () => {
// role "" triggers `role || "unknown"`; value/description empty
// triggers the falsy side of `value ? { value } : {}`.
const nodes: RawAXNode[] = [
{
nodeId: "1",
role: { value: "" },
name: { value: "n" },
value: { value: "" },
description: { value: "" },
childIds: [],
},
];
const out = formatAriaSnapshot(nodes, 100);
expect(out[0]?.role).toBe("unknown");
expect(out[0]?.value).toBeUndefined();
expect(out[0]?.description).toBeUndefined();
});
it("includes the description field when the AX node provides a truthy description", () => {
// Covers the truthy side of `description ? { description } : {}`.
const nodes: RawAXNode[] = [
{
nodeId: "1",
role: { value: "Button" },
name: { value: "n" },
description: { value: "explanatory" },
childIds: [],
},
];
const out = formatAriaSnapshot(nodes, 100);
expect(out[0]?.description).toBe("explanatory");
});
it("defaults childIds to an empty array when the AX node omits the field", () => {
// Covers the right-hand side of `(n.childIds ?? [])`.
const nodes: RawAXNode[] = [
{
nodeId: "solo",
role: { value: "Leaf" },
name: { value: "" },
},
];
const out = formatAriaSnapshot(nodes, 100);
expect(out).toHaveLength(1);
});
});
describe(".catch(() => {}) swallow arrows", () => {
it("swallows a failing Accessibility.enable in snapshotAria", async () => {
// Exercises the `.catch(() => {})` arrow on `Accessibility.enable`.
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Accessibility.enable") {
socket.send(JSON.stringify({ id: msg.id, error: { message: "denied" } }));
return;
}
if (msg.method === "Accessibility.getFullAXTree") {
socket.send(JSON.stringify({ id: msg.id, result: { nodes: [] } }));
}
});
wss = server.wss;
const snap = await snapshotAria({ wsUrl: server.wsUrl });
expect(snap.nodes).toEqual([]);
});
it("swallows a failing Runtime.enable in evaluateJavaScript", async () => {
// Exercises the `.catch(() => {})` arrow on `Runtime.enable`.
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, error: { message: "denied" } }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(
JSON.stringify({
id: msg.id,
result: { result: { type: "number", value: 1 } },
}),
);
}
});
wss = server.wss;
const res = await evaluateJavaScript({ wsUrl: server.wsUrl, expression: "1" });
expect(res.result.value).toBe(1);
});
it("swallows a failing Emulation.clearDeviceMetricsOverride in the screenshot finally", async () => {
// Exercises the `.catch(() => {})` on clearDeviceMetricsOverride inside
// the fullPage finally block.
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Page.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Page.getLayoutMetrics") {
socket.send(
JSON.stringify({
id: msg.id,
result: { cssContentSize: { width: 800, height: 600 } },
}),
);
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(
JSON.stringify({
id: msg.id,
result: { result: { value: { w: 400, h: 300, dpr: 1, sw: 800, sh: 600 } } },
}),
);
return;
}
if (msg.method === "Emulation.setDeviceMetricsOverride") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Emulation.clearDeviceMetricsOverride") {
socket.send(JSON.stringify({ id: msg.id, error: { message: "denied" } }));
return;
}
if (msg.method === "Page.captureScreenshot") {
socket.send(
JSON.stringify({
id: msg.id,
result: { data: Buffer.from("S").toString("base64") },
}),
);
}
});
wss = server.wss;
const buf = await captureScreenshot({ wsUrl: server.wsUrl, fullPage: true });
expect(buf.toString("utf8")).toBe("S");
});
});
describe("getDomText branch coverage", () => {
it("coerces a missing evaluated value to an empty string", async () => {
// Covers the right-hand side of `evaluated.result?.value ?? ""`.
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(JSON.stringify({ id: msg.id, result: { result: {} } }));
}
});
wss = server.wss;
const res = await getDomText({ wsUrl: server.wsUrl, format: "text" });
expect(res.text).toBe("");
});
});
});

View File

@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type WebSocket, WebSocketServer } from "ws";
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { rawDataToString } from "../infra/ws.js";
import { isWebSocketUrl } from "./cdp.helpers.js";
import { isDirectCdpWebSocketEndpoint, isWebSocketUrl } from "./cdp.helpers.js";
import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
import { parseHttpUrl } from "./config.js";
import { BrowserCdpEndpointBlockedError } from "./errors.js";
@@ -171,7 +171,7 @@ describe("cdp", () => {
expect(receivedHeaders.host).toBe(`127.0.0.1:${wsPort}`);
});
it("still enforces SSRF policy for direct WebSocket URLs", async () => {
it("enforces SSRF policy on the navigation target URL before any CDP connection attempt", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
try {
await expect(
@@ -329,7 +329,7 @@ describe("cdp", () => {
expect(res.result.value).toBe(2);
});
it("fails when /json/version omits webSocketDebuggerUrl", async () => {
it("fails when /json/version omits webSocketDebuggerUrl for an HTTP cdpUrl", async () => {
const httpPort = await startVersionHttpServer({});
await expect(
createTargetViaCdp({
@@ -339,6 +339,23 @@ describe("cdp", () => {
).rejects.toThrow("CDP /json/version missing webSocketDebuggerUrl");
});
it("falls back to direct WS connection when /json/version is unavailable for a bare ws:// cdpUrl", async () => {
// Simulates a Browserless/Browserbase-style provider: the cdpUrl IS a
// WebSocket root (no /devtools/ path) but there is no HTTP /json/version
// endpoint. The WS server accepts Target.createTarget directly.
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method === "Target.createTarget") {
socket.send(JSON.stringify({ id: msg.id, result: { targetId: "WS_FALLBACK" } }));
}
});
// No HTTP server on this port — discovery will fail, triggering the fallback.
const created = await createTargetViaCdp({
cdpUrl: `ws://127.0.0.1:${wsPort}`,
url: "https://example.com",
});
expect(created.targetId).toBe("WS_FALLBACK");
});
it("captures an aria snapshot via CDP", async () => {
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method === "Accessibility.enable") {
@@ -480,6 +497,41 @@ describe("isWebSocketUrl", () => {
});
});
describe("isDirectCdpWebSocketEndpoint", () => {
it("returns true for ws/wss URLs with a /devtools/<kind>/<id> path", () => {
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/devtools/browser/ABC")).toBe(true);
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/devtools/page/42")).toBe(true);
expect(isDirectCdpWebSocketEndpoint("wss://connect.example.com/devtools/browser/xyz")).toBe(
true,
);
expect(
isDirectCdpWebSocketEndpoint("wss://connect.example.com/devtools/browser/xyz?token=secret"),
).toBe(true);
});
it("returns false for bare ws/wss URLs without a /devtools/ path (needs discovery)", () => {
// Reproduces the configuration shape reported in #68027.
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222")).toBe(false);
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/")).toBe(false);
expect(isDirectCdpWebSocketEndpoint("wss://browserless.example")).toBe(false);
expect(isDirectCdpWebSocketEndpoint("wss://browserless.example/?token=abc")).toBe(false);
});
it("returns false for ws URLs whose path is not /devtools/*", () => {
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/json/version")).toBe(false);
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/devtools")).toBe(false);
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/devtools/")).toBe(false);
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/other/path")).toBe(false);
});
it("returns false for http/https URLs, invalid URLs, and empty strings", () => {
expect(isDirectCdpWebSocketEndpoint("http://127.0.0.1:9222/devtools/browser/ABC")).toBe(false);
expect(isDirectCdpWebSocketEndpoint("https://host/devtools/browser/ABC")).toBe(false);
expect(isDirectCdpWebSocketEndpoint("not-a-url")).toBe(false);
expect(isDirectCdpWebSocketEndpoint("")).toBe(false);
});
});
describe("parseHttpUrl with WebSocket protocols", () => {
it("accepts wss:// URLs and defaults to port 443", () => {
const result = parseHttpUrl("wss://connect.example.com?apiKey=abc", "test");

View File

@@ -3,8 +3,10 @@ import {
appendCdpPath,
assertCdpEndpointAllowed,
fetchJson,
isDirectCdpWebSocketEndpoint,
isLoopbackHost,
isWebSocketUrl,
normalizeCdpHttpBaseForJsonEndpoints,
withCdpSocket,
} from "./cdp.helpers.js";
import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js";
@@ -27,6 +29,10 @@ export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
if ((isLoopbackHost(ws.hostname) || isWildcardBind) && !isLoopbackHost(cdp.hostname)) {
ws.hostname = cdp.hostname;
const cdpPort = cdp.port || (cdp.protocol === "https:" ? "443" : "80");
// `cdpPort` is always truthy: either the explicit cdp.port (truthy
// string), or the "443"/"80" default from the ternary. The guard is
// defensive against future parser edge cases.
/* c8 ignore next 3 */
if (cdpPort) {
ws.port = cdpPort;
}
@@ -179,21 +185,43 @@ export async function createTargetViaCdp(opts: {
});
let wsUrl: string;
if (isWebSocketUrl(opts.cdpUrl)) {
// Direct WebSocket URL — skip /json/version discovery.
if (isDirectCdpWebSocketEndpoint(opts.cdpUrl)) {
// Handshake-ready direct WebSocket URL — skip /json/version discovery.
await assertCdpEndpointAllowed(opts.cdpUrl, opts.ssrfPolicy);
wsUrl = opts.cdpUrl;
} else {
// Standard HTTP(S) CDP endpoint — discover WebSocket URL via /json/version.
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
appendCdpPath(opts.cdpUrl, "/json/version"),
1500,
undefined,
opts.ssrfPolicy,
);
// Either an HTTP(S) CDP endpoint or a bare ws/wss root. Try
// /json/version discovery first. For bare ws/wss URLs, fall back to
// using the URL itself as a direct WS endpoint when discovery is
// unavailable — some providers (e.g. Browserless/Browserbase) expose
// a direct WebSocket root without a /json/version route.
const discoveryUrl = isWebSocketUrl(opts.cdpUrl)
? normalizeCdpHttpBaseForJsonEndpoints(opts.cdpUrl)
: opts.cdpUrl;
let version: { webSocketDebuggerUrl?: string } | null = null;
try {
version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
appendCdpPath(discoveryUrl, "/json/version"),
1500,
undefined,
opts.ssrfPolicy,
);
} catch (err) {
// Discovery failed for an HTTP/HTTPS URL — propagate immediately.
if (!isWebSocketUrl(opts.cdpUrl)) {
throw err;
}
// For bare ws/wss URLs, fall through: /json/version is unavailable
// so we attempt to use opts.cdpUrl as a direct WS endpoint below.
}
const wsUrlRaw = version?.webSocketDebuggerUrl?.trim() ?? "";
wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
if (!wsUrl) {
if (wsUrlRaw) {
wsUrl = normalizeCdpWsUrl(wsUrlRaw, discoveryUrl);
} else if (isWebSocketUrl(opts.cdpUrl)) {
// /json/version unavailable or returned no WebSocket URL. Treat the
// original URL as a direct WebSocket endpoint.
wsUrl = opts.cdpUrl;
} else {
throw new Error("CDP /json/version missing webSocketDebuggerUrl");
}
await assertCdpEndpointAllowed(wsUrl, opts.ssrfPolicy);
@@ -316,11 +344,17 @@ export function formatAriaSnapshot(nodes: RawAXNode[], limit: number): AriaSnaps
const stack: Array<{ id: string; depth: number }> = [{ id: root.nodeId, depth: 0 }];
while (stack.length && out.length < limit) {
const popped = stack.pop();
// `stack.pop()` only returns undefined on an empty stack, but the
// while guard already asserts `stack.length > 0`. Dead defensive guard.
/* c8 ignore next 3 */
if (!popped) {
break;
}
const { id, depth } = popped;
const n = byId.get(id);
// Every id pushed onto the stack came from `children.filter(c => byId.has(c))`,
// so byId.get(id) is always defined here. Dead defensive guard.
/* c8 ignore next 3 */
if (!n) {
continue;
}
@@ -342,6 +376,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];
// `children` is a string[] from an array filter over RawAXNode.childIds,
// so `child` is always a defined string here. Dead defensive guard.
/* c8 ignore next 3 */
if (child) {
stack.push({ id: child, depth: depth + 1 });
}

File diff suppressed because it is too large Load Diff

View File

@@ -468,16 +468,86 @@ describe("browser chrome helpers", () => {
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.
it("probes direct ws:// CDP URLs (with /devtools/ path) via handshake instead of HTTP", async () => {
// A direct WS endpoint like ws://host/devtools/browser/<uuid> is already
// the handshake target — isChromeReachable must NOT hit /json/version.
const fetchSpy = vi.fn().mockRejectedValue(new Error("should not be called"));
vi.stubGlobal("fetch", fetchSpy);
// No WS server listening → handshake fails → not reachable
await expect(isChromeReachable("ws://127.0.0.1:19999", 50)).resolves.toBe(false);
await expect(isChromeReachable("ws://127.0.0.1:19999/devtools/browser/ABC", 50)).resolves.toBe(
false,
);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("falls back to HTTP /json/version discovery for a bare ws:// CDP URL (issue #68027)", async () => {
// A user-supplied cdpUrl of `ws://host:port` without a /devtools/ path
// points at Chrome's debug root; Chrome only accepts WS upgrades on the
// specific path returned by `GET /json/version`. The reachability probe
// must normalise the ws scheme to http for discovery, not attempt a
// handshake at the bare root.
await withMockChromeCdpServer({
wsPath: "/devtools/browser/DISCOVERED",
run: async (baseUrl) => {
const url = new URL(baseUrl);
const wsOnlyBase = `ws://${url.host}`;
await expect(isChromeReachable(wsOnlyBase, 300)).resolves.toBe(true);
await expect(getChromeWebSocketUrl(wsOnlyBase, 300)).resolves.toBe(
`ws://${url.host}/devtools/browser/DISCOVERED`,
);
},
});
});
it("reports unreachable when a bare ws:// CDP URL points at a server with no /json/version and refuses WS", async () => {
// Negative counterpart to the #68027 happy path — a bare ws URL
// pointed at a port that neither serves /json/version nor accepts
// WS upgrades must resolve false without hanging.
const fetchSpy = vi.fn().mockRejectedValue(new Error("connection refused"));
vi.stubGlobal("fetch", fetchSpy);
// Port 19998 is not listening; the WS fallback probe will also fail.
await expect(isChromeReachable("ws://127.0.0.1:19998", 50)).resolves.toBe(false);
// fetch() must have been invoked — HTTP discovery is always tried first.
expect(fetchSpy).toHaveBeenCalled();
});
it("falls back to a direct WS probe when /json/version is unavailable for a bare ws:// URL", async () => {
// Covers the WS-fallback path in isChromeReachable: /json/version returns
// nothing (simulated by empty response) but the WS socket IS accepting
// connections (Browserless/Browserbase-style provider).
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({}), // empty — no webSocketDebuggerUrl
} as unknown as Response),
);
// A real WS server accepts the handshake.
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
const port = (wss.address() as AddressInfo).port;
try {
await expect(isChromeReachable(`ws://127.0.0.1:${port}`, 500)).resolves.toBe(true);
} finally {
await new Promise<void>((resolve) => wss.close(() => resolve()));
}
});
it("returns the original ws:// URL from getChromeWebSocketUrl when /json/version provides no debugger URL", async () => {
// Covers the getChromeWebSocketUrl WS-fallback: discovery succeeds but
// webSocketDebuggerUrl is absent — the original URL is returned as-is.
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({}),
} as unknown as Response),
);
await expect(getChromeWebSocketUrl("ws://127.0.0.1:12345", 50)).resolves.toBe(
"ws://127.0.0.1:12345",
);
});
it("stopOpenClawChrome no-ops when process is already killed", async () => {
const proc = makeChromeTestProc({ killed: true });
await stopChromeWithProc(proc, 10);

View File

@@ -18,7 +18,13 @@ import {
CHROME_STOP_TIMEOUT_MS,
CHROME_WS_READY_TIMEOUT_MS,
} from "./cdp-timeouts.js";
import { assertCdpEndpointAllowed, isWebSocketUrl, openCdpWebSocket } from "./cdp.helpers.js";
import {
assertCdpEndpointAllowed,
isDirectCdpWebSocketEndpoint,
isWebSocketUrl,
normalizeCdpHttpBaseForJsonEndpoints,
openCdpWebSocket,
} from "./cdp.helpers.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import {
diagnoseChromeCdp,
@@ -161,12 +167,26 @@ export async function isChromeReachable(
): Promise<boolean> {
try {
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
if (isWebSocketUrl(cdpUrl)) {
// Direct WebSocket endpoint — probe via WS handshake.
if (isDirectCdpWebSocketEndpoint(cdpUrl)) {
// Handshake-ready direct WS endpoint — probe via WS handshake.
return await canOpenWebSocket(cdpUrl, timeoutMs);
}
const version = await fetchChromeVersion(cdpUrl, timeoutMs, ssrfPolicy);
return Boolean(version);
// Either an http(s) discovery URL or a bare ws/wss root. Try
// /json/version discovery first. For bare ws/wss URLs, fall back to a
// direct WS handshake when discovery is unavailable — some providers
// (e.g. Browserless/Browserbase) expose a direct WebSocket root without
// a /json/version endpoint.
const discoveryUrl = isWebSocketUrl(cdpUrl)
? normalizeCdpHttpBaseForJsonEndpoints(cdpUrl)
: cdpUrl;
const version = await fetchChromeVersion(discoveryUrl, timeoutMs, ssrfPolicy);
if (version) {
return true;
}
if (isWebSocketUrl(cdpUrl)) {
return await canOpenWebSocket(cdpUrl, timeoutMs);
}
return false;
} catch {
return false;
}
@@ -190,16 +210,31 @@ export async function getChromeWebSocketUrl(
ssrfPolicy?: SsrFPolicy,
): Promise<string | null> {
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
if (isWebSocketUrl(cdpUrl)) {
// Direct WebSocket endpoint — the cdpUrl is already the WebSocket URL.
if (isDirectCdpWebSocketEndpoint(cdpUrl)) {
// Handshake-ready direct WebSocket endpoint — the cdpUrl is already
// the WebSocket URL.
return cdpUrl;
}
const version = await fetchChromeVersion(cdpUrl, timeoutMs, ssrfPolicy);
// Either an http(s) endpoint or a bare ws/wss root; discover the
// actual WebSocket URL via /json/version. Normalise the scheme so
// fetch() can reach the endpoint.
const discoveryUrl = isWebSocketUrl(cdpUrl)
? normalizeCdpHttpBaseForJsonEndpoints(cdpUrl)
: cdpUrl;
const version = await fetchChromeVersion(discoveryUrl, timeoutMs, ssrfPolicy);
const wsUrl = normalizeOptionalString(version?.webSocketDebuggerUrl) ?? "";
if (!wsUrl) {
// /json/version unavailable or returned no WebSocket URL. For bare
// ws/wss inputs, the URL itself may be a direct WebSocket endpoint
// (e.g. Browserless/Browserbase-style providers without /json/version).
// The SSRF check on cdpUrl was already performed at the start of this
// function, so we can return it directly.
if (isWebSocketUrl(cdpUrl)) {
return cdpUrl;
}
return null;
}
const normalizedWsUrl = normalizeCdpWsUrl(wsUrl, cdpUrl);
const normalizedWsUrl = normalizeCdpWsUrl(wsUrl, discoveryUrl);
await assertCdpEndpointAllowed(normalizedWsUrl, ssrfPolicy);
return normalizedWsUrl;
}

View File

@@ -181,6 +181,61 @@ describe("browser server-context ensureBrowserAvailable", () => {
expect(stopOpenClawChrome).not.toHaveBeenCalled();
});
it("resolves for attachOnly loopback profile with a bare ws:// cdpUrl when CDP is reachable (#68027)", async () => {
// Regression for #68027: a bare `ws://host:port` cdpUrl on a loopback
// attachOnly profile must not surface as
// `Browser attachOnly is enabled and profile "<name>" is not running.`
// when the underlying CDP endpoint is actually healthy. The low-level
// fix lives in chrome.ts/cdp.ts (see chrome.test.ts #68027 tests); this
// higher-level test locks the user-facing symptom at
// ensureBrowserAvailable() so future refactors of the availability flow
// cannot silently reintroduce the bug by munging/short-circuiting bare
// ws:// URLs before they reach the helpers.
const { launchOpenClawChrome, stopOpenClawChrome } = setupEnsureBrowserAvailableHarness();
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
const isChromeCdpReady = vi.mocked(chromeModule.isChromeCdpReady);
const state = makeBrowserServerState({
profile: {
name: "manual-cdp",
cdpUrl: "ws://127.0.0.1:9222",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
cdpPort: 9222,
color: "#00AA00",
driver: "openclaw",
attachOnly: true,
},
resolvedOverrides: {
defaultProfile: "manual-cdp",
ssrfPolicy: {},
},
});
const ctx = createBrowserRouteContext({ getState: () => state });
const profile = ctx.forProfile("manual-cdp");
isChromeReachable.mockResolvedValueOnce(true);
isChromeCdpReady.mockResolvedValueOnce(true);
await expect(profile.ensureBrowserAvailable()).resolves.toBeUndefined();
// The bare ws:// URL must pass through unchanged — the helpers own the
// discovery-first-then-fallback strategy for bare ws roots.
expect(isChromeReachable).toHaveBeenCalledWith(
"ws://127.0.0.1:9222",
state.resolved.remoteCdpTimeoutMs,
undefined,
);
expect(isChromeCdpReady).toHaveBeenCalledWith(
"ws://127.0.0.1:9222",
state.resolved.remoteCdpTimeoutMs,
state.resolved.remoteCdpHandshakeTimeoutMs,
undefined,
);
expect(launchOpenClawChrome).not.toHaveBeenCalled();
expect(stopOpenClawChrome).not.toHaveBeenCalled();
});
it("redacts credentials in remote CDP availability errors", async () => {
const { launchOpenClawChrome, stopOpenClawChrome } = setupEnsureBrowserAvailableHarness();
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);