browser: reconnect remote websocket tab routes lazily
This commit is contained in:
parent
74177d480e
commit
3c31992784
@ -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.
|
||||
|
||||
194
src/browser/routes/tabs.test.ts
Normal file
194
src/browser/routes/tabs.test.ts
Normal file
@ -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>): 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> = {}): 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<string, BrowserRouteHandler>();
|
||||
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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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[] });
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user