diff --git a/docs/tools/browser.md b/docs/tools/browser.md index ae66d46409d..d1727c5e533 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -219,8 +219,8 @@ Set up a [Browser Use](https://cloud.browser-use.com) cloud browser profile with browser: { enabled: true, defaultProfile: "browseruse", - remoteCdpTimeoutMs: 5000, - remoteCdpHandshakeTimeoutMs: 8000, + remoteCdpTimeoutMs: 3000, + remoteCdpHandshakeTimeoutMs: 5000, profiles: { browseruse: { // All Browser Use session params can be added as query params. diff --git a/src/browser/routes/tabs.test.ts b/src/browser/routes/tabs.test.ts new file mode 100644 index 00000000000..e009f3c580f --- /dev/null +++ b/src/browser/routes/tabs.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ResolvedBrowserProfile } from "../config.js"; +import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; +import { registerBrowserTabRoutes } from "./tabs.js"; +import type { + BrowserRequest, + BrowserResponse, + BrowserRouteHandler, + BrowserRouteRegistrar, +} from "./types.js"; + +function makeProfile(overrides: Partial): ResolvedBrowserProfile { + return { + name: "remote", + cdpPort: 443, + cdpUrl: "wss://connect.browser-use.com", + cdpHost: "connect.browser-use.com", + cdpIsLoopback: false, + color: "#00AA00", + driver: "openclaw", + attachOnly: false, + ...overrides, + }; +} + +function makeProfileContext(overrides: Partial = {}): ProfileContext { + return { + profile: makeProfile({}), + ensureBrowserAvailable: vi.fn(async () => {}), + ensureTabAvailable: vi.fn(async () => ({ + targetId: "T1", + title: "Tab 1", + url: "https://example.com", + type: "page", + })), + isHttpReachable: vi.fn(async () => false), + isReachable: vi.fn(async () => false), + listTabs: vi.fn(async () => []), + openTab: vi.fn(async () => ({ + targetId: "T1", + title: "Tab 1", + url: "https://example.com", + type: "page", + })), + focusTab: vi.fn(async () => {}), + closeTab: vi.fn(async () => {}), + stopRunningBrowser: vi.fn(async () => ({ stopped: false })), + resetProfile: vi.fn(async () => ({ moved: false, from: "/tmp/profile" })), + ...overrides, + }; +} + +function createRegistrar() { + const routes = new Map(); + const registrar: BrowserRouteRegistrar = { + get: (path, handler) => void routes.set(`GET ${path}`, handler), + post: (path, handler) => void routes.set(`POST ${path}`, handler), + delete: (path, handler) => void routes.set(`DELETE ${path}`, handler), + }; + return { routes, registrar }; +} + +function makeResponse() { + const result: { statusCode: number; body: unknown } = { statusCode: 200, body: null }; + const res: BrowserResponse = { + status: (code) => { + result.statusCode = code; + return res; + }, + json: (body) => { + result.body = body; + }, + }; + return { res, result }; +} + +function makeContext(profileCtx: ProfileContext): BrowserRouteContext { + return { + state: vi.fn(), + forProfile: vi.fn(() => profileCtx), + listProfiles: vi.fn(async () => []), + mapTabError: vi.fn(() => null), + ensureBrowserAvailable: vi.fn(async () => {}), + ensureTabAvailable: vi.fn(async () => ({ + targetId: "T1", + title: "Tab 1", + url: "https://example.com", + type: "page", + })), + isHttpReachable: vi.fn(async () => false), + isReachable: vi.fn(async () => false), + listTabs: vi.fn(async () => []), + openTab: vi.fn(async () => ({ + targetId: "T1", + title: "Tab 1", + url: "https://example.com", + type: "page", + })), + focusTab: vi.fn(async () => {}), + closeTab: vi.fn(async () => {}), + stopRunningBrowser: vi.fn(async () => ({ stopped: false })), + resetProfile: vi.fn(async () => ({ moved: false, from: "/tmp/profile" })), + }; +} + +describe("browser tab routes", () => { + it("lists tabs for remote websocket profiles without requiring a cached connection", async () => { + const listTabs = vi.fn(async () => [ + { targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }, + ]); + const profileCtx = makeProfileContext({ listTabs }); + const ctx = makeContext(profileCtx); + const { routes, registrar } = createRegistrar(); + registerBrowserTabRoutes(registrar, ctx); + + const handler = routes.get("GET /tabs"); + expect(handler).toBeTypeOf("function"); + + const { res, result } = makeResponse(); + await handler!( + { + params: {}, + query: {}, + } satisfies BrowserRequest, + res, + ); + + expect(profileCtx.isReachable).not.toHaveBeenCalled(); + expect(listTabs).toHaveBeenCalledTimes(1); + expect(result.body).toEqual({ + running: true, + tabs: [{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }], + }); + }); + + it("focuses tabs for remote websocket profiles without the browser-not-running preflight", async () => { + const focusTab = vi.fn(async () => {}); + const listTabs = vi.fn(async () => [ + { targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }, + ]); + const profileCtx = makeProfileContext({ listTabs, focusTab }); + const ctx = makeContext(profileCtx); + const { routes, registrar } = createRegistrar(); + registerBrowserTabRoutes(registrar, ctx); + + const handler = routes.get("POST /tabs/focus"); + expect(handler).toBeTypeOf("function"); + + const { res, result } = makeResponse(); + await handler!( + { + params: {}, + query: {}, + body: { targetId: "T1" }, + } satisfies BrowserRequest, + res, + ); + + expect(profileCtx.isReachable).not.toHaveBeenCalled(); + expect(listTabs).toHaveBeenCalledTimes(1); + expect(focusTab).toHaveBeenCalledWith("T1"); + expect(result.body).toEqual({ ok: true }); + }); + + it("lists tabs via action=list for remote websocket profiles without requiring a cached connection", async () => { + const listTabs = vi.fn(async () => [ + { targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }, + ]); + const profileCtx = makeProfileContext({ listTabs }); + const ctx = makeContext(profileCtx); + const { routes, registrar } = createRegistrar(); + registerBrowserTabRoutes(registrar, ctx); + + const handler = routes.get("POST /tabs/action"); + expect(handler).toBeTypeOf("function"); + + const { res, result } = makeResponse(); + await handler!( + { + params: {}, + query: {}, + body: { action: "list" }, + } satisfies BrowserRequest, + res, + ); + + expect(profileCtx.isReachable).not.toHaveBeenCalled(); + expect(listTabs).toHaveBeenCalledTimes(1); + expect(result.body).toEqual({ + ok: true, + tabs: [{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }], + }); + }); +}); diff --git a/src/browser/routes/tabs.ts b/src/browser/routes/tabs.ts index 87cb36c562c..5b3be7ea93c 100644 --- a/src/browser/routes/tabs.ts +++ b/src/browser/routes/tabs.ts @@ -1,3 +1,4 @@ +import { isWebSocketUrl } from "../cdp.helpers.js"; import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "../errors.js"; import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js"; @@ -49,7 +50,25 @@ async function withTabsProfileRoute(params: { } } +function usesLazyRemoteWebSocketReconnect(profileCtx: ProfileContext) { + return !profileCtx.profile.cdpIsLoopback && isWebSocketUrl(profileCtx.profile.cdpUrl); +} + async function ensureBrowserRunning(profileCtx: ProfileContext, res: BrowserResponse) { + if (usesLazyRemoteWebSocketReconnect(profileCtx)) { + try { + // Remote WebSocket profiles reconnect on demand through the Playwright-backed tab ops. + await profileCtx.listTabs(); + return true; + } catch { + jsonError( + res, + new BrowserProfileUnavailableError("browser not running").status, + "browser not running", + ); + return false; + } + } if (!(await profileCtx.isReachable(300))) { jsonError( res, @@ -106,6 +125,14 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse res, ctx, run: async (profileCtx) => { + if (usesLazyRemoteWebSocketReconnect(profileCtx)) { + try { + const tabs = await profileCtx.listTabs(); + return res.json({ running: true, tabs }); + } catch { + return res.json({ running: false, tabs: [] as unknown[] }); + } + } const reachable = await profileCtx.isReachable(300); if (!reachable) { return res.json({ running: false, tabs: [] as unknown[] }); @@ -178,6 +205,14 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse mapTabError: true, run: async (profileCtx) => { if (action === "list") { + if (usesLazyRemoteWebSocketReconnect(profileCtx)) { + try { + const tabs = await profileCtx.listTabs(); + return res.json({ ok: true, tabs }); + } catch { + return res.json({ ok: true, tabs: [] as unknown[] }); + } + } const reachable = await profileCtx.isReachable(300); if (!reachable) { return res.json({ ok: true, tabs: [] as unknown[] });