mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-25 15:37:32 +00:00
refactor(browser): dedupe browser and cli command wiring
This commit is contained in:
@@ -26,6 +26,23 @@ async function withRelayServer(
|
||||
}
|
||||
}
|
||||
|
||||
function handleNonVersionRequest(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
if (req.url?.startsWith("/json/version")) {
|
||||
return false;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end("not found");
|
||||
return true;
|
||||
}
|
||||
|
||||
async function probeRelay(baseUrl: string, relayAuthToken: string): Promise<boolean> {
|
||||
return await probeAuthenticatedOpenClawRelay({
|
||||
baseUrl,
|
||||
relayAuthHeader: "x-openclaw-relay-token",
|
||||
relayAuthToken,
|
||||
});
|
||||
}
|
||||
|
||||
describe("extension-relay-auth", () => {
|
||||
const TEST_GATEWAY_TOKEN = "test-gateway-token";
|
||||
let prevGatewayToken: string | undefined;
|
||||
@@ -63,9 +80,7 @@ describe("extension-relay-auth", () => {
|
||||
let seenToken: string | undefined;
|
||||
await withRelayServer(
|
||||
(req, res) => {
|
||||
if (!req.url?.startsWith("/json/version")) {
|
||||
res.writeHead(404);
|
||||
res.end("not found");
|
||||
if (handleNonVersionRequest(req, res)) {
|
||||
return;
|
||||
}
|
||||
const header = req.headers["x-openclaw-relay-token"];
|
||||
@@ -75,11 +90,7 @@ describe("extension-relay-auth", () => {
|
||||
},
|
||||
async ({ port }) => {
|
||||
const token = resolveRelayAuthTokenForPort(port);
|
||||
const ok = await probeAuthenticatedOpenClawRelay({
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
relayAuthHeader: "x-openclaw-relay-token",
|
||||
relayAuthToken: token,
|
||||
});
|
||||
const ok = await probeRelay(`http://127.0.0.1:${port}`, token);
|
||||
expect(ok).toBe(true);
|
||||
expect(seenToken).toBe(token);
|
||||
},
|
||||
@@ -89,20 +100,14 @@ describe("extension-relay-auth", () => {
|
||||
it("rejects unauthenticated probe responses", async () => {
|
||||
await withRelayServer(
|
||||
(req, res) => {
|
||||
if (!req.url?.startsWith("/json/version")) {
|
||||
res.writeHead(404);
|
||||
res.end("not found");
|
||||
if (handleNonVersionRequest(req, res)) {
|
||||
return;
|
||||
}
|
||||
res.writeHead(401);
|
||||
res.end("Unauthorized");
|
||||
},
|
||||
async ({ port }) => {
|
||||
const ok = await probeAuthenticatedOpenClawRelay({
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
relayAuthHeader: "x-openclaw-relay-token",
|
||||
relayAuthToken: "irrelevant",
|
||||
});
|
||||
const ok = await probeRelay(`http://127.0.0.1:${port}`, "irrelevant");
|
||||
expect(ok).toBe(false);
|
||||
},
|
||||
);
|
||||
@@ -111,20 +116,14 @@ describe("extension-relay-auth", () => {
|
||||
it("rejects probe responses with wrong browser identity", async () => {
|
||||
await withRelayServer(
|
||||
(req, res) => {
|
||||
if (!req.url?.startsWith("/json/version")) {
|
||||
res.writeHead(404);
|
||||
res.end("not found");
|
||||
if (handleNonVersionRequest(req, res)) {
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ Browser: "FakeRelay" }));
|
||||
},
|
||||
async ({ port }) => {
|
||||
const ok = await probeAuthenticatedOpenClawRelay({
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
relayAuthHeader: "x-openclaw-relay-token",
|
||||
relayAuthToken: "irrelevant",
|
||||
});
|
||||
const ok = await probeRelay(`http://127.0.0.1:${port}`, "irrelevant");
|
||||
expect(ok).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
273
src/browser/server-context.remote-profile-tab-ops.suite.ts
Normal file
273
src/browser/server-context.remote-profile-tab-ops.suite.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import "./server-context.chrome-test-harness.js";
|
||||
import * as chromeModule from "./chrome.js";
|
||||
import * as pwAiModule from "./pw-ai-module.js";
|
||||
import { createBrowserRouteContext } from "./server-context.js";
|
||||
import {
|
||||
createJsonListFetchMock,
|
||||
createRemoteRouteHarness,
|
||||
createSequentialPageLister,
|
||||
makeState,
|
||||
originalFetch,
|
||||
} from "./server-context.remote-tab-ops.harness.js";
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("browser server-context remote profile tab operations", () => {
|
||||
it("uses profile-level attachOnly when global attachOnly is false", async () => {
|
||||
const state = makeState("openclaw");
|
||||
state.resolved.attachOnly = false;
|
||||
state.resolved.profiles.openclaw = {
|
||||
cdpPort: 18800,
|
||||
attachOnly: true,
|
||||
color: "#FF4500",
|
||||
};
|
||||
|
||||
const reachableMock = vi.mocked(chromeModule.isChromeReachable).mockResolvedValueOnce(false);
|
||||
const launchMock = vi.mocked(chromeModule.launchOpenClawChrome);
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
|
||||
await expect(ctx.forProfile("openclaw").ensureBrowserAvailable()).rejects.toThrow(
|
||||
/attachOnly is enabled/i,
|
||||
);
|
||||
expect(reachableMock).toHaveBeenCalled();
|
||||
expect(launchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps attachOnly websocket failures off the loopback ownership error path", async () => {
|
||||
const state = makeState("openclaw");
|
||||
state.resolved.attachOnly = false;
|
||||
state.resolved.profiles.openclaw = {
|
||||
cdpPort: 18800,
|
||||
attachOnly: true,
|
||||
color: "#FF4500",
|
||||
};
|
||||
|
||||
const httpReachableMock = vi.mocked(chromeModule.isChromeReachable).mockResolvedValueOnce(true);
|
||||
const wsReachableMock = vi.mocked(chromeModule.isChromeCdpReady).mockResolvedValueOnce(false);
|
||||
const launchMock = vi.mocked(chromeModule.launchOpenClawChrome);
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
|
||||
await expect(ctx.forProfile("openclaw").ensureBrowserAvailable()).rejects.toThrow(
|
||||
/attachOnly is enabled and CDP websocket/i,
|
||||
);
|
||||
expect(httpReachableMock).toHaveBeenCalled();
|
||||
expect(wsReachableMock).toHaveBeenCalled();
|
||||
expect(launchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses Playwright tab operations when available", async () => {
|
||||
const listPagesViaPlaywright = vi.fn(async () => [
|
||||
{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" },
|
||||
]);
|
||||
const createPageViaPlaywright = vi.fn(async () => ({
|
||||
targetId: "T2",
|
||||
title: "Tab 2",
|
||||
url: "http://127.0.0.1:3000",
|
||||
type: "page",
|
||||
}));
|
||||
const closePageByTargetIdViaPlaywright = vi.fn(async () => {});
|
||||
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright,
|
||||
createPageViaPlaywright,
|
||||
closePageByTargetIdViaPlaywright,
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const { state, remote, fetchMock } = createRemoteRouteHarness();
|
||||
|
||||
const tabs = await remote.listTabs();
|
||||
expect(tabs.map((t) => t.targetId)).toEqual(["T1"]);
|
||||
|
||||
const opened = await remote.openTab("http://127.0.0.1:3000");
|
||||
expect(opened.targetId).toBe("T2");
|
||||
expect(state.profiles.get("remote")?.lastTargetId).toBe("T2");
|
||||
expect(createPageViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: "https://browserless.example/chrome?token=abc",
|
||||
url: "http://127.0.0.1:3000",
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
});
|
||||
|
||||
await remote.closeTab("T1");
|
||||
expect(closePageByTargetIdViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: "https://browserless.example/chrome?token=abc",
|
||||
targetId: "T1",
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers lastTargetId for remote profiles when targetId is omitted", async () => {
|
||||
const responses = [
|
||||
[
|
||||
{ targetId: "A", title: "A", url: "https://example.com", type: "page" },
|
||||
{ targetId: "B", title: "B", url: "https://www.example.com", type: "page" },
|
||||
],
|
||||
[
|
||||
{ targetId: "A", title: "A", url: "https://example.com", type: "page" },
|
||||
{ targetId: "B", title: "B", url: "https://www.example.com", type: "page" },
|
||||
],
|
||||
[
|
||||
{ targetId: "B", title: "B", url: "https://www.example.com", type: "page" },
|
||||
{ targetId: "A", title: "A", url: "https://example.com", type: "page" },
|
||||
],
|
||||
[
|
||||
{ targetId: "B", title: "B", url: "https://www.example.com", type: "page" },
|
||||
{ targetId: "A", title: "A", url: "https://example.com", type: "page" },
|
||||
],
|
||||
];
|
||||
|
||||
const listPagesViaPlaywright = vi.fn(createSequentialPageLister(responses));
|
||||
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright,
|
||||
createPageViaPlaywright: vi.fn(async () => {
|
||||
throw new Error("unexpected create");
|
||||
}),
|
||||
closePageByTargetIdViaPlaywright: vi.fn(async () => {
|
||||
throw new Error("unexpected close");
|
||||
}),
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const { remote } = createRemoteRouteHarness();
|
||||
|
||||
const first = await remote.ensureTabAvailable();
|
||||
expect(first.targetId).toBe("A");
|
||||
const second = await remote.ensureTabAvailable();
|
||||
expect(second.targetId).toBe("A");
|
||||
});
|
||||
|
||||
it("falls back to the only tab for remote profiles when targetId is stale", async () => {
|
||||
const responses = [
|
||||
[{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }],
|
||||
[{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }],
|
||||
];
|
||||
const listPagesViaPlaywright = vi.fn(createSequentialPageLister(responses));
|
||||
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright,
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const { remote } = createRemoteRouteHarness();
|
||||
const chosen = await remote.ensureTabAvailable("STALE_TARGET");
|
||||
expect(chosen.targetId).toBe("T1");
|
||||
});
|
||||
|
||||
it("keeps rejecting stale targetId for remote profiles when multiple tabs exist", async () => {
|
||||
const responses = [
|
||||
[
|
||||
{ targetId: "A", title: "A", url: "https://a.example", type: "page" },
|
||||
{ targetId: "B", title: "B", url: "https://b.example", type: "page" },
|
||||
],
|
||||
[
|
||||
{ targetId: "A", title: "A", url: "https://a.example", type: "page" },
|
||||
{ targetId: "B", title: "B", url: "https://b.example", type: "page" },
|
||||
],
|
||||
];
|
||||
const listPagesViaPlaywright = vi.fn(createSequentialPageLister(responses));
|
||||
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright,
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const { remote } = createRemoteRouteHarness();
|
||||
await expect(remote.ensureTabAvailable("STALE_TARGET")).rejects.toThrow(/tab not found/i);
|
||||
});
|
||||
|
||||
it("uses Playwright focus for remote profiles when available", async () => {
|
||||
const listPagesViaPlaywright = vi.fn(async () => [
|
||||
{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" },
|
||||
]);
|
||||
const focusPageByTargetIdViaPlaywright = vi.fn(async () => {});
|
||||
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright,
|
||||
focusPageByTargetIdViaPlaywright,
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const { state, remote, fetchMock } = createRemoteRouteHarness();
|
||||
|
||||
await remote.focusTab("T1");
|
||||
expect(focusPageByTargetIdViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: "https://browserless.example/chrome?token=abc",
|
||||
targetId: "T1",
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(state.profiles.get("remote")?.lastTargetId).toBe("T1");
|
||||
});
|
||||
|
||||
it("does not swallow Playwright runtime errors for remote profiles", async () => {
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright: vi.fn(async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const { remote, fetchMock } = createRemoteRouteHarness();
|
||||
|
||||
await expect(remote.listTabs()).rejects.toThrow(/boom/);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to /json/list when Playwright is not available", async () => {
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue(null);
|
||||
const { remote } = createRemoteRouteHarness(
|
||||
vi.fn(
|
||||
createJsonListFetchMock([
|
||||
{
|
||||
id: "T1",
|
||||
title: "Tab 1",
|
||||
url: "https://example.com",
|
||||
webSocketDebuggerUrl: "wss://browserless.example/devtools/page/T1",
|
||||
type: "page",
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const tabs = await remote.listTabs();
|
||||
expect(tabs.map((t) => t.targetId)).toEqual(["T1"]);
|
||||
});
|
||||
|
||||
it("does not enforce managed tab cap for remote openclaw profiles", async () => {
|
||||
const listPagesViaPlaywright = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{ targetId: "T1", title: "1", url: "https://1.example", type: "page" },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ targetId: "T1", title: "1", url: "https://1.example", type: "page" },
|
||||
{ targetId: "T2", title: "2", url: "https://2.example", type: "page" },
|
||||
{ targetId: "T3", title: "3", url: "https://3.example", type: "page" },
|
||||
{ targetId: "T4", title: "4", url: "https://4.example", type: "page" },
|
||||
{ targetId: "T5", title: "5", url: "https://5.example", type: "page" },
|
||||
{ targetId: "T6", title: "6", url: "https://6.example", type: "page" },
|
||||
{ targetId: "T7", title: "7", url: "https://7.example", type: "page" },
|
||||
{ targetId: "T8", title: "8", url: "https://8.example", type: "page" },
|
||||
{ targetId: "T9", title: "9", url: "https://9.example", type: "page" },
|
||||
]);
|
||||
|
||||
const createPageViaPlaywright = vi.fn(async () => ({
|
||||
targetId: "T1",
|
||||
title: "Tab 1",
|
||||
url: "https://1.example",
|
||||
type: "page",
|
||||
}));
|
||||
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright,
|
||||
createPageViaPlaywright,
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
throw new Error(`unexpected fetch: ${String(url)}`);
|
||||
});
|
||||
|
||||
const { remote } = createRemoteRouteHarness(fetchMock);
|
||||
const opened = await remote.openTab("https://1.example");
|
||||
expect(opened.targetId).toBe("T1");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,273 +1 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import "./server-context.chrome-test-harness.js";
|
||||
import * as chromeModule from "./chrome.js";
|
||||
import * as pwAiModule from "./pw-ai-module.js";
|
||||
import { createBrowserRouteContext } from "./server-context.js";
|
||||
import {
|
||||
createJsonListFetchMock,
|
||||
createRemoteRouteHarness,
|
||||
createSequentialPageLister,
|
||||
makeState,
|
||||
originalFetch,
|
||||
} from "./server-context.remote-tab-ops.harness.js";
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("browser server-context remote profile tab operations", () => {
|
||||
it("uses profile-level attachOnly when global attachOnly is false", async () => {
|
||||
const state = makeState("openclaw");
|
||||
state.resolved.attachOnly = false;
|
||||
state.resolved.profiles.openclaw = {
|
||||
cdpPort: 18800,
|
||||
attachOnly: true,
|
||||
color: "#FF4500",
|
||||
};
|
||||
|
||||
const reachableMock = vi.mocked(chromeModule.isChromeReachable).mockResolvedValueOnce(false);
|
||||
const launchMock = vi.mocked(chromeModule.launchOpenClawChrome);
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
|
||||
await expect(ctx.forProfile("openclaw").ensureBrowserAvailable()).rejects.toThrow(
|
||||
/attachOnly is enabled/i,
|
||||
);
|
||||
expect(reachableMock).toHaveBeenCalled();
|
||||
expect(launchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps attachOnly websocket failures off the loopback ownership error path", async () => {
|
||||
const state = makeState("openclaw");
|
||||
state.resolved.attachOnly = false;
|
||||
state.resolved.profiles.openclaw = {
|
||||
cdpPort: 18800,
|
||||
attachOnly: true,
|
||||
color: "#FF4500",
|
||||
};
|
||||
|
||||
const httpReachableMock = vi.mocked(chromeModule.isChromeReachable).mockResolvedValueOnce(true);
|
||||
const wsReachableMock = vi.mocked(chromeModule.isChromeCdpReady).mockResolvedValueOnce(false);
|
||||
const launchMock = vi.mocked(chromeModule.launchOpenClawChrome);
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
|
||||
await expect(ctx.forProfile("openclaw").ensureBrowserAvailable()).rejects.toThrow(
|
||||
/attachOnly is enabled and CDP websocket/i,
|
||||
);
|
||||
expect(httpReachableMock).toHaveBeenCalled();
|
||||
expect(wsReachableMock).toHaveBeenCalled();
|
||||
expect(launchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses Playwright tab operations when available", async () => {
|
||||
const listPagesViaPlaywright = vi.fn(async () => [
|
||||
{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" },
|
||||
]);
|
||||
const createPageViaPlaywright = vi.fn(async () => ({
|
||||
targetId: "T2",
|
||||
title: "Tab 2",
|
||||
url: "http://127.0.0.1:3000",
|
||||
type: "page",
|
||||
}));
|
||||
const closePageByTargetIdViaPlaywright = vi.fn(async () => {});
|
||||
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright,
|
||||
createPageViaPlaywright,
|
||||
closePageByTargetIdViaPlaywright,
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const { state, remote, fetchMock } = createRemoteRouteHarness();
|
||||
|
||||
const tabs = await remote.listTabs();
|
||||
expect(tabs.map((t) => t.targetId)).toEqual(["T1"]);
|
||||
|
||||
const opened = await remote.openTab("http://127.0.0.1:3000");
|
||||
expect(opened.targetId).toBe("T2");
|
||||
expect(state.profiles.get("remote")?.lastTargetId).toBe("T2");
|
||||
expect(createPageViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: "https://browserless.example/chrome?token=abc",
|
||||
url: "http://127.0.0.1:3000",
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
});
|
||||
|
||||
await remote.closeTab("T1");
|
||||
expect(closePageByTargetIdViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: "https://browserless.example/chrome?token=abc",
|
||||
targetId: "T1",
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers lastTargetId for remote profiles when targetId is omitted", async () => {
|
||||
const responses = [
|
||||
[
|
||||
{ targetId: "A", title: "A", url: "https://example.com", type: "page" },
|
||||
{ targetId: "B", title: "B", url: "https://www.example.com", type: "page" },
|
||||
],
|
||||
[
|
||||
{ targetId: "A", title: "A", url: "https://example.com", type: "page" },
|
||||
{ targetId: "B", title: "B", url: "https://www.example.com", type: "page" },
|
||||
],
|
||||
[
|
||||
{ targetId: "B", title: "B", url: "https://www.example.com", type: "page" },
|
||||
{ targetId: "A", title: "A", url: "https://example.com", type: "page" },
|
||||
],
|
||||
[
|
||||
{ targetId: "B", title: "B", url: "https://www.example.com", type: "page" },
|
||||
{ targetId: "A", title: "A", url: "https://example.com", type: "page" },
|
||||
],
|
||||
];
|
||||
|
||||
const listPagesViaPlaywright = vi.fn(createSequentialPageLister(responses));
|
||||
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright,
|
||||
createPageViaPlaywright: vi.fn(async () => {
|
||||
throw new Error("unexpected create");
|
||||
}),
|
||||
closePageByTargetIdViaPlaywright: vi.fn(async () => {
|
||||
throw new Error("unexpected close");
|
||||
}),
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const { remote } = createRemoteRouteHarness();
|
||||
|
||||
const first = await remote.ensureTabAvailable();
|
||||
expect(first.targetId).toBe("A");
|
||||
const second = await remote.ensureTabAvailable();
|
||||
expect(second.targetId).toBe("A");
|
||||
});
|
||||
|
||||
it("falls back to the only tab for remote profiles when targetId is stale", async () => {
|
||||
const responses = [
|
||||
[{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }],
|
||||
[{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }],
|
||||
];
|
||||
const listPagesViaPlaywright = vi.fn(createSequentialPageLister(responses));
|
||||
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright,
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const { remote } = createRemoteRouteHarness();
|
||||
const chosen = await remote.ensureTabAvailable("STALE_TARGET");
|
||||
expect(chosen.targetId).toBe("T1");
|
||||
});
|
||||
|
||||
it("keeps rejecting stale targetId for remote profiles when multiple tabs exist", async () => {
|
||||
const responses = [
|
||||
[
|
||||
{ targetId: "A", title: "A", url: "https://a.example", type: "page" },
|
||||
{ targetId: "B", title: "B", url: "https://b.example", type: "page" },
|
||||
],
|
||||
[
|
||||
{ targetId: "A", title: "A", url: "https://a.example", type: "page" },
|
||||
{ targetId: "B", title: "B", url: "https://b.example", type: "page" },
|
||||
],
|
||||
];
|
||||
const listPagesViaPlaywright = vi.fn(createSequentialPageLister(responses));
|
||||
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright,
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const { remote } = createRemoteRouteHarness();
|
||||
await expect(remote.ensureTabAvailable("STALE_TARGET")).rejects.toThrow(/tab not found/i);
|
||||
});
|
||||
|
||||
it("uses Playwright focus for remote profiles when available", async () => {
|
||||
const listPagesViaPlaywright = vi.fn(async () => [
|
||||
{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" },
|
||||
]);
|
||||
const focusPageByTargetIdViaPlaywright = vi.fn(async () => {});
|
||||
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright,
|
||||
focusPageByTargetIdViaPlaywright,
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const { state, remote, fetchMock } = createRemoteRouteHarness();
|
||||
|
||||
await remote.focusTab("T1");
|
||||
expect(focusPageByTargetIdViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: "https://browserless.example/chrome?token=abc",
|
||||
targetId: "T1",
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(state.profiles.get("remote")?.lastTargetId).toBe("T1");
|
||||
});
|
||||
|
||||
it("does not swallow Playwright runtime errors for remote profiles", async () => {
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright: vi.fn(async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const { remote, fetchMock } = createRemoteRouteHarness();
|
||||
|
||||
await expect(remote.listTabs()).rejects.toThrow(/boom/);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to /json/list when Playwright is not available", async () => {
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue(null);
|
||||
const { remote } = createRemoteRouteHarness(
|
||||
vi.fn(
|
||||
createJsonListFetchMock([
|
||||
{
|
||||
id: "T1",
|
||||
title: "Tab 1",
|
||||
url: "https://example.com",
|
||||
webSocketDebuggerUrl: "wss://browserless.example/devtools/page/T1",
|
||||
type: "page",
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const tabs = await remote.listTabs();
|
||||
expect(tabs.map((t) => t.targetId)).toEqual(["T1"]);
|
||||
});
|
||||
|
||||
it("does not enforce managed tab cap for remote openclaw profiles", async () => {
|
||||
const listPagesViaPlaywright = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{ targetId: "T1", title: "1", url: "https://1.example", type: "page" },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ targetId: "T1", title: "1", url: "https://1.example", type: "page" },
|
||||
{ targetId: "T2", title: "2", url: "https://2.example", type: "page" },
|
||||
{ targetId: "T3", title: "3", url: "https://3.example", type: "page" },
|
||||
{ targetId: "T4", title: "4", url: "https://4.example", type: "page" },
|
||||
{ targetId: "T5", title: "5", url: "https://5.example", type: "page" },
|
||||
{ targetId: "T6", title: "6", url: "https://6.example", type: "page" },
|
||||
{ targetId: "T7", title: "7", url: "https://7.example", type: "page" },
|
||||
{ targetId: "T8", title: "8", url: "https://8.example", type: "page" },
|
||||
{ targetId: "T9", title: "9", url: "https://9.example", type: "page" },
|
||||
]);
|
||||
|
||||
const createPageViaPlaywright = vi.fn(async () => ({
|
||||
targetId: "T1",
|
||||
title: "Tab 1",
|
||||
url: "https://1.example",
|
||||
type: "page",
|
||||
}));
|
||||
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright,
|
||||
createPageViaPlaywright,
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
throw new Error(`unexpected fetch: ${String(url)}`);
|
||||
});
|
||||
|
||||
const { remote } = createRemoteRouteHarness(fetchMock);
|
||||
const opened = await remote.openTab("https://1.example");
|
||||
expect(opened.targetId).toBe("T1");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
import "./server-context.remote-profile-tab-ops.suite.js";
|
||||
|
||||
@@ -1,623 +1,2 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
|
||||
import "./server-context.chrome-test-harness.js";
|
||||
import * as cdpModule from "./cdp.js";
|
||||
import * as chromeModule from "./chrome.js";
|
||||
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
||||
import * as pwAiModule from "./pw-ai-module.js";
|
||||
import type { BrowserServerState } from "./server-context.js";
|
||||
import { createBrowserRouteContext } from "./server-context.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function makeState(
|
||||
profile: "remote" | "openclaw",
|
||||
): BrowserServerState & { profiles: Map<string, { lastTargetId?: string | null }> } {
|
||||
return {
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
server: null as any,
|
||||
port: 0,
|
||||
resolved: {
|
||||
enabled: true,
|
||||
controlPort: 18791,
|
||||
cdpPortRangeStart: 18800,
|
||||
cdpPortRangeEnd: 18899,
|
||||
cdpProtocol: profile === "remote" ? "https" : "http",
|
||||
cdpHost: profile === "remote" ? "browserless.example" : "127.0.0.1",
|
||||
cdpIsLoopback: profile !== "remote",
|
||||
remoteCdpTimeoutMs: 1500,
|
||||
remoteCdpHandshakeTimeoutMs: 3000,
|
||||
evaluateEnabled: false,
|
||||
extraArgs: [],
|
||||
color: "#FF4500",
|
||||
headless: true,
|
||||
noSandbox: false,
|
||||
attachOnly: false,
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
defaultProfile: profile,
|
||||
profiles: {
|
||||
remote: {
|
||||
cdpUrl: "https://browserless.example/chrome?token=abc",
|
||||
cdpPort: 443,
|
||||
color: "#00AA00",
|
||||
},
|
||||
openclaw: { cdpPort: 18800, color: "#FF4500" },
|
||||
},
|
||||
},
|
||||
profiles: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeUnexpectedFetchMock() {
|
||||
return vi.fn(async () => {
|
||||
throw new Error("unexpected fetch");
|
||||
});
|
||||
}
|
||||
|
||||
function createRemoteRouteHarness(fetchMock?: ReturnType<typeof vi.fn>) {
|
||||
const activeFetchMock = fetchMock ?? makeUnexpectedFetchMock();
|
||||
global.fetch = withFetchPreconnect(activeFetchMock);
|
||||
const state = makeState("remote");
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
return { state, remote: ctx.forProfile("remote"), fetchMock: activeFetchMock };
|
||||
}
|
||||
|
||||
function createSequentialPageLister<T>(responses: T[]) {
|
||||
return vi.fn(async () => {
|
||||
const next = responses.shift();
|
||||
if (!next) {
|
||||
throw new Error("no more responses");
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
type JsonListEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
webSocketDebuggerUrl: string;
|
||||
type: "page";
|
||||
};
|
||||
|
||||
function createJsonListFetchMock(entries: JsonListEntry[]) {
|
||||
return vi.fn(async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (!u.includes("/json/list")) {
|
||||
throw new Error(`unexpected fetch: ${u}`);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => entries,
|
||||
} as unknown as Response;
|
||||
});
|
||||
}
|
||||
|
||||
function createOpenclawManagedTab(id: string, index: number): JsonListEntry {
|
||||
return {
|
||||
id,
|
||||
title: String(index),
|
||||
url: `http://127.0.0.1:300${index}`,
|
||||
webSocketDebuggerUrl: `ws://127.0.0.1/devtools/page/${id}`,
|
||||
type: "page",
|
||||
};
|
||||
}
|
||||
|
||||
function createOpenclawManagedTabs(params?: {
|
||||
includeNew?: boolean;
|
||||
newFirst?: boolean;
|
||||
}): JsonListEntry[] {
|
||||
const oldTabs = Array.from({ length: 8 }, (_, idx) =>
|
||||
createOpenclawManagedTab(`OLD${idx + 1}`, idx + 1),
|
||||
);
|
||||
if (params?.includeNew === false) {
|
||||
return oldTabs;
|
||||
}
|
||||
const newTab = createOpenclawManagedTab("NEW", 9);
|
||||
return params?.newFirst ? [newTab, ...oldTabs] : [...oldTabs, newTab];
|
||||
}
|
||||
|
||||
function createOpenclawRouteHarness(
|
||||
fetchMock: ReturnType<typeof vi.fn>,
|
||||
params?: { attachOnly?: boolean; seedRunningProfile?: boolean },
|
||||
) {
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
if (params?.attachOnly) {
|
||||
state.resolved.attachOnly = true;
|
||||
}
|
||||
if (params?.seedRunningProfile ?? true) {
|
||||
(state.profiles as Map<string, unknown>).set("openclaw", {
|
||||
profile: { name: "openclaw" },
|
||||
running: { pid: 1234, proc: { on: vi.fn() } },
|
||||
lastTargetId: null,
|
||||
});
|
||||
}
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
return { state, openclaw: ctx.forProfile("openclaw") };
|
||||
}
|
||||
|
||||
function createJsonOkResponse(payload: unknown): Response {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => payload,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function createManagedTabsFetchMock(params: {
|
||||
existingTabs: JsonListEntry[];
|
||||
onClose?: (url: string) => Promise<Response> | Response;
|
||||
}) {
|
||||
return vi.fn(async (url: unknown) => {
|
||||
const value = String(url);
|
||||
if (value.includes("/json/list")) {
|
||||
return createJsonOkResponse(params.existingTabs);
|
||||
}
|
||||
if (value.includes("/json/close/")) {
|
||||
if (params.onClose) {
|
||||
return params.onClose(value);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${value}`);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${value}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function expectManagedOldestTabClose(fetchMock: ReturnType<typeof vi.fn>) {
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/json/close/OLD1"),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("browser server-context remote profile tab operations", () => {
|
||||
it("uses profile-level attachOnly when global attachOnly is false", async () => {
|
||||
const state = makeState("openclaw");
|
||||
state.resolved.attachOnly = false;
|
||||
state.resolved.profiles.openclaw = {
|
||||
cdpPort: 18800,
|
||||
attachOnly: true,
|
||||
color: "#FF4500",
|
||||
};
|
||||
|
||||
const reachableMock = vi.mocked(chromeModule.isChromeReachable).mockResolvedValueOnce(false);
|
||||
const launchMock = vi.mocked(chromeModule.launchOpenClawChrome);
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
|
||||
await expect(ctx.forProfile("openclaw").ensureBrowserAvailable()).rejects.toThrow(
|
||||
/attachOnly is enabled/i,
|
||||
);
|
||||
expect(reachableMock).toHaveBeenCalled();
|
||||
expect(launchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps attachOnly websocket failures off the loopback ownership error path", async () => {
|
||||
const state = makeState("openclaw");
|
||||
state.resolved.attachOnly = false;
|
||||
state.resolved.profiles.openclaw = {
|
||||
cdpPort: 18800,
|
||||
attachOnly: true,
|
||||
color: "#FF4500",
|
||||
};
|
||||
|
||||
const httpReachableMock = vi.mocked(chromeModule.isChromeReachable).mockResolvedValueOnce(true);
|
||||
const wsReachableMock = vi.mocked(chromeModule.isChromeCdpReady).mockResolvedValueOnce(false);
|
||||
const launchMock = vi.mocked(chromeModule.launchOpenClawChrome);
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
|
||||
await expect(ctx.forProfile("openclaw").ensureBrowserAvailable()).rejects.toThrow(
|
||||
/attachOnly is enabled and CDP websocket/i,
|
||||
);
|
||||
expect(httpReachableMock).toHaveBeenCalled();
|
||||
expect(wsReachableMock).toHaveBeenCalled();
|
||||
expect(launchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses Playwright tab operations when available", async () => {
|
||||
const listPagesViaPlaywright = vi.fn(async () => [
|
||||
{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" },
|
||||
]);
|
||||
const createPageViaPlaywright = vi.fn(async () => ({
|
||||
targetId: "T2",
|
||||
title: "Tab 2",
|
||||
url: "http://127.0.0.1:3000",
|
||||
type: "page",
|
||||
}));
|
||||
const closePageByTargetIdViaPlaywright = vi.fn(async () => {});
|
||||
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright,
|
||||
createPageViaPlaywright,
|
||||
closePageByTargetIdViaPlaywright,
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const { state, remote, fetchMock } = createRemoteRouteHarness();
|
||||
|
||||
const tabs = await remote.listTabs();
|
||||
expect(tabs.map((t) => t.targetId)).toEqual(["T1"]);
|
||||
|
||||
const opened = await remote.openTab("http://127.0.0.1:3000");
|
||||
expect(opened.targetId).toBe("T2");
|
||||
expect(state.profiles.get("remote")?.lastTargetId).toBe("T2");
|
||||
expect(createPageViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: "https://browserless.example/chrome?token=abc",
|
||||
url: "http://127.0.0.1:3000",
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
});
|
||||
|
||||
await remote.closeTab("T1");
|
||||
expect(closePageByTargetIdViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: "https://browserless.example/chrome?token=abc",
|
||||
targetId: "T1",
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers lastTargetId for remote profiles when targetId is omitted", async () => {
|
||||
const responses = [
|
||||
// ensureTabAvailable() calls listTabs twice
|
||||
[
|
||||
{ targetId: "A", title: "A", url: "https://example.com", type: "page" },
|
||||
{ targetId: "B", title: "B", url: "https://www.example.com", type: "page" },
|
||||
],
|
||||
[
|
||||
{ targetId: "A", title: "A", url: "https://example.com", type: "page" },
|
||||
{ targetId: "B", title: "B", url: "https://www.example.com", type: "page" },
|
||||
],
|
||||
// second ensureTabAvailable() calls listTabs twice, order flips
|
||||
[
|
||||
{ targetId: "B", title: "B", url: "https://www.example.com", type: "page" },
|
||||
{ targetId: "A", title: "A", url: "https://example.com", type: "page" },
|
||||
],
|
||||
[
|
||||
{ targetId: "B", title: "B", url: "https://www.example.com", type: "page" },
|
||||
{ targetId: "A", title: "A", url: "https://example.com", type: "page" },
|
||||
],
|
||||
];
|
||||
|
||||
const listPagesViaPlaywright = vi.fn(async () => {
|
||||
const next = responses.shift();
|
||||
if (!next) {
|
||||
throw new Error("no more responses");
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright,
|
||||
createPageViaPlaywright: vi.fn(async () => {
|
||||
throw new Error("unexpected create");
|
||||
}),
|
||||
closePageByTargetIdViaPlaywright: vi.fn(async () => {
|
||||
throw new Error("unexpected close");
|
||||
}),
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const { remote } = createRemoteRouteHarness();
|
||||
|
||||
const first = await remote.ensureTabAvailable();
|
||||
expect(first.targetId).toBe("A");
|
||||
const second = await remote.ensureTabAvailable();
|
||||
expect(second.targetId).toBe("A");
|
||||
});
|
||||
|
||||
it("falls back to the only tab for remote profiles when targetId is stale", async () => {
|
||||
const responses = [
|
||||
[{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }],
|
||||
[{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }],
|
||||
];
|
||||
const listPagesViaPlaywright = createSequentialPageLister(responses);
|
||||
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright,
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const { remote } = createRemoteRouteHarness();
|
||||
const chosen = await remote.ensureTabAvailable("STALE_TARGET");
|
||||
expect(chosen.targetId).toBe("T1");
|
||||
});
|
||||
|
||||
it("keeps rejecting stale targetId for remote profiles when multiple tabs exist", async () => {
|
||||
const responses = [
|
||||
[
|
||||
{ targetId: "A", title: "A", url: "https://a.example", type: "page" },
|
||||
{ targetId: "B", title: "B", url: "https://b.example", type: "page" },
|
||||
],
|
||||
[
|
||||
{ targetId: "A", title: "A", url: "https://a.example", type: "page" },
|
||||
{ targetId: "B", title: "B", url: "https://b.example", type: "page" },
|
||||
],
|
||||
];
|
||||
const listPagesViaPlaywright = createSequentialPageLister(responses);
|
||||
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright,
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const { remote } = createRemoteRouteHarness();
|
||||
await expect(remote.ensureTabAvailable("STALE_TARGET")).rejects.toThrow(/tab not found/i);
|
||||
});
|
||||
|
||||
it("uses Playwright focus for remote profiles when available", async () => {
|
||||
const listPagesViaPlaywright = vi.fn(async () => [
|
||||
{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" },
|
||||
]);
|
||||
const focusPageByTargetIdViaPlaywright = vi.fn(async () => {});
|
||||
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright,
|
||||
focusPageByTargetIdViaPlaywright,
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const { state, remote, fetchMock } = createRemoteRouteHarness();
|
||||
|
||||
await remote.focusTab("T1");
|
||||
expect(focusPageByTargetIdViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: "https://browserless.example/chrome?token=abc",
|
||||
targetId: "T1",
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(state.profiles.get("remote")?.lastTargetId).toBe("T1");
|
||||
});
|
||||
|
||||
it("does not swallow Playwright runtime errors for remote profiles", async () => {
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright: vi.fn(async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const { remote, fetchMock } = createRemoteRouteHarness();
|
||||
|
||||
await expect(remote.listTabs()).rejects.toThrow(/boom/);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to /json/list when Playwright is not available", async () => {
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue(null);
|
||||
|
||||
const fetchMock = createJsonListFetchMock([
|
||||
{
|
||||
id: "T1",
|
||||
title: "Tab 1",
|
||||
url: "https://example.com",
|
||||
webSocketDebuggerUrl: "wss://browserless.example/devtools/page/T1",
|
||||
type: "page",
|
||||
},
|
||||
]);
|
||||
|
||||
const { remote } = createRemoteRouteHarness(fetchMock);
|
||||
|
||||
const tabs = await remote.listTabs();
|
||||
expect(tabs.map((t) => t.targetId)).toEqual(["T1"]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not enforce managed tab cap for remote openclaw profiles", async () => {
|
||||
const listPagesViaPlaywright = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{ targetId: "T1", title: "1", url: "https://1.example", type: "page" },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ targetId: "T1", title: "1", url: "https://1.example", type: "page" },
|
||||
{ targetId: "T2", title: "2", url: "https://2.example", type: "page" },
|
||||
{ targetId: "T3", title: "3", url: "https://3.example", type: "page" },
|
||||
{ targetId: "T4", title: "4", url: "https://4.example", type: "page" },
|
||||
{ targetId: "T5", title: "5", url: "https://5.example", type: "page" },
|
||||
{ targetId: "T6", title: "6", url: "https://6.example", type: "page" },
|
||||
{ targetId: "T7", title: "7", url: "https://7.example", type: "page" },
|
||||
{ targetId: "T8", title: "8", url: "https://8.example", type: "page" },
|
||||
{ targetId: "T9", title: "9", url: "https://9.example", type: "page" },
|
||||
]);
|
||||
|
||||
const createPageViaPlaywright = vi.fn(async () => ({
|
||||
targetId: "T1",
|
||||
title: "Tab 1",
|
||||
url: "https://1.example",
|
||||
type: "page",
|
||||
}));
|
||||
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
|
||||
listPagesViaPlaywright,
|
||||
createPageViaPlaywright,
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
throw new Error(`unexpected fetch: ${String(url)}`);
|
||||
});
|
||||
|
||||
const { remote } = createRemoteRouteHarness(fetchMock);
|
||||
const opened = await remote.openTab("https://1.example");
|
||||
expect(opened.targetId).toBe("T1");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser server-context tab selection state", () => {
|
||||
it("updates lastTargetId when openTab is created via CDP", async () => {
|
||||
const createTargetViaCdp = vi
|
||||
.spyOn(cdpModule, "createTargetViaCdp")
|
||||
.mockResolvedValue({ targetId: "CREATED" });
|
||||
|
||||
const fetchMock = createJsonListFetchMock([
|
||||
{
|
||||
id: "CREATED",
|
||||
title: "New Tab",
|
||||
url: "http://127.0.0.1:8080",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED",
|
||||
type: "page",
|
||||
},
|
||||
]);
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
|
||||
const state = makeState("openclaw");
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
const opened = await openclaw.openTab("http://127.0.0.1:8080");
|
||||
expect(opened.targetId).toBe("CREATED");
|
||||
expect(state.profiles.get("openclaw")?.lastTargetId).toBe("CREATED");
|
||||
expect(createTargetViaCdp).toHaveBeenCalledWith({
|
||||
cdpUrl: "http://127.0.0.1:18800",
|
||||
url: "http://127.0.0.1:8080",
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("closes excess managed tabs after opening a new tab", async () => {
|
||||
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" });
|
||||
const existingTabs = createOpenclawManagedTabs();
|
||||
|
||||
const fetchMock = createManagedTabsFetchMock({
|
||||
existingTabs,
|
||||
onClose: (value) => {
|
||||
if (value.includes("/json/close/OLD1")) {
|
||||
return createJsonOkResponse({});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${value}`);
|
||||
},
|
||||
});
|
||||
|
||||
const { openclaw } = createOpenclawRouteHarness(fetchMock);
|
||||
|
||||
const opened = await openclaw.openTab("http://127.0.0.1:3009");
|
||||
expect(opened.targetId).toBe("NEW");
|
||||
await expectManagedOldestTabClose(fetchMock);
|
||||
});
|
||||
|
||||
it("never closes the just-opened managed tab during cap cleanup", async () => {
|
||||
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" });
|
||||
const existingTabs = createOpenclawManagedTabs({ newFirst: true });
|
||||
|
||||
const fetchMock = createManagedTabsFetchMock({
|
||||
existingTabs,
|
||||
onClose: (value) => {
|
||||
if (value.includes("/json/close/OLD1")) {
|
||||
return createJsonOkResponse({});
|
||||
}
|
||||
if (value.includes("/json/close/NEW")) {
|
||||
throw new Error("cleanup must not close NEW");
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${value}`);
|
||||
},
|
||||
});
|
||||
|
||||
const { openclaw } = createOpenclawRouteHarness(fetchMock);
|
||||
|
||||
const opened = await openclaw.openTab("http://127.0.0.1:3009");
|
||||
expect(opened.targetId).toBe("NEW");
|
||||
await expectManagedOldestTabClose(fetchMock);
|
||||
expect(fetchMock).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("/json/close/NEW"),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fail tab open when managed-tab cleanup list fails", async () => {
|
||||
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" });
|
||||
|
||||
let listCount = 0;
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
const value = String(url);
|
||||
if (value.includes("/json/list")) {
|
||||
listCount += 1;
|
||||
if (listCount === 1) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
id: "NEW",
|
||||
title: "New Tab",
|
||||
url: "http://127.0.0.1:3009",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/NEW",
|
||||
type: "page",
|
||||
},
|
||||
],
|
||||
} as unknown as Response;
|
||||
}
|
||||
throw new Error("/json/list timeout");
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${value}`);
|
||||
});
|
||||
|
||||
const { openclaw } = createOpenclawRouteHarness(fetchMock);
|
||||
|
||||
const opened = await openclaw.openTab("http://127.0.0.1:3009");
|
||||
expect(opened.targetId).toBe("NEW");
|
||||
});
|
||||
|
||||
it("does not run managed tab cleanup in attachOnly mode", async () => {
|
||||
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" });
|
||||
const existingTabs = createOpenclawManagedTabs();
|
||||
|
||||
const fetchMock = createManagedTabsFetchMock({
|
||||
existingTabs,
|
||||
onClose: (_value) => {
|
||||
throw new Error("should not close tabs in attachOnly mode");
|
||||
},
|
||||
});
|
||||
|
||||
const { openclaw } = createOpenclawRouteHarness(fetchMock, {
|
||||
attachOnly: true,
|
||||
seedRunningProfile: false,
|
||||
});
|
||||
|
||||
const opened = await openclaw.openTab("http://127.0.0.1:3009");
|
||||
expect(opened.targetId).toBe("NEW");
|
||||
expect(fetchMock).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("/json/close/"),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not block openTab on slow best-effort cleanup closes", async () => {
|
||||
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" });
|
||||
const existingTabs = createOpenclawManagedTabs();
|
||||
|
||||
const fetchMock = createManagedTabsFetchMock({
|
||||
existingTabs,
|
||||
onClose: (value) => {
|
||||
if (value.includes("/json/close/OLD1")) {
|
||||
return new Promise<Response>(() => {});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${value}`);
|
||||
},
|
||||
});
|
||||
|
||||
const { openclaw } = createOpenclawRouteHarness(fetchMock);
|
||||
|
||||
const opened = await Promise.race([
|
||||
openclaw.openTab("http://127.0.0.1:3009"),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("openTab timed out waiting for cleanup")), 300),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(opened.targetId).toBe("NEW");
|
||||
});
|
||||
|
||||
it("blocks unsupported non-network URLs before any HTTP tab-open fallback", async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
throw new Error("unexpected fetch");
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
await expect(openclaw.openTab("file:///etc/passwd")).rejects.toBeInstanceOf(
|
||||
InvalidBrowserNavigationUrlError,
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
import "./server-context.remote-profile-tab-ops.suite.js";
|
||||
import "./server-context.tab-selection-state.suite.js";
|
||||
|
||||
245
src/browser/server-context.tab-selection-state.suite.ts
Normal file
245
src/browser/server-context.tab-selection-state.suite.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
|
||||
import "./server-context.chrome-test-harness.js";
|
||||
import * as cdpModule from "./cdp.js";
|
||||
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
||||
import { createBrowserRouteContext } from "./server-context.js";
|
||||
import {
|
||||
makeManagedTabsWithNew,
|
||||
makeState,
|
||||
originalFetch,
|
||||
} from "./server-context.remote-tab-ops.harness.js";
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function seedRunningProfileState(
|
||||
state: ReturnType<typeof makeState>,
|
||||
profileName = "openclaw",
|
||||
): void {
|
||||
(state.profiles as Map<string, unknown>).set(profileName, {
|
||||
profile: { name: profileName },
|
||||
running: { pid: 1234, proc: { on: vi.fn() } },
|
||||
lastTargetId: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectOldManagedTabClose(fetchMock: ReturnType<typeof vi.fn>): Promise<void> {
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/json/close/OLD1"),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function createOldTabCleanupFetchMock(
|
||||
existingTabs: ReturnType<typeof makeManagedTabsWithNew>,
|
||||
params?: { rejectNewTabClose?: boolean },
|
||||
): ReturnType<typeof vi.fn> {
|
||||
return vi.fn(async (url: unknown) => {
|
||||
const value = String(url);
|
||||
if (value.includes("/json/list")) {
|
||||
return { ok: true, json: async () => existingTabs } as unknown as Response;
|
||||
}
|
||||
if (value.includes("/json/close/OLD1")) {
|
||||
return { ok: true, json: async () => ({}) } as unknown as Response;
|
||||
}
|
||||
if (params?.rejectNewTabClose && value.includes("/json/close/NEW")) {
|
||||
throw new Error("cleanup must not close NEW");
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${value}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe("browser server-context tab selection state", () => {
|
||||
it("updates lastTargetId when openTab is created via CDP", async () => {
|
||||
const createTargetViaCdp = vi
|
||||
.spyOn(cdpModule, "createTargetViaCdp")
|
||||
.mockResolvedValue({ targetId: "CREATED" });
|
||||
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (!u.includes("/json/list")) {
|
||||
throw new Error(`unexpected fetch: ${u}`);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
id: "CREATED",
|
||||
title: "New Tab",
|
||||
url: "http://127.0.0.1:8080",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED",
|
||||
type: "page",
|
||||
},
|
||||
],
|
||||
} as unknown as Response;
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
const opened = await openclaw.openTab("http://127.0.0.1:8080");
|
||||
expect(opened.targetId).toBe("CREATED");
|
||||
expect(state.profiles.get("openclaw")?.lastTargetId).toBe("CREATED");
|
||||
expect(createTargetViaCdp).toHaveBeenCalledWith({
|
||||
cdpUrl: "http://127.0.0.1:18800",
|
||||
url: "http://127.0.0.1:8080",
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("closes excess managed tabs after opening a new tab", async () => {
|
||||
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" });
|
||||
const existingTabs = makeManagedTabsWithNew();
|
||||
const fetchMock = createOldTabCleanupFetchMock(existingTabs);
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
seedRunningProfileState(state);
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
const opened = await openclaw.openTab("http://127.0.0.1:3009");
|
||||
expect(opened.targetId).toBe("NEW");
|
||||
await expectOldManagedTabClose(fetchMock);
|
||||
});
|
||||
|
||||
it("never closes the just-opened managed tab during cap cleanup", async () => {
|
||||
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" });
|
||||
const existingTabs = makeManagedTabsWithNew({ newFirst: true });
|
||||
const fetchMock = createOldTabCleanupFetchMock(existingTabs, { rejectNewTabClose: true });
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
seedRunningProfileState(state);
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
const opened = await openclaw.openTab("http://127.0.0.1:3009");
|
||||
expect(opened.targetId).toBe("NEW");
|
||||
await expectOldManagedTabClose(fetchMock);
|
||||
expect(fetchMock).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("/json/close/NEW"),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fail tab open when managed-tab cleanup list fails", async () => {
|
||||
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" });
|
||||
|
||||
let listCount = 0;
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
const value = String(url);
|
||||
if (value.includes("/json/list")) {
|
||||
listCount += 1;
|
||||
if (listCount === 1) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
id: "NEW",
|
||||
title: "New Tab",
|
||||
url: "http://127.0.0.1:3009",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/NEW",
|
||||
type: "page",
|
||||
},
|
||||
],
|
||||
} as unknown as Response;
|
||||
}
|
||||
throw new Error("/json/list timeout");
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${value}`);
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
seedRunningProfileState(state);
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
const opened = await openclaw.openTab("http://127.0.0.1:3009");
|
||||
expect(opened.targetId).toBe("NEW");
|
||||
});
|
||||
|
||||
it("does not run managed tab cleanup in attachOnly mode", async () => {
|
||||
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" });
|
||||
const existingTabs = makeManagedTabsWithNew();
|
||||
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
const value = String(url);
|
||||
if (value.includes("/json/list")) {
|
||||
return { ok: true, json: async () => existingTabs } as unknown as Response;
|
||||
}
|
||||
if (value.includes("/json/close/")) {
|
||||
throw new Error("should not close tabs in attachOnly mode");
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${value}`);
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
state.resolved.attachOnly = true;
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
const opened = await openclaw.openTab("http://127.0.0.1:3009");
|
||||
expect(opened.targetId).toBe("NEW");
|
||||
expect(fetchMock).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("/json/close/"),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not block openTab on slow best-effort cleanup closes", async () => {
|
||||
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" });
|
||||
const existingTabs = makeManagedTabsWithNew();
|
||||
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
const value = String(url);
|
||||
if (value.includes("/json/list")) {
|
||||
return { ok: true, json: async () => existingTabs } as unknown as Response;
|
||||
}
|
||||
if (value.includes("/json/close/OLD1")) {
|
||||
return new Promise<Response>(() => {});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${value}`);
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
seedRunningProfileState(state);
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
const opened = await Promise.race([
|
||||
openclaw.openTab("http://127.0.0.1:3009"),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("openTab timed out waiting for cleanup")), 300),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(opened.targetId).toBe("NEW");
|
||||
});
|
||||
|
||||
it("blocks unsupported non-network URLs before any HTTP tab-open fallback", async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
throw new Error("unexpected fetch");
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
await expect(openclaw.openTab("file:///etc/passwd")).rejects.toBeInstanceOf(
|
||||
InvalidBrowserNavigationUrlError,
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,255 +1 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
|
||||
import "./server-context.chrome-test-harness.js";
|
||||
import * as cdpModule from "./cdp.js";
|
||||
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
||||
import { createBrowserRouteContext } from "./server-context.js";
|
||||
import {
|
||||
makeManagedTabsWithNew,
|
||||
makeState,
|
||||
originalFetch,
|
||||
} from "./server-context.remote-tab-ops.harness.js";
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("browser server-context tab selection state", () => {
|
||||
it("updates lastTargetId when openTab is created via CDP", async () => {
|
||||
const createTargetViaCdp = vi
|
||||
.spyOn(cdpModule, "createTargetViaCdp")
|
||||
.mockResolvedValue({ targetId: "CREATED" });
|
||||
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (!u.includes("/json/list")) {
|
||||
throw new Error(`unexpected fetch: ${u}`);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
id: "CREATED",
|
||||
title: "New Tab",
|
||||
url: "http://127.0.0.1:8080",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED",
|
||||
type: "page",
|
||||
},
|
||||
],
|
||||
} as unknown as Response;
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
const opened = await openclaw.openTab("http://127.0.0.1:8080");
|
||||
expect(opened.targetId).toBe("CREATED");
|
||||
expect(state.profiles.get("openclaw")?.lastTargetId).toBe("CREATED");
|
||||
expect(createTargetViaCdp).toHaveBeenCalledWith({
|
||||
cdpUrl: "http://127.0.0.1:18800",
|
||||
url: "http://127.0.0.1:8080",
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("closes excess managed tabs after opening a new tab", async () => {
|
||||
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" });
|
||||
const existingTabs = makeManagedTabsWithNew();
|
||||
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
const value = String(url);
|
||||
if (value.includes("/json/list")) {
|
||||
return { ok: true, json: async () => existingTabs } as unknown as Response;
|
||||
}
|
||||
if (value.includes("/json/close/OLD1")) {
|
||||
return { ok: true, json: async () => ({}) } as unknown as Response;
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${value}`);
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
(state.profiles as Map<string, unknown>).set("openclaw", {
|
||||
profile: { name: "openclaw" },
|
||||
running: { pid: 1234, proc: { on: vi.fn() } },
|
||||
lastTargetId: null,
|
||||
});
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
const opened = await openclaw.openTab("http://127.0.0.1:3009");
|
||||
expect(opened.targetId).toBe("NEW");
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/json/close/OLD1"),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("never closes the just-opened managed tab during cap cleanup", async () => {
|
||||
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" });
|
||||
const existingTabs = makeManagedTabsWithNew({ newFirst: true });
|
||||
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
const value = String(url);
|
||||
if (value.includes("/json/list")) {
|
||||
return { ok: true, json: async () => existingTabs } as unknown as Response;
|
||||
}
|
||||
if (value.includes("/json/close/OLD1")) {
|
||||
return { ok: true, json: async () => ({}) } as unknown as Response;
|
||||
}
|
||||
if (value.includes("/json/close/NEW")) {
|
||||
throw new Error("cleanup must not close NEW");
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${value}`);
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
(state.profiles as Map<string, unknown>).set("openclaw", {
|
||||
profile: { name: "openclaw" },
|
||||
running: { pid: 1234, proc: { on: vi.fn() } },
|
||||
lastTargetId: null,
|
||||
});
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
const opened = await openclaw.openTab("http://127.0.0.1:3009");
|
||||
expect(opened.targetId).toBe("NEW");
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/json/close/OLD1"),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("/json/close/NEW"),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fail tab open when managed-tab cleanup list fails", async () => {
|
||||
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" });
|
||||
|
||||
let listCount = 0;
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
const value = String(url);
|
||||
if (value.includes("/json/list")) {
|
||||
listCount += 1;
|
||||
if (listCount === 1) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
id: "NEW",
|
||||
title: "New Tab",
|
||||
url: "http://127.0.0.1:3009",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/NEW",
|
||||
type: "page",
|
||||
},
|
||||
],
|
||||
} as unknown as Response;
|
||||
}
|
||||
throw new Error("/json/list timeout");
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${value}`);
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
(state.profiles as Map<string, unknown>).set("openclaw", {
|
||||
profile: { name: "openclaw" },
|
||||
running: { pid: 1234, proc: { on: vi.fn() } },
|
||||
lastTargetId: null,
|
||||
});
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
const opened = await openclaw.openTab("http://127.0.0.1:3009");
|
||||
expect(opened.targetId).toBe("NEW");
|
||||
});
|
||||
|
||||
it("does not run managed tab cleanup in attachOnly mode", async () => {
|
||||
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" });
|
||||
const existingTabs = makeManagedTabsWithNew();
|
||||
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
const value = String(url);
|
||||
if (value.includes("/json/list")) {
|
||||
return { ok: true, json: async () => existingTabs } as unknown as Response;
|
||||
}
|
||||
if (value.includes("/json/close/")) {
|
||||
throw new Error("should not close tabs in attachOnly mode");
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${value}`);
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
state.resolved.attachOnly = true;
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
const opened = await openclaw.openTab("http://127.0.0.1:3009");
|
||||
expect(opened.targetId).toBe("NEW");
|
||||
expect(fetchMock).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("/json/close/"),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not block openTab on slow best-effort cleanup closes", async () => {
|
||||
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" });
|
||||
const existingTabs = makeManagedTabsWithNew();
|
||||
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
const value = String(url);
|
||||
if (value.includes("/json/list")) {
|
||||
return { ok: true, json: async () => existingTabs } as unknown as Response;
|
||||
}
|
||||
if (value.includes("/json/close/OLD1")) {
|
||||
return new Promise<Response>(() => {});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${value}`);
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
(state.profiles as Map<string, unknown>).set("openclaw", {
|
||||
profile: { name: "openclaw" },
|
||||
running: { pid: 1234, proc: { on: vi.fn() } },
|
||||
lastTargetId: null,
|
||||
});
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
const opened = await Promise.race([
|
||||
openclaw.openTab("http://127.0.0.1:3009"),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("openTab timed out waiting for cleanup")), 300),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(opened.targetId).toBe("NEW");
|
||||
});
|
||||
|
||||
it("blocks unsupported non-network URLs before any HTTP tab-open fallback", async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
throw new Error("unexpected fetch");
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
await expect(openclaw.openTab("file:///etc/passwd")).rejects.toBeInstanceOf(
|
||||
InvalidBrowserNavigationUrlError,
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
import "./server-context.tab-selection-state.suite.js";
|
||||
|
||||
Reference in New Issue
Block a user