1172 lines
36 KiB
TypeScript
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()));
|
|
});
|
|
});
|