Files
openclaw/src/browser/extension-relay.test.ts
2026-03-02 22:29:04 +00:00

1172 lines
36 KiB
TypeScript

import { createServer } from "node:http";
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
import WebSocket from "ws";
import { captureEnv } from "../test-utils/env.js";
import {
ensureChromeExtensionRelayServer,
getChromeExtensionRelayAuthHeaders,
stopChromeExtensionRelayServer,
} from "./extension-relay.js";
import { getFreePort } from "./test-port.js";
const RELAY_MESSAGE_TIMEOUT_MS = 1_200;
const RELAY_LIST_MATCH_TIMEOUT_MS = 1_000;
const RELAY_TEST_TIMEOUT_MS = 10_000;
function waitForOpen(ws: WebSocket) {
return new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", reject);
});
}
function waitForError(ws: WebSocket) {
return new Promise<Error>((resolve, reject) => {
ws.once("error", (err) => resolve(err instanceof Error ? err : new Error(String(err))));
ws.once("open", () => reject(new Error("expected websocket error")));
});
}
function waitForClose(ws: WebSocket, timeoutMs = RELAY_MESSAGE_TIMEOUT_MS) {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error("timeout"));
}, timeoutMs);
ws.once("close", () => {
clearTimeout(timer);
resolve();
});
ws.once("error", (err) => {
clearTimeout(timer);
reject(err instanceof Error ? err : new Error(String(err)));
});
});
}
function relayAuthHeaders(url: string) {
return getChromeExtensionRelayAuthHeaders(url);
}
function createMessageQueue(ws: WebSocket) {
const queue: string[] = [];
let waiter: ((value: string) => void) | null = null;
let waiterReject: ((err: Error) => void) | null = null;
let waiterTimer: NodeJS.Timeout | null = null;
const flushWaiter = (value: string) => {
if (!waiter) {
return false;
}
const resolve = waiter;
waiter = null;
const reject = waiterReject;
waiterReject = null;
if (waiterTimer) {
clearTimeout(waiterTimer);
}
waiterTimer = null;
if (reject) {
// no-op (kept for symmetry)
}
resolve(value);
return true;
};
ws.on("message", (data) => {
const text =
typeof data === "string"
? data
: Buffer.isBuffer(data)
? data.toString("utf8")
: Array.isArray(data)
? Buffer.concat(data).toString("utf8")
: Buffer.from(data).toString("utf8");
if (flushWaiter(text)) {
return;
}
queue.push(text);
});
ws.on("error", (err) => {
if (!waiterReject) {
return;
}
const reject = waiterReject;
waiterReject = null;
waiter = null;
if (waiterTimer) {
clearTimeout(waiterTimer);
}
waiterTimer = null;
reject(err instanceof Error ? err : new Error(String(err)));
});
const next = (timeoutMs = RELAY_MESSAGE_TIMEOUT_MS) =>
new Promise<string>((resolve, reject) => {
const existing = queue.shift();
if (existing !== undefined) {
return resolve(existing);
}
waiter = resolve;
waiterReject = reject;
waiterTimer = setTimeout(() => {
waiter = null;
waiterReject = null;
waiterTimer = null;
reject(new Error("timeout"));
}, timeoutMs);
});
return { next };
}
async function waitForListMatch<T>(
fetchList: () => Promise<T>,
predicate: (value: T) => boolean,
timeoutMs = RELAY_LIST_MATCH_TIMEOUT_MS,
intervalMs = 20,
): Promise<T> {
const deadline = Date.now() + timeoutMs;
let latest: T | null = null;
while (Date.now() <= deadline) {
latest = await fetchList();
if (predicate(latest)) {
return latest;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error("timeout waiting for list match");
}
describe("chrome extension relay server", () => {
const TEST_GATEWAY_TOKEN = "test-gateway-token";
let cdpUrl = "";
let sharedCdpUrl = "";
let envSnapshot: ReturnType<typeof captureEnv>;
beforeEach(() => {
envSnapshot = captureEnv([
"OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS",
"OPENCLAW_EXTENSION_RELAY_COMMAND_RECONNECT_WAIT_MS",
]);
process.env.OPENCLAW_GATEWAY_TOKEN = TEST_GATEWAY_TOKEN;
delete process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS;
delete process.env.OPENCLAW_EXTENSION_RELAY_COMMAND_RECONNECT_WAIT_MS;
});
afterEach(async () => {
if (cdpUrl) {
await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {});
cdpUrl = "";
}
envSnapshot.restore();
});
afterAll(async () => {
if (!sharedCdpUrl) {
return;
}
await stopChromeExtensionRelayServer({ cdpUrl: sharedCdpUrl }).catch(() => {});
sharedCdpUrl = "";
});
async function ensureSharedRelayServer() {
if (sharedCdpUrl) {
return sharedCdpUrl;
}
const port = await getFreePort();
sharedCdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl: sharedCdpUrl });
return sharedCdpUrl;
}
async function startRelayWithExtension() {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext);
return { port, ext };
}
it("advertises CDP WS only when extension is connected", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const v1 = (await fetch(`${cdpUrl}/json/version`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as {
webSocketDebuggerUrl?: string;
};
expect(v1.webSocketDebuggerUrl).toBeUndefined();
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext);
const v2 = (await fetch(`${cdpUrl}/json/version`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as {
webSocketDebuggerUrl?: string;
};
expect(String(v2.webSocketDebuggerUrl ?? "")).toContain(`/cdp`);
ext.close();
});
it("uses relay-scoped token only for known relay ports", async () => {
const port = await getFreePort();
const unknown = getChromeExtensionRelayAuthHeaders(`http://127.0.0.1:${port}`);
expect(unknown).toEqual({});
const sharedUrl = await ensureSharedRelayServer();
const headers = getChromeExtensionRelayAuthHeaders(sharedUrl);
expect(Object.keys(headers)).toContain("x-openclaw-relay-token");
expect(headers["x-openclaw-relay-token"]).not.toBe(TEST_GATEWAY_TOKEN);
});
it("rejects CDP access without relay auth token", async () => {
const sharedUrl = await ensureSharedRelayServer();
const sharedPort = new URL(sharedUrl).port;
const res = await fetch(`${sharedUrl}/json/version`);
expect(res.status).toBe(401);
const cdp = new WebSocket(`ws://127.0.0.1:${sharedPort}/cdp`);
const err = await waitForError(cdp);
expect(err.message).toContain("401");
});
it("returns 400 for malformed percent-encoding in target action routes", async () => {
const sharedUrl = await ensureSharedRelayServer();
const res = await fetch(`${sharedUrl}/json/activate/%E0%A4%A`, {
headers: relayAuthHeaders(sharedUrl),
});
expect(res.status).toBe(400);
expect(await res.text()).toContain("invalid targetId encoding");
});
it("deduplicates concurrent relay starts for the same requested port", async () => {
const sharedUrl = await ensureSharedRelayServer();
const port = Number(new URL(sharedUrl).port);
const [first, second] = await Promise.all([
ensureChromeExtensionRelayServer({ cdpUrl: sharedUrl }),
ensureChromeExtensionRelayServer({ cdpUrl: sharedUrl }),
]);
expect(first).toBe(second);
expect(first.port).toBe(port);
});
it("allows CORS preflight from chrome-extension origins", async () => {
const sharedUrl = await ensureSharedRelayServer();
const origin = "chrome-extension://abcdefghijklmnop";
const res = await fetch(`${sharedUrl}/json/version`, {
method: "OPTIONS",
headers: {
Origin: origin,
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "x-openclaw-relay-token",
},
});
expect(res.status).toBe(204);
expect(res.headers.get("access-control-allow-origin")).toBe(origin);
expect(res.headers.get("access-control-allow-headers") ?? "").toContain(
"x-openclaw-relay-token",
);
});
it("rejects CORS preflight from non-extension origins", async () => {
const sharedUrl = await ensureSharedRelayServer();
const res = await fetch(`${sharedUrl}/json/version`, {
method: "OPTIONS",
headers: {
Origin: "https://example.com",
"Access-Control-Request-Method": "GET",
},
});
expect(res.status).toBe(403);
});
it("returns CORS headers on JSON responses for extension origins", async () => {
const sharedUrl = await ensureSharedRelayServer();
const origin = "chrome-extension://abcdefghijklmnop";
const res = await fetch(`${sharedUrl}/json/version`, {
headers: {
Origin: origin,
...relayAuthHeaders(sharedUrl),
},
});
expect(res.status).toBe(200);
expect(res.headers.get("access-control-allow-origin")).toBe(origin);
});
it("rejects extension websocket access without relay auth token", async () => {
const sharedUrl = await ensureSharedRelayServer();
const sharedPort = new URL(sharedUrl).port;
const ext = new WebSocket(`ws://127.0.0.1:${sharedPort}/extension`);
const err = await waitForError(ext);
expect(err.message).toContain("401");
});
it("rejects a second live extension connection with 409", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const ext1 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext1);
const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
const err = await waitForError(ext2);
expect(err.message).toContain("409");
ext1.close();
});
it("allows immediate reconnect when prior extension socket is closing", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const ext1 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext1);
const ext1Closed = new Promise<void>((resolve) => ext1.once("close", () => resolve()));
ext1.close();
const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext2);
await ext1Closed;
const status = (await fetch(`${cdpUrl}/extension/status`).then((r) => r.json())) as {
connected?: boolean;
};
expect(status.connected).toBe(true);
ext2.close();
});
it("keeps CDP clients alive across a brief extension reconnect", async () => {
const { port, ext: ext1 } = await startRelayWithExtension();
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
});
await waitForOpen(cdp);
let cdpClosed = false;
cdp.once("close", () => {
cdpClosed = true;
});
const ext1Closed = waitForClose(ext1, 2_000);
ext1.close();
await ext1Closed;
const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext2);
expect(cdpClosed).toBe(false);
cdp.close();
ext2.close();
});
it("keeps /json/version websocket endpoint during short extension disconnects", async () => {
const { port, ext } = await startRelayWithExtension();
ext.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "cb-tab-disconnect",
targetInfo: {
targetId: "t-disconnect",
type: "page",
title: "Disconnect test",
url: "https://example.com",
},
waitingForDebugger: false,
},
},
}),
);
await waitForListMatch(
async () =>
(await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{ id?: string }>,
(list) => list.some((entry) => entry.id === "t-disconnect"),
);
const extClosed = waitForClose(ext, 2_000);
ext.close();
await extClosed;
const version = (await fetch(`${cdpUrl}/json/version`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as {
webSocketDebuggerUrl?: string;
};
expect(String(version.webSocketDebuggerUrl ?? "")).toContain("/cdp");
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
});
await waitForOpen(cdp);
cdp.close();
});
it("accepts re-announce attach events with minimal targetInfo", async () => {
const { ext } = await startRelayWithExtension();
ext.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "cb-tab-minimal",
targetInfo: {
targetId: "t-minimal",
},
waitingForDebugger: false,
},
},
}),
);
await waitForListMatch(
async () =>
(await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{ id?: string }>,
(entries) => entries.some((entry) => entry.id === "t-minimal"),
);
});
it("waits briefly for extension reconnect before failing CDP commands", async () => {
const { port, ext: ext1 } = await startRelayWithExtension();
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
});
await waitForOpen(cdp);
const cdpQueue = createMessageQueue(cdp);
const ext1Closed = waitForClose(ext1, 2_000);
ext1.close();
await ext1Closed;
cdp.send(JSON.stringify({ id: 41, method: "Runtime.enable" }));
await new Promise((r) => setTimeout(r, 30));
const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
const ext2Queue = createMessageQueue(ext2);
await waitForOpen(ext2);
while (true) {
const msg = JSON.parse(await ext2Queue.next(4_000)) as {
id?: number;
method?: string;
};
if (msg.method === "ping") {
ext2.send(JSON.stringify({ method: "pong" }));
continue;
}
if (msg.method === "forwardCDPCommand" && typeof msg.id === "number") {
ext2.send(JSON.stringify({ id: msg.id, result: { ok: true } }));
break;
}
}
const response = JSON.parse(await cdpQueue.next(6_000)) as {
id?: number;
result?: { ok?: boolean };
error?: { message?: string };
};
expect(response.id).toBe(41);
expect(response.error).toBeUndefined();
expect(response.result?.ok).toBe(true);
cdp.close();
ext2.close();
});
it("closes CDP clients after reconnect grace when extension stays disconnected", async () => {
process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS = "150";
const { port, ext } = await startRelayWithExtension();
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
});
await waitForOpen(cdp);
ext.close();
await waitForClose(cdp, 2_000);
});
it("stops advertising websocket endpoint after reconnect grace expires", async () => {
process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS = "120";
const { ext } = await startRelayWithExtension();
ext.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "cb-tab-grace-expire",
targetInfo: {
targetId: "t-grace-expire",
type: "page",
title: "Grace expire",
url: "https://example.com",
},
waitingForDebugger: false,
},
},
}),
);
await waitForListMatch(
async () =>
(await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{ id?: string }>,
(list) => list.some((entry) => entry.id === "t-grace-expire"),
);
ext.close();
await expect
.poll(
async () => {
const version = (await fetch(`${cdpUrl}/json/version`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as { webSocketDebuggerUrl?: string };
return version.webSocketDebuggerUrl === undefined;
},
{ timeout: 800, interval: 20 },
)
.toBe(true);
});
it("accepts extension websocket access with relay token query param", async () => {
const sharedUrl = await ensureSharedRelayServer();
const sharedPort = new URL(sharedUrl).port;
const token = relayAuthHeaders(`ws://127.0.0.1:${sharedPort}/extension`)[
"x-openclaw-relay-token"
];
expect(token).toBeTruthy();
const ext = new WebSocket(
`ws://127.0.0.1:${sharedPort}/extension?token=${encodeURIComponent(String(token))}`,
);
await waitForOpen(ext);
ext.close();
});
it("accepts /json endpoints with relay token query param", async () => {
const sharedUrl = await ensureSharedRelayServer();
const token = relayAuthHeaders(sharedUrl)["x-openclaw-relay-token"];
expect(token).toBeTruthy();
const versionRes = await fetch(
`${sharedUrl}/json/version?token=${encodeURIComponent(String(token))}`,
);
expect(versionRes.status).toBe(200);
});
it("accepts raw gateway token for relay auth compatibility", async () => {
const sharedUrl = await ensureSharedRelayServer();
const sharedPort = new URL(sharedUrl).port;
const versionRes = await fetch(`${sharedUrl}/json/version`, {
headers: { "x-openclaw-relay-token": TEST_GATEWAY_TOKEN },
});
expect(versionRes.status).toBe(200);
const ext = new WebSocket(
`ws://127.0.0.1:${sharedPort}/extension?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`,
);
await waitForOpen(ext);
ext.close();
});
it(
"tracks attached page targets and exposes them via CDP + /json/list",
async () => {
const { port, ext } = await startRelayWithExtension();
// Simulate a tab attach coming from the extension.
ext.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "cb-tab-1",
targetInfo: {
targetId: "t1",
type: "page",
title: "Example",
url: "https://example.com",
},
waitingForDebugger: false,
},
},
}),
);
const list = (await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{
id?: string;
url?: string;
title?: string;
}>;
expect(list.some((t) => t.id === "t1" && t.url === "https://example.com")).toBe(true);
// Simulate navigation updating tab metadata.
ext.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.targetInfoChanged",
params: {
targetInfo: {
targetId: "t1",
type: "page",
title: "DER STANDARD",
url: "https://www.derstandard.at/",
},
},
},
}),
);
await waitForListMatch(
async () =>
(await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{
id?: string;
url?: string;
title?: string;
}>,
(list) =>
list.some(
(t) =>
t.id === "t1" &&
t.url === "https://www.derstandard.at/" &&
t.title === "DER STANDARD",
),
);
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
});
await waitForOpen(cdp);
const q = createMessageQueue(cdp);
cdp.send(JSON.stringify({ id: 1, method: "Target.getTargets" }));
const res1 = JSON.parse(await q.next()) as { id: number; result?: unknown };
expect(res1.id).toBe(1);
const targetInfos = (
res1.result as { targetInfos?: Array<{ targetId?: string }> } | undefined
)?.targetInfos;
expect((targetInfos ?? []).some((target) => target.targetId === "t1")).toBe(true);
cdp.send(
JSON.stringify({
id: 2,
method: "Target.attachToTarget",
params: { targetId: "t1" },
}),
);
const received: Array<{
id?: number;
method?: string;
result?: unknown;
params?: unknown;
}> = [];
received.push(JSON.parse(await q.next()) as never);
received.push(JSON.parse(await q.next()) as never);
const res2 = received.find((m) => m.id === 2);
expect(res2?.id).toBe(2);
expect((res2?.result as { sessionId?: string } | undefined)?.sessionId).toBe("cb-tab-1");
const evt = received.find((m) => m.method === "Target.attachedToTarget");
expect(evt?.method).toBe("Target.attachedToTarget");
expect(
(evt?.params as { targetInfo?: { targetId?: string } } | undefined)?.targetInfo?.targetId,
).toBe("t1");
cdp.close();
ext.close();
},
RELAY_TEST_TIMEOUT_MS,
);
it("removes cached targets from /json/list when targetDestroyed arrives", async () => {
const { ext } = await startRelayWithExtension();
ext.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "cb-tab-1",
targetInfo: {
targetId: "t1",
type: "page",
title: "Example",
url: "https://example.com",
},
waitingForDebugger: false,
},
},
}),
);
await waitForListMatch(
async () =>
(await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{ id?: string }>,
(list) => list.some((target) => target.id === "t1"),
);
ext.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.targetDestroyed",
params: { targetId: "t1" },
},
}),
);
await waitForListMatch(
async () =>
(await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{ id?: string }>,
(list) => list.every((target) => target.id !== "t1"),
);
ext.close();
});
it("prunes stale cached targets after target-not-found command errors", async () => {
const { port, ext } = await startRelayWithExtension();
const extQueue = createMessageQueue(ext);
ext.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "cb-tab-1",
targetInfo: {
targetId: "t1",
type: "page",
title: "Example",
url: "https://example.com",
},
waitingForDebugger: false,
},
},
}),
);
await waitForListMatch(
async () =>
(await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{ id?: string }>,
(list) => list.some((target) => target.id === "t1"),
);
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
});
await waitForOpen(cdp);
const cdpQueue = createMessageQueue(cdp);
cdp.send(
JSON.stringify({
id: 77,
method: "Runtime.evaluate",
sessionId: "cb-tab-1",
params: { expression: "1+1" },
}),
);
let forwardedId: number | null = null;
for (let attempt = 0; attempt < 6; attempt++) {
const msg = JSON.parse(await extQueue.next()) as { method?: string; id?: number };
if (msg.method === "forwardCDPCommand" && typeof msg.id === "number") {
forwardedId = msg.id;
break;
}
}
expect(forwardedId).not.toBeNull();
ext.send(
JSON.stringify({
id: forwardedId,
error: "No target with given id",
}),
);
let response: { id?: number; error?: { message?: string } } | null = null;
for (let attempt = 0; attempt < 6; attempt++) {
const msg = JSON.parse(await cdpQueue.next()) as {
id?: number;
error?: { message?: string };
};
if (msg.id === 77) {
response = msg;
break;
}
}
expect(response?.id).toBe(77);
expect(response?.error?.message ?? "").toContain("No target with given id");
await waitForListMatch(
async () =>
(await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{ id?: string }>,
(list) => list.every((target) => target.id !== "t1"),
);
cdp.close();
ext.close();
});
it("rebroadcasts attach when a session id is reused for a new target", async () => {
const { port, ext } = await startRelayWithExtension();
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
});
await waitForOpen(cdp);
const q = createMessageQueue(cdp);
ext.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "shared-session",
targetInfo: {
targetId: "t1",
type: "page",
title: "First",
url: "https://example.com",
},
waitingForDebugger: false,
},
},
}),
);
const first = JSON.parse(await q.next()) as { method?: string; params?: unknown };
expect(first.method).toBe("Target.attachedToTarget");
expect(
(first.params as { targetInfo?: { targetId?: string } } | undefined)?.targetInfo?.targetId,
).toBe("t1");
ext.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "shared-session",
targetInfo: {
targetId: "t2",
type: "page",
title: "Second",
url: "https://example.org",
},
waitingForDebugger: false,
},
},
}),
);
const received: Array<{ method?: string; params?: unknown }> = [];
received.push(JSON.parse(await q.next()) as never);
received.push(JSON.parse(await q.next()) as never);
const detached = received.find((m) => m.method === "Target.detachedFromTarget");
const attached = received.find((m) => m.method === "Target.attachedToTarget");
expect((detached?.params as { targetId?: string } | undefined)?.targetId).toBe("t1");
expect(
(attached?.params as { targetInfo?: { targetId?: string } } | undefined)?.targetInfo
?.targetId,
).toBe("t2");
cdp.close();
ext.close();
});
it("reuses an already-bound relay port when another process owns it", async () => {
const port = await getFreePort();
let probeToken: string | undefined;
const fakeRelay = createServer((req, res) => {
if (req.url?.startsWith("/json/version")) {
const header = req.headers["x-openclaw-relay-token"];
probeToken = Array.isArray(header) ? header[0] : header;
if (!probeToken) {
res.writeHead(401);
res.end("Unauthorized");
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ Browser: "OpenClaw/extension-relay" }));
return;
}
if (req.url?.startsWith("/extension/status")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ connected: false }));
return;
}
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.end("OK");
});
await new Promise<void>((resolve, reject) => {
fakeRelay.listen(port, "127.0.0.1", () => resolve());
fakeRelay.once("error", reject);
});
try {
cdpUrl = `http://127.0.0.1:${port}`;
const relay = await ensureChromeExtensionRelayServer({ cdpUrl });
expect(relay.port).toBe(port);
const status = (await fetch(`${cdpUrl}/extension/status`).then((r) => r.json())) as {
connected?: boolean;
};
expect(status.connected).toBe(false);
expect(probeToken).toBeTruthy();
expect(probeToken).not.toBe("test-gateway-token");
} finally {
await new Promise<void>((resolve) => fakeRelay.close(() => resolve()));
}
});
it(
"restores tabs after extension reconnects and re-announces",
async () => {
process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS = "200";
const { port, ext: ext1 } = await startRelayWithExtension();
ext1.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "cb-tab-10",
targetInfo: {
targetId: "t10",
type: "page",
title: "My Page",
url: "https://example.com",
},
waitingForDebugger: false,
},
},
}),
);
await waitForListMatch(
async () =>
(await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{ id?: string }>,
(list) => list.some((t) => t.id === "t10"),
);
// Disconnect extension and wait for grace period cleanup.
const ext1Closed = waitForClose(ext1, 2_000);
ext1.close();
await ext1Closed;
await waitForListMatch(
async () =>
(await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{ id?: string }>,
(list) => list.length === 0,
);
// Reconnect and re-announce the same tab (simulates reannounceAttachedTabs).
const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext2);
ext2.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "cb-tab-10",
targetInfo: {
targetId: "t10",
type: "page",
title: "My Page",
url: "https://example.com",
},
waitingForDebugger: false,
},
},
}),
);
const list2 = await waitForListMatch(
async () =>
(await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{ id?: string; title?: string }>,
(list) => list.some((t) => t.id === "t10"),
);
expect(list2.some((t) => t.id === "t10" && t.title === "My Page")).toBe(true);
ext2.close();
},
RELAY_TEST_TIMEOUT_MS,
);
it(
"preserves tab across a fast extension reconnect within grace period",
async () => {
process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS = "2000";
const { port, ext: ext1 } = await startRelayWithExtension();
ext1.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "cb-tab-20",
targetInfo: {
targetId: "t20",
type: "page",
title: "Persistent",
url: "https://example.org",
},
waitingForDebugger: false,
},
},
}),
);
await waitForListMatch(
async () =>
(await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{ id?: string }>,
(list) => list.some((t) => t.id === "t20"),
);
// Disconnect briefly (within grace period).
const ext1Closed = waitForClose(ext1, 2_000);
ext1.close();
await ext1Closed;
// Tab should still be listed during grace period.
const listDuringGrace = (await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{ id?: string }>;
expect(listDuringGrace.some((t) => t.id === "t20")).toBe(true);
// Reconnect within grace and re-announce with updated info.
const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext2);
ext2.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "cb-tab-20",
targetInfo: {
targetId: "t20",
type: "page",
title: "Persistent Updated",
url: "https://example.org/new",
},
waitingForDebugger: false,
},
},
}),
);
const list2 = await waitForListMatch(
async () =>
(await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{ id?: string; title?: string; url?: string }>,
(list) => list.some((t) => t.id === "t20" && t.title === "Persistent Updated"),
);
expect(list2.some((t) => t.id === "t20" && t.url === "https://example.org/new")).toBe(true);
ext2.close();
},
RELAY_TEST_TIMEOUT_MS,
);
it("does not swallow EADDRINUSE when occupied port is not an openclaw relay", async () => {
const port = await getFreePort();
const blocker = createServer((_, res) => {
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.end("not-relay");
});
await new Promise<void>((resolve, reject) => {
blocker.listen(port, "127.0.0.1", () => resolve());
blocker.once("error", reject);
});
const blockedUrl = `http://127.0.0.1:${port}`;
await expect(ensureChromeExtensionRelayServer({ cdpUrl: blockedUrl })).rejects.toThrow(
/EADDRINUSE/i,
);
await new Promise<void>((resolve) => blocker.close(() => resolve()));
});
});