From 7ba1c17ef98b0cac62a880cbe95569bfb5ca65e2 Mon Sep 17 00:00:00 2001 From: hnbdr Date: Thu, 10 Sep 2026 20:27:27 +0300 Subject: [PATCH 1/6] feat(webview): add dev-only browser bridge for standalone Chrome UI Runs the Zoo Code interface as the main page of a regular Chrome tab instead of a nested VSCode webview iframe. Inside the iframe the UI shares one DevTools panel with the rest of the window and cannot load DevTools extensions, which makes inspection awkward. As a top-level document the UI gets its own DevTools with installable extensions (React DevTools, etc.), and the extension IPC becomes plain socket.io traffic that can be monitored live in the DevTools network panel as websocket frames. - Add BrowserBridgeServer: a loopback-only socket.io server owned by each ClineProvider, with a virtual webview that keeps the existing WebviewMessage/ExtensionMessage protocol unchanged. - Register a dev-only `zoo-code.openInBrowser` command (activated only with ROO_BROWSER_BRIDGE=1 in Development mode; intentionally not contributed in package.json so end users never see it) that starts the bridge, swaps the real webview for a placeholder with a link, and opens the browser tab. - webview-ui: BrowserBridgeClient in VSCodeAPIWrapper activates when the tab is opened with ?bridgePort=..., plus a dark-theme fallback for --vscode-* CSS variables in standalone browser mode. --- .vscode/launch.json | 5 +- pnpm-lock.yaml | 137 +++++++++ .../__tests__/registerCommands.spec.ts | 135 ++++++++- src/activate/registerCommands.ts | 54 ++++ src/core/webview/ClineProvider.ts | 94 +++++- .../webview/__tests__/ClineProvider.spec.ts | 66 +++++ .../webview/__tests__/browserBridge.spec.ts | 156 ++++++++++ src/core/webview/browserBridge.ts | 272 ++++++++++++++++++ src/package.json | 2 + webview-ui/package.json | 1 + webview-ui/src/index.css | 178 ++++++++++++ webview-ui/src/utils/vscode.ts | 73 ++++- 12 files changed, 1159 insertions(+), 14 deletions(-) create mode 100644 src/core/webview/__tests__/browserBridge.spec.ts create mode 100644 src/core/webview/browserBridge.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 5f023be65b..145dce808e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -16,7 +16,10 @@ "preLaunchTask": "${defaultBuildTask}", "env": { "NODE_ENV": "development", - "VSCODE_DEBUG_MODE": "true" + "VSCODE_DEBUG_MODE": "true", + // Dev-only: registers the `zoo-code.openInBrowser` command (socket.io + // browser bridge for render debugging). See src/activate/registerCommands.ts. + "ROO_BROWSER_BRIDGE": "1" }, "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"], "presentation": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47f526185f..70a2fad932 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -595,6 +595,9 @@ importers: simple-git: specifier: ^3.27.0 version: 3.36.0 + socket.io: + specifier: ^4.8.3 + version: 4.8.3 strip-bom: specifier: ^5.0.0 version: 5.0.0 @@ -692,6 +695,9 @@ importers: rimraf: specifier: 6.0.1 version: 6.0.1 + socket.io-client: + specifier: 4.8.3 + version: 4.8.3 vitest: specifier: 4.1.9 version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -860,6 +866,9 @@ importers: shiki: specifier: ^3.2.1 version: 3.4.1 + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 source-map: specifier: ^0.7.4 version: 0.7.6 @@ -3099,6 +3108,9 @@ packages: resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==} engines: {node: '>=18.0.0'} + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} @@ -3340,6 +3352,9 @@ packages: '@types/clone-deep@4.0.4': resolution: {integrity: sha512-vXh6JuuaAha6sqEbJueYdh5zNBPPgG1OYumuz2UvLvriN6ABHDSW8ludREGWJb1MLIzbwZn4q4zUbUCerJTJfA==} + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -3569,6 +3584,9 @@ packages: '@types/vscode@1.100.0': resolution: {integrity: sha512-4uNyvzHoraXEeCamR3+fzcBlh7Afs4Ifjs4epINyUX/jvdk0uzLnwiDY35UKDKnkCHP5Nu3dljl2H8lR6s+rQw==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -4072,6 +4090,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -4895,6 +4917,17 @@ packages: end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + engine.io-client@6.6.6: + resolution: {integrity: sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.10: + resolution: {integrity: sha512-9/lX2bdlizlCXMHRMOIm03VBQHQYC7VvydcxtTAUJRxNW1QzM/2PMFSmr6h/lCiMHcyCP6abK+t9Q+j4vekk8Q==} + engines: {node: '>=10.2.0'} + enhanced-resolve@5.21.6: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} @@ -7854,6 +7887,21 @@ packages: resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} engines: {node: '>= 18'} + socket.io-adapter@2.5.8: + resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==} + + socket.io-client@4.8.3: + resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.7: + resolution: {integrity: sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.3: + resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} + engines: {node: '>=10.2.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -8826,6 +8874,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -11297,6 +11349,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@socket.io/component-emitter@3.1.2': {} + '@stablelib/base64@1.0.1': {} '@standard-schema/spec@1.1.0': {} @@ -11572,6 +11626,10 @@ snapshots: '@types/clone-deep@4.0.4': {} + '@types/cors@2.8.19': + dependencies: + '@types/node': 22.20.1 + '@types/d3-array@3.2.2': {} '@types/d3-axis@3.0.6': @@ -11812,6 +11870,10 @@ snapshots: '@types/vscode@1.100.0': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.20.1 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.33': @@ -12428,6 +12490,8 @@ snapshots: base64-js@1.5.1: {} + base64id@2.0.0: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 @@ -13237,6 +13301,36 @@ snapshots: dependencies: once: 1.4.0 + engine.io-client@6.6.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3(supports-color@8.1.1) + engine.io-parser: 5.2.3 + ws: 8.21.3 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + + engine.io@6.6.10: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 22.20.1 + '@types/ws': 8.18.1 + accepts: 1.3.8 + cookie: 0.7.2 + cors: 2.8.5 + debug: 4.4.3(supports-color@8.1.1) + engine.io-parser: 5.2.3 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + enhanced-resolve@5.21.6: dependencies: graceful-fs: 4.2.11 @@ -16933,6 +17027,47 @@ snapshots: smol-toml@1.7.0: {} + socket.io-adapter@2.5.8: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-client@4.8.3: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3(supports-color@8.1.1) + engine.io-client: 6.6.6 + socket.io-parser: 4.2.7 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.7: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + socket.io@4.8.3: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.5 + debug: 4.4.3(supports-color@8.1.1) + engine.io: 6.6.10 + socket.io-adapter: 2.5.8 + socket.io-parser: 4.2.7 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + source-map-js@1.2.1: {} source-map@0.5.6: {} @@ -17895,6 +18030,8 @@ snapshots: xmlchars@2.2.0: {} + xmlhttprequest-ssl@2.1.2: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 67a2b935ec..5b0805651d 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -2,7 +2,13 @@ import type { Mock } from "vitest" import * as vscode from "vscode" import { ClineProvider } from "../../core/webview/ClineProvider" -import { getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" +import { + getVisibleProviderOrLog, + openClineInNewTab, + registerBrowserBridgeCommand, + registerCommands, + setPanel, +} from "../registerCommands" vi.mock("execa", () => ({ execa: vi.fn(), @@ -15,6 +21,7 @@ vi.mock("vscode", () => ({ }, Uri: { joinPath: vi.fn((_base: unknown, ..._pathSegments: string[]) => ({ path: _pathSegments.join("/") })), + parse: vi.fn((value: string) => ({ toString: () => value })), }, ViewColumn: { Two: 2, @@ -22,8 +29,12 @@ vi.mock("vscode", () => ({ window: { createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn() }), createWebviewPanel: vi.fn(), + showErrorMessage: vi.fn(), visibleTextEditors: [], }, + env: { + openExternal: vi.fn().mockResolvedValue(true), + }, workspace: { workspaceFolders: [ { @@ -41,6 +52,13 @@ vi.mock("vscode", () => ({ vi.mock("../../core/webview/ClineProvider") +vi.mock("../../core/webview/browserBridge", () => ({ + BrowserBridgeServer: { + start: vi.fn(), + getBrowserUrl: vi.fn((port: number) => `http://localhost:5173/?bridgePort=${port}`), + }, +})) + vi.mock("../../shared/package", () => ({ Package: { name: "zoo-code", @@ -133,7 +151,11 @@ describe("getVisibleProviderOrLog", () => { describe("registerCommands handlers", () => { let mockOutputChannel: vscode.OutputChannel let mockContext: vscode.ExtensionContext - let mockVisibleProvider: { postMessageToWebview: Mock } + let mockVisibleProvider: { + postMessageToWebview: Mock + getActiveBrowserBridgePort: Mock + enableBrowserBridge: Mock + } let mockProvider: { postMessageToWebview: Mock } let handlers: Record unknown> @@ -158,6 +180,8 @@ describe("registerCommands handlers", () => { mockVisibleProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined), + getActiveBrowserBridgePort: vi.fn().mockReturnValue(undefined), + enableBrowserBridge: vi.fn(), } mockProvider = { @@ -376,6 +400,113 @@ describe("registerCommands handlers", () => { }) }) +// The openInBrowser command is dev-only tooling: it is not contributed in +// package.json and is registered directly through registerBrowserBridgeCommand +// (only when ROO_BROWSER_BRIDGE=1 in a Development host), so these tests drive +// that function instead of the main registration loop. +describe("registerBrowserBridgeCommand", () => { + let mockOutputChannel: vscode.OutputChannel + let mockVisibleProvider: { + getActiveBrowserBridgePort: Mock + enableBrowserBridge: Mock + } + let openInBrowser: () => Promise + + const getOpenedUrl = (): string => { + const uri = vi.mocked(vscode.env.openExternal).mock.calls[0]?.[0] as { toString(): string } | undefined + return uri?.toString() ?? "" + } + + beforeEach(() => { + vi.clearAllMocks() + + mockOutputChannel = { + appendLine: vi.fn(), + append: vi.fn(), + clear: vi.fn(), + hide: vi.fn(), + name: "mock", + replace: vi.fn(), + show: vi.fn(), + dispose: vi.fn(), + } + + mockVisibleProvider = { + getActiveBrowserBridgePort: vi.fn().mockReturnValue(undefined), + enableBrowserBridge: vi.fn(), + } + ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(mockVisibleProvider) + + let handler: (() => Promise) | undefined + ;(vscode.commands.registerCommand as Mock).mockImplementation((id: string, cb: () => Promise) => { + expect(id).toBe("zoo-code.openInBrowser") + handler = cb + return { dispose: vi.fn() } + }) + + registerBrowserBridgeCommand({ + context: { subscriptions: [] } as unknown as vscode.ExtensionContext, + outputChannel: mockOutputChannel, + provider: {} as ClineProvider, + }) + openInBrowser = () => handler!() + }) + + it("starts a bridge, enables it on the provider, and opens the URL with the port", async () => { + const bridge = { + port: 43210, + dispose: vi.fn(), + getBrowserUrl: vi.fn().mockReturnValue("http://localhost:5173/?bridgePort=43210"), + } + const { BrowserBridgeServer } = await import("../../core/webview/browserBridge") + vi.mocked(BrowserBridgeServer.start).mockResolvedValue(bridge as never) + + await openInBrowser() + + expect(BrowserBridgeServer.start).toHaveBeenCalledTimes(1) + expect(mockVisibleProvider.enableBrowserBridge).toHaveBeenCalledWith(bridge) + expect(mockVisibleProvider.getActiveBrowserBridgePort).toHaveBeenCalled() + expect(vscode.env.openExternal).toHaveBeenCalledTimes(1) + expect(getOpenedUrl()).toBe("http://localhost:5173/?bridgePort=43210") + }) + + it("reuses the existing bridge and does not start a second one", async () => { + const { BrowserBridgeServer } = await import("../../core/webview/browserBridge") + mockVisibleProvider.getActiveBrowserBridgePort.mockReturnValue(43210) + + await openInBrowser() + + expect(BrowserBridgeServer.start).not.toHaveBeenCalled() + expect(mockVisibleProvider.enableBrowserBridge).not.toHaveBeenCalled() + expect(vscode.env.openExternal).toHaveBeenCalledTimes(1) + expect(getOpenedUrl()).toBe("http://localhost:5173/?bridgePort=43210") + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "[openInBrowser] Reusing existing browser bridge on port 43210.", + ) + }) + + it("aborts when there is no visible provider", async () => { + const { BrowserBridgeServer } = await import("../../core/webview/browserBridge") + ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(undefined) + + await openInBrowser() + + expect(BrowserBridgeServer.start).not.toHaveBeenCalled() + expect(vscode.env.openExternal).not.toHaveBeenCalled() + }) + + it("logs when the bridge fails to start", async () => { + const { BrowserBridgeServer } = await import("../../core/webview/browserBridge") + vi.mocked(BrowserBridgeServer.start).mockResolvedValue(undefined as never) + + await openInBrowser() + + expect(mockVisibleProvider.enableBrowserBridge).not.toHaveBeenCalled() + expect(vscode.env.openExternal).not.toHaveBeenCalled() + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith("[openInBrowser] Failed to start the browser bridge.") + }) +}) + describe("openClineInNewTab", () => { let mockOutputChannel: vscode.OutputChannel let mockContext: vscode.ExtensionContext diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 692aabfd68..705150b1e3 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -15,6 +15,7 @@ import { importSettingsWithFeedback } from "../core/config/importExport" import { MdmService } from "../services/mdm/MdmService" import { registerRipgrepDiagnosticCommand } from "../services/ripgrep/diagnostic" import { t } from "../i18n" +import { BrowserBridgeServer } from "../core/webview/browserBridge" /** * Helper to get the visible ClineProvider instance or log if not found. @@ -71,6 +72,59 @@ export const registerCommands = (options: RegisterCommandOptions) => { } context.subscriptions.push(registerRipgrepDiagnosticCommand()) + + // Dev-only tooling: the browser bridge command is registered at runtime only + // when ROO_BROWSER_BRIDGE=1 is set in the extension host environment (see + // .vscode/launch.json) AND the extension runs in Development mode. It is + // intentionally absent from package.json contributions, so public users + // never see a toolbar button or a localized command-palette entry. + if (process.env.ROO_BROWSER_BRIDGE === "1" && context.extensionMode === vscode.ExtensionMode.Development) { + context.subscriptions.push(registerBrowserBridgeCommand(options)) + } +} + +/** + * Registers the dev-only `openInBrowser` command that switches a provider to + * the socket.io browser bridge. Returns the disposable so the caller can + * manage its lifecycle via `context.subscriptions`. + */ +export function registerBrowserBridgeCommand({ outputChannel }: RegisterCommandOptions): vscode.Disposable { + return vscode.commands.registerCommand(`${Package.name}.openInBrowser`, async () => { + const visibleProvider = getVisibleProviderOrLog(outputChannel) + if (!visibleProvider) { + return + } + + // One provider -> one bridge -> one port, permanently. If this provider + // is already in browser mode, reuse its bridge instead of starting a + // second one (an extra socket.io server would leak its port). + const activePort = visibleProvider.getActiveBrowserBridgePort() + + if (activePort !== undefined) { + outputChannel.appendLine(`[openInBrowser] Reusing existing browser bridge on port ${activePort}.`) + await vscode.env.openExternal(vscode.Uri.parse(BrowserBridgeServer.getBrowserUrl(activePort))) + return + } + + const bridge = await BrowserBridgeServer.start( + (message) => outputChannel.appendLine(message), + (error) => { + // Surface bridge failures to the developer, not just the output + // channel — a silently dead command is a dead end. + void vscode.window.showErrorMessage(`Failed to start the browser bridge: ${error.message}`) + }, + ) + if (!bridge) { + outputChannel.appendLine("[openInBrowser] Failed to start the browser bridge.") + return + } + // Irreversible switch: from now on the provider posts to the virtual + // webview (socket.io) and the real iframe renders a placeholder with a + // clickable link to the browser tab. + visibleProvider.enableBrowserBridge(bridge) + + await vscode.env.openExternal(vscode.Uri.parse(bridge.getBrowserUrl())) + }) } // `showRipgrepDiagnostic` is registered separately by diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 495fe454b7..542d041a02 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -114,6 +114,7 @@ import { CustomModesManager } from "../config/CustomModesManager" import { Task } from "../task/Task" import { webviewMessageHandler } from "./webviewMessageHandler" +import type { BrowserBridgeServer } from "./browserBridge" import type { ClineMessage, TodoItem } from "@roo-code/types" import { type ApiMessage, @@ -207,6 +208,13 @@ export class ClineProvider >() private nextThemeFixtureProbeId = 0 private view?: vscode.WebviewView | vscode.WebviewPanel + // Owned socket.io bridge when this provider was switched to browser mode. + // Each provider owns its own bridge (and therefore its own port); it is + // disposed together with the provider. + private browserBridge?: BrowserBridgeServer + // The webview used for message passing. Equals `view.webview` normally, or + // the bridge's virtual webview when the browser bridge is active. + private bridgeWebview?: vscode.Webview private taskRegistry = new TaskRegistry() private taskScheduler = new TaskScheduler() private static readonly delegationTransitionLocks = new Map>() @@ -897,6 +905,12 @@ export class ClineProvider this.customModesManager?.dispose() this.taskHistoryStore.dispose() this.flushGlobalStateWriteThrough() + + // Release the provider-owned browser bridge (socket.io server + port). + this.browserBridge?.dispose() + this.browserBridge = undefined + this.bridgeWebview = undefined + this.log("Disposed all disposables") ClineProvider.activeInstances.delete(this) @@ -1041,12 +1055,23 @@ export class ClineProvider localResourceRoots: resourceRoots, } - webviewView.webview.html = - this.contextProxy.extensionMode === vscode.ExtensionMode.Development && - process.env.ROO_CODE_THEME_FIXTURE_PROBE !== "1" - ? await this.getHMRHtmlContent(webviewView.webview) - : await this.getHtmlContent(webviewView.webview) + // When the browser bridge is active the UI runs only in Chrome; render + // an informational placeholder inside the real iframe (no React, no + // scripts, but with a clickable link back to the browser tab) so the + // environments don't run simultaneously. The virtual webview (backed by + // socket.io) already has its message listener registered in + // enableBrowserBridge(), so it is not re-registered here. + const browserBridgeActive = this.browserBridge !== undefined + if (browserBridgeActive) { + webviewView.webview.html = this.browserBridge?.getPlaceholderHtml() ?? "" + } else { + webviewView.webview.html = + this.contextProxy.extensionMode === vscode.ExtensionMode.Development && + process.env.ROO_CODE_THEME_FIXTURE_PROBE !== "1" + ? await this.getHMRHtmlContent(webviewView.webview) + : await this.getHtmlContent(webviewView.webview) + } // Initialize out-of-scope variables that need to receive persistent // global state values. await this.getState().then( @@ -1078,8 +1103,12 @@ export class ClineProvider ) // Sets up an event listener to listen for messages passed from the webview view context - // and executes code based on the message that is received. - this.setWebviewMessageListener(webviewView.webview) + // and executes code based on the message that is received. When the bridge is active + // the listener was already registered on the virtual webview in + // enableBrowserBridge(); re-registering here would double-handle every message. + if (!this.bridgeWebview) { + this.setWebviewMessageListener(webviewView.webview) + } // Initialize code index status subscription for the current workspace. this.updateCodeIndexStatusSubscription() @@ -1478,13 +1507,57 @@ export class ClineProvider return } + const webview = this.bridgeWebview ?? this.view?.webview + if (!webview) { + return + } + try { - await this.view?.webview.postMessage(message) + await webview.postMessage(message) } catch { // View disposed, drop message silently } } + /** + * Irreversibly switches this provider to the browser bridge. + * + * From this point on the provider posts to the virtual webview (backed by + * socket.io) and the real iframe renders a placeholder with a clickable + * link to the browser tab so the two environments never run + * simultaneously. Safe to call repeatedly — the switch only happens once. + * The provider takes ownership of the bridge and disposes it together with + * itself. + */ + public enableBrowserBridge(bridge: BrowserBridgeServer): void { + if (this.bridgeWebview) { + // Already switched: this provider is permanently bound to its first + // bridge (one provider -> one bridge -> one port). Refuse the new + // bridge and dispose it so its socket.io server/port does not leak. + bridge.dispose() + return + } + + this.browserBridge = bridge + this.bridgeWebview = bridge.getOrCreateVirtualWebview() + // The message listener must be registered on the virtual webview exactly + // once. resolveWebviewView() checks `this.bridgeWebview` and skips its own + // registration, so re-resolves don't double-handle messages. + this.setWebviewMessageListener(this.bridgeWebview) + if (this.view) { + this.view.webview.html = bridge.getPlaceholderHtml() + } + } + + /** + * The port of the provider-owned browser bridge, or `undefined` when the + * provider has not been switched to browser mode yet. Lets command handlers + * reuse the existing bridge instead of creating a second one. + */ + public getActiveBrowserBridgePort(): number | undefined { + return this.browserBridge?.port + } + public requestWebviewThemeFixture(timeoutMs = 5_000): Promise { if (process.env.ROO_CODE_THEME_FIXTURE_PROBE !== "1") { return Promise.reject(new Error("Theme fixture probing is disabled")) @@ -4534,8 +4607,9 @@ export class ClineProvider const fileUri = vscode.Uri.file(filePath) // Check if we have a webview available - if (this.view?.webview) { - const webviewUri = this.view.webview.asWebviewUri(fileUri) + const activeWebview = this.bridgeWebview ?? this.view?.webview + if (activeWebview) { + const webviewUri = activeWebview.asWebviewUri(fileUri) return webviewUri.toString() } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index bfd4706dcc..9bd4e2a18e 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -621,6 +621,72 @@ describe("ClineProvider", () => { expect(mockWebviewView.webview.html).toContain("Zoo Code") }) + describe("browser bridge ownership", () => { + const createFakeBridge = (port: number) => ({ + port, + dispose: vi.fn(), + getOrCreateVirtualWebview: vi.fn().mockReturnValue(mockWebviewView.webview), + getPlaceholderHtml: vi.fn().mockReturnValue(``), + getBrowserUrl: vi.fn().mockReturnValue(`http://localhost:5173/?bridgePort=${port}`), + }) + + test("getActiveBrowserBridgePort returns undefined before the bridge is enabled", () => { + expect(provider.getActiveBrowserBridgePort()).toBeUndefined() + }) + + test("enableBrowserBridge adopts the bridge and getActiveBrowserBridgePort reports its port", () => { + const bridge = createFakeBridge(43210) + provider.enableBrowserBridge(bridge as never) + + expect(provider.getActiveBrowserBridgePort()).toBe(43210) + expect(bridge.getOrCreateVirtualWebview).toHaveBeenCalledTimes(1) + }) + + test("enableBrowserBridge ignores and disposes a second bridge (one provider -> one bridge)", () => { + const firstBridge = createFakeBridge(43210) + const secondBridge = createFakeBridge(43211) + provider.enableBrowserBridge(firstBridge as never) + provider.enableBrowserBridge(secondBridge as never) + + // The first bridge stays authoritative; the second is rejected and disposed. + expect(provider.getActiveBrowserBridgePort()).toBe(43210) + expect(secondBridge.dispose).toHaveBeenCalledTimes(1) + expect(firstBridge.dispose).not.toHaveBeenCalled() + expect(firstBridge.getOrCreateVirtualWebview).toHaveBeenCalledTimes(1) + expect(secondBridge.getOrCreateVirtualWebview).not.toHaveBeenCalled() + }) + + test("enableBrowserBridge renders the bridge placeholder html in an already-resolved view", () => { + const bridge = createFakeBridge(43210) + // @ts-ignore - accessing private property for testing + provider.view = mockWebviewView + + provider.enableBrowserBridge(bridge as never) + + expect(bridge.getPlaceholderHtml).toHaveBeenCalledTimes(1) + expect(mockWebviewView.webview.html).toContain("bridge placeholder 43210") + }) + + test("resolveWebviewView renders the bridge placeholder when the bridge is active", async () => { + const bridge = createFakeBridge(43210) + provider.enableBrowserBridge(bridge as never) + + await provider.resolveWebviewView(mockWebviewView) + + expect(mockWebviewView.webview.html).toContain("bridge placeholder 43210") + }) + + test("dispose releases the provider-owned bridge", async () => { + const bridge = createFakeBridge(43210) + provider.enableBrowserBridge(bridge as never) + + await provider.dispose() + + expect(bridge.dispose).toHaveBeenCalledTimes(1) + expect(provider.getActiveBrowserBridgePort()).toBeUndefined() + }) + }) + describe("logWebviewHiddenDiagnostics", () => { let visibilityCallback: () => void diff --git a/src/core/webview/__tests__/browserBridge.spec.ts b/src/core/webview/__tests__/browserBridge.spec.ts new file mode 100644 index 0000000000..88cce58e36 --- /dev/null +++ b/src/core/webview/__tests__/browserBridge.spec.ts @@ -0,0 +1,156 @@ +// npx vitest run core/webview/__tests__/browserBridge.spec.ts +// +// BrowserBridgeServer / createVirtualWebview round-trip tests. +// +// These spin up a real socket.io server on an ephemeral port and connect with +// socket.io-client, exactly like the webview-ui BrowserBridgeClient does, so a +// regression in the socket.io v4 wiring (client events arrive on the socket, +// not the Server) is caught here. + +import { io, type Socket } from "socket.io-client" + +import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" + +import { allowNetConnect } from "../../../vitest.setup" +import { BrowserBridgeServer, getBrowserBridgePort } from "../browserBridge" + +// vitest.setup.ts disables real network requests via nock by default. The +// bridge tests connect a real socket.io client to a loopback server, so allow +// net connect for 127.0.0.1 (both the websocket upgrade and the polling +// transport use the same host). +allowNetConnect(/^127\.0\.0\.1(?::\d+)?$/) + +function connectToBridge(port: number): Promise { + return new Promise((resolve, reject) => { + const socket = io(`http://127.0.0.1:${port}`, { + transports: ["websocket", "polling"], + reconnection: false, + timeout: 5_000, + }) + socket.once("connect", () => resolve(socket)) + socket.once("connect_error", (error) => { + socket.disconnect() + reject(error) + }) + }) +} + +function waitFor(predicate: () => T | undefined, timeoutMs = 5_000): Promise { + return new Promise((resolve, reject) => { + const started = Date.now() + const poll = () => { + const value = predicate() + if (value !== undefined) { + resolve(value) + } else if (Date.now() - started > timeoutMs) { + reject(new Error("Timed out waiting for bridge message")) + } else { + setTimeout(poll, 10) + } + } + poll() + }) +} + +describe("BrowserBridgeServer", () => { + let bridge: BrowserBridgeServer | undefined + const sockets: Socket[] = [] + + beforeEach(async () => { + expect(getBrowserBridgePort()).toBe(0) // default: OS-assigned port + bridge = await BrowserBridgeServer.start(() => {}) + expect(bridge).toBeDefined() + }) + + afterEach(async () => { + for (const socket of sockets.splice(0)) { + socket.disconnect() + } + bridge?.dispose() + bridge = undefined + }) + + it("forwards webview->extension messages received from a browser client", async () => { + const server = bridge! + const received: WebviewMessage[] = [] + server.onWebviewMessage((message) => received.push(message)) + + const client = await connectToBridge(server.port) + sockets.push(client) + + const sent: WebviewMessage = { type: "clearTask" } + client.emit("webviewMessage", sent) + + const arrived = await waitFor(() => (received.length > 0 ? received[0] : undefined)) + expect(arrived).toEqual(sent) + }) + + it("broadcasts extension->webview messages to connected clients", async () => { + const server = bridge! + const client = await connectToBridge(server.port) + sockets.push(client) + + let inbound: unknown + client.on("extensionMessage", (message: unknown) => { + inbound = message + }) + + const sent: ExtensionMessage = { type: "state", state: { clineMessages: [] } as never } + server.broadcast(sent) + + const arrived = await waitFor(() => inbound) + expect(arrived).toEqual(sent) + }) + + it("delivers virtual-webview messages to the provider listener (full round trip)", async () => { + const server = bridge! + const webview = server.getOrCreateVirtualWebview() + + const providerReceived: WebviewMessage[] = [] + webview.onDidReceiveMessage((message) => { + providerReceived.push(message as WebviewMessage) + }) + + const client = await connectToBridge(server.port) + sockets.push(client) + + const sent: WebviewMessage = { type: "clearTask" } + client.emit("webviewMessage", sent) + + const arrived = await waitFor(() => (providerReceived.length > 0 ? providerReceived[0] : undefined)) + expect(arrived).toEqual(sent) + }) + + it("getBrowserUrl builds the Vite dev-server URL with the bridge port", () => { + const server = bridge! + expect(server.getBrowserUrl()).toBe(`http://localhost:5173/?bridgePort=${server.port}`) + expect(BrowserBridgeServer.getBrowserUrl(43210)).toBe("http://localhost:5173/?bridgePort=43210") + }) + + it("getPlaceholderHtml renders browser-mode info with a clickable valid link", () => { + const server = bridge! + const html = server.getPlaceholderHtml() + + expect(html).toContain("") + // Browser-mode notice: the tab cannot be restored. + expect(html).toContain("browser mode") + expect(html).toContain("cannot be restored") + // The link points at the exact URL that opens the browser tab. + expect(html).toContain(`href="${server.getBrowserUrl()}"`) + }) + + it("stop delivers after the listener is disposed", async () => { + const server = bridge! + const received: WebviewMessage[] = [] + const subscription = server.onWebviewMessage((message) => received.push(message)) + + const client = await connectToBridge(server.port) + sockets.push(client) + + subscription.dispose() + client.emit("webviewMessage", { type: "clearTask" } satisfies WebviewMessage) + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(received).toHaveLength(0) + }) +}) diff --git a/src/core/webview/browserBridge.ts b/src/core/webview/browserBridge.ts new file mode 100644 index 0000000000..1e6bcd083d --- /dev/null +++ b/src/core/webview/browserBridge.ts @@ -0,0 +1,272 @@ +import { createServer } from "http" + +import { Server as SocketIoServer, type Socket } from "socket.io" +import { Uri } from "vscode" +import type { Disposable, Webview } from "vscode" + +import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" + +/** + * Browser bridge — standalone-browser UI transport for the Zoo Code webview + * (development-only tooling for analyzing render issues with full + * React DevTools / Chrome DevTools support). + * + * Each `ClineProvider` owns exactly one {@link BrowserBridgeServer} instance. + * The bridge is started on demand from the dev-only `zoo-code.openInBrowser` + * command, which is registered only when `ROO_BROWSER_BRIDGE=1` is set in a + * Development extension host (see `registerCommands.ts`); it is not + * contributed in package.json. Once started, the extension host serves a + * socket.io server on `127.0.0.1:` and swaps the real VSCode webview for + * a virtual one ({@link createVirtualWebview}). The UI then lives entirely in a + * normal Chrome tab (loaded from the Vite dev server) instead of the VSCode + * webview iframe, while the message protocol (`WebviewMessage` / + * `ExtensionMessage`) stays unchanged. + * + * Ports are unique per bridge: the OS assigns a free port unless + * `ROO_BROWSER_BRIDGE_PORT` is set (a dev-only override). The actual port is + * exposed via {@link BrowserBridgeServer.port} and passed to the browser tab + * as a `?bridgePort=` URL query parameter, so any number of Zoo Code + * tabs or sidebar panels can run in the browser simultaneously, each on its + * own port. + */ + +export const DEFAULT_BROWSER_BRIDGE_PORT = 0 + +/** + * Base URL of the webview-ui Vite dev server. It always runs on a fixed port + * (see webview-ui/vite.config.ts); if it's not up, the browser tab simply + * shows a connection error — good enough for a dev-only tool. + */ +const VITE_BASE_URL = "http://localhost:5173" + +/** + * The port the bridge binds to. Defaults to `0` (let the OS pick a free port) + * so every provider gets a unique port. `ROO_BROWSER_BRIDGE_PORT` can still + * override this for parallel dev hosts that need a fixed, known port. + */ +export function getBrowserBridgePort(): number { + const raw = process.env.ROO_BROWSER_BRIDGE_PORT + if (raw === undefined || raw === "") { + return DEFAULT_BROWSER_BRIDGE_PORT + } + const port = Number(raw) + return Number.isInteger(port) && port > 0 && port < 65536 ? port : DEFAULT_BROWSER_BRIDGE_PORT +} + +/** + * Resolves the port a bound http server is actually listening on. When + * `port: 0` was requested, the OS picks a free port and reports it back here. + */ +function getBoundPort(httpServer: SocketIoServer["httpServer"], requestedPort: number): number { + const address = httpServer.address() + if (address && typeof address === "object") { + return address.port + } + return requestedPort +} + +/** + * Server side of the bridge. Binds to 127.0.0.1 only and restricts CORS to + * local origins, since this is a development-only transport. + */ +export class BrowserBridgeServer implements Disposable { + private readonly server: SocketIoServer + private readonly _port: number + + private readonly webviewMessageListeners = new Set<(message: WebviewMessage) => void>() + + private virtualWebview: Webview | undefined + + private constructor(server: SocketIoServer, port: number) { + this.server = server + this._port = port + + // In socket.io v4 client-emitted events arrive on the individual + // socket, not on the Server instance: forward each socket's + // "webviewMessage" events to the onWebviewMessage() subscribers. + server.on("connection", (socket: Socket) => { + socket.on("webviewMessage", (message: WebviewMessage) => { + for (const listener of this.webviewMessageListeners) { + listener(message) + } + }) + }) + } + + /** The TCP port this bridge is listening on. */ + get port(): number { + return this._port + } + + /** + * URL a browser tab must load to connect to the bridge listening on + * `port` (the Vite dev server plus the `?bridgePort` query parameter). + */ + static getBrowserUrl(port: number): string { + return `${VITE_BASE_URL}/?bridgePort=${port}` + } + + /** + * URL a browser tab must load to connect to this bridge. + */ + getBrowserUrl(): string { + return BrowserBridgeServer.getBrowserUrl(this._port) + } + + /** + * Placeholder rendered inside the real VSCode webview when the browser + * bridge is active. The UI lives only in Chrome; this keeps the iframe + * empty (no React, no scripts), while native VSCode webview chrome + * (tab/sidebar shell) stays. The message tells the developer the tab + * cannot be restored and links back to the browser tab so it can be + * reopened by clicking. + */ + getPlaceholderHtml(): string { + const url = this.getBrowserUrl() + return /*html*/ ` + + + + Zoo Code + + +
+

+ You have started browser mode. The Zoo Code UI now runs in a + Chrome tab connected via the browser bridge, and this tab + cannot be restored. +

+

+ ${url} +

+
+ +` + } + + /** + * Starts the bridge server on {@link getBrowserBridgePort}. Returns + * undefined when the port is already taken (only possible with an explicit + * `ROO_BROWSER_BRIDGE_PORT`, e.g. a second dev host). + * + * `onError` (when provided) is invoked with the failure so callers can + * surface it to the user (e.g. `vscode.window.showErrorMessage`) instead + * of only writing to a log. + */ + static async start( + log: (message: string) => void = console.log, + onError?: (error: Error) => void, + ): Promise { + const requestedPort = getBrowserBridgePort() + + // Create the http server explicitly (the socket.io Server constructor + // with options alone does not own one) and let socket.io take over its + // lifecycle. + const httpServer = createServer() + const server = new SocketIoServer(httpServer, { + // Development-only transport: bind loopback and allow local origins only. + cors: { + origin: [/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/], + }, + transports: ["websocket", "polling"], + }) + + try { + await new Promise((resolve, reject) => { + httpServer.once("error", reject) + httpServer.listen({ port: requestedPort, host: "127.0.0.1" }, () => { + httpServer.off("error", reject) + resolve() + }) + }) + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)) + log(`[BrowserBridge] Failed to start on 127.0.0.1:${requestedPort} -> ${failure.message}`) + onError?.(failure) + return undefined + } + + const port = getBoundPort(httpServer, requestedPort) + log(`[BrowserBridge] Listening on ws://127.0.0.1:${port}`) + return new BrowserBridgeServer(server, port) + } + + /** + * The single virtual webview for this bridge. Reused across webview + * re-resolves so bridge-level listeners are registered exactly once. + */ + getOrCreateVirtualWebview(): Webview { + if (!this.virtualWebview) { + this.virtualWebview = createVirtualWebview(this) + } + return this.virtualWebview + } + + /** + * Broadcasts an extension->webview message to all connected browser clients. + */ + broadcast(message: ExtensionMessage): void { + this.server.emit("extensionMessage", message) + } + + /** + * Subscribes to webview->extension messages coming from browser clients. + * Returns a dispose function. + */ + onWebviewMessage(listener: (message: WebviewMessage) => void): Disposable { + this.webviewMessageListeners.add(listener) + return { + dispose: () => { + this.webviewMessageListeners.delete(listener) + }, + } + } + + dispose(): void { + try { + void this.server.close() + } catch { + // Already closed + } + } +} + +/** + * A virtual `vscode.Webview` implementation backed by the browser bridge. + * + * Mirrors the CLI mock-webview pattern: `postMessage` broadcasts to the browser, + * `onDidReceiveMessage` forwards browser messages to the provider. The real + * iframe webview is intentionally not used — the UI runs only in Chrome. + */ +export function createVirtualWebview(bridge: BrowserBridgeServer): Webview { + const messageListeners = new Set<(message: WebviewMessage) => void>() + + bridge.onWebviewMessage((message) => { + for (const listener of messageListeners) { + listener(message) + } + }) + + return { + options: { enableScripts: true }, + cspSource: "vscode-webview://bridge", + html: "", + postMessage(message: unknown): Thenable { + bridge.broadcast(message as ExtensionMessage) + return Promise.resolve(true) + }, + onDidReceiveMessage(listener: (message: unknown) => void): Disposable { + const wrapped = listener as (message: WebviewMessage) => void + messageListeners.add(wrapped) + return { + dispose: () => { + messageListeners.delete(wrapped) + }, + } + }, + asWebviewUri(localResource: Uri): Uri { + // The browser UI never renders webview URIs; return the input unchanged. + return localResource + }, + } +} diff --git a/src/package.json b/src/package.json index 7467b50b7b..840cdb8445 100644 --- a/src/package.json +++ b/src/package.json @@ -517,6 +517,7 @@ "serialize-error": "^12.0.0", "shell-quote": "^1.8.2", "simple-git": "^3.27.0", + "socket.io": "^4.8.3", "strip-bom": "^5.0.0", "tiktoken": "^1.0.21", "tree-sitter-wasms": "^0.1.13", @@ -551,6 +552,7 @@ "nock": "14.0.15", "ovsx": "0.10.12", "rimraf": "6.0.1", + "socket.io-client": "4.8.3", "vitest": "4.1.9" } } diff --git a/webview-ui/package.json b/webview-ui/package.json index 450288eff5..ff15cc6a07 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -72,6 +72,7 @@ "remove-markdown": "^0.6.4", "shell-quote": "^1.8.2", "shiki": "^3.2.1", + "socket.io-client": "^4.8.3", "source-map": "^0.7.4", "stacktrace-js": "^2.0.2", "styled-components": "^6.1.13", diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index bccbdf7031..fab7265e27 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -185,6 +185,184 @@ --radius: 0.5rem; } + /* + * Standalone browser mode (browser bridge): VS Code is not present, so the + * --vscode-* variables it normally injects into the webview are missing. + * Provide a minimal dark-theme fallback scoped to the marker class added by + * the browser bridge client so the UI renders with a usable theme. + */ + html.roo-browser-mode { + --vscode-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --vscode-font-size: 13px; + + --vscode-editor-background: #1e1e1e; + --vscode-editor-foreground: #d4d4d4; + --vscode-editorGroup-border: #444444; + --vscode-editorWarning-foreground: #cca700; + --vscode-editorWarning-background: #352a05; + + --vscode-foreground: #cccccc; + + --vscode-button-foreground: #ffffff; + --vscode-button-background: #0e639c; + --vscode-button-secondaryForeground: #ffffff; + --vscode-button-secondaryBackground: #3a3d41; + --vscode-button-hoverBackground: #1177bb; + + --vscode-dropdown-foreground: #cccccc; + --vscode-dropdown-background: #3c3c3c; + --vscode-dropdown-border: #3c3c3c; + + --vscode-input-foreground: #cccccc; + --vscode-input-background: #3c3c3c; + --vscode-input-border: #3c3c3c; + + --vscode-focusBorder: #007fd4; + + --vscode-badge-foreground: #ffffff; + --vscode-badge-background: #4d4d4d; + + --vscode-notifications-foreground: #cccccc; + --vscode-notifications-background: #252526; + --vscode-notifications-border: #303031; + + --vscode-descriptionForeground: #9d9d9d; + --vscode-errorForeground: #f48771; + + --vscode-list-hoverForeground: #ffffff; + --vscode-list-hoverBackground: #2a2d2e; + --vscode-list-focusBackground: #094771; + --vscode-list-activeSelectionBackground: #094771; + --vscode-list-activeSelectionForeground: #ffffff; + + --vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31); + --vscode-toolbar-hoverOutline: rgba(255, 255, 255, 0.12); + + --vscode-panel-border: #444444; + + --vscode-sideBar-foreground: #cccccc; + --vscode-sideBar-background: #252526; + --vscode-sideBar-border: #444444; + + --vscode-sideBarSectionHeader-foreground: #cccccc; + --vscode-sideBarSectionHeader-background: #2b2b2b; + --vscode-sideBarSectionHeader-border: #444444; + + --vscode-titleBar-activeForeground: #cccccc; + --vscode-titleBar-inactiveForeground: #9d9d9d; + + --vscode-charts-green: #89d185; + --vscode-charts-red: #f14c4c; + --vscode-charts-yellow: #cca700; + --vscode-charts-blue: #3794ff; + --vscode-charts-orange: #d18616; + + --vscode-inputValidation-infoForeground: #3794ff; + --vscode-inputValidation-infoBackground: #063b49; + --vscode-inputValidation-infoBorder: #007acc; + --vscode-inputValidation-warningForeground: #cca700; + --vscode-inputValidation-warningBackground: #352a05; + --vscode-inputValidation-warningBorder: #b89500; + --vscode-inputValidation-errorForeground: #f48771; + --vscode-inputValidation-errorBackground: #5a1d1d; + --vscode-inputValidation-errorBorder: #be1100; + + --vscode-textCodeBlock-background: #2d2d2d; + --vscode-menu-foreground: #cccccc; + --vscode-menu-background: #252526; + --vscode-menu-selectionBackground: #094771; + + --vscode-textLink-foreground: #3794ff; + --vscode-disabledForeground: #6f6f6f; + --vscode-banner-background: #004578; + --vscode-banner-foreground: #ffffff; + + --vscode-font-weight: normal; + --vscode-textLink-activeForeground: #3794ff; + --vscode-scrollbarSlider-background: rgba(121, 121, 121, 0.4); + --vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7); + --vscode-scrollbarSlider-activeBackground: rgba(191, 191, 191, 0.4); + --vscode-textPreformat-foreground: #d7ba7d; + --vscode-textPreformat-background: transparent; + --vscode-textBlockQuote-background: #222222; + --vscode-textBlockQuote-border: #3e3e42; + --vscode-keybindingLabel-background: rgba(128, 128, 128, 0.17); + --vscode-keybindingLabel-foreground: #cccccc; + --vscode-keybindingLabel-border: rgba(51, 51, 51, 0.6); + --vscode-keybindingLabel-bottomBorder: rgba(68, 68, 68, 0.6); + --vscode-widget-shadow: rgba(0, 0, 0, 0.36); + --vscode-editorWidget-foreground: #cccccc; + --vscode-editorWidget-background: #252526; + --vscode-editorWidget-border: #454545; + --vscode-progressBar-background: #0e70c0; + --vscode-icon-foreground: #c5c5c5; + --vscode-editorError-foreground: #f14c4c; + --vscode-diffEditor-insertedTextBackground: rgba(155, 185, 85, 0.2); + --vscode-diffEditor-removedTextBackground: rgba(255, 0, 0, 0.2); + } + + /* + * VS Code additionally injects an unlayered "_defaultStyles" stylesheet + * into every webview (html/body background + text color, font, scrollbars, + * link/code colors). A plain browser tab has none of that, which is why + * body and input text rendered black-on-white even with the variables + * above. Mirror the essentials here, scoped to browser mode. + */ + html.roo-browser-mode { + background-color: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); + } + + html.roo-browser-mode body { + overscroll-behavior-x: none; + background-color: transparent; + color: var(--vscode-editor-foreground); + font-family: var(--vscode-font-family); + font-weight: var(--vscode-font-weight); + font-size: var(--vscode-font-size); + padding: 0 20px; + } + + html.roo-browser-mode a, + html.roo-browser-mode a code { + color: var(--vscode-textLink-foreground); + } + + html.roo-browser-mode a:hover { + color: var(--vscode-textLink-activeForeground); + } + + html.roo-browser-mode code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + color: var(--vscode-textPreformat-foreground); + } + + html.roo-browser-mode blockquote { + background: var(--vscode-textBlockQuote-background); + border-left: 1px solid var(--vscode-textBlockQuote-border); + } + + html.roo-browser-mode ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + html.roo-browser-mode ::-webkit-scrollbar-corner { + background-color: var(--vscode-editor-background); + } + + html.roo-browser-mode ::-webkit-scrollbar-thumb { + background-color: var(--vscode-scrollbarSlider-background); + } + + html.roo-browser-mode ::-webkit-scrollbar-thumb:hover { + background-color: var(--vscode-scrollbarSlider-hoverBackground); + } + + html.roo-browser-mode ::-webkit-scrollbar-thumb:active { + background-color: var(--vscode-scrollbarSlider-activeBackground); + } + /* Higher specififty than VSCode's theme and root. */ /* Used for baseline theme overrides, but avoid using for styling. */ diff --git a/webview-ui/src/utils/vscode.ts b/webview-ui/src/utils/vscode.ts index 2cc0a58909..e3c94a7c24 100644 --- a/webview-ui/src/utils/vscode.ts +++ b/webview-ui/src/utils/vscode.ts @@ -1,7 +1,67 @@ import type { WebviewApi } from "vscode-webview" +import { io, type Socket } from "socket.io-client" import { WebviewMessage } from "@roo/WebviewMessage" +/** + * Browser bridge transport for standalone (non-webview) browser mode. + * + * When the UI runs in a normal Chrome tab (loaded from the Vite dev server), + * `acquireVsCodeApi` is undefined. Messages then flow over socket.io to the + * extension host instead of the VSCode webview message protocol: + * + * - `postMessage(message)` emits `webviewMessage` to the bridge server + * - inbound `extensionMessage` events are re-dispatched through + * `window.postMessage` so the existing `window.addEventListener("message")` + * consumers in the app work unchanged. + */ +class BrowserBridgeClient { + private readonly socket: Socket + + constructor(port: number) { + this.socket = io(`http://127.0.0.1:${port}`, { + transports: ["websocket", "polling"], + }) + + // Mark the document so CSS can provide a dark-theme fallback for the + // --vscode-* variables that VS Code normally injects into the webview. + document.documentElement.classList.add("roo-browser-mode") + + this.socket.on("extensionMessage", (message: unknown) => { + window.postMessage(message, "*") + }) + + this.socket.on("connect_error", (error) => { + console.warn("[BrowserBridge] socket.io connect error:", error) + }) + } + + public postMessage(message: WebviewMessage) { + this.socket.emit("webviewMessage", message) + } + + public dispose() { + this.socket.disconnect() + } +} + +/** + * The bridge port is served as a `?bridgePort=` query parameter on the + * URL opened in Chrome by the "Open in Chrome" command. Its presence tells the + * UI that the browser bridge is active — there is no env-var or build-time + * flag involved. Returns `undefined` when the param is absent. + */ +function getBridgePortFromUrl(): number | undefined { + const raw = new URLSearchParams(window.location.search).get("bridgePort") + if (raw !== null && raw !== "") { + const port = Number(raw) + if (Number.isInteger(port) && port > 0 && port < 65536) { + return port + } + } + return undefined +} + /** * A utility wrapper around the acquireVsCodeApi() function, which enables * message passing and state management between the webview and extension @@ -13,12 +73,21 @@ import { WebviewMessage } from "@roo/WebviewMessage" */ class VSCodeAPIWrapper { private readonly vsCodeApi: WebviewApi | undefined + private readonly browserBridge: BrowserBridgeClient | undefined constructor() { // Check if the acquireVsCodeApi function exists in the current development // context (i.e. VS Code development window or web browser) if (typeof acquireVsCodeApi === "function") { this.vsCodeApi = acquireVsCodeApi() + } else { + // Use the browser bridge only when the "Open in Chrome" flow opened + // this tab with a ?bridgePort=... query param. Otherwise (e.g. a + // plain dev-server tab) fall back to localStorage-only behavior. + const bridgePort = getBridgePortFromUrl() + if (bridgePort !== undefined) { + this.browserBridge = new BrowserBridgeClient(bridgePort) + } } } @@ -26,13 +95,15 @@ class VSCodeAPIWrapper { * Post a message (i.e. send arbitrary data) to the owner of the webview. * * @remarks When running webview code inside a web browser, postMessage will instead - * log the given message to the console. + * send the message over the browser bridge (socket.io) to the extension host. * * @param message Arbitrary data (must be JSON serializable) to send to the extension context. */ public postMessage(message: WebviewMessage) { if (this.vsCodeApi) { this.vsCodeApi.postMessage(message) + } else if (this.browserBridge) { + this.browserBridge.postMessage(message) } else { console.log(message) } From 4db8276ee3239b87c30b2b96efe1e05f8b522c7b Mon Sep 17 00:00:00 2001 From: hnbdr Date: Fri, 11 Sep 2026 17:14:44 +0300 Subject: [PATCH 2/6] test: revert browser-bridge test changes to pre-feature state Follow-up commits split the bridge work into a clean sequence: production refactor first, new tests on top of the refactored API. This commit restores the specs to their original state and drops the browserBridge spec that targeted the removed per-instance API. --- .../__tests__/registerCommands.spec.ts | 135 +-------------- .../webview/__tests__/ClineProvider.spec.ts | 66 -------- .../webview/__tests__/browserBridge.spec.ts | 156 ------------------ 3 files changed, 2 insertions(+), 355 deletions(-) delete mode 100644 src/core/webview/__tests__/browserBridge.spec.ts diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 5b0805651d..67a2b935ec 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -2,13 +2,7 @@ import type { Mock } from "vitest" import * as vscode from "vscode" import { ClineProvider } from "../../core/webview/ClineProvider" -import { - getVisibleProviderOrLog, - openClineInNewTab, - registerBrowserBridgeCommand, - registerCommands, - setPanel, -} from "../registerCommands" +import { getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" vi.mock("execa", () => ({ execa: vi.fn(), @@ -21,7 +15,6 @@ vi.mock("vscode", () => ({ }, Uri: { joinPath: vi.fn((_base: unknown, ..._pathSegments: string[]) => ({ path: _pathSegments.join("/") })), - parse: vi.fn((value: string) => ({ toString: () => value })), }, ViewColumn: { Two: 2, @@ -29,12 +22,8 @@ vi.mock("vscode", () => ({ window: { createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn() }), createWebviewPanel: vi.fn(), - showErrorMessage: vi.fn(), visibleTextEditors: [], }, - env: { - openExternal: vi.fn().mockResolvedValue(true), - }, workspace: { workspaceFolders: [ { @@ -52,13 +41,6 @@ vi.mock("vscode", () => ({ vi.mock("../../core/webview/ClineProvider") -vi.mock("../../core/webview/browserBridge", () => ({ - BrowserBridgeServer: { - start: vi.fn(), - getBrowserUrl: vi.fn((port: number) => `http://localhost:5173/?bridgePort=${port}`), - }, -})) - vi.mock("../../shared/package", () => ({ Package: { name: "zoo-code", @@ -151,11 +133,7 @@ describe("getVisibleProviderOrLog", () => { describe("registerCommands handlers", () => { let mockOutputChannel: vscode.OutputChannel let mockContext: vscode.ExtensionContext - let mockVisibleProvider: { - postMessageToWebview: Mock - getActiveBrowserBridgePort: Mock - enableBrowserBridge: Mock - } + let mockVisibleProvider: { postMessageToWebview: Mock } let mockProvider: { postMessageToWebview: Mock } let handlers: Record unknown> @@ -180,8 +158,6 @@ describe("registerCommands handlers", () => { mockVisibleProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined), - getActiveBrowserBridgePort: vi.fn().mockReturnValue(undefined), - enableBrowserBridge: vi.fn(), } mockProvider = { @@ -400,113 +376,6 @@ describe("registerCommands handlers", () => { }) }) -// The openInBrowser command is dev-only tooling: it is not contributed in -// package.json and is registered directly through registerBrowserBridgeCommand -// (only when ROO_BROWSER_BRIDGE=1 in a Development host), so these tests drive -// that function instead of the main registration loop. -describe("registerBrowserBridgeCommand", () => { - let mockOutputChannel: vscode.OutputChannel - let mockVisibleProvider: { - getActiveBrowserBridgePort: Mock - enableBrowserBridge: Mock - } - let openInBrowser: () => Promise - - const getOpenedUrl = (): string => { - const uri = vi.mocked(vscode.env.openExternal).mock.calls[0]?.[0] as { toString(): string } | undefined - return uri?.toString() ?? "" - } - - beforeEach(() => { - vi.clearAllMocks() - - mockOutputChannel = { - appendLine: vi.fn(), - append: vi.fn(), - clear: vi.fn(), - hide: vi.fn(), - name: "mock", - replace: vi.fn(), - show: vi.fn(), - dispose: vi.fn(), - } - - mockVisibleProvider = { - getActiveBrowserBridgePort: vi.fn().mockReturnValue(undefined), - enableBrowserBridge: vi.fn(), - } - ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(mockVisibleProvider) - - let handler: (() => Promise) | undefined - ;(vscode.commands.registerCommand as Mock).mockImplementation((id: string, cb: () => Promise) => { - expect(id).toBe("zoo-code.openInBrowser") - handler = cb - return { dispose: vi.fn() } - }) - - registerBrowserBridgeCommand({ - context: { subscriptions: [] } as unknown as vscode.ExtensionContext, - outputChannel: mockOutputChannel, - provider: {} as ClineProvider, - }) - openInBrowser = () => handler!() - }) - - it("starts a bridge, enables it on the provider, and opens the URL with the port", async () => { - const bridge = { - port: 43210, - dispose: vi.fn(), - getBrowserUrl: vi.fn().mockReturnValue("http://localhost:5173/?bridgePort=43210"), - } - const { BrowserBridgeServer } = await import("../../core/webview/browserBridge") - vi.mocked(BrowserBridgeServer.start).mockResolvedValue(bridge as never) - - await openInBrowser() - - expect(BrowserBridgeServer.start).toHaveBeenCalledTimes(1) - expect(mockVisibleProvider.enableBrowserBridge).toHaveBeenCalledWith(bridge) - expect(mockVisibleProvider.getActiveBrowserBridgePort).toHaveBeenCalled() - expect(vscode.env.openExternal).toHaveBeenCalledTimes(1) - expect(getOpenedUrl()).toBe("http://localhost:5173/?bridgePort=43210") - }) - - it("reuses the existing bridge and does not start a second one", async () => { - const { BrowserBridgeServer } = await import("../../core/webview/browserBridge") - mockVisibleProvider.getActiveBrowserBridgePort.mockReturnValue(43210) - - await openInBrowser() - - expect(BrowserBridgeServer.start).not.toHaveBeenCalled() - expect(mockVisibleProvider.enableBrowserBridge).not.toHaveBeenCalled() - expect(vscode.env.openExternal).toHaveBeenCalledTimes(1) - expect(getOpenedUrl()).toBe("http://localhost:5173/?bridgePort=43210") - expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( - "[openInBrowser] Reusing existing browser bridge on port 43210.", - ) - }) - - it("aborts when there is no visible provider", async () => { - const { BrowserBridgeServer } = await import("../../core/webview/browserBridge") - ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(undefined) - - await openInBrowser() - - expect(BrowserBridgeServer.start).not.toHaveBeenCalled() - expect(vscode.env.openExternal).not.toHaveBeenCalled() - }) - - it("logs when the bridge fails to start", async () => { - const { BrowserBridgeServer } = await import("../../core/webview/browserBridge") - vi.mocked(BrowserBridgeServer.start).mockResolvedValue(undefined as never) - - await openInBrowser() - - expect(mockVisibleProvider.enableBrowserBridge).not.toHaveBeenCalled() - expect(vscode.env.openExternal).not.toHaveBeenCalled() - expect(mockOutputChannel.appendLine).toHaveBeenCalledWith("[openInBrowser] Failed to start the browser bridge.") - }) -}) - describe("openClineInNewTab", () => { let mockOutputChannel: vscode.OutputChannel let mockContext: vscode.ExtensionContext diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 9bd4e2a18e..bfd4706dcc 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -621,72 +621,6 @@ describe("ClineProvider", () => { expect(mockWebviewView.webview.html).toContain("Zoo Code") }) - describe("browser bridge ownership", () => { - const createFakeBridge = (port: number) => ({ - port, - dispose: vi.fn(), - getOrCreateVirtualWebview: vi.fn().mockReturnValue(mockWebviewView.webview), - getPlaceholderHtml: vi.fn().mockReturnValue(``), - getBrowserUrl: vi.fn().mockReturnValue(`http://localhost:5173/?bridgePort=${port}`), - }) - - test("getActiveBrowserBridgePort returns undefined before the bridge is enabled", () => { - expect(provider.getActiveBrowserBridgePort()).toBeUndefined() - }) - - test("enableBrowserBridge adopts the bridge and getActiveBrowserBridgePort reports its port", () => { - const bridge = createFakeBridge(43210) - provider.enableBrowserBridge(bridge as never) - - expect(provider.getActiveBrowserBridgePort()).toBe(43210) - expect(bridge.getOrCreateVirtualWebview).toHaveBeenCalledTimes(1) - }) - - test("enableBrowserBridge ignores and disposes a second bridge (one provider -> one bridge)", () => { - const firstBridge = createFakeBridge(43210) - const secondBridge = createFakeBridge(43211) - provider.enableBrowserBridge(firstBridge as never) - provider.enableBrowserBridge(secondBridge as never) - - // The first bridge stays authoritative; the second is rejected and disposed. - expect(provider.getActiveBrowserBridgePort()).toBe(43210) - expect(secondBridge.dispose).toHaveBeenCalledTimes(1) - expect(firstBridge.dispose).not.toHaveBeenCalled() - expect(firstBridge.getOrCreateVirtualWebview).toHaveBeenCalledTimes(1) - expect(secondBridge.getOrCreateVirtualWebview).not.toHaveBeenCalled() - }) - - test("enableBrowserBridge renders the bridge placeholder html in an already-resolved view", () => { - const bridge = createFakeBridge(43210) - // @ts-ignore - accessing private property for testing - provider.view = mockWebviewView - - provider.enableBrowserBridge(bridge as never) - - expect(bridge.getPlaceholderHtml).toHaveBeenCalledTimes(1) - expect(mockWebviewView.webview.html).toContain("bridge placeholder 43210") - }) - - test("resolveWebviewView renders the bridge placeholder when the bridge is active", async () => { - const bridge = createFakeBridge(43210) - provider.enableBrowserBridge(bridge as never) - - await provider.resolveWebviewView(mockWebviewView) - - expect(mockWebviewView.webview.html).toContain("bridge placeholder 43210") - }) - - test("dispose releases the provider-owned bridge", async () => { - const bridge = createFakeBridge(43210) - provider.enableBrowserBridge(bridge as never) - - await provider.dispose() - - expect(bridge.dispose).toHaveBeenCalledTimes(1) - expect(provider.getActiveBrowserBridgePort()).toBeUndefined() - }) - }) - describe("logWebviewHiddenDiagnostics", () => { let visibilityCallback: () => void diff --git a/src/core/webview/__tests__/browserBridge.spec.ts b/src/core/webview/__tests__/browserBridge.spec.ts deleted file mode 100644 index 88cce58e36..0000000000 --- a/src/core/webview/__tests__/browserBridge.spec.ts +++ /dev/null @@ -1,156 +0,0 @@ -// npx vitest run core/webview/__tests__/browserBridge.spec.ts -// -// BrowserBridgeServer / createVirtualWebview round-trip tests. -// -// These spin up a real socket.io server on an ephemeral port and connect with -// socket.io-client, exactly like the webview-ui BrowserBridgeClient does, so a -// regression in the socket.io v4 wiring (client events arrive on the socket, -// not the Server) is caught here. - -import { io, type Socket } from "socket.io-client" - -import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" - -import { allowNetConnect } from "../../../vitest.setup" -import { BrowserBridgeServer, getBrowserBridgePort } from "../browserBridge" - -// vitest.setup.ts disables real network requests via nock by default. The -// bridge tests connect a real socket.io client to a loopback server, so allow -// net connect for 127.0.0.1 (both the websocket upgrade and the polling -// transport use the same host). -allowNetConnect(/^127\.0\.0\.1(?::\d+)?$/) - -function connectToBridge(port: number): Promise { - return new Promise((resolve, reject) => { - const socket = io(`http://127.0.0.1:${port}`, { - transports: ["websocket", "polling"], - reconnection: false, - timeout: 5_000, - }) - socket.once("connect", () => resolve(socket)) - socket.once("connect_error", (error) => { - socket.disconnect() - reject(error) - }) - }) -} - -function waitFor(predicate: () => T | undefined, timeoutMs = 5_000): Promise { - return new Promise((resolve, reject) => { - const started = Date.now() - const poll = () => { - const value = predicate() - if (value !== undefined) { - resolve(value) - } else if (Date.now() - started > timeoutMs) { - reject(new Error("Timed out waiting for bridge message")) - } else { - setTimeout(poll, 10) - } - } - poll() - }) -} - -describe("BrowserBridgeServer", () => { - let bridge: BrowserBridgeServer | undefined - const sockets: Socket[] = [] - - beforeEach(async () => { - expect(getBrowserBridgePort()).toBe(0) // default: OS-assigned port - bridge = await BrowserBridgeServer.start(() => {}) - expect(bridge).toBeDefined() - }) - - afterEach(async () => { - for (const socket of sockets.splice(0)) { - socket.disconnect() - } - bridge?.dispose() - bridge = undefined - }) - - it("forwards webview->extension messages received from a browser client", async () => { - const server = bridge! - const received: WebviewMessage[] = [] - server.onWebviewMessage((message) => received.push(message)) - - const client = await connectToBridge(server.port) - sockets.push(client) - - const sent: WebviewMessage = { type: "clearTask" } - client.emit("webviewMessage", sent) - - const arrived = await waitFor(() => (received.length > 0 ? received[0] : undefined)) - expect(arrived).toEqual(sent) - }) - - it("broadcasts extension->webview messages to connected clients", async () => { - const server = bridge! - const client = await connectToBridge(server.port) - sockets.push(client) - - let inbound: unknown - client.on("extensionMessage", (message: unknown) => { - inbound = message - }) - - const sent: ExtensionMessage = { type: "state", state: { clineMessages: [] } as never } - server.broadcast(sent) - - const arrived = await waitFor(() => inbound) - expect(arrived).toEqual(sent) - }) - - it("delivers virtual-webview messages to the provider listener (full round trip)", async () => { - const server = bridge! - const webview = server.getOrCreateVirtualWebview() - - const providerReceived: WebviewMessage[] = [] - webview.onDidReceiveMessage((message) => { - providerReceived.push(message as WebviewMessage) - }) - - const client = await connectToBridge(server.port) - sockets.push(client) - - const sent: WebviewMessage = { type: "clearTask" } - client.emit("webviewMessage", sent) - - const arrived = await waitFor(() => (providerReceived.length > 0 ? providerReceived[0] : undefined)) - expect(arrived).toEqual(sent) - }) - - it("getBrowserUrl builds the Vite dev-server URL with the bridge port", () => { - const server = bridge! - expect(server.getBrowserUrl()).toBe(`http://localhost:5173/?bridgePort=${server.port}`) - expect(BrowserBridgeServer.getBrowserUrl(43210)).toBe("http://localhost:5173/?bridgePort=43210") - }) - - it("getPlaceholderHtml renders browser-mode info with a clickable valid link", () => { - const server = bridge! - const html = server.getPlaceholderHtml() - - expect(html).toContain("") - // Browser-mode notice: the tab cannot be restored. - expect(html).toContain("browser mode") - expect(html).toContain("cannot be restored") - // The link points at the exact URL that opens the browser tab. - expect(html).toContain(`href="${server.getBrowserUrl()}"`) - }) - - it("stop delivers after the listener is disposed", async () => { - const server = bridge! - const received: WebviewMessage[] = [] - const subscription = server.onWebviewMessage((message) => received.push(message)) - - const client = await connectToBridge(server.port) - sockets.push(client) - - subscription.dispose() - client.emit("webviewMessage", { type: "clearTask" } satisfies WebviewMessage) - await new Promise((resolve) => setTimeout(resolve, 150)) - - expect(received).toHaveLength(0) - }) -}) From 63799f265c15def914a45fd39365d9a650683eb8 Mon Sep 17 00:00:00 2001 From: hnbdr Date: Fri, 11 Sep 2026 17:27:30 +0300 Subject: [PATCH 3/6] refactor(webview): centralize browser bridge ownership in BrowserBridgeServer Reshapes the dev-only browser bridge so production files carry the minimum possible surface: - BrowserBridgeServer owns a module-private WeakMap; registration, enable, placeholder, routing and disposal are static entry points. One bridge per host; a rejected newcomer disposes itself. - The bridge reaches provider internals (webview view, message-listener wiring) through a private structural cast instead of widening ClineProvider's API: no new members on the provider, only injected call sites. - openInBrowser command registration moves into BrowserBridgeServer.registerCommand (env + Development-mode gated); registerCommands.ts shrinks to one call. - webview-ui: BrowserBridgeClient extracted from VSCodeAPIWrapper into its own module with lazy socket.io-client import and DEV-only activation; standalone browser-mode CSS moves out of index.css into browserBridge.css so it can be dead-code-eliminated from production builds (bundle externalization follows in a later commit). --- src/activate/registerCommands.ts | 55 +--- src/core/webview/ClineProvider.ts | 72 +---- src/core/webview/browserBridge.ts | 312 +++++++++++++++----- webview-ui/src/browserBridge.css | 187 ++++++++++++ webview-ui/src/index.css | 179 +---------- webview-ui/src/utils/browserBridgeClient.ts | 150 ++++++++++ webview-ui/src/utils/vscode.ts | 76 +---- 7 files changed, 607 insertions(+), 424 deletions(-) create mode 100644 webview-ui/src/browserBridge.css create mode 100644 webview-ui/src/utils/browserBridgeClient.ts diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 705150b1e3..e9b5a5b667 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -4,7 +4,6 @@ import delay from "delay" import type { CommandId } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { Package } from "../shared/package" import { getCommand } from "../utils/commands" import { ClineProvider } from "../core/webview/ClineProvider" import { ContextProxy } from "../core/config/ContextProxy" @@ -73,58 +72,8 @@ export const registerCommands = (options: RegisterCommandOptions) => { context.subscriptions.push(registerRipgrepDiagnosticCommand()) - // Dev-only tooling: the browser bridge command is registered at runtime only - // when ROO_BROWSER_BRIDGE=1 is set in the extension host environment (see - // .vscode/launch.json) AND the extension runs in Development mode. It is - // intentionally absent from package.json contributions, so public users - // never see a toolbar button or a localized command-palette entry. - if (process.env.ROO_BROWSER_BRIDGE === "1" && context.extensionMode === vscode.ExtensionMode.Development) { - context.subscriptions.push(registerBrowserBridgeCommand(options)) - } -} - -/** - * Registers the dev-only `openInBrowser` command that switches a provider to - * the socket.io browser bridge. Returns the disposable so the caller can - * manage its lifecycle via `context.subscriptions`. - */ -export function registerBrowserBridgeCommand({ outputChannel }: RegisterCommandOptions): vscode.Disposable { - return vscode.commands.registerCommand(`${Package.name}.openInBrowser`, async () => { - const visibleProvider = getVisibleProviderOrLog(outputChannel) - if (!visibleProvider) { - return - } - - // One provider -> one bridge -> one port, permanently. If this provider - // is already in browser mode, reuse its bridge instead of starting a - // second one (an extra socket.io server would leak its port). - const activePort = visibleProvider.getActiveBrowserBridgePort() - - if (activePort !== undefined) { - outputChannel.appendLine(`[openInBrowser] Reusing existing browser bridge on port ${activePort}.`) - await vscode.env.openExternal(vscode.Uri.parse(BrowserBridgeServer.getBrowserUrl(activePort))) - return - } - - const bridge = await BrowserBridgeServer.start( - (message) => outputChannel.appendLine(message), - (error) => { - // Surface bridge failures to the developer, not just the output - // channel — a silently dead command is a dead end. - void vscode.window.showErrorMessage(`Failed to start the browser bridge: ${error.message}`) - }, - ) - if (!bridge) { - outputChannel.appendLine("[openInBrowser] Failed to start the browser bridge.") - return - } - // Irreversible switch: from now on the provider posts to the virtual - // webview (socket.io) and the real iframe renders a placeholder with a - // clickable link to the browser tab. - visibleProvider.enableBrowserBridge(bridge) - - await vscode.env.openExternal(vscode.Uri.parse(bridge.getBrowserUrl())) - }) + // Dev-only tooling: self-gating no-op in production (see browserBridge.ts). + BrowserBridgeServer.registerCommand(context, options.outputChannel, () => ClineProvider.getVisibleInstance()) } // `showRipgrepDiagnostic` is registered separately by diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 542d041a02..12829eb32f 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -114,7 +114,7 @@ import { CustomModesManager } from "../config/CustomModesManager" import { Task } from "../task/Task" import { webviewMessageHandler } from "./webviewMessageHandler" -import type { BrowserBridgeServer } from "./browserBridge" +import { BrowserBridgeServer } from "./browserBridge" import type { ClineMessage, TodoItem } from "@roo-code/types" import { type ApiMessage, @@ -208,13 +208,6 @@ export class ClineProvider >() private nextThemeFixtureProbeId = 0 private view?: vscode.WebviewView | vscode.WebviewPanel - // Owned socket.io bridge when this provider was switched to browser mode. - // Each provider owns its own bridge (and therefore its own port); it is - // disposed together with the provider. - private browserBridge?: BrowserBridgeServer - // The webview used for message passing. Equals `view.webview` normally, or - // the bridge's virtual webview when the browser bridge is active. - private bridgeWebview?: vscode.Webview private taskRegistry = new TaskRegistry() private taskScheduler = new TaskScheduler() private static readonly delegationTransitionLocks = new Map>() @@ -907,9 +900,7 @@ export class ClineProvider this.flushGlobalStateWriteThrough() // Release the provider-owned browser bridge (socket.io server + port). - this.browserBridge?.dispose() - this.browserBridge = undefined - this.bridgeWebview = undefined + BrowserBridgeServer.disposeFor(this) this.log("Disposed all disposables") ClineProvider.activeInstances.delete(this) @@ -1059,12 +1050,10 @@ export class ClineProvider // an informational placeholder inside the real iframe (no React, no // scripts, but with a clickable link back to the browser tab) so the // environments don't run simultaneously. The virtual webview (backed by - // socket.io) already has its message listener registered in - // enableBrowserBridge(), so it is not re-registered here. - const browserBridgeActive = this.browserBridge !== undefined - - if (browserBridgeActive) { - webviewView.webview.html = this.browserBridge?.getPlaceholderHtml() ?? "" + // socket.io) already has its message listener registered when the + // bridge was enabled, so it is not re-registered here. + if (BrowserBridgeServer.active(this)) { + BrowserBridgeServer.setPlaceholder(this) } else { webviewView.webview.html = this.contextProxy.extensionMode === vscode.ExtensionMode.Development && @@ -1104,9 +1093,9 @@ export class ClineProvider // Sets up an event listener to listen for messages passed from the webview view context // and executes code based on the message that is received. When the bridge is active - // the listener was already registered on the virtual webview in - // enableBrowserBridge(); re-registering here would double-handle every message. - if (!this.bridgeWebview) { + // the listener was already registered on the virtual webview; + // re-registering here would double-handle every message. + if (!BrowserBridgeServer.active(this)) { this.setWebviewMessageListener(webviewView.webview) } @@ -1507,7 +1496,7 @@ export class ClineProvider return } - const webview = this.bridgeWebview ?? this.view?.webview + const webview = BrowserBridgeServer.webviewFor(this) ?? this.view?.webview if (!webview) { return } @@ -1519,45 +1508,6 @@ export class ClineProvider } } - /** - * Irreversibly switches this provider to the browser bridge. - * - * From this point on the provider posts to the virtual webview (backed by - * socket.io) and the real iframe renders a placeholder with a clickable - * link to the browser tab so the two environments never run - * simultaneously. Safe to call repeatedly — the switch only happens once. - * The provider takes ownership of the bridge and disposes it together with - * itself. - */ - public enableBrowserBridge(bridge: BrowserBridgeServer): void { - if (this.bridgeWebview) { - // Already switched: this provider is permanently bound to its first - // bridge (one provider -> one bridge -> one port). Refuse the new - // bridge and dispose it so its socket.io server/port does not leak. - bridge.dispose() - return - } - - this.browserBridge = bridge - this.bridgeWebview = bridge.getOrCreateVirtualWebview() - // The message listener must be registered on the virtual webview exactly - // once. resolveWebviewView() checks `this.bridgeWebview` and skips its own - // registration, so re-resolves don't double-handle messages. - this.setWebviewMessageListener(this.bridgeWebview) - if (this.view) { - this.view.webview.html = bridge.getPlaceholderHtml() - } - } - - /** - * The port of the provider-owned browser bridge, or `undefined` when the - * provider has not been switched to browser mode yet. Lets command handlers - * reuse the existing bridge instead of creating a second one. - */ - public getActiveBrowserBridgePort(): number | undefined { - return this.browserBridge?.port - } - public requestWebviewThemeFixture(timeoutMs = 5_000): Promise { if (process.env.ROO_CODE_THEME_FIXTURE_PROBE !== "1") { return Promise.reject(new Error("Theme fixture probing is disabled")) @@ -4607,7 +4557,7 @@ export class ClineProvider const fileUri = vscode.Uri.file(filePath) // Check if we have a webview available - const activeWebview = this.bridgeWebview ?? this.view?.webview + const activeWebview = BrowserBridgeServer.webviewFor(this) ?? this.view?.webview if (activeWebview) { const webviewUri = activeWebview.asWebviewUri(fileUri) return webviewUri.toString() diff --git a/src/core/webview/browserBridge.ts b/src/core/webview/browserBridge.ts index 1e6bcd083d..8ea97f1824 100644 --- a/src/core/webview/browserBridge.ts +++ b/src/core/webview/browserBridge.ts @@ -1,33 +1,40 @@ import { createServer } from "http" -import { Server as SocketIoServer, type Socket } from "socket.io" -import { Uri } from "vscode" +import type { Server as SocketIoServer, Socket } from "socket.io" +import * as vscode from "vscode" import type { Disposable, Webview } from "vscode" import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" +import { Package } from "../../shared/package" + /** * Browser bridge — standalone-browser UI transport for the Zoo Code webview * (development-only tooling for analyzing render issues with full * React DevTools / Chrome DevTools support). * - * Each `ClineProvider` owns exactly one {@link BrowserBridgeServer} instance. + * `BrowserBridgeServer` is a single statically-imported class used as a + * namespace of verbose statics; per-provider bridge state lives in the module + * {@link BrowserBridgeServer.bridges} WeakMap keyed by the owning host (a + * structural {@link BridgeHost}, so this module never imports ClineProvider). * The bridge is started on demand from the dev-only `zoo-code.openInBrowser` - * command, which is registered only when `ROO_BROWSER_BRIDGE=1` is set in a - * Development extension host (see `registerCommands.ts`); it is not + * command, registered by {@link BrowserBridgeServer.registerCommand} only when + * `ROO_BROWSER_BRIDGE=1` is set in a Development extension host; it is not * contributed in package.json. Once started, the extension host serves a * socket.io server on `127.0.0.1:` and swaps the real VSCode webview for - * a virtual one ({@link createVirtualWebview}). The UI then lives entirely in a - * normal Chrome tab (loaded from the Vite dev server) instead of the VSCode - * webview iframe, while the message protocol (`WebviewMessage` / - * `ExtensionMessage`) stays unchanged. + * a virtual one. The UI then lives entirely in a normal Chrome tab (loaded + * from the Vite dev server) instead of the VSCode webview iframe, while the + * message protocol (`WebviewMessage` / `ExtensionMessage`) stays unchanged. + * + * socket.io itself is imported lazily inside {@link BrowserBridgeServer.start} + * (and externalized in production bundles), so the shipping extension carries + * zero bytes of the bridge server library. * * Ports are unique per bridge: the OS assigns a free port unless * `ROO_BROWSER_BRIDGE_PORT` is set (a dev-only override). The actual port is - * exposed via {@link BrowserBridgeServer.port} and passed to the browser tab - * as a `?bridgePort=` URL query parameter, so any number of Zoo Code - * tabs or sidebar panels can run in the browser simultaneously, each on its - * own port. + * passed to the browser tab as a `?bridgePort=` URL query parameter, so + * any number of Zoo Code tabs or sidebar panels can run in the browser + * simultaneously, each on its own port. */ export const DEFAULT_BROWSER_BRIDGE_PORT = 0 @@ -65,11 +72,159 @@ function getBoundPort(httpServer: SocketIoServer["httpServer"], requestedPort: n return requestedPort } +/** + * Opaque host key for the per-provider bridge state (in practice a + * `ClineProvider`). Typing it as a plain object keeps the bridge usable from + * the activation layer without importing ClineProvider (no module cycle). + */ +export type BridgeHost = object + +/** + * The two provider internals the bridge needs: the currently resolved real + * webview (to render the browser-mode placeholder into) and the private + * webview-message wiring entry point. Both are private on ClineProvider, so + * the bridge reaches them through element access via this local view rather + * than expanding the provider's public API with bridge-specific members. + */ +interface BridgeHostInternals { + view?: vscode.WebviewView | vscode.WebviewPanel + setWebviewMessageListener(webview: Webview): void +} + /** * Server side of the bridge. Binds to 127.0.0.1 only and restricts CORS to * local origins, since this is a development-only transport. */ -export class BrowserBridgeServer implements Disposable { +export class BrowserBridgeServer { + /** + * Per-host bridge registry. One host (provider) -> one bridge -> one port, + * permanently, until {@link BrowserBridgeServer.disposeFor} runs. + */ + private static readonly bridges = new WeakMap() + + // ---- dev-only command registration (self-gating; one line at the call site) ---- + + /** + * Registers the dev-only `openInBrowser` command when (and only when) + * `ROO_BROWSER_BRIDGE=1` is set in a Development extension host. In every + * other environment (i.e. production) this is a no-op, so the command — + * and everything reachable only through it — is dead code. + * + * The command handler owns the whole flow: visible-provider lookup -> + * reuse existing bridge or start a new one -> enable (bind) -> open the + * browser tab. + */ + static registerCommand( + context: vscode.ExtensionContext, + outputChannel: vscode.OutputChannel, + getVisibleProvider: () => BridgeHost | undefined, + ): void { + // Dev-only tooling: intentionally absent from package.json + // contributions, so public users never see a toolbar button or a + // localized command-palette entry. + if (process.env.ROO_BROWSER_BRIDGE !== "1") { + return + } + if (context.extensionMode !== vscode.ExtensionMode.Development) { + return + } + + context.subscriptions.push( + vscode.commands.registerCommand(`${Package.name}.openInBrowser`, async () => { + const host = getVisibleProvider() + if (!host) { + outputChannel.appendLine("Cannot find any visible Roo Code instances.") + return + } + + // One host -> one bridge -> one port, permanently. If this + // provider is already in browser mode, reuse its bridge + // instead of starting a second one (an extra socket.io server + // would leak its port). + const existing = BrowserBridgeServer.bridges.get(host) + if (existing) { + outputChannel.appendLine( + `[openInBrowser] Reusing existing browser bridge on port ${existing._port}.`, + ) + await vscode.env.openExternal(vscode.Uri.parse(BrowserBridgeServer.getBrowserUrl(existing._port))) + return + } + + const bridge = await BrowserBridgeServer.start( + (message) => outputChannel.appendLine(message), + (error) => { + // Surface bridge failures to the developer, not just the + // output channel — a silently dead command is a dead end. + void vscode.window.showErrorMessage(`Failed to start the browser bridge: ${error.message}`) + }, + ) + if (!bridge) { + outputChannel.appendLine("[openInBrowser] Failed to start the browser bridge.") + return + } + + // Irreversible switch: from now on the provider posts to the + // virtual webview (socket.io) and the real iframe renders a + // placeholder with a clickable link to the browser tab. + BrowserBridgeServer.bind(host, bridge) + + await vscode.env.openExternal(vscode.Uri.parse(bridge.getBrowserUrl())) + }), + ) + } + + // ---- provider-facing statics ---- + + /** + * Puts `host` into browser mode: start-if-needed + one-bridge-per-host + * guard + virtual webview + listener wiring + placeholder refresh. Bridge + * startup is asynchronous; callers that need the port/URL (e.g. the + * `openInBrowser` command) go through {@link registerCommand}'s handler. + */ + static enable(host: BridgeHost, listen?: (webview: Webview) => void): void { + if (BrowserBridgeServer.bridges.has(host)) { + return + } + void BrowserBridgeServer.start().then((bridge) => { + if (bridge) { + BrowserBridgeServer.bind(host, bridge, listen) + } + }) + } + + /** True while `host` has an active bridge — the only query callers need. */ + static active(host: BridgeHost): boolean { + return BrowserBridgeServer.bridges.has(host) + } + + /** + * Renders the bridge placeholder into the host's already-resolved real + * webview (no-op while the view is not resolved or no bridge is active). + */ + static setPlaceholder(host: BridgeHost): void { + const bridge = BrowserBridgeServer.bridges.get(host) + const view = (host as BridgeHostInternals).view + if (bridge && view) { + view.webview.html = bridge.getPlaceholderHtml() + } + } + + /** The virtual webview when a bridge is active, `undefined` otherwise. */ + static webviewFor(host: BridgeHost): Webview | undefined { + return BrowserBridgeServer.bridges.get(host)?.getOrCreateVirtualWebview() + } + + /** Closes the socket.io server and drops the WeakMap entry for `host`. */ + static disposeFor(host: BridgeHost): void { + const bridge = BrowserBridgeServer.bridges.get(host) + if (bridge) { + bridge.dispose() + BrowserBridgeServer.bridges.delete(host) + } + } + + // ---- private internals ---- + private readonly server: SocketIoServer private readonly _port: number @@ -93,11 +248,6 @@ export class BrowserBridgeServer implements Disposable { }) } - /** The TCP port this bridge is listening on. */ - get port(): number { - return this._port - } - /** * URL a browser tab must load to connect to the bridge listening on * `port` (the Vite dev server plus the `?bridgePort` query parameter). @@ -107,10 +257,28 @@ export class BrowserBridgeServer implements Disposable { } /** - * URL a browser tab must load to connect to this bridge. + * Attaches `bridge` to `host` under the one-bridge-per-host rule: a + * rejected newcomer (e.g. a second enable racing the first) disposes + * itself instead of overwriting the active bridge. */ - getBrowserUrl(): string { - return BrowserBridgeServer.getBrowserUrl(this._port) + private static bind(host: BridgeHost, bridge: BrowserBridgeServer, listen?: (webview: Webview) => void): void { + if (BrowserBridgeServer.bridges.has(host)) { + bridge.dispose() + return + } + BrowserBridgeServer.bridges.set(host, bridge) + + const webview = bridge.getOrCreateVirtualWebview() + if (listen) { + listen(webview) + } else { + // Element-access call into the provider's private wiring (see + // BridgeHostInternals) — keeps ClineProvider's public API clean. + const internals = host as BridgeHostInternals + internals.setWebviewMessageListener(webview) + } + + BrowserBridgeServer.setPlaceholder(host) } /** @@ -121,7 +289,7 @@ export class BrowserBridgeServer implements Disposable { * cannot be restored and links back to the browser tab so it can be * reopened by clicking. */ - getPlaceholderHtml(): string { + private getPlaceholderHtml(): string { const url = this.getBrowserUrl() return /*html*/ ` @@ -144,6 +312,11 @@ export class BrowserBridgeServer implements Disposable { ` } + /** The URL a browser tab must load to connect to this bridge. */ + private getBrowserUrl(): string { + return BrowserBridgeServer.getBrowserUrl(this._port) + } + /** * Starts the bridge server on {@link getBrowserBridgePort}. Returns * undefined when the port is already taken (only possible with an explicit @@ -153,17 +326,22 @@ export class BrowserBridgeServer implements Disposable { * surface it to the user (e.g. `vscode.window.showErrorMessage`) instead * of only writing to a log. */ - static async start( + private static async start( log: (message: string) => void = console.log, onError?: (error: Error) => void, ): Promise { const requestedPort = getBrowserBridgePort() + // Lazy import keeps socket.io off the eager module graph: the only + // reachable caller is the dev-only command path (plus tests), so the + // production bundle (where socket.io is externalized) ships none of it. + const { Server: SocketIoServerClass } = await import("socket.io") + // Create the http server explicitly (the socket.io Server constructor // with options alone does not own one) and let socket.io take over its // lifecycle. const httpServer = createServer() - const server = new SocketIoServer(httpServer, { + const server = new SocketIoServerClass(httpServer, { // Development-only transport: bind loopback and allow local origins only. cors: { origin: [/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/], @@ -183,6 +361,7 @@ export class BrowserBridgeServer implements Disposable { const failure = error instanceof Error ? error : new Error(String(error)) log(`[BrowserBridge] Failed to start on 127.0.0.1:${requestedPort} -> ${failure.message}`) onError?.(failure) + await new Promise((resolve) => void server.close(() => resolve())) return undefined } @@ -195,9 +374,9 @@ export class BrowserBridgeServer implements Disposable { * The single virtual webview for this bridge. Reused across webview * re-resolves so bridge-level listeners are registered exactly once. */ - getOrCreateVirtualWebview(): Webview { + private getOrCreateVirtualWebview(): Webview { if (!this.virtualWebview) { - this.virtualWebview = createVirtualWebview(this) + this.virtualWebview = BrowserBridgeServer.createVirtualWebview(this) } return this.virtualWebview } @@ -205,7 +384,7 @@ export class BrowserBridgeServer implements Disposable { /** * Broadcasts an extension->webview message to all connected browser clients. */ - broadcast(message: ExtensionMessage): void { + private broadcast(message: ExtensionMessage): void { this.server.emit("extensionMessage", message) } @@ -213,7 +392,7 @@ export class BrowserBridgeServer implements Disposable { * Subscribes to webview->extension messages coming from browser clients. * Returns a dispose function. */ - onWebviewMessage(listener: (message: WebviewMessage) => void): Disposable { + private onWebviewMessage(listener: (message: WebviewMessage) => void): Disposable { this.webviewMessageListeners.add(listener) return { dispose: () => { @@ -222,51 +401,52 @@ export class BrowserBridgeServer implements Disposable { } } - dispose(): void { + private dispose(): void { try { void this.server.close() } catch { // Already closed } } -} -/** - * A virtual `vscode.Webview` implementation backed by the browser bridge. - * - * Mirrors the CLI mock-webview pattern: `postMessage` broadcasts to the browser, - * `onDidReceiveMessage` forwards browser messages to the provider. The real - * iframe webview is intentionally not used — the UI runs only in Chrome. - */ -export function createVirtualWebview(bridge: BrowserBridgeServer): Webview { - const messageListeners = new Set<(message: WebviewMessage) => void>() + /** + * A virtual `vscode.Webview` implementation backed by the browser bridge. + * + * Mirrors the CLI mock-webview pattern: `postMessage` broadcasts to the + * browser, `onDidReceiveMessage` forwards browser messages to the + * provider. The real iframe webview is intentionally not used — the UI + * runs only in Chrome. + */ + private static createVirtualWebview(bridge: BrowserBridgeServer): Webview { + const messageListeners = new Set<(message: WebviewMessage) => void>() - bridge.onWebviewMessage((message) => { - for (const listener of messageListeners) { - listener(message) - } - }) - - return { - options: { enableScripts: true }, - cspSource: "vscode-webview://bridge", - html: "", - postMessage(message: unknown): Thenable { - bridge.broadcast(message as ExtensionMessage) - return Promise.resolve(true) - }, - onDidReceiveMessage(listener: (message: unknown) => void): Disposable { - const wrapped = listener as (message: WebviewMessage) => void - messageListeners.add(wrapped) - return { - dispose: () => { - messageListeners.delete(wrapped) - }, + bridge.onWebviewMessage((message) => { + for (const listener of messageListeners) { + listener(message) } - }, - asWebviewUri(localResource: Uri): Uri { - // The browser UI never renders webview URIs; return the input unchanged. - return localResource - }, + }) + + return { + options: { enableScripts: true }, + cspSource: "vscode-webview://bridge", + html: "", + postMessage(message: unknown): Thenable { + bridge.broadcast(message as ExtensionMessage) + return Promise.resolve(true) + }, + onDidReceiveMessage(listener: (message: unknown) => void): Disposable { + const wrapped = listener as (message: WebviewMessage) => void + messageListeners.add(wrapped) + return { + dispose: () => { + messageListeners.delete(wrapped) + }, + } + }, + asWebviewUri(localResource: vscode.Uri): vscode.Uri { + // The browser UI never renders webview URIs; return the input unchanged. + return localResource + }, + } } } diff --git a/webview-ui/src/browserBridge.css b/webview-ui/src/browserBridge.css new file mode 100644 index 0000000000..47fdbfc549 --- /dev/null +++ b/webview-ui/src/browserBridge.css @@ -0,0 +1,187 @@ +/* + * Standalone browser mode (browser bridge) styles. + * + * Dynamically imported by BrowserBridgeClient.maybeConnect() only when the + * bridge is active, so the production webview bundle never carries these + * rules (see import.meta.env.DEV dead-code elimination in + * src/utils/browserBridgeClient.ts). + */ +@layer base { + /* + * VS Code is not present in a browser tab, so the --vscode-* variables it + * normally injects into the webview are missing. Provide a minimal + * dark-theme fallback scoped to the marker class added by the browser + * bridge client so the UI renders with a usable theme. + */ + html.roo-browser-mode { + --vscode-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --vscode-font-size: 13px; + + --vscode-editor-background: #1e1e1e; + --vscode-editor-foreground: #d4d4d4; + --vscode-editorGroup-border: #444444; + --vscode-editorWarning-foreground: #cca700; + --vscode-editorWarning-background: #352a05; + + --vscode-foreground: #cccccc; + + --vscode-button-foreground: #ffffff; + --vscode-button-background: #0e639c; + --vscode-button-secondaryForeground: #ffffff; + --vscode-button-secondaryBackground: #3a3d41; + --vscode-button-hoverBackground: #1177bb; + + --vscode-dropdown-foreground: #cccccc; + --vscode-dropdown-background: #3c3c3c; + --vscode-dropdown-border: #3c3c3c; + + --vscode-input-foreground: #cccccc; + --vscode-input-background: #3c3c3c; + --vscode-input-border: #3c3c3c; + + --vscode-focusBorder: #007fd4; + + --vscode-badge-foreground: #ffffff; + --vscode-badge-background: #4d4d4d; + + --vscode-notifications-foreground: #cccccc; + --vscode-notifications-background: #252526; + --vscode-notifications-border: #303031; + + --vscode-descriptionForeground: #9d9d9d; + --vscode-errorForeground: #f48771; + + --vscode-list-hoverForeground: #ffffff; + --vscode-list-hoverBackground: #2a2d2e; + --vscode-list-focusBackground: #094771; + --vscode-list-activeSelectionBackground: #094771; + --vscode-list-activeSelectionForeground: #ffffff; + + --vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31); + --vscode-toolbar-hoverOutline: rgba(255, 255, 255, 0.12); + + --vscode-panel-border: #444444; + + --vscode-sideBar-foreground: #cccccc; + --vscode-sideBar-background: #252526; + --vscode-sideBar-border: #444444; + + --vscode-sideBarSectionHeader-foreground: #cccccc; + --vscode-sideBarSectionHeader-background: #2b2b2b; + --vscode-sideBarSectionHeader-border: #444444; + + --vscode-titleBar-activeForeground: #cccccc; + --vscode-titleBar-inactiveForeground: #9d9d9d; + + --vscode-charts-green: #89d185; + --vscode-charts-red: #f14c4c; + --vscode-charts-yellow: #cca700; + --vscode-charts-blue: #3794ff; + --vscode-charts-orange: #d18616; + + --vscode-inputValidation-infoForeground: #3794ff; + --vscode-inputValidation-infoBackground: #063b49; + --vscode-inputValidation-infoBorder: #007acc; + --vscode-inputValidation-warningForeground: #cca700; + --vscode-inputValidation-warningBackground: #352a05; + --vscode-inputValidation-warningBorder: #b89500; + --vscode-inputValidation-errorForeground: #f48771; + --vscode-inputValidation-errorBackground: #5a1d1d; + --vscode-inputValidation-errorBorder: #be1100; + + --vscode-textCodeBlock-background: #2d2d2d; + --vscode-menu-foreground: #cccccc; + --vscode-menu-background: #252526; + --vscode-menu-selectionBackground: #094771; + + --vscode-textLink-foreground: #3794ff; + --vscode-disabledForeground: #6f6f6f; + --vscode-banner-background: #004578; + --vscode-banner-foreground: #ffffff; + + --vscode-font-weight: normal; + --vscode-textLink-activeForeground: #3794ff; + --vscode-scrollbarSlider-background: rgba(121, 121, 121, 0.4); + --vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7); + --vscode-scrollbarSlider-activeBackground: rgba(191, 191, 191, 0.4); + --vscode-textPreformat-foreground: #d7ba7d; + --vscode-textPreformat-background: transparent; + --vscode-textBlockQuote-background: #222222; + --vscode-textBlockQuote-border: #3e3e42; + --vscode-keybindingLabel-background: rgba(128, 128, 128, 0.17); + --vscode-keybindingLabel-foreground: #cccccc; + --vscode-keybindingLabel-border: rgba(51, 51, 51, 0.6); + --vscode-keybindingLabel-bottomBorder: rgba(68, 68, 68, 0.6); + --vscode-widget-shadow: rgba(0, 0, 0, 0.36); + --vscode-editorWidget-foreground: #cccccc; + --vscode-editorWidget-background: #252526; + --vscode-editorWidget-border: #454545; + --vscode-progressBar-background: #0e70c0; + --vscode-icon-foreground: #c5c5c5; + --vscode-editorError-foreground: #f14c4c; + --vscode-diffEditor-insertedTextBackground: rgba(155, 185, 85, 0.2); + --vscode-diffEditor-removedTextBackground: rgba(255, 0, 0, 0.2); + } + + /* + * VS Code additionally injects an unlayered "_defaultStyles" stylesheet + * into every webview (html/body background + text color, font, scrollbars, + * link/code colors). A plain browser tab has none of that, which is why + * body and input text rendered black-on-white even with the variables + * above. Mirror the essentials here, scoped to browser mode. + */ + html.roo-browser-mode { + background-color: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); + } + + html.roo-browser-mode body { + overscroll-behavior-x: none; + background-color: transparent; + color: var(--vscode-editor-foreground); + font-family: var(--vscode-font-family); + font-weight: var(--vscode-font-weight); + font-size: var(--vscode-font-size); + padding: 0 20px; + } + + html.roo-browser-mode a, + html.roo-browser-mode a code { + color: var(--vscode-textLink-foreground); + } + + html.roo-browser-mode a:hover { + color: var(--vscode-textLink-activeForeground); + } + + html.roo-browser-mode code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + color: var(--vscode-textPreformat-foreground); + } + + html.roo-browser-mode blockquote { + background: var(--vscode-textBlockQuote-background); + border-left: 1px solid var(--vscode-textBlockQuote-border); + } + + html.roo-browser-mode ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + html.roo-browser-mode ::-webkit-scrollbar-corner { + background-color: var(--vscode-editor-background); + } + + html.roo-browser-mode ::-webkit-scrollbar-thumb { + background-color: var(--vscode-scrollbarSlider-background); + } + + html.roo-browser-mode ::-webkit-scrollbar-thumb:hover { + background-color: var(--vscode-scrollbarSlider-hoverBackground); + } + + html.roo-browser-mode ::-webkit-scrollbar-thumb:active { + background-color: var(--vscode-scrollbarSlider-activeBackground); + } +} diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index fab7265e27..22578817ff 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -186,182 +186,11 @@ } /* - * Standalone browser mode (browser bridge): VS Code is not present, so the - * --vscode-* variables it normally injects into the webview are missing. - * Provide a minimal dark-theme fallback scoped to the marker class added by - * the browser bridge client so the UI renders with a usable theme. + * Standalone browser mode (browser bridge) styles live in + * src/browserBridge.css and are dynamically imported by + * BrowserBridgeClient only when the bridge is active, keeping them out + * of the production webview bundle. */ - html.roo-browser-mode { - --vscode-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - --vscode-font-size: 13px; - - --vscode-editor-background: #1e1e1e; - --vscode-editor-foreground: #d4d4d4; - --vscode-editorGroup-border: #444444; - --vscode-editorWarning-foreground: #cca700; - --vscode-editorWarning-background: #352a05; - - --vscode-foreground: #cccccc; - - --vscode-button-foreground: #ffffff; - --vscode-button-background: #0e639c; - --vscode-button-secondaryForeground: #ffffff; - --vscode-button-secondaryBackground: #3a3d41; - --vscode-button-hoverBackground: #1177bb; - - --vscode-dropdown-foreground: #cccccc; - --vscode-dropdown-background: #3c3c3c; - --vscode-dropdown-border: #3c3c3c; - - --vscode-input-foreground: #cccccc; - --vscode-input-background: #3c3c3c; - --vscode-input-border: #3c3c3c; - - --vscode-focusBorder: #007fd4; - - --vscode-badge-foreground: #ffffff; - --vscode-badge-background: #4d4d4d; - - --vscode-notifications-foreground: #cccccc; - --vscode-notifications-background: #252526; - --vscode-notifications-border: #303031; - - --vscode-descriptionForeground: #9d9d9d; - --vscode-errorForeground: #f48771; - - --vscode-list-hoverForeground: #ffffff; - --vscode-list-hoverBackground: #2a2d2e; - --vscode-list-focusBackground: #094771; - --vscode-list-activeSelectionBackground: #094771; - --vscode-list-activeSelectionForeground: #ffffff; - - --vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31); - --vscode-toolbar-hoverOutline: rgba(255, 255, 255, 0.12); - - --vscode-panel-border: #444444; - - --vscode-sideBar-foreground: #cccccc; - --vscode-sideBar-background: #252526; - --vscode-sideBar-border: #444444; - - --vscode-sideBarSectionHeader-foreground: #cccccc; - --vscode-sideBarSectionHeader-background: #2b2b2b; - --vscode-sideBarSectionHeader-border: #444444; - - --vscode-titleBar-activeForeground: #cccccc; - --vscode-titleBar-inactiveForeground: #9d9d9d; - - --vscode-charts-green: #89d185; - --vscode-charts-red: #f14c4c; - --vscode-charts-yellow: #cca700; - --vscode-charts-blue: #3794ff; - --vscode-charts-orange: #d18616; - - --vscode-inputValidation-infoForeground: #3794ff; - --vscode-inputValidation-infoBackground: #063b49; - --vscode-inputValidation-infoBorder: #007acc; - --vscode-inputValidation-warningForeground: #cca700; - --vscode-inputValidation-warningBackground: #352a05; - --vscode-inputValidation-warningBorder: #b89500; - --vscode-inputValidation-errorForeground: #f48771; - --vscode-inputValidation-errorBackground: #5a1d1d; - --vscode-inputValidation-errorBorder: #be1100; - - --vscode-textCodeBlock-background: #2d2d2d; - --vscode-menu-foreground: #cccccc; - --vscode-menu-background: #252526; - --vscode-menu-selectionBackground: #094771; - - --vscode-textLink-foreground: #3794ff; - --vscode-disabledForeground: #6f6f6f; - --vscode-banner-background: #004578; - --vscode-banner-foreground: #ffffff; - - --vscode-font-weight: normal; - --vscode-textLink-activeForeground: #3794ff; - --vscode-scrollbarSlider-background: rgba(121, 121, 121, 0.4); - --vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7); - --vscode-scrollbarSlider-activeBackground: rgba(191, 191, 191, 0.4); - --vscode-textPreformat-foreground: #d7ba7d; - --vscode-textPreformat-background: transparent; - --vscode-textBlockQuote-background: #222222; - --vscode-textBlockQuote-border: #3e3e42; - --vscode-keybindingLabel-background: rgba(128, 128, 128, 0.17); - --vscode-keybindingLabel-foreground: #cccccc; - --vscode-keybindingLabel-border: rgba(51, 51, 51, 0.6); - --vscode-keybindingLabel-bottomBorder: rgba(68, 68, 68, 0.6); - --vscode-widget-shadow: rgba(0, 0, 0, 0.36); - --vscode-editorWidget-foreground: #cccccc; - --vscode-editorWidget-background: #252526; - --vscode-editorWidget-border: #454545; - --vscode-progressBar-background: #0e70c0; - --vscode-icon-foreground: #c5c5c5; - --vscode-editorError-foreground: #f14c4c; - --vscode-diffEditor-insertedTextBackground: rgba(155, 185, 85, 0.2); - --vscode-diffEditor-removedTextBackground: rgba(255, 0, 0, 0.2); - } - - /* - * VS Code additionally injects an unlayered "_defaultStyles" stylesheet - * into every webview (html/body background + text color, font, scrollbars, - * link/code colors). A plain browser tab has none of that, which is why - * body and input text rendered black-on-white even with the variables - * above. Mirror the essentials here, scoped to browser mode. - */ - html.roo-browser-mode { - background-color: var(--vscode-editor-background); - color: var(--vscode-editor-foreground); - } - - html.roo-browser-mode body { - overscroll-behavior-x: none; - background-color: transparent; - color: var(--vscode-editor-foreground); - font-family: var(--vscode-font-family); - font-weight: var(--vscode-font-weight); - font-size: var(--vscode-font-size); - padding: 0 20px; - } - - html.roo-browser-mode a, - html.roo-browser-mode a code { - color: var(--vscode-textLink-foreground); - } - - html.roo-browser-mode a:hover { - color: var(--vscode-textLink-activeForeground); - } - - html.roo-browser-mode code { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - color: var(--vscode-textPreformat-foreground); - } - - html.roo-browser-mode blockquote { - background: var(--vscode-textBlockQuote-background); - border-left: 1px solid var(--vscode-textBlockQuote-border); - } - - html.roo-browser-mode ::-webkit-scrollbar { - width: 10px; - height: 10px; - } - - html.roo-browser-mode ::-webkit-scrollbar-corner { - background-color: var(--vscode-editor-background); - } - - html.roo-browser-mode ::-webkit-scrollbar-thumb { - background-color: var(--vscode-scrollbarSlider-background); - } - - html.roo-browser-mode ::-webkit-scrollbar-thumb:hover { - background-color: var(--vscode-scrollbarSlider-hoverBackground); - } - - html.roo-browser-mode ::-webkit-scrollbar-thumb:active { - background-color: var(--vscode-scrollbarSlider-activeBackground); - } /* Higher specififty than VSCode's theme and root. */ /* Used for baseline theme overrides, but avoid using for styling. */ diff --git a/webview-ui/src/utils/browserBridgeClient.ts b/webview-ui/src/utils/browserBridgeClient.ts new file mode 100644 index 0000000000..ee5682f4d5 --- /dev/null +++ b/webview-ui/src/utils/browserBridgeClient.ts @@ -0,0 +1,150 @@ +import { WebviewMessage } from "@roo/WebviewMessage" + +/** + * Browser bridge — client side of the standalone-browser UI transport. + * + * Mirrors the server-side design (`src/core/webview/browserBridge.ts`): one + * self-contained class exposing verbose statics that own all bridge state + * internally and self-gate their own enablement. `VSCodeAPIWrapper` keeps no + * bridge fields — it only calls {@link BrowserBridgeClient.maybeConnect}, + * {@link BrowserBridgeClient.active}, and {@link BrowserBridgeClient.postMessage}. + * + * When the UI runs in a normal Chrome tab (loaded from the Vite dev server), + * `acquireVsCodeApi` is undefined. Messages then flow over socket.io to the + * extension host instead of the VSCode webview message protocol: + * + * - `postMessage(message)` emits `webviewMessage` to the bridge server + * - inbound `extensionMessage` events are re-dispatched through + * `window.postMessage` so the existing `window.addEventListener("message")` + * consumers in the app work unchanged. + * + * Production bundle isolation: the first thing `maybeConnect` does is return + * when `import.meta.env.DEV` is false. Vite replaces that with a literal + * `false` at build time, so the entire remainder of the body — including the + * dynamic `import("socket.io-client")` and the browser-mode CSS import — is + * dead code the bundler strips, and neither ever lands in the shipped assets. + */ + +/** + * The bridge port is served as a `?bridgePort=` query parameter on the + * URL opened in Chrome by the "Open in Chrome" command. Its presence tells the + * UI that the browser bridge is active — there is no env-var or build-time + * flag involved. Returns `undefined` when the param is absent. + */ +function getBridgePortFromUrl(): number | undefined { + const raw = new URLSearchParams(window.location.search).get("bridgePort") + if (raw !== null && raw !== "") { + const port = Number(raw) + if (Number.isInteger(port) && port > 0 && port < 65536) { + return port + } + } + return undefined +} + +type BridgeSocket = { + on(event: string, listener: (...args: any[]) => void): void + emit(event: string, ...args: any[]): void + disconnect(): void +} + +export class BrowserBridgeClient { + private static instance: BrowserBridgeClient | undefined + private static queue: WebviewMessage[] = [] + + private socket: BridgeSocket | undefined + + private constructor(port: number) { + void this.connect(port) + } + + private async connect(port: number): Promise { + // The only socket.io-client reference in the whole webview bundle, and + // it is reachable only from dev builds (see the class doc comment). + const { io } = await import("socket.io-client") + + // Mark the document so CSS can provide a dark-theme fallback for the + // --vscode-* variables that VS Code normally injects into the webview. + document.documentElement.classList.add("roo-browser-mode") + // Browser-mode styles are shipped as a separate chunk that production + // builds never emit (unreachable from dead code above). + await import("../browserBridge.css") + + const socket: BridgeSocket = io(`http://127.0.0.1:${port}`, { + transports: ["websocket", "polling"], + }) + this.socket = socket + + socket.on("connect", () => { + const pending = BrowserBridgeClient.queue + BrowserBridgeClient.queue = [] + for (const message of pending) { + socket.emit("webviewMessage", message) + } + }) + + socket.on("extensionMessage", (message: unknown) => { + window.postMessage(message, "*") + }) + + socket.on("connect_error", (error: unknown) => { + console.warn("[BrowserBridge] socket.io connect error:", error) + }) + } + + /** + * Connects to the bridge when (and only when) this tab was opened by the + * dev-only "Open in Chrome" command. Self-gating, in this order: + * + * 1. production build (`import.meta.env.DEV === false`) → return; dead-code + * elimination strips everything below from the shipped bundle + * 2. no `?bridgePort=` param → return (plain dev-server tab keeps the + * localStorage-only fallback behavior) + * 3. lazy-load socket.io-client, apply browser-mode CSS, connect, and + * flush any queued messages + */ + static maybeConnect(): void { + if (!import.meta.env.DEV) { + return + } + if (BrowserBridgeClient.instance) { + return + } + const port = getBridgePortFromUrl() + if (port === undefined) { + return + } + BrowserBridgeClient.instance = new BrowserBridgeClient(port) + } + + /** True while the bridge client exists (connecting or connected). */ + static active(): boolean { + if (!import.meta.env.DEV) { + return false + } + return BrowserBridgeClient.instance !== undefined + } + + /** + * Sends a webview->extension message over the bridge. Queues while the + * socket is still connecting so early app messages are not lost. + */ + static postMessage(message: WebviewMessage): void { + const instance = BrowserBridgeClient.instance + if (!instance) { + return + } + if (instance.socket) { + instance.socket.emit("webviewMessage", message) + } else { + BrowserBridgeClient.queue.push(message) + } + } + + /** Test seam: tear the singleton down (not part of the app lifecycle). */ + static resetForTests(): void { + BrowserBridgeClient.instance?.socket?.disconnect() + BrowserBridgeClient.instance = undefined + BrowserBridgeClient.queue = [] + } +} diff --git a/webview-ui/src/utils/vscode.ts b/webview-ui/src/utils/vscode.ts index e3c94a7c24..ccdfd39d63 100644 --- a/webview-ui/src/utils/vscode.ts +++ b/webview-ui/src/utils/vscode.ts @@ -1,66 +1,8 @@ import type { WebviewApi } from "vscode-webview" -import { io, type Socket } from "socket.io-client" import { WebviewMessage } from "@roo/WebviewMessage" -/** - * Browser bridge transport for standalone (non-webview) browser mode. - * - * When the UI runs in a normal Chrome tab (loaded from the Vite dev server), - * `acquireVsCodeApi` is undefined. Messages then flow over socket.io to the - * extension host instead of the VSCode webview message protocol: - * - * - `postMessage(message)` emits `webviewMessage` to the bridge server - * - inbound `extensionMessage` events are re-dispatched through - * `window.postMessage` so the existing `window.addEventListener("message")` - * consumers in the app work unchanged. - */ -class BrowserBridgeClient { - private readonly socket: Socket - - constructor(port: number) { - this.socket = io(`http://127.0.0.1:${port}`, { - transports: ["websocket", "polling"], - }) - - // Mark the document so CSS can provide a dark-theme fallback for the - // --vscode-* variables that VS Code normally injects into the webview. - document.documentElement.classList.add("roo-browser-mode") - - this.socket.on("extensionMessage", (message: unknown) => { - window.postMessage(message, "*") - }) - - this.socket.on("connect_error", (error) => { - console.warn("[BrowserBridge] socket.io connect error:", error) - }) - } - - public postMessage(message: WebviewMessage) { - this.socket.emit("webviewMessage", message) - } - - public dispose() { - this.socket.disconnect() - } -} - -/** - * The bridge port is served as a `?bridgePort=` query parameter on the - * URL opened in Chrome by the "Open in Chrome" command. Its presence tells the - * UI that the browser bridge is active — there is no env-var or build-time - * flag involved. Returns `undefined` when the param is absent. - */ -function getBridgePortFromUrl(): number | undefined { - const raw = new URLSearchParams(window.location.search).get("bridgePort") - if (raw !== null && raw !== "") { - const port = Number(raw) - if (Number.isInteger(port) && port > 0 && port < 65536) { - return port - } - } - return undefined -} +import { BrowserBridgeClient } from "./browserBridgeClient" /** * A utility wrapper around the acquireVsCodeApi() function, which enables @@ -73,7 +15,6 @@ function getBridgePortFromUrl(): number | undefined { */ class VSCodeAPIWrapper { private readonly vsCodeApi: WebviewApi | undefined - private readonly browserBridge: BrowserBridgeClient | undefined constructor() { // Check if the acquireVsCodeApi function exists in the current development @@ -81,13 +22,10 @@ class VSCodeAPIWrapper { if (typeof acquireVsCodeApi === "function") { this.vsCodeApi = acquireVsCodeApi() } else { - // Use the browser bridge only when the "Open in Chrome" flow opened - // this tab with a ?bridgePort=... query param. Otherwise (e.g. a - // plain dev-server tab) fall back to localStorage-only behavior. - const bridgePort = getBridgePortFromUrl() - if (bridgePort !== undefined) { - this.browserBridge = new BrowserBridgeClient(bridgePort) - } + // Browser mode is self-gated inside the client: it connects only + // when this dev-server tab was opened by the "Open in Chrome" + // command with a ?bridgePort=... query param. + void BrowserBridgeClient.maybeConnect() } } @@ -102,8 +40,8 @@ class VSCodeAPIWrapper { public postMessage(message: WebviewMessage) { if (this.vsCodeApi) { this.vsCodeApi.postMessage(message) - } else if (this.browserBridge) { - this.browserBridge.postMessage(message) + } else if (BrowserBridgeClient.active()) { + BrowserBridgeClient.postMessage(message) } else { console.log(message) } From d57863d321b3724ae865fea108e29688935db0c5 Mon Sep 17 00:00:00 2001 From: hnbdr Date: Fri, 11 Sep 2026 18:37:08 +0300 Subject: [PATCH 4/6] build(webview): keep browser bridge out of production bundles - esbuild: externalize socket.io in --production bundles (its only dynamic import sits behind BrowserBridgeServer's ROO_BROWSER_BRIDGE self-gate and can never execute in prod), verified dist/extension.js contains zero socket.io/engine.io bytes - webview-ui: gate BrowserBridgeClient call sites behind import.meta.env.DEV so Rolldown tree-shakes the client class, its lazy socket.io-client import, and the browserBridge.css chunk out of production assets (verified: no socket.io / bridgePort / roo-browser-mode markers in build assets) - deps: move socket.io (src) and socket.io-client (webview-ui) to devDependencies + lockfile regen --- pnpm-lock.yaml | 12 ++++++------ src/esbuild.mjs | 14 +++++++++++++- src/package.json | 2 +- webview-ui/package.json | 2 +- webview-ui/src/utils/browserBridgeClient.ts | 11 ++++++----- webview-ui/src/utils/vscode.ts | 14 +++++++++----- 6 files changed, 36 insertions(+), 19 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70a2fad932..122e2cd151 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -595,9 +595,6 @@ importers: simple-git: specifier: ^3.27.0 version: 3.36.0 - socket.io: - specifier: ^4.8.3 - version: 4.8.3 strip-bom: specifier: ^5.0.0 version: 5.0.0 @@ -695,6 +692,9 @@ importers: rimraf: specifier: 6.0.1 version: 6.0.1 + socket.io: + specifier: 4.8.3 + version: 4.8.3 socket.io-client: specifier: 4.8.3 version: 4.8.3 @@ -866,9 +866,6 @@ importers: shiki: specifier: ^3.2.1 version: 3.4.1 - socket.io-client: - specifier: ^4.8.3 - version: 4.8.3 source-map: specifier: ^0.7.4 version: 0.7.6 @@ -963,6 +960,9 @@ importers: monocart-reporter: specifier: ^2.9.20 version: 2.12.2 + socket.io-client: + specifier: 4.8.3 + version: 4.8.3 vite: specifier: 8.1.0 version: 8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) diff --git a/src/esbuild.mjs b/src/esbuild.mjs index 8159581f36..8e92d98600 100644 --- a/src/esbuild.mjs +++ b/src/esbuild.mjs @@ -118,6 +118,18 @@ async function main() { /** * @type {import('esbuild').BuildOptions} */ + // Invariant: socket.io is dev-only tooling for the browser bridge. Its single + // dynamic `import()` lives in BrowserBridgeServer.start(), which is reachable + // only through `registerCommand`'s `ROO_BROWSER_BRIDGE=1` + Development + // self-gate and can never execute in a production extension host. Keeping it + // external in production bundles ships zero bytes of socket.io/engine.io/ws + // (the VSIX is packaged with `--no-dependencies` anyway), while dev bundles + // keep inlining it so the bridge works straight from node_modules. + const external = ["vscode", "esbuild", "global-agent", "@vscode/ripgrep"] + if (production) { + external.push("socket.io") + } + const extensionConfig = { ...buildOptions, plugins, @@ -126,7 +138,7 @@ async function main() { // global-agent must be external because it dynamically patches Node.js http/https modules // which breaks when bundled. It needs access to the actual Node.js module instances. // undici must be bundled because our VSIX is packaged with `--no-dependencies`. - external: ["vscode", "esbuild", "global-agent", "@vscode/ripgrep"], + external, } /** diff --git a/src/package.json b/src/package.json index 840cdb8445..1ee511907a 100644 --- a/src/package.json +++ b/src/package.json @@ -517,7 +517,6 @@ "serialize-error": "^12.0.0", "shell-quote": "^1.8.2", "simple-git": "^3.27.0", - "socket.io": "^4.8.3", "strip-bom": "^5.0.0", "tiktoken": "^1.0.21", "tree-sitter-wasms": "^0.1.13", @@ -552,6 +551,7 @@ "nock": "14.0.15", "ovsx": "0.10.12", "rimraf": "6.0.1", + "socket.io": "4.8.3", "socket.io-client": "4.8.3", "vitest": "4.1.9" } diff --git a/webview-ui/package.json b/webview-ui/package.json index ff15cc6a07..6c24e5db74 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -72,7 +72,6 @@ "remove-markdown": "^0.6.4", "shell-quote": "^1.8.2", "shiki": "^3.2.1", - "socket.io-client": "^4.8.3", "source-map": "^0.7.4", "stacktrace-js": "^2.0.2", "styled-components": "^6.1.13", @@ -106,6 +105,7 @@ "babel-plugin-react-compiler": "1.0.0", "jsdom": "26.1.0", "monocart-reporter": "^2.9.20", + "socket.io-client": "4.8.3", "vite": "8.1.0", "vitest": "4.1.9" } diff --git a/webview-ui/src/utils/browserBridgeClient.ts b/webview-ui/src/utils/browserBridgeClient.ts index ee5682f4d5..b7c6b2ad2a 100644 --- a/webview-ui/src/utils/browserBridgeClient.ts +++ b/webview-ui/src/utils/browserBridgeClient.ts @@ -18,11 +18,12 @@ import { WebviewMessage } from "@roo/WebviewMessage" * `window.postMessage` so the existing `window.addEventListener("message")` * consumers in the app work unchanged. * - * Production bundle isolation: the first thing `maybeConnect` does is return - * when `import.meta.env.DEV` is false. Vite replaces that with a literal - * `false` at build time, so the entire remainder of the body — including the - * dynamic `import("socket.io-client")` and the browser-mode CSS import — is - * dead code the bundler strips, and neither ever lands in the shipped assets. + * Production bundle isolation works on two levels: every call site in + * `vscode.ts` is wrapped in `import.meta.env.DEV &&`, which Vite replaces with + * a literal `false` at build time, so this class becomes unreachable and the + * bundler tree-shakes it entirely (dynamic `import("socket.io-client")` and + * the browser-mode CSS import never land in the shipped assets). As defense in + * depth, `maybeConnect`/`active` also self-gate on `import.meta.env.DEV`. */ /** diff --git a/webview-ui/src/utils/vscode.ts b/webview-ui/src/utils/vscode.ts index ccdfd39d63..6d9000470a 100644 --- a/webview-ui/src/utils/vscode.ts +++ b/webview-ui/src/utils/vscode.ts @@ -21,10 +21,12 @@ class VSCodeAPIWrapper { // context (i.e. VS Code development window or web browser) if (typeof acquireVsCodeApi === "function") { this.vsCodeApi = acquireVsCodeApi() - } else { - // Browser mode is self-gated inside the client: it connects only - // when this dev-server tab was opened by the "Open in Chrome" - // command with a ?bridgePort=... query param. + } else if (import.meta.env.DEV) { + // Build-time gate: `false` in production makes BrowserBridgeClient + // unreachable so the bundler tree-shakes the whole class (and the + // lazy socket.io-client import inside it). The client self-gates + // again at runtime: it connects only when this dev-server tab was + // opened by the "Open in Chrome" command with a ?bridgePort= param. void BrowserBridgeClient.maybeConnect() } } @@ -40,7 +42,9 @@ class VSCodeAPIWrapper { public postMessage(message: WebviewMessage) { if (this.vsCodeApi) { this.vsCodeApi.postMessage(message) - } else if (BrowserBridgeClient.active()) { + } else if (import.meta.env.DEV && BrowserBridgeClient.active()) { + // Same build-time gate as the constructor: keeps the bridge class + // out of the production bundle entirely. BrowserBridgeClient.postMessage(message) } else { console.log(message) From 4d7b248707ba6b82ba6a9dd5fe1d676d472544b4 Mon Sep 17 00:00:00 2001 From: hnbdr Date: Fri, 11 Sep 2026 22:24:00 +0300 Subject: [PATCH 5/6] test: cover browser bridge statics API, dev-only gating, and webview bridge client - browserBridge.spec.ts: WeakMap registry (enable/active/webviewFor/setPlaceholder/disposeFor, one-bridge-per-host guard), registerCommand self-gating (env + Development mode, happy/reuse/failure paths), and a real socket.io round trip through the virtual webview --- .../__tests__/registerCommands.spec.ts | 19 + .../webview/__tests__/ClineProvider.spec.ts | 65 +++ .../webview/__tests__/browserBridge.spec.ts | 438 ++++++++++++++++++ .../__tests__/browserBridgeClient.spec.ts | 194 ++++++++ webview-ui/src/utils/__tests__/vscode.spec.ts | 76 +++ 5 files changed, 792 insertions(+) create mode 100644 src/core/webview/__tests__/browserBridge.spec.ts create mode 100644 webview-ui/src/utils/__tests__/browserBridgeClient.spec.ts create mode 100644 webview-ui/src/utils/__tests__/vscode.spec.ts diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 67a2b935ec..078f80db5a 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -2,6 +2,7 @@ import type { Mock } from "vitest" import * as vscode from "vscode" import { ClineProvider } from "../../core/webview/ClineProvider" +import { BrowserBridgeServer } from "../../core/webview/browserBridge" import { getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" vi.mock("execa", () => ({ @@ -93,6 +94,13 @@ vi.mock("../../services/ripgrep/diagnostic", () => ({ registerRipgrepDiagnosticCommand: vi.fn().mockReturnValue({ dispose: vi.fn() }), })) +// The browser bridge command is dev-only tooling; registerCommands must merely +// hand it the activation context (its self-gating is covered in +// core/webview/__tests__/browserBridge.spec.ts). +vi.mock("../../core/webview/browserBridge", () => ({ + BrowserBridgeServer: { registerCommand: vi.fn() }, +})) + describe("getVisibleProviderOrLog", () => { let mockOutputChannel: vscode.OutputChannel @@ -192,6 +200,17 @@ describe("registerCommands handlers", () => { expect(mockContext.subscriptions).toContain(disposable) }) + it("delegates the dev-only browser bridge command registration to BrowserBridgeServer", async () => { + const registerCommandSpy = vi.mocked(BrowserBridgeServer.registerCommand) + expect(registerCommandSpy).toHaveBeenCalledTimes(1) + expect(registerCommandSpy).toHaveBeenCalledWith(mockContext, mockOutputChannel, expect.any(Function)) + + // The third argument is the visible-provider callback the bridge uses + // to find the provider to put into browser mode. + const getVisibleProvider = registerCommandSpy.mock.calls[0][2] + expect(getVisibleProvider()).toBe(mockVisibleProvider) + }) + it("settingsButtonClicked posts both settingsButtonClicked and didBecomeVisible actions", () => { handlers["zoo-code.settingsButtonClicked"]() diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index bfd4706dcc..32605eb257 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -31,6 +31,7 @@ import { Task, TaskOptions } from "../../task/Task" import { safeWriteJson } from "../../../utils/safeWriteJson" import { ClineProvider } from "../ClineProvider" +import { BrowserBridgeServer } from "../browserBridge" import { webviewMessageHandler } from "../webviewMessageHandler" import { Terminal } from "../../../integrations/terminal/Terminal" import { MessageManager } from "../../message-manager" @@ -621,6 +622,70 @@ describe("ClineProvider", () => { expect(mockWebviewView.webview.html).toContain("Zoo Code") }) + describe("browser bridge ownership (statics API)", () => { + // These exercise the provider against a real BrowserBridgeServer (the + // WeakMap registry in ../browserBridge), so no statics are mocked: the + // bridge is enabled with the static API and released in afterEach. + const waitForBridge = async () => { + const started = Date.now() + while (!BrowserBridgeServer.active(provider)) { + if (Date.now() - started > 5_000) { + throw new Error("Bridge did not become active") + } + await new Promise((resolve) => setTimeout(resolve, 10)) + } + } + + afterEach(() => { + BrowserBridgeServer.disposeFor(provider) + }) + + test("webviewFor is undefined while no bridge is active", () => { + expect(BrowserBridgeServer.active(provider)).toBe(false) + expect(BrowserBridgeServer.webviewFor(provider)).toBeUndefined() + }) + + test("resolveWebviewView renders the bridge placeholder and skips the real listener when active", async () => { + BrowserBridgeServer.enable(provider) + await waitForBridge() + + await provider.resolveWebviewView(mockWebviewView) + + // Placeholder instead of the app HTML (no React, no scripts), and the + // real iframe's message listener is NOT registered (the virtual + // webview already owns the wiring — re-registering would + // double-handle messages). + expect(mockWebviewView.webview.html).toContain("browser mode") + expect(mockWebviewView.webview.html).not.toContain(" { + BrowserBridgeServer.enable(provider) + await waitForBridge() + await provider.resolveWebviewView(mockWebviewView) + + const virtualWebview = BrowserBridgeServer.webviewFor(provider)! + const virtualPostSpy = vi.spyOn(virtualWebview, "postMessage") + + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + await provider.postMessageToWebview(message) + + expect(virtualPostSpy).toHaveBeenCalledWith(message) + expect(mockPostMessage).not.toHaveBeenCalled() + }) + + test("dispose releases the provider-owned bridge", async () => { + BrowserBridgeServer.enable(provider) + await waitForBridge() + + await provider.dispose() + + expect(BrowserBridgeServer.active(provider)).toBe(false) + expect(BrowserBridgeServer.webviewFor(provider)).toBeUndefined() + }) + }) + describe("logWebviewHiddenDiagnostics", () => { let visibilityCallback: () => void diff --git a/src/core/webview/__tests__/browserBridge.spec.ts b/src/core/webview/__tests__/browserBridge.spec.ts new file mode 100644 index 0000000000..989e03bd92 --- /dev/null +++ b/src/core/webview/__tests__/browserBridge.spec.ts @@ -0,0 +1,438 @@ +// npx vitest run core/webview/__tests__/browserBridge.spec.ts +// +// Browser bridge coverage for the dev-only isolation model: +// - getBrowserBridgePort env parsing (the ROO_BROWSER_BRIDGE_PORT alt mode) +// - the statics/WeakMap registry: enable/active/webviewFor/setPlaceholder/ +// disposeFor, the one-bridge-per-host rule, and the rejected-newcomer bind +// - registerCommand self-gating (env unset / wrong extension mode) and the +// full command flow: start + bind + openExternal, the reuse path, and the +// occupied-port failure path +// - a real socket.io round trip (server + socket.io-client, mirroring the +// webview-ui BrowserBridgeClient wiring, so a socket.io v4 event-routing +// regression is caught here) + +import { createServer } from "http" +import type { AddressInfo } from "net" + +import { io, type Socket } from "socket.io-client" +import type { Mock } from "vitest" +import * as vscode from "vscode" + +import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" + +import { allowNetConnect } from "../../../vitest.setup" +import type { BridgeHost } from "../browserBridge" +import { BrowserBridgeServer, getBrowserBridgePort } from "../browserBridge" + +// The shared src/__mocks__/vscode.js lacks the ExtensionMode/env/commands/ +// window surface the bridge touches at runtime, so this spec supplies its own +// module mock instead. +vi.mock("vscode", () => ({ + ExtensionMode: { Production: 1, Development: 2, Test: 3 }, + commands: { registerCommand: vi.fn() }, + env: { openExternal: vi.fn().mockResolvedValue(true) }, + Uri: { + parse: vi.fn((value: string) => ({ toString: () => value })), + }, + window: { showErrorMessage: vi.fn() }, +})) + +// vitest.setup.ts disables real network requests via nock by default. The +// round-trip tests connect a real socket.io client to a loopback server, so +// allow net connect for 127.0.0.1 (the websocket upgrade and the polling +// transport use the same host). +allowNetConnect(/^127\.0\.0\.1(?::\d+)?$/) + +function connectToBridge(port: number): Promise { + return new Promise((resolve, reject) => { + const socket = io(`http://127.0.0.1:${port}`, { + transports: ["websocket", "polling"], + reconnection: false, + timeout: 5_000, + }) + socket.once("connect", () => resolve(socket)) + socket.once("connect_error", (error) => { + socket.disconnect() + reject(error) + }) + }) +} + +function waitFor(predicate: () => T | undefined, timeoutMs = 5_000): Promise { + return new Promise((resolve, reject) => { + const started = Date.now() + const poll = () => { + const value = predicate() + if (value !== undefined) { + resolve(value) + } else if (Date.now() - started > timeoutMs) { + reject(new Error("Timed out waiting for bridge condition")) + } else { + setTimeout(poll, 10) + } + } + poll() + }) +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) + +type HostStub = { + view?: { webview: { html: string } } + setWebviewMessageListener: Mock +} + +function createHost(): HostStub { + return { view: { webview: { html: "" } }, setWebviewMessageListener: vi.fn() } +} + +/** Starts a real bridge (private statics are reached via element access). */ +async function startBridge(): Promise { + const bridge = await BrowserBridgeServer["start"](() => {}) + if (!bridge) { + throw new Error("Test bridge failed to start") + } + return bridge +} + +describe("getBrowserBridgePort", () => { + const original = process.env.ROO_BROWSER_BRIDGE_PORT + + afterEach(() => { + if (original === undefined) { + delete process.env.ROO_BROWSER_BRIDGE_PORT + } else { + process.env.ROO_BROWSER_BRIDGE_PORT = original + } + }) + + test.each([ + ["unset", undefined, 0], + ["empty", "", 0], + ["valid port", "51234", 51234], + ["non-numeric", "abc", 0], + ["float", "1.5", 0], + ["zero", "0", 0], + ["negative", "-1", 0], + ["out of range", "99999", 0], + ])("%s -> %i", (_label, value, expected) => { + if (value === undefined) { + delete process.env.ROO_BROWSER_BRIDGE_PORT + } else { + process.env.ROO_BROWSER_BRIDGE_PORT = value + } + expect(getBrowserBridgePort()).toBe(expected) + }) +}) + +describe("BrowserBridgeServer statics (WeakMap registry)", () => { + const hosts: HostStub[] = [] + const sockets: Socket[] = [] + + afterEach(async () => { + for (const socket of sockets.splice(0)) { + socket.disconnect() + } + for (const host of hosts.splice(0)) { + BrowserBridgeServer.disposeFor(host) + } + }) + + it("is inert for unknown hosts", () => { + const stranger = createHost() + hosts.push(stranger) + + expect(BrowserBridgeServer.active(stranger)).toBe(false) + expect(BrowserBridgeServer.webviewFor(stranger)).toBeUndefined() + expect(() => BrowserBridgeServer.setPlaceholder(stranger)).not.toThrow() + expect(() => BrowserBridgeServer.disposeFor(stranger)).not.toThrow() + expect(stranger.view!.webview.html).toBe("") + }) + + it("enable binds a started bridge to the host and wires the provider listener", async () => { + const host = createHost() + hosts.push(host) + + BrowserBridgeServer.enable(host) + await waitFor(() => (BrowserBridgeServer.active(host) ? true : undefined)) + + expect(BrowserBridgeServer.active(host)).toBe(true) + // Default wiring goes through the host's private setWebviewMessageListener. + expect(host.setWebviewMessageListener).toHaveBeenCalledTimes(1) + // The placeholder is rendered into the already-resolved real webview. + expect(host.view!.webview.html).toContain("browser mode") + expect(host.view!.webview.html).toContain("?bridgePort=") + }) + + it("enable uses an explicit listen callback instead of host internals", async () => { + const host = createHost() + hosts.push(host) + const listen = vi.fn() + + BrowserBridgeServer.enable(host, listen) + await waitFor(() => (BrowserBridgeServer.active(host) ? true : undefined)) + + expect(listen).toHaveBeenCalledTimes(1) + expect(listen.mock.calls[0][0]).toBe(BrowserBridgeServer.webviewFor(host)) + expect(host.setWebviewMessageListener).not.toHaveBeenCalled() + }) + + it("enable is idempotent: the first bridge stays authoritative", async () => { + const host = createHost() + hosts.push(host) + + BrowserBridgeServer.enable(host) + await waitFor(() => (BrowserBridgeServer.active(host) ? true : undefined)) + const webview = BrowserBridgeServer.webviewFor(host) + expect(webview).toBeDefined() + + // A second enable for the same host is a no-op (no second server leaks). + const secondListen = vi.fn() + BrowserBridgeServer.enable(host, secondListen) + await tick() + + expect(secondListen).not.toHaveBeenCalled() + expect(BrowserBridgeServer.webviewFor(host)).toBe(webview) + }) + + it("bind disposes a rejected newcomer (one bridge per host)", async () => { + const host = createHost() + hosts.push(host) + const first = await startBridge() + const second = await startBridge() + + BrowserBridgeServer["bind"](host, first) + + // Wrap the private dispose to observe the rejection path while still + // closing the newcomer's real server (no leaked ports). + const originalDispose = second["dispose"].bind(second) + const disposeSpy = vi.fn(() => originalDispose()) + second["dispose"] = disposeSpy + BrowserBridgeServer["bind"](host, second) + + expect(disposeSpy).toHaveBeenCalledTimes(1) + expect(BrowserBridgeServer.webviewFor(host)).toBe(first["getOrCreateVirtualWebview"]()) + expect(host.view!.webview.html).toContain(`?bridgePort=${first["_port"]}`) + }) + + it("disposeFor releases the host and drops the registry entry", async () => { + const host = createHost() + hosts.push(host) + + BrowserBridgeServer.enable(host) + await waitFor(() => (BrowserBridgeServer.active(host) ? true : undefined)) + const firstWebview = BrowserBridgeServer.webviewFor(host) + expect(firstWebview).toBeDefined() + + BrowserBridgeServer.disposeFor(host) + + expect(BrowserBridgeServer.active(host)).toBe(false) + expect(BrowserBridgeServer.webviewFor(host)).toBeUndefined() + + // Re-enabling after dispose starts a fresh bridge. + BrowserBridgeServer.enable(host) + await waitFor(() => (BrowserBridgeServer.active(host) ? true : undefined)) + expect(BrowserBridgeServer.webviewFor(host)).toBeDefined() + expect(BrowserBridgeServer.webviewFor(host)).not.toBe(firstWebview) + }) + + it("setPlaceholder refreshes the placeholder on a later view resolve", async () => { + const host = createHost() + hosts.push(host) + delete host.view + + BrowserBridgeServer.enable(host) + await waitFor(() => (BrowserBridgeServer.active(host) ? true : undefined)) + // No resolved view yet: the placeholder render is a no-op. + expect(host.view).toBeUndefined() + + host.view = { webview: { html: "" } } + BrowserBridgeServer.setPlaceholder(host) + expect(host.view.webview.html).toContain("browser mode") + }) + + it("getBrowserUrl builds the Vite dev-server URL with the bridge port", () => { + expect(BrowserBridgeServer.getBrowserUrl(43210)).toBe("http://localhost:5173/?bridgePort=43210") + }) + + it("reuses the same virtual webview across calls", async () => { + const host = createHost() + hosts.push(host) + BrowserBridgeServer.enable(host) + await waitFor(() => (BrowserBridgeServer.active(host) ? true : undefined)) + + expect(BrowserBridgeServer.webviewFor(host)).toBe(BrowserBridgeServer.webviewFor(host)) + }) + + it("delivers a full round trip through the virtual webview", async () => { + const host = createHost() + hosts.push(host) + BrowserBridgeServer.enable(host) + await waitFor(() => (BrowserBridgeServer.active(host) ? true : undefined)) + + const webview = BrowserBridgeServer.webviewFor(host)! + const bridge = BrowserBridgeServer["bridges"].get(host)! + + const providerReceived: WebviewMessage[] = [] + const subscription = webview.onDidReceiveMessage((message) => { + providerReceived.push(message as WebviewMessage) + }) + + const client = await connectToBridge(bridge["_port"]) + sockets.push(client) + + // webview -> extension + const sent: WebviewMessage = { type: "clearTask" } + client.emit("webviewMessage", sent) + const arrived = await waitFor(() => (providerReceived.length > 0 ? providerReceived[0] : undefined)) + expect(arrived).toEqual(sent) + + // After the subscription is disposed, delivery stops. + subscription.dispose() + client.emit("webviewMessage", sent) + await new Promise((resolve) => setTimeout(resolve, 150)) + expect(providerReceived).toHaveLength(1) + + // extension -> webview broadcast reaches connected clients. + let inbound: unknown + client.on("extensionMessage", (message: unknown) => { + inbound = message + }) + const extensionMessage: ExtensionMessage = { type: "state", state: { clineMessages: [] } as never } + await webview.postMessage(extensionMessage) + const broadcast = await waitFor(() => inbound) + expect(broadcast).toEqual(extensionMessage) + }) +}) + +describe("BrowserBridgeServer.registerCommand (dev-only self-gating)", () => { + let handlers: Record Promise> + let context: { extensionMode: number; subscriptions: { dispose: Mock }[] } + let outputChannel: { appendLine: Mock } + let host: HostStub + let visible: HostStub | undefined + + beforeEach(() => { + vi.clearAllMocks() + handlers = {} + ;(vscode.commands.registerCommand as Mock).mockImplementation((id: string, callback: () => Promise) => { + handlers[id] = callback + return { dispose: vi.fn() } + }) + context = { extensionMode: vscode.ExtensionMode.Development, subscriptions: [] } + outputChannel = { appendLine: vi.fn() } + host = createHost() + visible = host + delete process.env.ROO_BROWSER_BRIDGE + delete process.env.ROO_BROWSER_BRIDGE_PORT + }) + + afterEach(() => { + BrowserBridgeServer.disposeFor(host) + delete process.env.ROO_BROWSER_BRIDGE + delete process.env.ROO_BROWSER_BRIDGE_PORT + }) + + function register() { + BrowserBridgeServer.registerCommand( + context as never, + outputChannel as never, + () => visible as BridgeHost | undefined, + ) + } + + it("is a no-op when ROO_BROWSER_BRIDGE is unset", () => { + register() + expect(vscode.commands.registerCommand).not.toHaveBeenCalled() + expect(context.subscriptions).toHaveLength(0) + }) + + it("is a no-op when ROO_BROWSER_BRIDGE=1 but the host is not Development", () => { + process.env.ROO_BROWSER_BRIDGE = "1" + for (const mode of [vscode.ExtensionMode.Production, vscode.ExtensionMode.Test]) { + context.extensionMode = mode + register() + } + expect(vscode.commands.registerCommand).not.toHaveBeenCalled() + expect(context.subscriptions).toHaveLength(0) + }) + + it("registers zoo-code.openInBrowser in a gated dev host", () => { + process.env.ROO_BROWSER_BRIDGE = "1" + register() + expect(vscode.commands.registerCommand).toHaveBeenCalledWith("zoo-code.openInBrowser", expect.any(Function)) + expect(context.subscriptions).toHaveLength(1) + }) + + it("logs when no visible provider exists", async () => { + process.env.ROO_BROWSER_BRIDGE = "1" + register() + visible = undefined + + await handlers["zoo-code.openInBrowser"]() + + expect(outputChannel.appendLine).toHaveBeenCalledWith("Cannot find any visible Roo Code instances.") + expect(vscode.env.openExternal).not.toHaveBeenCalled() + expect(BrowserBridgeServer.active(host)).toBe(false) + }) + + it("starts, binds and opens the browser tab on the happy path", async () => { + process.env.ROO_BROWSER_BRIDGE = "1" + register() + + await handlers["zoo-code.openInBrowser"]() + + expect(BrowserBridgeServer.active(host)).toBe(true) + expect(host.setWebviewMessageListener).toHaveBeenCalledTimes(1) + expect(host.view!.webview.html).toContain("browser mode") + + expect(vscode.env.openExternal).toHaveBeenCalledTimes(1) + const url = (vscode.Uri.parse as Mock).mock.calls[0][0] as string + expect(url).toMatch(/^http:\/\/localhost:5173\/\?bridgePort=\d+$/) + expect(outputChannel.appendLine).toHaveBeenCalledWith(expect.stringContaining("[BrowserBridge] Listening on")) + }) + + it("reuses the existing bridge instead of starting a second server", async () => { + process.env.ROO_BROWSER_BRIDGE = "1" + register() + + await handlers["zoo-code.openInBrowser"]() + const port = BrowserBridgeServer["bridges"].get(host)!["_port"] + + await handlers["zoo-code.openInBrowser"]() + + expect(outputChannel.appendLine).toHaveBeenCalledWith( + `[openInBrowser] Reusing existing browser bridge on port ${port}.`, + ) + expect(vscode.env.openExternal).toHaveBeenCalledTimes(2) + expect((vscode.Uri.parse as Mock).mock.calls[1][0]).toBe(`http://localhost:5173/?bridgePort=${port}`) + // Still exactly one bind: the listener was not re-registered. + expect(host.setWebviewMessageListener).toHaveBeenCalledTimes(1) + }) + + it("surfaces startup failures (occupied fixed port) to the developer", async () => { + process.env.ROO_BROWSER_BRIDGE = "1" + // Occupy the exact port the bridge will be told to use. + const blocker = createServer() + await new Promise((resolve) => blocker.listen(0, "127.0.0.1", resolve)) + const occupied = (blocker.address() as AddressInfo).port + process.env.ROO_BROWSER_BRIDGE_PORT = String(occupied) + register() + + try { + await handlers["zoo-code.openInBrowser"]() + + expect(outputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining(`[BrowserBridge] Failed to start on 127.0.0.1:${occupied}`), + ) + expect(outputChannel.appendLine).toHaveBeenCalledWith("[openInBrowser] Failed to start the browser bridge.") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining("Failed to start the browser bridge"), + ) + expect(BrowserBridgeServer.active(host)).toBe(false) + expect(vscode.env.openExternal).not.toHaveBeenCalled() + } finally { + await new Promise((resolve) => blocker.close(() => resolve())) + } + }) +}) diff --git a/webview-ui/src/utils/__tests__/browserBridgeClient.spec.ts b/webview-ui/src/utils/__tests__/browserBridgeClient.spec.ts new file mode 100644 index 0000000000..d56d2e2655 --- /dev/null +++ b/webview-ui/src/utils/__tests__/browserBridgeClient.spec.ts @@ -0,0 +1,194 @@ +// npx vitest run src/utils/__tests__/browserBridgeClient.spec.ts + +import { BrowserBridgeClient } from "../browserBridgeClient" + +// Capture the sockets created through the lazy `import("socket.io-client")` in +// BrowserBridgeClient.connect() without ever opening a real connection. +const { ioMock } = vi.hoisted(() => { + type MockSocket = { + on: ReturnType + emit: ReturnType + disconnect: ReturnType + handlers: Record void)[]> + } + + const sockets: MockSocket[] = [] + + return { + ioMock: vi.fn((_url: string, _opts?: unknown) => { + const handlers: MockSocket["handlers"] = {} + const socket: MockSocket = { + handlers, + on: vi.fn((event: string, listener: (...args: any[]) => void) => { + handlers[event] = [...(handlers[event] ?? []), listener] + }), + emit: vi.fn(), + disconnect: vi.fn(), + } + sockets.push(socket) + return socket + }), + } +}) + +vi.mock("socket.io-client", () => ({ io: ioMock })) + +/** + * Flush the awaited module imports inside `connect()` (socket.io-client, the + * browser-mode CSS, then socket creation) so the mock socket exists. + */ +async function awaitSockets(count = 1): Promise { + await vi.waitFor(() => expect(ioMock).toHaveBeenCalledTimes(count), { timeout: 2_000 }) +} + +type MockSocket = { + on: ReturnType + emit: ReturnType + disconnect: ReturnType + handlers: Record void)[]> +} + +function createdSocket(): MockSocket { + return ioMock.mock.results[0].value as MockSocket +} + +function setSearch(search: string): void { + window.history.replaceState({}, "", search ? `/${search}` : "/") +} + +describe("BrowserBridgeClient", () => { + beforeEach(() => { + ioMock.mockClear() + setSearch("") + document.documentElement.classList.remove("roo-browser-mode") + }) + + afterEach(() => { + BrowserBridgeClient.resetForTests() + }) + + describe("maybeConnect self-gating", () => { + it("stays inert in a plain dev-server tab without ?bridgePort", async () => { + BrowserBridgeClient.maybeConnect() + + expect(BrowserBridgeClient.active()).toBe(false) + await Promise.resolve() + await Promise.resolve() + expect(ioMock).not.toHaveBeenCalled() + expect(document.documentElement.classList.contains("roo-browser-mode")).toBe(false) + }) + + it.each([ + "?bridgePort=abc", + "?bridgePort=", + "?bridgePort=0", + "?bridgePort=-1", + "?bridgePort=65536", + "?bridgePort=80.5", + ])("ignores the invalid port value in %s", async (search) => { + setSearch(search) + + BrowserBridgeClient.maybeConnect() + + expect(BrowserBridgeClient.active()).toBe(false) + await Promise.resolve() + await Promise.resolve() + expect(ioMock).not.toHaveBeenCalled() + }) + + it("connects and marks the document when a valid ?bridgePort is present", async () => { + setSearch("?bridgePort=9999") + + BrowserBridgeClient.maybeConnect() + + expect(BrowserBridgeClient.active()).toBe(true) + await awaitSockets() + expect(ioMock).toHaveBeenCalledWith("http://127.0.0.1:9999", { + transports: ["websocket", "polling"], + }) + expect(document.documentElement.classList.contains("roo-browser-mode")).toBe(true) + }) + + it("is a no-op once an instance exists (singleton guard)", async () => { + setSearch("?bridgePort=9999") + + BrowserBridgeClient.maybeConnect() + BrowserBridgeClient.maybeConnect() + + await awaitSockets() + expect(ioMock).toHaveBeenCalledTimes(1) + }) + }) + + describe("message flow", () => { + const ready = { type: "webviewDidLaunch" } as any + const later = { type: "showTaskWithId", text: "task-1" } as any + + it("is a no-op without an instance", () => { + expect(() => BrowserBridgeClient.postMessage(ready)).not.toThrow() + expect(ioMock).not.toHaveBeenCalled() + }) + + it("queues messages while connecting and flushes them in order on connect", async () => { + setSearch("?bridgePort=9999") + + BrowserBridgeClient.maybeConnect() + // The socket is not attached yet: both messages must be buffered. + BrowserBridgeClient.postMessage(ready) + BrowserBridgeClient.postMessage(later) + + await awaitSockets() + const socket = createdSocket() + expect(socket.emit).not.toHaveBeenCalled() + + for (const listener of socket.handlers["connect"]) { + listener() + } + + expect(socket.emit.mock.calls).toEqual([ + ["webviewMessage", ready], + ["webviewMessage", later], + ]) + + // The queue is drained: post-send goes straight to the socket. + const sent = { type: "acceptInput" } as any + BrowserBridgeClient.postMessage(sent) + expect(socket.emit).toHaveBeenLastCalledWith("webviewMessage", sent) + }) + + it("re-dispatches inbound extensionMessage events through window.postMessage", async () => { + const postMessageSpy = vi.spyOn(window, "postMessage") + setSearch("?bridgePort=9999") + + BrowserBridgeClient.maybeConnect() + await awaitSockets() + const socket = createdSocket() + + const extensionMessage = { type: "state", values: {} } as any + for (const listener of socket.handlers["extensionMessage"]) { + listener(extensionMessage) + } + + expect(postMessageSpy).toHaveBeenCalledWith(extensionMessage, "*") + postMessageSpy.mockRestore() + }) + }) + + describe("resetForTests", () => { + it("tears the singleton down and disconnects the socket", async () => { + setSearch("?bridgePort=9999") + + BrowserBridgeClient.maybeConnect() + await awaitSockets() + const socket = createdSocket() + + BrowserBridgeClient.resetForTests() + + expect(BrowserBridgeClient.active()).toBe(false) + expect(socket.disconnect).toHaveBeenCalledTimes(1) + + BrowserBridgeClient.postMessage({ type: "acceptInput" } as any) + expect(socket.emit).not.toHaveBeenCalled() + }) + }) +}) diff --git a/webview-ui/src/utils/__tests__/vscode.spec.ts b/webview-ui/src/utils/__tests__/vscode.spec.ts new file mode 100644 index 0000000000..007515e14c --- /dev/null +++ b/webview-ui/src/utils/__tests__/vscode.spec.ts @@ -0,0 +1,76 @@ +// npx vitest run src/utils/__tests__/vscode.spec.ts + +// The browser bridge is exercised through its public statics only; the real +// client (and its lazy socket.io-client import) is covered by +// `browserBridgeClient.spec.ts`. +const { bridgeMock } = vi.hoisted(() => ({ + bridgeMock: { + maybeConnect: vi.fn(), + active: vi.fn(() => false), + postMessage: vi.fn(), + }, +})) + +vi.mock("../browserBridgeClient", () => ({ BrowserBridgeClient: bridgeMock })) + +/** + * `vscode.ts` exports a module-level singleton, so each scenario re-imports + * the module with a fresh module registry to run its constructor. + */ +async function importFresh(): Promise { + vi.resetModules() + return await import("../vscode") +} + +describe("vscode (VSCodeAPIWrapper) browser bridge wiring", () => { + afterEach(() => { + vi.unstubAllGlobals() + bridgeMock.maybeConnect.mockClear() + bridgeMock.active.mockReset().mockReturnValue(false) + bridgeMock.postMessage.mockClear() + }) + + it("asks the bridge client to connect on construction when no vscode api exists", async () => { + await importFresh() + + // jsdom has no acquireVsCodeApi, so the dev-gated else-branch runs. + expect(bridgeMock.maybeConnect).toHaveBeenCalledTimes(1) + }) + + it("never touches the bridge when acquireVsCodeApi is available", async () => { + const vsCodeApi = { postMessage: vi.fn(), getState: vi.fn(), setState: vi.fn() } + vi.stubGlobal("acquireVsCodeApi", () => vsCodeApi) + + const { vscode } = await importFresh() + vscode.postMessage({ type: "webviewDidLaunch" } as any) + + expect(vsCodeApi.postMessage).toHaveBeenCalledWith({ type: "webviewDidLaunch" }) + expect(bridgeMock.maybeConnect).not.toHaveBeenCalled() + expect(bridgeMock.postMessage).not.toHaveBeenCalled() + }) + + it("routes postMessage over the bridge while it is active", async () => { + bridgeMock.active.mockReturnValue(true) + + const { vscode } = await importFresh() + const message = { type: "showTaskWithId", text: "task-1" } as any + vscode.postMessage(message) + + expect(bridgeMock.active).toHaveBeenCalled() + expect(bridgeMock.postMessage).toHaveBeenCalledWith(message) + }) + + it("falls back to console logging when the bridge is inactive", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}) + + const { vscode } = await importFresh() + const message = { type: "acceptInput" } as any + vscode.postMessage(message) + + expect(bridgeMock.active).toHaveBeenCalled() + expect(bridgeMock.postMessage).not.toHaveBeenCalled() + expect(consoleSpy).toHaveBeenCalledWith(message) + + consoleSpy.mockRestore() + }) +}) From d181f8306bc662854357e14e27597fee1ef86021 Mon Sep 17 00:00:00 2001 From: hnbdr Date: Sat, 12 Sep 2026 19:41:09 +0300 Subject: [PATCH 6/6] test: kill remaining browser bridge mutation survivors with focused tests and narrow directives --- .../webview/__tests__/ClineProvider.spec.ts | 124 ++++++++++ .../webview/__tests__/browserBridge.spec.ts | 228 +++++++++++++++++- src/core/webview/browserBridge.ts | 8 +- .../__tests__/browserBridgeClient.spec.ts | 90 +++++++ webview-ui/src/utils/__tests__/vscode.spec.ts | 11 + webview-ui/src/utils/browserBridgeClient.ts | 2 + 6 files changed, 461 insertions(+), 2 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 32605eb257..286bba2ae4 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -686,6 +686,130 @@ describe("ClineProvider", () => { }) }) + describe("resolveWebviewView html source selection", () => { + const originalProbeSetting = process.env.ROO_CODE_THEME_FIXTURE_PROBE + + function providerWithMode(extensionMode: number): ClineProvider { + const context = { ...mockContext, extensionMode } as unknown as vscode.ExtensionContext + return new ClineProvider(context, mockOutputChannel, "sidebar", new ContextProxy(context)) + } + + afterEach(() => { + if (originalProbeSetting === undefined) { + delete process.env.ROO_CODE_THEME_FIXTURE_PROBE + } else { + process.env.ROO_CODE_THEME_FIXTURE_PROBE = originalProbeSetting + } + }) + + test("development mode without the probe flag serves the HMR html", async () => { + delete process.env.ROO_CODE_THEME_FIXTURE_PROBE + provider = providerWithMode(vscode.ExtensionMode.Development) + const hmrSpy = vi.fn().mockResolvedValue("hmr") + const htmlSpy = vi.fn().mockResolvedValue("dist") + provider["getHMRHtmlContent"] = hmrSpy + provider["getHtmlContent"] = htmlSpy + + await provider.resolveWebviewView(mockWebviewView) + + expect(hmrSpy).toHaveBeenCalledWith(mockWebviewView.webview) + expect(htmlSpy).not.toHaveBeenCalled() + expect(mockWebviewView.webview.html).toContain("hmr") + }) + + test("development mode with the theme fixture probe serves the built html", async () => { + process.env.ROO_CODE_THEME_FIXTURE_PROBE = "1" + provider = providerWithMode(vscode.ExtensionMode.Development) + const hmrSpy = vi.fn().mockResolvedValue("hmr") + const htmlSpy = vi.fn().mockResolvedValue("dist") + provider["getHMRHtmlContent"] = hmrSpy + provider["getHtmlContent"] = htmlSpy + + await provider.resolveWebviewView(mockWebviewView) + + expect(htmlSpy).toHaveBeenCalledWith(mockWebviewView.webview) + expect(hmrSpy).not.toHaveBeenCalled() + expect(mockWebviewView.webview.html).toContain("dist") + }) + + test("production mode serves the built html even without the probe flag", async () => { + delete process.env.ROO_CODE_THEME_FIXTURE_PROBE + provider = providerWithMode(vscode.ExtensionMode.Production) + const hmrSpy = vi.fn().mockResolvedValue("hmr") + const htmlSpy = vi.fn().mockResolvedValue("dist") + provider["getHMRHtmlContent"] = hmrSpy + provider["getHtmlContent"] = htmlSpy + + await provider.resolveWebviewView(mockWebviewView) + + expect(htmlSpy).toHaveBeenCalledWith(mockWebviewView.webview) + expect(hmrSpy).not.toHaveBeenCalled() + expect(mockWebviewView.webview.html).toContain("dist") + }) + }) + + describe("convertToWebviewUri", () => { + const waitForBridge = async () => { + const started = Date.now() + while (!BrowserBridgeServer.active(provider)) { + if (Date.now() - started > 5_000) { + throw new Error("Bridge did not become active") + } + await new Promise((resolve) => setTimeout(resolve, 10)) + } + } + + const fakeFileUri = { toString: () => "file:///test/asset.png" } + + afterEach(() => { + BrowserBridgeServer.disposeFor(provider) + }) + + test("uses the virtual webview when the browser bridge is active", async () => { + ;(vscode.Uri.file as ReturnType).mockReturnValue(fakeFileUri) + BrowserBridgeServer.enable(provider) + await waitForBridge() + // No real view resolved: only the bridge's virtual webview is available. + provider["view"] = undefined + const webview = BrowserBridgeServer.webviewFor(provider)! + const uriSpy = vi.spyOn(webview, "asWebviewUri") + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + expect(provider.convertToWebviewUri("/test/asset.png")).toBe("file:///test/asset.png") + + expect(uriSpy).toHaveBeenCalledWith(fakeFileUri) + expect(errorSpy).not.toHaveBeenCalled() + errorSpy.mockRestore() + }) + + test("uses the resolved real webview when no bridge is active", async () => { + ;(vscode.Uri.file as ReturnType).mockReturnValue(fakeFileUri) + provider["view"] = mockWebviewView + const converted = { toString: () => "vscode-webview://converted" } + mockWebviewView.webview.asWebviewUri.mockReturnValue(converted) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + expect(provider.convertToWebviewUri("/test/asset.png")).toBe("vscode-webview://converted") + + expect(mockWebviewView.webview.asWebviewUri).toHaveBeenCalledWith(fakeFileUri) + expect(errorSpy).not.toHaveBeenCalled() + errorSpy.mockRestore() + }) + + test("logs the no-webview error and falls back to the file URI", () => { + ;(vscode.Uri.file as ReturnType).mockReturnValue(fakeFileUri) + provider["view"] = undefined + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + expect(provider.convertToWebviewUri("/test/asset.png")).toBe("file:///test/asset.png") + + // The exact message proves the intended no-webview branch ran + // (any thrown-error path would log the generic conversion failure). + expect(errorSpy).toHaveBeenCalledWith("No webview available for URI conversion") + errorSpy.mockRestore() + }) + }) + describe("logWebviewHiddenDiagnostics", () => { let visibilityCallback: () => void diff --git a/src/core/webview/__tests__/browserBridge.spec.ts b/src/core/webview/__tests__/browserBridge.spec.ts index 989e03bd92..1cdfae3de0 100644 --- a/src/core/webview/__tests__/browserBridge.spec.ts +++ b/src/core/webview/__tests__/browserBridge.spec.ts @@ -22,7 +22,7 @@ import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" import { allowNetConnect } from "../../../vitest.setup" import type { BridgeHost } from "../browserBridge" -import { BrowserBridgeServer, getBrowserBridgePort } from "../browserBridge" +import { BrowserBridgeServer, getBoundPort, getBrowserBridgePort } from "../browserBridge" // The shared src/__mocks__/vscode.js lacks the ExtensionMode/env/commands/ // window surface the bridge touches at runtime, so this spec supplies its own @@ -43,6 +43,33 @@ vi.mock("vscode", () => ({ // transport use the same host). allowNetConnect(/^127\.0\.0\.1(?::\d+)?$/) +// Capture the Server constructor options the bridge passes through the lazy +// `import("socket.io")` in BrowserBridgeServer.start, without altering the +// real server behavior (the round-trip tests still run against socket.io). +type CapturedServerOptions = { + cors: { origin: RegExp[] } + transports: string[] +} + +const { socketIoOptions } = vi.hoisted(() => ({ + socketIoOptions: { current: undefined as CapturedServerOptions | undefined }, +})) + +vi.mock("socket.io", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + Server: class CapturingServer extends actual.Server { + constructor(...args: ConstructorParameters) { + super(...args) + // The bridge always constructs `new Server(httpServer, options)`. + const options = args[1] ?? args[0] + socketIoOptions.current = options as CapturedServerOptions | undefined + } + }, + } +}) + function connectToBridge(port: number): Promise { return new Promise((resolve, reject) => { const socket = io(`http://127.0.0.1:${port}`, { @@ -114,6 +141,7 @@ describe("getBrowserBridgePort", () => { ["float", "1.5", 0], ["zero", "0", 0], ["negative", "-1", 0], + ["upper boundary", "65536", 0], ["out of range", "99999", 0], ])("%s -> %i", (_label, value, expected) => { if (value === undefined) { @@ -125,6 +153,70 @@ describe("getBrowserBridgePort", () => { }) }) +describe("getBoundPort", () => { + it("returns the OS-assigned port for an AddressInfo object", () => { + const httpServer = { address: () => ({ port: 43210, address: "127.0.0.1", family: "IPv4" }) } + expect(getBoundPort(httpServer as never, 0)).toBe(43210) + }) + + it("falls back to the requested port for a string (pipe) address", () => { + const httpServer = { address: () => "\\\\.\\pipe\\bridge" } + expect(getBoundPort(httpServer as never, 8080)).toBe(8080) + }) + + it("falls back to the requested port while unbound (null address)", () => { + const httpServer = { address: () => null } + expect(getBoundPort(httpServer as never, 8080)).toBe(8080) + }) +}) + +describe("BrowserBridgeServer.start (socket.io options)", () => { + beforeEach(() => { + socketIoOptions.current = undefined + }) + + function captureOptions(): CapturedServerOptions { + const options = socketIoOptions.current + expect(options).toBeDefined() + return options! + } + + it("passes local-origin CORS and the websocket+polling transports", async () => { + const bridge = await startBridge() + try { + const options = captureOptions() + expect(options.cors).toEqual({ origin: [expect.any(RegExp)] }) + expect(options.transports).toEqual(["websocket", "polling"]) + } finally { + bridge["dispose"]() + } + }) + + it("restricts the CORS origin regex to bare localhost/loopback URLs", async () => { + const bridge = await startBridge() + try { + const origin = captureOptions().cors.origin[0] + const allowed = ["http://localhost", "http://127.0.0.1", "http://localhost:5173", "http://127.0.0.1:65535"] + const denied = [ + "http://localhost:5173/path", + "xhttp://localhost", + "http://evil.com", + "http://localhost:abc", + "http://localhost:5173x", + "https://localhost", + ] + for (const value of allowed) { + expect(origin.test(value)).toBe(true) + } + for (const value of denied) { + expect(origin.test(value)).toBe(false) + } + } finally { + bridge["dispose"]() + } + }) +}) + describe("BrowserBridgeServer statics (WeakMap registry)", () => { const hosts: HostStub[] = [] const sockets: Socket[] = [] @@ -303,6 +395,140 @@ describe("BrowserBridgeServer statics (WeakMap registry)", () => { const broadcast = await waitFor(() => inbound) expect(broadcast).toEqual(extensionMessage) }) + + it("disposeFor disposes the bridge server before dropping the entry", async () => { + const host = createHost() + hosts.push(host) + + BrowserBridgeServer.enable(host) + await waitFor(() => (BrowserBridgeServer.active(host) ? true : undefined)) + const bridge = BrowserBridgeServer["bridges"].get(host)! + + // Wrap the private dispose so the real server still closes (no leaked port). + const originalDispose = bridge["dispose"].bind(bridge) + const disposeSpy = vi.fn(() => originalDispose()) + bridge["dispose"] = disposeSpy + + BrowserBridgeServer.disposeFor(host) + + expect(disposeSpy).toHaveBeenCalledTimes(1) + expect(BrowserBridgeServer.active(host)).toBe(false) + }) + + it("the virtual webview mirrors the vscode.Webview contract", async () => { + const host = createHost() + hosts.push(host) + BrowserBridgeServer.enable(host) + await waitFor(() => (BrowserBridgeServer.active(host) ? true : undefined)) + + const webview = BrowserBridgeServer.webviewFor(host)! + expect(webview.options).toEqual({ enableScripts: true }) + expect(webview.cspSource).toBe("vscode-webview://bridge") + expect(webview.html).toBe("") + await expect(webview.postMessage({ type: "action", action: "chatButtonClicked" })).resolves.toBe(true) + const uri = { toString: () => "file:///test/asset.png" } as never + expect(webview.asWebviewUri(uri)).toBe(uri) + }) + + it("an onWebviewMessage subscription stops delivering after dispose", async () => { + const host = createHost() + hosts.push(host) + BrowserBridgeServer.enable(host) + await waitFor(() => (BrowserBridgeServer.active(host) ? true : undefined)) + const bridge = BrowserBridgeServer["bridges"].get(host)! + + const received: WebviewMessage[] = [] + const disposable = bridge["onWebviewMessage"]((message) => { + received.push(message) + }) + expect(typeof disposable.dispose).toBe("function") + + const client = await connectToBridge(bridge["_port"]) + sockets.push(client) + + const first: WebviewMessage = { type: "clearTask" } + client.emit("webviewMessage", first) + await waitFor(() => (received.length > 0 ? received[0] : undefined)) + + disposable.dispose() + client.emit("webviewMessage", { type: "acceptInput" }) + await new Promise((resolve) => setTimeout(resolve, 150)) + expect(received).toEqual([first]) + }) +}) + +describe("BrowserBridgeServer occupied-port failure paths", () => { + const originalPort = process.env.ROO_BROWSER_BRIDGE_PORT + let blocker: ReturnType + let occupied: number + + beforeEach(async () => { + // Occupy a real port and force the bridge to request exactly that one, + // so start() deterministically fails with EADDRINUSE. + blocker = createServer() + await new Promise((resolve) => blocker.listen(0, "127.0.0.1", resolve)) + occupied = (blocker.address() as AddressInfo).port + process.env.ROO_BROWSER_BRIDGE_PORT = String(occupied) + }) + + afterEach(async () => { + if (originalPort === undefined) { + delete process.env.ROO_BROWSER_BRIDGE_PORT + } else { + process.env.ROO_BROWSER_BRIDGE_PORT = originalPort + } + await new Promise((resolve) => blocker.close(() => resolve())) + }) + + it("start without onError resolves undefined instead of rejecting", async () => { + await expect(BrowserBridgeServer["start"](() => {})).resolves.toBeUndefined() + }) + + it("enable leaves the host inert when the bridge fails to start", async () => { + const host = createHost() + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}) + try { + BrowserBridgeServer.enable(host) + await waitFor(() => + logSpy.mock.calls.some(([message]) => String(message).includes("[BrowserBridge] Failed to start")) + ? true + : undefined, + ) + // Grace for the awaited server.close() and any (mutant) bind attempt. + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(BrowserBridgeServer.active(host)).toBe(false) + expect(host.setWebviewMessageListener).not.toHaveBeenCalled() + } finally { + logSpy.mockRestore() + BrowserBridgeServer.disposeFor(host) + } + }) + + it("a second enable never starts a second listening server", async () => { + const host = createHost() + // Port 0 (not the occupied override) so the second start *can* succeed: + // without the bridges.has guard its "[BrowserBridge] Listening" log is + // the observable proof a second server bound. + delete process.env.ROO_BROWSER_BRIDGE_PORT + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}) + try { + BrowserBridgeServer.enable(host) + await waitFor(() => (BrowserBridgeServer.active(host) ? true : undefined)) + + BrowserBridgeServer.enable(host) + // Grace for a would-be second server to bind and log. + await new Promise((resolve) => setTimeout(resolve, 250)) + + const listening = logSpy.mock.calls.filter(([message]) => + String(message).includes("[BrowserBridge] Listening"), + ) + expect(listening).toHaveLength(1) + } finally { + logSpy.mockRestore() + BrowserBridgeServer.disposeFor(host) + } + }) }) describe("BrowserBridgeServer.registerCommand (dev-only self-gating)", () => { diff --git a/src/core/webview/browserBridge.ts b/src/core/webview/browserBridge.ts index 8ea97f1824..9fc397c238 100644 --- a/src/core/webview/browserBridge.ts +++ b/src/core/webview/browserBridge.ts @@ -53,18 +53,23 @@ const VITE_BASE_URL = "http://localhost:5173" */ export function getBrowserBridgePort(): number { const raw = process.env.ROO_BROWSER_BRIDGE_PORT + // Stryker disable next-line ConditionalExpression,LogicalOperator,StringLiteral: ""/undefined both fall through Number() to DEFAULT_BROWSER_BRIDGE_PORT (equivalent mutants) if (raw === undefined || raw === "") { return DEFAULT_BROWSER_BRIDGE_PORT } const port = Number(raw) + // Stryker disable next-line EqualityOperator: port 0 is returned unchanged because DEFAULT_BROWSER_BRIDGE_PORT is 0 (equivalent mutant) return Number.isInteger(port) && port > 0 && port < 65536 ? port : DEFAULT_BROWSER_BRIDGE_PORT } /** * Resolves the port a bound http server is actually listening on. When * `port: 0` was requested, the OS picks a free port and reports it back here. + * + * Exported for tests only; production callers go through + * {@link BrowserBridgeServer.start}. */ -function getBoundPort(httpServer: SocketIoServer["httpServer"], requestedPort: number): number { +export function getBoundPort(httpServer: SocketIoServer["httpServer"], requestedPort: number): number { const address = httpServer.address() if (address && typeof address === "object") { return address.port @@ -353,6 +358,7 @@ export class BrowserBridgeServer { await new Promise((resolve, reject) => { httpServer.once("error", reject) httpServer.listen({ port: requestedPort, host: "127.0.0.1" }, () => { + // Stryker disable next-line StringLiteral: a settled promise ignores extra rejects; the listener is only removed to avoid a latent leak (unobservable in-process) httpServer.off("error", reject) resolve() }) diff --git a/webview-ui/src/utils/__tests__/browserBridgeClient.spec.ts b/webview-ui/src/utils/__tests__/browserBridgeClient.spec.ts index d56d2e2655..e58e8c0d63 100644 --- a/webview-ui/src/utils/__tests__/browserBridgeClient.spec.ts +++ b/webview-ui/src/utils/__tests__/browserBridgeClient.spec.ts @@ -120,6 +120,37 @@ describe("BrowserBridgeClient", () => { }) }) + describe("queue initialization", () => { + // The static field initializer runs once per module load, so only a + // freshly-reset module can observe a corrupted initial queue. + it("flushes exactly the posted messages on a freshly-loaded module", async () => { + ioMock.mockClear() + vi.resetModules() + const { BrowserBridgeClient: FreshClient } = await import("../browserBridgeClient") + try { + const ready = { type: "webviewDidLaunch" } as any + const later = { type: "showTaskWithId", text: "task-1" } as any + setSearch("?bridgePort=9999") + + FreshClient.maybeConnect() + FreshClient.postMessage(ready) + FreshClient.postMessage(later) + + await awaitSockets() + const socket = createdSocket() + for (const listener of socket.handlers["connect"]) { + listener() + } + expect(socket.emit.mock.calls).toEqual([ + ["webviewMessage", ready], + ["webviewMessage", later], + ]) + } finally { + FreshClient.resetForTests() + } + }) + }) + describe("message flow", () => { const ready = { type: "webviewDidLaunch" } as any const later = { type: "showTaskWithId", text: "task-1" } as any @@ -172,6 +203,65 @@ describe("BrowserBridgeClient", () => { expect(postMessageSpy).toHaveBeenCalledWith(extensionMessage, "*") postMessageSpy.mockRestore() }) + + it("registers a connect_error listener that warns with the failure", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + setSearch("?bridgePort=9999") + + BrowserBridgeClient.maybeConnect() + await awaitSockets() + const socket = createdSocket() + + // The listener must exist under the exact event name socket.io emits. + expect(socket.handlers["connect_error"]).toHaveLength(1) + + const failure = new Error("boom") + for (const listener of socket.handlers["connect_error"]) { + listener(failure) + } + expect(warnSpy).toHaveBeenCalledWith("[BrowserBridge] socket.io connect error:", failure) + warnSpy.mockRestore() + }) + }) + + describe("resetForTests before the socket exists", () => { + it("tears down an instance whose connect() has not attached a socket yet", () => { + setSearch("?bridgePort=9999") + + BrowserBridgeClient.maybeConnect() + // The lazy socket.io-client import has not resolved: `socket` is + // still undefined, so the optional chaining in resetForTests matters. + expect(() => BrowserBridgeClient.resetForTests()).not.toThrow() + expect(BrowserBridgeClient.active()).toBe(false) + }) + }) + + describe("production-build self-gating (import.meta.env.DEV)", () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it("maybeConnect stays inert when DEV is false, even with a valid port", async () => { + vi.stubEnv("DEV", false) + setSearch("?bridgePort=9999") + + BrowserBridgeClient.maybeConnect() + + expect(BrowserBridgeClient.active()).toBe(false) + await Promise.resolve() + await Promise.resolve() + expect(ioMock).not.toHaveBeenCalled() + }) + + it("active() reports false when DEV flips off, even with a live instance", async () => { + setSearch("?bridgePort=9999") + BrowserBridgeClient.maybeConnect() + await awaitSockets() + expect(BrowserBridgeClient.active()).toBe(true) + + vi.stubEnv("DEV", false) + expect(BrowserBridgeClient.active()).toBe(false) + }) }) describe("resetForTests", () => { diff --git a/webview-ui/src/utils/__tests__/vscode.spec.ts b/webview-ui/src/utils/__tests__/vscode.spec.ts index 007515e14c..67b7055db5 100644 --- a/webview-ui/src/utils/__tests__/vscode.spec.ts +++ b/webview-ui/src/utils/__tests__/vscode.spec.ts @@ -25,6 +25,7 @@ async function importFresh(): Promise { describe("vscode (VSCodeAPIWrapper) browser bridge wiring", () => { afterEach(() => { vi.unstubAllGlobals() + vi.unstubAllEnvs() bridgeMock.maybeConnect.mockClear() bridgeMock.active.mockReset().mockReturnValue(false) bridgeMock.postMessage.mockClear() @@ -37,6 +38,16 @@ describe("vscode (VSCodeAPIWrapper) browser bridge wiring", () => { expect(bridgeMock.maybeConnect).toHaveBeenCalledTimes(1) }) + it("skips the bridge entirely in a production build (DEV false)", async () => { + vi.stubEnv("DEV", false) + + await importFresh() + + // With the build-time gate false the else-branch must stay dead even + // though acquireVsCodeApi is missing. + expect(bridgeMock.maybeConnect).not.toHaveBeenCalled() + }) + it("never touches the bridge when acquireVsCodeApi is available", async () => { const vsCodeApi = { postMessage: vi.fn(), getState: vi.fn(), setState: vi.fn() } vi.stubGlobal("acquireVsCodeApi", () => vsCodeApi) diff --git a/webview-ui/src/utils/browserBridgeClient.ts b/webview-ui/src/utils/browserBridgeClient.ts index b7c6b2ad2a..3de9541036 100644 --- a/webview-ui/src/utils/browserBridgeClient.ts +++ b/webview-ui/src/utils/browserBridgeClient.ts @@ -34,6 +34,7 @@ import { WebviewMessage } from "@roo/WebviewMessage" */ function getBridgePortFromUrl(): number | undefined { const raw = new URLSearchParams(window.location.search).get("bridgePort") + // Stryker disable next-line ConditionalExpression,LogicalOperator,StringLiteral: "" and null both fall through Number()/the port>0 bound check to the same undefined result (equivalent mutants) if (raw !== null && raw !== "") { const port = Number(raw) if (Number.isInteger(port) && port > 0 && port < 65536) { @@ -78,6 +79,7 @@ export class BrowserBridgeClient { socket.on("connect", () => { const pending = BrowserBridgeClient.queue + // Stryker disable next-line ArrayDeclaration: the drained `pending` snapshot is read before the reassignment, so the reset value is unobservable through the public API BrowserBridgeClient.queue = [] for (const message of pending) { socket.emit("webviewMessage", message)