import { beforeEach, describe, expect, it, vi } from "vitest"; const controlServiceMocks = vi.hoisted(() => ({ createBrowserControlContext: vi.fn(() => ({ control: true })), startBrowserControlServiceFromConfig: vi.fn(async () => true), })); const dispatcherMocks = vi.hoisted(() => ({ dispatch: vi.fn(), createBrowserRouteDispatcher: vi.fn(() => ({ dispatch: dispatcherMocks.dispatch, })), })); const configMocks = vi.hoisted(() => ({ loadConfig: vi.fn(() => ({ browser: {}, nodeHost: { browserProxy: { enabled: true, allowProfiles: [] as string[] } }, })), })); const browserConfigMocks = vi.hoisted(() => ({ resolveBrowserConfig: vi.fn(() => ({ enabled: true, defaultProfile: "openclaw", })), })); vi.mock("../browser/control-service.js", () => controlServiceMocks); vi.mock("../browser/routes/dispatcher.js", () => dispatcherMocks); vi.mock("../config/config.js", () => configMocks); vi.mock("../browser/config.js", () => browserConfigMocks); vi.mock("../media/mime.js", () => ({ detectMime: vi.fn(async () => "image/png"), })); let runBrowserProxyCommand: typeof import("./invoke-browser.js").runBrowserProxyCommand; describe("runBrowserProxyCommand", () => { beforeEach(async () => { // No-isolate runs can reuse a cached invoke-browser module that was loaded // via node-host entrypoints before this file's mocks were declared. vi.useRealTimers(); vi.resetModules(); dispatcherMocks.dispatch.mockReset(); dispatcherMocks.createBrowserRouteDispatcher.mockReset().mockImplementation(() => ({ dispatch: dispatcherMocks.dispatch, })); controlServiceMocks.createBrowserControlContext.mockReset().mockReturnValue({ control: true }); controlServiceMocks.startBrowserControlServiceFromConfig.mockReset().mockResolvedValue(true); configMocks.loadConfig.mockReset().mockReturnValue({ browser: {}, nodeHost: { browserProxy: { enabled: true, allowProfiles: [] as string[] } }, }); browserConfigMocks.resolveBrowserConfig.mockReset().mockReturnValue({ enabled: true, defaultProfile: "openclaw", }); ({ runBrowserProxyCommand } = await import("./invoke-browser.js")); configMocks.loadConfig.mockReturnValue({ browser: {}, nodeHost: { browserProxy: { enabled: true, allowProfiles: [] as string[] } }, }); browserConfigMocks.resolveBrowserConfig.mockReturnValue({ enabled: true, defaultProfile: "openclaw", }); controlServiceMocks.startBrowserControlServiceFromConfig.mockResolvedValue(true); }); it("adds profile and browser status details on ws-backed timeouts", async () => { vi.useFakeTimers(); dispatcherMocks.dispatch .mockImplementationOnce(async () => { await new Promise(() => {}); }) .mockResolvedValueOnce({ status: 200, body: { running: true, cdpHttp: true, cdpReady: false, cdpUrl: "http://127.0.0.1:18792", }, }); const result = expect( runBrowserProxyCommand( JSON.stringify({ method: "GET", path: "/snapshot", profile: "openclaw", timeoutMs: 5, }), ), ).rejects.toThrow( /browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=openclaw; status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=http:\/\/127\.0\.0\.1:18792\)/, ); await vi.advanceTimersByTimeAsync(10); await result; }); it("includes chrome-mcp transport in timeout diagnostics when no CDP URL exists", async () => { vi.useFakeTimers(); dispatcherMocks.dispatch .mockImplementationOnce(async () => { await new Promise(() => {}); }) .mockResolvedValueOnce({ status: 200, body: { running: true, transport: "chrome-mcp", cdpHttp: true, cdpReady: false, cdpUrl: null, }, }); const result = expect( runBrowserProxyCommand( JSON.stringify({ method: "GET", path: "/snapshot", profile: "user", timeoutMs: 5, }), ), ).rejects.toThrow( /browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=user; status\(running=true, cdpHttp=true, cdpReady=false, transport=chrome-mcp\)/, ); await vi.advanceTimersByTimeAsync(10); await result; }); it("redacts sensitive cdpUrl details in timeout diagnostics", async () => { vi.useFakeTimers(); dispatcherMocks.dispatch .mockImplementationOnce(async () => { await new Promise(() => {}); }) .mockResolvedValueOnce({ status: 200, body: { running: true, cdpHttp: true, cdpReady: false, cdpUrl: "https://alice:supersecretpasswordvalue1234@example.com/chrome?token=supersecrettokenvalue1234567890", }, }); const result = expect( runBrowserProxyCommand( JSON.stringify({ method: "GET", path: "/snapshot", profile: "remote", timeoutMs: 5, }), ), ).rejects.toThrow( /status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=https:\/\/example\.com\/chrome\?token=supers…7890\)/, ); await vi.advanceTimersByTimeAsync(10); await result; }); it("keeps non-timeout browser errors intact", async () => { dispatcherMocks.dispatch.mockResolvedValue({ status: 500, body: { error: "tab not found" }, }); await expect( runBrowserProxyCommand( JSON.stringify({ method: "POST", path: "/act", profile: "openclaw", timeoutMs: 50, }), ), ).rejects.toThrow("tab not found"); }); it("rejects unauthorized query.profile when allowProfiles is configured", async () => { configMocks.loadConfig.mockReturnValue({ browser: {}, nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } }, }); await expect( runBrowserProxyCommand( JSON.stringify({ method: "GET", path: "/snapshot", query: { profile: "user" }, timeoutMs: 50, }), ), ).rejects.toThrow("INVALID_REQUEST: browser profile not allowed"); expect(dispatcherMocks.dispatch).not.toHaveBeenCalled(); }); it("rejects unauthorized body.profile when allowProfiles is configured", async () => { configMocks.loadConfig.mockReturnValue({ browser: {}, nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } }, }); await expect( runBrowserProxyCommand( JSON.stringify({ method: "POST", path: "/stop", body: { profile: "user" }, timeoutMs: 50, }), ), ).rejects.toThrow("INVALID_REQUEST: browser profile not allowed"); expect(dispatcherMocks.dispatch).not.toHaveBeenCalled(); }); it("rejects persistent profile creation when allowProfiles is configured", async () => { configMocks.loadConfig.mockReturnValue({ browser: {}, nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } }, }); await expect( runBrowserProxyCommand( JSON.stringify({ method: "POST", path: "/profiles/create", body: { name: "poc", cdpUrl: "http://127.0.0.1:9222" }, timeoutMs: 50, }), ), ).rejects.toThrow( "INVALID_REQUEST: browser.proxy cannot create or delete persistent browser profiles when allowProfiles is configured", ); expect(dispatcherMocks.dispatch).not.toHaveBeenCalled(); }); it("rejects persistent profile deletion when allowProfiles is configured", async () => { configMocks.loadConfig.mockReturnValue({ browser: {}, nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } }, }); await expect( runBrowserProxyCommand( JSON.stringify({ method: "DELETE", path: "/profiles/poc", timeoutMs: 50, }), ), ).rejects.toThrow( "INVALID_REQUEST: browser.proxy cannot create or delete persistent browser profiles when allowProfiles is configured", ); expect(dispatcherMocks.dispatch).not.toHaveBeenCalled(); }); it("canonicalizes an allowlisted body profile into the dispatched query", async () => { configMocks.loadConfig.mockReturnValue({ browser: {}, nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } }, }); dispatcherMocks.dispatch.mockResolvedValue({ status: 200, body: { ok: true }, }); await runBrowserProxyCommand( JSON.stringify({ method: "POST", path: "/stop", body: { profile: "openclaw" }, timeoutMs: 50, }), ); expect(dispatcherMocks.dispatch).toHaveBeenCalledWith( expect.objectContaining({ path: "/stop", query: { profile: "openclaw" }, }), ); }); it("preserves legacy proxy behavior when allowProfiles is empty", async () => { dispatcherMocks.dispatch.mockResolvedValue({ status: 200, body: { ok: true }, }); await runBrowserProxyCommand( JSON.stringify({ method: "POST", path: "/profiles/create", body: { name: "poc", cdpUrl: "http://127.0.0.1:9222" }, timeoutMs: 50, }), ); expect(dispatcherMocks.dispatch).toHaveBeenCalledWith( expect.objectContaining({ method: "POST", path: "/profiles/create", body: { name: "poc", cdpUrl: "http://127.0.0.1:9222" }, }), ); }); });