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..122e2cd151 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -692,6 +692,12 @@ 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 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)) @@ -954,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) @@ -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..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/activate/registerCommands.ts b/src/activate/registerCommands.ts index 692aabfd68..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" @@ -15,6 +14,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 +71,9 @@ export const registerCommands = (options: RegisterCommandOptions) => { } context.subscriptions.push(registerRipgrepDiagnosticCommand()) + + // 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 495fe454b7..12829eb32f 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 { BrowserBridgeServer } from "./browserBridge" import type { ClineMessage, TodoItem } from "@roo-code/types" import { type ApiMessage, @@ -897,6 +898,10 @@ export class ClineProvider this.customModesManager?.dispose() this.taskHistoryStore.dispose() this.flushGlobalStateWriteThrough() + + // Release the provider-owned browser bridge (socket.io server + port). + BrowserBridgeServer.disposeFor(this) + this.log("Disposed all disposables") ClineProvider.activeInstances.delete(this) @@ -1041,12 +1046,21 @@ 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 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 && + 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 +1092,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; + // re-registering here would double-handle every message. + if (!BrowserBridgeServer.active(this)) { + this.setWebviewMessageListener(webviewView.webview) + } // Initialize code index status subscription for the current workspace. this.updateCodeIndexStatusSubscription() @@ -1478,8 +1496,13 @@ export class ClineProvider return } + const webview = BrowserBridgeServer.webviewFor(this) ?? this.view?.webview + if (!webview) { + return + } + try { - await this.view?.webview.postMessage(message) + await webview.postMessage(message) } catch { // View disposed, drop message silently } @@ -4534,8 +4557,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 = BrowserBridgeServer.webviewFor(this) ?? 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..286bba2ae4 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,194 @@ 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("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 new file mode 100644 index 0000000000..1cdfae3de0 --- /dev/null +++ b/src/core/webview/__tests__/browserBridge.spec.ts @@ -0,0 +1,664 @@ +// 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, 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 +// 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+)?$/) + +// 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}`, { + 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], + ["upper boundary", "65536", 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("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[] = [] + + 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) + }) + + 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)", () => { + 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/src/core/webview/browserBridge.ts b/src/core/webview/browserBridge.ts new file mode 100644 index 0000000000..9fc397c238 --- /dev/null +++ b/src/core/webview/browserBridge.ts @@ -0,0 +1,458 @@ +import { createServer } from "http" + +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). + * + * `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, 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. 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 + * 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 + // 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}. + */ +export function getBoundPort(httpServer: SocketIoServer["httpServer"], requestedPort: number): number { + const address = httpServer.address() + if (address && typeof address === "object") { + return address.port + } + 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 { + /** + * 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 + + 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) + } + }) + }) + } + + /** + * 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}` + } + + /** + * 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. + */ + 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) + } + + /** + * 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. + */ + private 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} +

+
+ +` + } + + /** 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 + * `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. + */ + 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 SocketIoServerClass(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" }, () => { + // 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() + }) + }) + } 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) + await new Promise((resolve) => void server.close(() => resolve())) + 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. + */ + private getOrCreateVirtualWebview(): Webview { + if (!this.virtualWebview) { + this.virtualWebview = BrowserBridgeServer.createVirtualWebview(this) + } + return this.virtualWebview + } + + /** + * Broadcasts an extension->webview message to all connected browser clients. + */ + private broadcast(message: ExtensionMessage): void { + this.server.emit("extensionMessage", message) + } + + /** + * Subscribes to webview->extension messages coming from browser clients. + * Returns a dispose function. + */ + private onWebviewMessage(listener: (message: WebviewMessage) => void): Disposable { + this.webviewMessageListeners.add(listener) + return { + dispose: () => { + this.webviewMessageListeners.delete(listener) + }, + } + } + + 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. + */ + 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) + }, + } + }, + asWebviewUri(localResource: vscode.Uri): vscode.Uri { + // The browser UI never renders webview URIs; return the input unchanged. + return localResource + }, + } + } +} 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 7467b50b7b..1ee511907a 100644 --- a/src/package.json +++ b/src/package.json @@ -551,6 +551,8 @@ "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 450288eff5..6c24e5db74 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -105,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/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 bccbdf7031..22578817ff 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -185,6 +185,13 @@ --radius: 0.5rem; } + /* + * 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. + */ + /* 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/__tests__/browserBridgeClient.spec.ts b/webview-ui/src/utils/__tests__/browserBridgeClient.spec.ts new file mode 100644 index 0000000000..e58e8c0d63 --- /dev/null +++ b/webview-ui/src/utils/__tests__/browserBridgeClient.spec.ts @@ -0,0 +1,284 @@ +// 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("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 + + 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() + }) + + 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", () => { + 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..67b7055db5 --- /dev/null +++ b/webview-ui/src/utils/__tests__/vscode.spec.ts @@ -0,0 +1,87 @@ +// 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() + vi.unstubAllEnvs() + 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("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) + + 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() + }) +}) diff --git a/webview-ui/src/utils/browserBridgeClient.ts b/webview-ui/src/utils/browserBridgeClient.ts new file mode 100644 index 0000000000..3de9541036 --- /dev/null +++ b/webview-ui/src/utils/browserBridgeClient.ts @@ -0,0 +1,153 @@ +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 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`. + */ + +/** + * 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") + // 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) { + 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 + // 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) + } + }) + + 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 2cc0a58909..6d9000470a 100644 --- a/webview-ui/src/utils/vscode.ts +++ b/webview-ui/src/utils/vscode.ts @@ -2,6 +2,8 @@ import type { WebviewApi } from "vscode-webview" import { WebviewMessage } from "@roo/WebviewMessage" +import { BrowserBridgeClient } from "./browserBridgeClient" + /** * A utility wrapper around the acquireVsCodeApi() function, which enables * message passing and state management between the webview and extension @@ -19,6 +21,13 @@ class VSCodeAPIWrapper { // context (i.e. VS Code development window or web browser) if (typeof acquireVsCodeApi === "function") { this.vsCodeApi = acquireVsCodeApi() + } 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() } } @@ -26,13 +35,17 @@ 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 (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) }