diff --git a/extensions/browser/src/browser/server-context.remote-profile-tab-ops.suite.ts b/extensions/browser/src/browser/server-context.remote-profile-tab-ops.suite.ts deleted file mode 100644 index c9a7fa34216..00000000000 --- a/extensions/browser/src/browser/server-context.remote-profile-tab-ops.suite.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; - -const originalFetch = globalThis.fetch; - -let chromeModule: typeof import("./chrome.js"); -let InvalidBrowserNavigationUrlError: typeof import("./navigation-guard.js").InvalidBrowserNavigationUrlError; -let pwAiModule: typeof import("./pw-ai-module.js"); -let closePlaywrightBrowserConnection: typeof import("./pw-session.js").closePlaywrightBrowserConnection; -let createBrowserRouteContext: typeof import("./server-context.js").createBrowserRouteContext; -let createJsonListFetchMock: typeof import("./server-context.remote-tab-ops.harness.js").createJsonListFetchMock; -let createRemoteRouteHarness: typeof import("./server-context.remote-tab-ops.harness.js").createRemoteRouteHarness; -let createSequentialPageLister: typeof import("./server-context.remote-tab-ops.harness.js").createSequentialPageLister; -let makeState: typeof import("./server-context.remote-tab-ops.harness.js").makeState; - -beforeAll(async () => { - vi.resetModules(); - await import("./server-context.chrome-test-harness.js"); - chromeModule = await import("./chrome.js"); - ({ InvalidBrowserNavigationUrlError } = await import("./navigation-guard.js")); - pwAiModule = await import("./pw-ai-module.js"); - ({ closePlaywrightBrowserConnection } = await import("./pw-session.js")); - ({ createBrowserRouteContext } = await import("./server-context.js")); - ({ createJsonListFetchMock, createRemoteRouteHarness, createSequentialPageLister, makeState } = - await import("./server-context.remote-tab-ops.harness.js")); -}); - -beforeEach(() => { - vi.clearAllMocks(); - globalThis.fetch = originalFetch; -}); - -afterEach(async () => { - await closePlaywrightBrowserConnection().catch(() => {}); - 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>); - - 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>); - - const { remote } = createRemoteRouteHarness(); - - const first = await remote.ensureTabAvailable(); - expect(first.targetId).toBe("A"); - const second = await remote.ensureTabAvailable(); - expect(second.targetId).toBe("A"); - }); - - it("rejects stale targetId for remote profiles even when only one tab remains", 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>); - - const { remote } = createRemoteRouteHarness(); - await expect(remote.ensureTabAvailable("STALE_TARGET")).rejects.toThrow(/tab not found/i); - }); - - 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>); - - 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>); - - 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>); - - 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("fails closed for remote tab opens in strict mode without Playwright", async () => { - vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue(null); - const { state, remote, fetchMock } = createRemoteRouteHarness(); - state.resolved.ssrfPolicy = {}; - - await expect(remote.openTab("https://example.com")).rejects.toBeInstanceOf( - InvalidBrowserNavigationUrlError, - ); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - 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>); - - 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(); - }); -}); diff --git a/extensions/browser/src/browser/server-context.remote-profile-tab-ops.test.ts b/extensions/browser/src/browser/server-context.remote-profile-tab-ops.test.ts index 2d4b563e0ad..c9a7fa34216 100644 --- a/extensions/browser/src/browser/server-context.remote-profile-tab-ops.test.ts +++ b/extensions/browser/src/browser/server-context.remote-profile-tab-ops.test.ts @@ -1 +1,302 @@ -import "./server-context.remote-profile-tab-ops.suite.js"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const originalFetch = globalThis.fetch; + +let chromeModule: typeof import("./chrome.js"); +let InvalidBrowserNavigationUrlError: typeof import("./navigation-guard.js").InvalidBrowserNavigationUrlError; +let pwAiModule: typeof import("./pw-ai-module.js"); +let closePlaywrightBrowserConnection: typeof import("./pw-session.js").closePlaywrightBrowserConnection; +let createBrowserRouteContext: typeof import("./server-context.js").createBrowserRouteContext; +let createJsonListFetchMock: typeof import("./server-context.remote-tab-ops.harness.js").createJsonListFetchMock; +let createRemoteRouteHarness: typeof import("./server-context.remote-tab-ops.harness.js").createRemoteRouteHarness; +let createSequentialPageLister: typeof import("./server-context.remote-tab-ops.harness.js").createSequentialPageLister; +let makeState: typeof import("./server-context.remote-tab-ops.harness.js").makeState; + +beforeAll(async () => { + vi.resetModules(); + await import("./server-context.chrome-test-harness.js"); + chromeModule = await import("./chrome.js"); + ({ InvalidBrowserNavigationUrlError } = await import("./navigation-guard.js")); + pwAiModule = await import("./pw-ai-module.js"); + ({ closePlaywrightBrowserConnection } = await import("./pw-session.js")); + ({ createBrowserRouteContext } = await import("./server-context.js")); + ({ createJsonListFetchMock, createRemoteRouteHarness, createSequentialPageLister, makeState } = + await import("./server-context.remote-tab-ops.harness.js")); +}); + +beforeEach(() => { + vi.clearAllMocks(); + globalThis.fetch = originalFetch; +}); + +afterEach(async () => { + await closePlaywrightBrowserConnection().catch(() => {}); + 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>); + + 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>); + + const { remote } = createRemoteRouteHarness(); + + const first = await remote.ensureTabAvailable(); + expect(first.targetId).toBe("A"); + const second = await remote.ensureTabAvailable(); + expect(second.targetId).toBe("A"); + }); + + it("rejects stale targetId for remote profiles even when only one tab remains", 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>); + + const { remote } = createRemoteRouteHarness(); + await expect(remote.ensureTabAvailable("STALE_TARGET")).rejects.toThrow(/tab not found/i); + }); + + 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>); + + 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>); + + 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>); + + 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("fails closed for remote tab opens in strict mode without Playwright", async () => { + vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue(null); + const { state, remote, fetchMock } = createRemoteRouteHarness(); + state.resolved.ssrfPolicy = {}; + + await expect(remote.openTab("https://example.com")).rejects.toBeInstanceOf( + InvalidBrowserNavigationUrlError, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + 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>); + + 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(); + }); +}); diff --git a/extensions/browser/src/browser/server-context.tab-selection-state.suite.ts b/extensions/browser/src/browser/server-context.tab-selection-state.suite.ts deleted file mode 100644 index 12f42c68f22..00000000000 --- a/extensions/browser/src/browser/server-context.tab-selection-state.suite.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { withFetchPreconnect } from "../../test-support.js"; - -vi.hoisted(() => { - vi.resetModules(); -}); - -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(async () => { - const { closePlaywrightBrowserConnection } = await import("./pw-session.js"); - await closePlaywrightBrowserConnection().catch(() => {}); - globalThis.fetch = originalFetch; - vi.restoreAllMocks(); -}); - -function seedRunningProfileState( - state: ReturnType, - profileName = "openclaw", -): void { - (state.profiles as Map).set(profileName, { - profile: { name: profileName }, - running: { pid: 1234, proc: { on: vi.fn() } }, - lastTargetId: null, - }); -} - -async function expectOldManagedTabClose(fetchMock: ReturnType): Promise { - await vi.waitFor(() => { - expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining("/json/close/OLD1"), - expect.any(Object), - ); - }); -} - -function createOldTabCleanupFetchMock( - existingTabs: ReturnType, - params?: { rejectNewTabClose?: boolean }, -): ReturnType { - 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}`); - }); -} - -function createManagedTabListFetchMock(params: { - existingTabs: ReturnType; - onClose: (url: string) => Response | Promise; -}): ReturnType { - return vi.fn(async (url: unknown) => { - const value = String(url); - if (value.includes("/json/list")) { - return { ok: true, json: async () => params.existingTabs } as unknown as Response; - } - if (value.includes("/json/close/")) { - return await params.onClose(value); - } - throw new Error(`unexpected fetch: ${value}`); - }); -} - -async function openManagedTabWithRunningProfile(params: { - fetchMock: ReturnType; - url?: string; -}) { - global.fetch = withFetchPreconnect(params.fetchMock); - const state = makeState("openclaw"); - seedRunningProfileState(state); - const ctx = createBrowserRouteContext({ getState: () => state }); - const openclaw = ctx.forProfile("openclaw"); - return await openclaw.openTab(params.url ?? "http://127.0.0.1:3009"); -} - -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); - - const opened = await openManagedTabWithRunningProfile({ fetchMock }); - 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 }); - - const opened = await openManagedTabWithRunningProfile({ fetchMock }); - 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 = createManagedTabListFetchMock({ - existingTabs, - onClose: () => { - throw new Error("should not close tabs in attachOnly mode"); - }, - }); - - 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 = createManagedTabListFetchMock({ - existingTabs, - onClose: (url) => { - if (url.includes("/json/close/OLD1")) { - return new Promise(() => {}); - } - throw new Error(`unexpected fetch: ${url}`); - }, - }); - - const opened = await Promise.race([ - openManagedTabWithRunningProfile({ fetchMock }), - new Promise((_, 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(); - }); -}); diff --git a/extensions/browser/src/browser/server-context.tab-selection-state.test.ts b/extensions/browser/src/browser/server-context.tab-selection-state.test.ts index edf81068246..12f42c68f22 100644 --- a/extensions/browser/src/browser/server-context.tab-selection-state.test.ts +++ b/extensions/browser/src/browser/server-context.tab-selection-state.test.ts @@ -1 +1,255 @@ -import "./server-context.tab-selection-state.suite.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { withFetchPreconnect } from "../../test-support.js"; + +vi.hoisted(() => { + vi.resetModules(); +}); + +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(async () => { + const { closePlaywrightBrowserConnection } = await import("./pw-session.js"); + await closePlaywrightBrowserConnection().catch(() => {}); + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +function seedRunningProfileState( + state: ReturnType, + profileName = "openclaw", +): void { + (state.profiles as Map).set(profileName, { + profile: { name: profileName }, + running: { pid: 1234, proc: { on: vi.fn() } }, + lastTargetId: null, + }); +} + +async function expectOldManagedTabClose(fetchMock: ReturnType): Promise { + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/json/close/OLD1"), + expect.any(Object), + ); + }); +} + +function createOldTabCleanupFetchMock( + existingTabs: ReturnType, + params?: { rejectNewTabClose?: boolean }, +): ReturnType { + 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}`); + }); +} + +function createManagedTabListFetchMock(params: { + existingTabs: ReturnType; + onClose: (url: string) => Response | Promise; +}): ReturnType { + return vi.fn(async (url: unknown) => { + const value = String(url); + if (value.includes("/json/list")) { + return { ok: true, json: async () => params.existingTabs } as unknown as Response; + } + if (value.includes("/json/close/")) { + return await params.onClose(value); + } + throw new Error(`unexpected fetch: ${value}`); + }); +} + +async function openManagedTabWithRunningProfile(params: { + fetchMock: ReturnType; + url?: string; +}) { + global.fetch = withFetchPreconnect(params.fetchMock); + const state = makeState("openclaw"); + seedRunningProfileState(state); + const ctx = createBrowserRouteContext({ getState: () => state }); + const openclaw = ctx.forProfile("openclaw"); + return await openclaw.openTab(params.url ?? "http://127.0.0.1:3009"); +} + +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); + + const opened = await openManagedTabWithRunningProfile({ fetchMock }); + 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 }); + + const opened = await openManagedTabWithRunningProfile({ fetchMock }); + 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 = createManagedTabListFetchMock({ + existingTabs, + onClose: () => { + throw new Error("should not close tabs in attachOnly mode"); + }, + }); + + 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 = createManagedTabListFetchMock({ + existingTabs, + onClose: (url) => { + if (url.includes("/json/close/OLD1")) { + return new Promise(() => {}); + } + throw new Error(`unexpected fetch: ${url}`); + }, + }); + + const opened = await Promise.race([ + openManagedTabWithRunningProfile({ fetchMock }), + new Promise((_, 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(); + }); +});