From b51166e879a96c7cbaa139cdb8dbbf024d1765cb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Feb 2026 17:36:09 +0000 Subject: [PATCH] refactor(browser): share control lifecycle helpers --- src/browser/control-service.ts | 41 ++------- src/browser/server-lifecycle.test.ts | 123 +++++++++++++++++++++++++++ src/browser/server-lifecycle.ts | 48 +++++++++++ src/browser/server.ts | 44 ++-------- 4 files changed, 189 insertions(+), 67 deletions(-) create mode 100644 src/browser/server-lifecycle.test.ts create mode 100644 src/browser/server-lifecycle.ts diff --git a/src/browser/control-service.ts b/src/browser/control-service.ts index 55445fce60..031bc5e00c 100644 --- a/src/browser/control-service.ts +++ b/src/browser/control-service.ts @@ -1,13 +1,9 @@ import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { resolveBrowserConfig, resolveProfile } from "./config.js"; +import { resolveBrowserConfig } from "./config.js"; import { ensureBrowserControlAuth } from "./control-auth.js"; -import { ensureChromeExtensionRelayServer } from "./extension-relay.js"; -import { - type BrowserServerState, - createBrowserRouteContext, - listKnownProfileNames, -} from "./server-context.js"; +import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js"; +import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js"; let state: BrowserServerState | null = null; const log = createSubsystemLogger("browser"); @@ -50,17 +46,10 @@ export async function startBrowserControlServiceFromConfig(): Promise { - logService.warn(`Chrome extension relay init failed for profile "${name}": ${String(err)}`); - }); - } + await ensureExtensionRelayForProfiles({ + resolved, + onWarn: (message) => logService.warn(message), + }); logService.info( `Browser control service ready (profiles=${Object.keys(resolved.profiles).length})`, @@ -74,23 +63,11 @@ export async function stopBrowserControlService(): Promise { return; } - const ctx = createBrowserRouteContext({ + await stopKnownBrowserProfiles({ getState: () => state, - refreshConfigFromDisk: true, + onWarn: (message) => logService.warn(message), }); - try { - for (const name of listKnownProfileNames(current)) { - try { - await ctx.forProfile(name).stopRunningBrowser(); - } catch { - // ignore - } - } - } catch (err) { - logService.warn(`openclaw browser stop failed: ${String(err)}`); - } - state = null; // Optional: Playwright is not always available (e.g. embedded gateway builds). diff --git a/src/browser/server-lifecycle.test.ts b/src/browser/server-lifecycle.test.ts new file mode 100644 index 0000000000..a7e18630d8 --- /dev/null +++ b/src/browser/server-lifecycle.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { resolveProfileMock, ensureChromeExtensionRelayServerMock } = vi.hoisted(() => ({ + resolveProfileMock: vi.fn(), + ensureChromeExtensionRelayServerMock: vi.fn(), +})); + +const { createBrowserRouteContextMock, listKnownProfileNamesMock } = vi.hoisted(() => ({ + createBrowserRouteContextMock: vi.fn(), + listKnownProfileNamesMock: vi.fn(), +})); + +vi.mock("./config.js", () => ({ + resolveProfile: resolveProfileMock, +})); + +vi.mock("./extension-relay.js", () => ({ + ensureChromeExtensionRelayServer: ensureChromeExtensionRelayServerMock, +})); + +vi.mock("./server-context.js", () => ({ + createBrowserRouteContext: createBrowserRouteContextMock, + listKnownProfileNames: listKnownProfileNamesMock, +})); + +import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js"; + +describe("ensureExtensionRelayForProfiles", () => { + beforeEach(() => { + resolveProfileMock.mockReset(); + ensureChromeExtensionRelayServerMock.mockReset(); + }); + + it("starts relay only for extension profiles", async () => { + resolveProfileMock.mockImplementation((_resolved: unknown, name: string) => { + if (name === "chrome") { + return { driver: "extension", cdpUrl: "http://127.0.0.1:18888" }; + } + return { driver: "openclaw", cdpUrl: "http://127.0.0.1:18889" }; + }); + ensureChromeExtensionRelayServerMock.mockResolvedValue(undefined); + + await ensureExtensionRelayForProfiles({ + resolved: { + profiles: { + chrome: {}, + openclaw: {}, + }, + } as never, + onWarn: vi.fn(), + }); + + expect(ensureChromeExtensionRelayServerMock).toHaveBeenCalledTimes(1); + expect(ensureChromeExtensionRelayServerMock).toHaveBeenCalledWith({ + cdpUrl: "http://127.0.0.1:18888", + }); + }); + + it("reports relay startup errors", async () => { + resolveProfileMock.mockReturnValue({ driver: "extension", cdpUrl: "http://127.0.0.1:18888" }); + ensureChromeExtensionRelayServerMock.mockRejectedValue(new Error("boom")); + const onWarn = vi.fn(); + + await ensureExtensionRelayForProfiles({ + resolved: { profiles: { chrome: {} } } as never, + onWarn, + }); + + expect(onWarn).toHaveBeenCalledWith( + 'Chrome extension relay init failed for profile "chrome": Error: boom', + ); + }); +}); + +describe("stopKnownBrowserProfiles", () => { + beforeEach(() => { + createBrowserRouteContextMock.mockReset(); + listKnownProfileNamesMock.mockReset(); + }); + + it("stops all known profiles and ignores per-profile failures", async () => { + listKnownProfileNamesMock.mockReturnValue(["openclaw", "chrome"]); + const stopMap: Record> = { + openclaw: vi.fn(async () => {}), + chrome: vi.fn(async () => { + throw new Error("profile stop failed"); + }), + }; + createBrowserRouteContextMock.mockReturnValue({ + forProfile: (name: string) => ({ + stopRunningBrowser: stopMap[name], + }), + }); + const onWarn = vi.fn(); + const state = { resolved: { profiles: {} }, profiles: new Map() }; + + await stopKnownBrowserProfiles({ + getState: () => state as never, + onWarn, + }); + + expect(stopMap.openclaw).toHaveBeenCalledTimes(1); + expect(stopMap.chrome).toHaveBeenCalledTimes(1); + expect(onWarn).not.toHaveBeenCalled(); + }); + + it("warns when profile enumeration fails", async () => { + listKnownProfileNamesMock.mockImplementation(() => { + throw new Error("oops"); + }); + createBrowserRouteContextMock.mockReturnValue({ + forProfile: vi.fn(), + }); + const onWarn = vi.fn(); + + await stopKnownBrowserProfiles({ + getState: () => ({ resolved: { profiles: {} }, profiles: new Map() }) as never, + onWarn, + }); + + expect(onWarn).toHaveBeenCalledWith("openclaw browser stop failed: Error: oops"); + }); +}); diff --git a/src/browser/server-lifecycle.ts b/src/browser/server-lifecycle.ts new file mode 100644 index 0000000000..64d10cb7b9 --- /dev/null +++ b/src/browser/server-lifecycle.ts @@ -0,0 +1,48 @@ +import type { ResolvedBrowserConfig } from "./config.js"; +import { resolveProfile } from "./config.js"; +import { ensureChromeExtensionRelayServer } from "./extension-relay.js"; +import { + type BrowserServerState, + createBrowserRouteContext, + listKnownProfileNames, +} from "./server-context.js"; + +export async function ensureExtensionRelayForProfiles(params: { + resolved: ResolvedBrowserConfig; + onWarn: (message: string) => void; +}) { + for (const name of Object.keys(params.resolved.profiles)) { + const profile = resolveProfile(params.resolved, name); + if (!profile || profile.driver !== "extension") { + continue; + } + await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch((err) => { + params.onWarn(`Chrome extension relay init failed for profile "${name}": ${String(err)}`); + }); + } +} + +export async function stopKnownBrowserProfiles(params: { + getState: () => BrowserServerState | null; + onWarn: (message: string) => void; +}) { + const current = params.getState(); + if (!current) { + return; + } + const ctx = createBrowserRouteContext({ + getState: params.getState, + refreshConfigFromDisk: true, + }); + try { + for (const name of listKnownProfileNames(current)) { + try { + await ctx.forProfile(name).stopRunningBrowser(); + } catch { + // ignore + } + } + } catch (err) { + params.onWarn(`openclaw browser stop failed: ${String(err)}`); + } +} diff --git a/src/browser/server.ts b/src/browser/server.ts index 3cc8037068..60c5586384 100644 --- a/src/browser/server.ts +++ b/src/browser/server.ts @@ -2,17 +2,13 @@ import type { Server } from "node:http"; import express from "express"; import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { resolveBrowserConfig, resolveProfile } from "./config.js"; +import { resolveBrowserConfig } from "./config.js"; import { ensureBrowserControlAuth, resolveBrowserControlAuth } from "./control-auth.js"; -import { ensureChromeExtensionRelayServer } from "./extension-relay.js"; import { isPwAiLoaded } from "./pw-ai-state.js"; import { registerBrowserRoutes } from "./routes/index.js"; import type { BrowserRouteRegistrar } from "./routes/types.js"; -import { - type BrowserServerState, - createBrowserRouteContext, - listKnownProfileNames, -} from "./server-context.js"; +import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js"; +import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js"; import { installBrowserAuthMiddleware, installBrowserCommonMiddleware, @@ -74,17 +70,10 @@ export async function startBrowserControlServerFromConfig(): Promise { - logServer.warn(`Chrome extension relay init failed for profile "${name}": ${String(err)}`); - }); - } + await ensureExtensionRelayForProfiles({ + resolved, + onWarn: (message) => logServer.warn(message), + }); const authMode = browserAuth.token ? "token" : browserAuth.password ? "password" : "off"; logServer.info(`Browser control listening on http://127.0.0.1:${port}/ (auth=${authMode})`); @@ -97,26 +86,11 @@ export async function stopBrowserControlServer(): Promise { return; } - const ctx = createBrowserRouteContext({ + await stopKnownBrowserProfiles({ getState: () => state, - refreshConfigFromDisk: true, + onWarn: (message) => logServer.warn(message), }); - try { - const current = state; - if (current) { - for (const name of listKnownProfileNames(current)) { - try { - await ctx.forProfile(name).stopRunningBrowser(); - } catch { - // ignore - } - } - } - } catch (err) { - logServer.warn(`openclaw browser stop failed: ${String(err)}`); - } - if (current.server) { await new Promise((resolve) => { current.server?.close(() => resolve());