@@ -22,7 +22,7 @@ import type { ExtensionMessage, WebviewMessage } from "@roo-code/types"
2222
2323import { allowNetConnect } from "../../../vitest.setup"
2424import type { BridgeHost } from "../browserBridge"
25- import { BrowserBridgeServer , getBrowserBridgePort } from "../browserBridge"
25+ import { BrowserBridgeServer , getBoundPort , getBrowserBridgePort } from "../browserBridge"
2626
2727// The shared src/__mocks__/vscode.js lacks the ExtensionMode/env/commands/
2828// window surface the bridge touches at runtime, so this spec supplies its own
@@ -43,6 +43,33 @@ vi.mock("vscode", () => ({
4343// transport use the same host).
4444allowNetConnect ( / ^ 1 2 7 \. 0 \. 0 \. 1 (?: : \d + ) ? $ / )
4545
46+ // Capture the Server constructor options the bridge passes through the lazy
47+ // `import("socket.io")` in BrowserBridgeServer.start, without altering the
48+ // real server behavior (the round-trip tests still run against socket.io).
49+ type CapturedServerOptions = {
50+ cors : { origin : RegExp [ ] }
51+ transports : string [ ]
52+ }
53+
54+ const { socketIoOptions } = vi . hoisted ( ( ) => ( {
55+ socketIoOptions : { current : undefined as CapturedServerOptions | undefined } ,
56+ } ) )
57+
58+ vi . mock ( "socket.io" , async ( importOriginal ) => {
59+ const actual = await importOriginal < typeof import ( "socket.io" ) > ( )
60+ return {
61+ ...actual ,
62+ Server : class CapturingServer extends actual . Server {
63+ constructor ( ...args : ConstructorParameters < typeof actual . Server > ) {
64+ super ( ...args )
65+ // The bridge always constructs `new Server(httpServer, options)`.
66+ const options = args [ 1 ] ? ? args [ 0 ]
67+ socketIoOptions . current = options as CapturedServerOptions | undefined
68+ }
69+ } ,
70+ }
71+ } )
72+
4673function connectToBridge ( port : number ) : Promise < Socket > {
4774 return new Promise ( ( resolve , reject ) => {
4875 const socket = io ( `http://127.0.0.1:${ port } ` , {
@@ -114,6 +141,7 @@ describe("getBrowserBridgePort", () => {
114141 [ "float" , "1.5" , 0 ] ,
115142 [ "zero" , "0" , 0 ] ,
116143 [ "negative" , "-1" , 0 ] ,
144+ [ "upper boundary" , "65536" , 0 ] ,
117145 [ "out of range" , "99999" , 0 ] ,
118146 ] ) ( "%s -> %i" , ( _label , value , expected ) => {
119147 if ( value === undefined ) {
@@ -125,6 +153,70 @@ describe("getBrowserBridgePort", () => {
125153 } )
126154} )
127155
156+ describe ( "getBoundPort" , ( ) => {
157+ it ( "returns the OS-assigned port for an AddressInfo object" , ( ) => {
158+ const httpServer = { address : ( ) => ( { port : 43210 , address : "127.0.0.1" , family : "IPv4" } ) }
159+ expect ( getBoundPort ( httpServer as never , 0 ) ) . toBe ( 43210 )
160+ } )
161+
162+ it ( "falls back to the requested port for a string (pipe) address" , ( ) => {
163+ const httpServer = { address : ( ) => "\\\\.\\pipe\\bridge" }
164+ expect ( getBoundPort ( httpServer as never , 8080 ) ) . toBe ( 8080 )
165+ } )
166+
167+ it ( "falls back to the requested port while unbound (null address)" , ( ) => {
168+ const httpServer = { address : ( ) => null }
169+ expect ( getBoundPort ( httpServer as never , 8080 ) ) . toBe ( 8080 )
170+ } )
171+ } )
172+
173+ describe ( "BrowserBridgeServer.start (socket.io options)" , ( ) => {
174+ beforeEach ( ( ) => {
175+ socketIoOptions . current = undefined
176+ } )
177+
178+ function captureOptions ( ) : CapturedServerOptions {
179+ const options = socketIoOptions . current
180+ expect ( options ) . toBeDefined ( )
181+ return options !
182+ }
183+
184+ it ( "passes local-origin CORS and the websocket+polling transports" , async ( ) => {
185+ const bridge = await startBridge ( )
186+ try {
187+ const options = captureOptions ( )
188+ expect ( options . cors ) . toEqual ( { origin : [ expect . any ( RegExp ) ] } )
189+ expect ( options . transports ) . toEqual ( [ "websocket" , "polling" ] )
190+ } finally {
191+ bridge [ "dispose" ] ( )
192+ }
193+ } )
194+
195+ it ( "restricts the CORS origin regex to bare localhost/loopback URLs" , async ( ) => {
196+ const bridge = await startBridge ( )
197+ try {
198+ const origin = captureOptions ( ) . cors . origin [ 0 ]
199+ const allowed = [ "http://localhost" , "http://127.0.0.1" , "http://localhost:5173" , "http://127.0.0.1:65535" ]
200+ const denied = [
201+ "http://localhost:5173/path" ,
202+ "xhttp://localhost" ,
203+ "http://evil.com" ,
204+ "http://localhost:abc" ,
205+ "http://localhost:5173x" ,
206+ "https://localhost" ,
207+ ]
208+ for ( const value of allowed ) {
209+ expect ( origin . test ( value ) ) . toBe ( true )
210+ }
211+ for ( const value of denied ) {
212+ expect ( origin . test ( value ) ) . toBe ( false )
213+ }
214+ } finally {
215+ bridge [ "dispose" ] ( )
216+ }
217+ } )
218+ } )
219+
128220describe ( "BrowserBridgeServer statics (WeakMap registry)" , ( ) => {
129221 const hosts : HostStub [ ] = [ ]
130222 const sockets : Socket [ ] = [ ]
@@ -303,6 +395,140 @@ describe("BrowserBridgeServer statics (WeakMap registry)", () => {
303395 const broadcast = await waitFor ( ( ) => inbound )
304396 expect ( broadcast ) . toEqual ( extensionMessage )
305397 } )
398+
399+ it ( "disposeFor disposes the bridge server before dropping the entry" , async ( ) => {
400+ const host = createHost ( )
401+ hosts . push ( host )
402+
403+ BrowserBridgeServer . enable ( host )
404+ await waitFor ( ( ) => ( BrowserBridgeServer . active ( host ) ? true : undefined ) )
405+ const bridge = BrowserBridgeServer [ "bridges" ] . get ( host ) !
406+
407+ // Wrap the private dispose so the real server still closes (no leaked port).
408+ const originalDispose = bridge [ "dispose" ] . bind ( bridge )
409+ const disposeSpy = vi . fn ( ( ) => originalDispose ( ) )
410+ bridge [ "dispose" ] = disposeSpy
411+
412+ BrowserBridgeServer . disposeFor ( host )
413+
414+ expect ( disposeSpy ) . toHaveBeenCalledTimes ( 1 )
415+ expect ( BrowserBridgeServer . active ( host ) ) . toBe ( false )
416+ } )
417+
418+ it ( "the virtual webview mirrors the vscode.Webview contract" , async ( ) => {
419+ const host = createHost ( )
420+ hosts . push ( host )
421+ BrowserBridgeServer . enable ( host )
422+ await waitFor ( ( ) => ( BrowserBridgeServer . active ( host ) ? true : undefined ) )
423+
424+ const webview = BrowserBridgeServer . webviewFor ( host ) !
425+ expect ( webview . options ) . toEqual ( { enableScripts : true } )
426+ expect ( webview . cspSource ) . toBe ( "vscode-webview://bridge" )
427+ expect ( webview . html ) . toBe ( "" )
428+ await expect ( webview . postMessage ( { type : "action" , action : "chatButtonClicked" } ) ) . resolves . toBe ( true )
429+ const uri = { toString : ( ) => "file:///test/asset.png" } as never
430+ expect ( webview . asWebviewUri ( uri ) ) . toBe ( uri )
431+ } )
432+
433+ it ( "an onWebviewMessage subscription stops delivering after dispose" , async ( ) => {
434+ const host = createHost ( )
435+ hosts . push ( host )
436+ BrowserBridgeServer . enable ( host )
437+ await waitFor ( ( ) => ( BrowserBridgeServer . active ( host ) ? true : undefined ) )
438+ const bridge = BrowserBridgeServer [ "bridges" ] . get ( host ) !
439+
440+ const received : WebviewMessage [ ] = [ ]
441+ const disposable = bridge [ "onWebviewMessage" ] ( ( message ) => {
442+ received . push ( message )
443+ } )
444+ expect ( typeof disposable . dispose ) . toBe ( "function" )
445+
446+ const client = await connectToBridge ( bridge [ "_port" ] )
447+ sockets . push ( client )
448+
449+ const first : WebviewMessage = { type : "clearTask" }
450+ client . emit ( "webviewMessage" , first )
451+ await waitFor ( ( ) => ( received . length > 0 ? received [ 0 ] : undefined ) )
452+
453+ disposable . dispose ( )
454+ client . emit ( "webviewMessage" , { type : "acceptInput" } )
455+ await new Promise ( ( resolve ) => setTimeout ( resolve , 150 ) )
456+ expect ( received ) . toEqual ( [ first ] )
457+ } )
458+ } )
459+
460+ describe ( "BrowserBridgeServer occupied-port failure paths" , ( ) => {
461+ const originalPort = process . env . ROO_BROWSER_BRIDGE_PORT
462+ let blocker : ReturnType < typeof createServer >
463+ let occupied : number
464+
465+ beforeEach ( async ( ) => {
466+ // Occupy a real port and force the bridge to request exactly that one,
467+ // so start() deterministically fails with EADDRINUSE.
468+ blocker = createServer ( )
469+ await new Promise < void > ( ( resolve ) => blocker . listen ( 0 , "127.0.0.1" , resolve ) )
470+ occupied = ( blocker . address ( ) as AddressInfo ) . port
471+ process . env . ROO_BROWSER_BRIDGE_PORT = String ( occupied )
472+ } )
473+
474+ afterEach ( async ( ) => {
475+ if ( originalPort === undefined ) {
476+ delete process . env . ROO_BROWSER_BRIDGE_PORT
477+ } else {
478+ process . env . ROO_BROWSER_BRIDGE_PORT = originalPort
479+ }
480+ await new Promise < void > ( ( resolve ) => blocker . close ( ( ) => resolve ( ) ) )
481+ } )
482+
483+ it ( "start without onError resolves undefined instead of rejecting" , async ( ) => {
484+ await expect ( BrowserBridgeServer [ "start" ] ( ( ) => { } ) ) . resolves . toBeUndefined ( )
485+ } )
486+
487+ it ( "enable leaves the host inert when the bridge fails to start" , async ( ) => {
488+ const host = createHost ( )
489+ const logSpy = vi . spyOn ( console , "log" ) . mockImplementation ( ( ) => { } )
490+ try {
491+ BrowserBridgeServer . enable ( host )
492+ await waitFor ( ( ) =>
493+ logSpy . mock . calls . some ( ( [ message ] ) => String ( message ) . includes ( "[BrowserBridge] Failed to start" ) )
494+ ? true
495+ : undefined ,
496+ )
497+ // Grace for the awaited server.close() and any (mutant) bind attempt.
498+ await new Promise ( ( resolve ) => setTimeout ( resolve , 100 ) )
499+
500+ expect ( BrowserBridgeServer . active ( host ) ) . toBe ( false )
501+ expect ( host . setWebviewMessageListener ) . not . toHaveBeenCalled ( )
502+ } finally {
503+ logSpy . mockRestore ( )
504+ BrowserBridgeServer . disposeFor ( host )
505+ }
506+ } )
507+
508+ it ( "a second enable never starts a second listening server" , async ( ) => {
509+ const host = createHost ( )
510+ // Port 0 (not the occupied override) so the second start *can* succeed:
511+ // without the bridges.has guard its "[BrowserBridge] Listening" log is
512+ // the observable proof a second server bound.
513+ delete process . env . ROO_BROWSER_BRIDGE_PORT
514+ const logSpy = vi . spyOn ( console , "log" ) . mockImplementation ( ( ) => { } )
515+ try {
516+ BrowserBridgeServer . enable ( host )
517+ await waitFor ( ( ) => ( BrowserBridgeServer . active ( host ) ? true : undefined ) )
518+
519+ BrowserBridgeServer . enable ( host )
520+ // Grace for a would-be second server to bind and log.
521+ await new Promise ( ( resolve ) => setTimeout ( resolve , 250 ) )
522+
523+ const listening = logSpy . mock . calls . filter ( ( [ message ] ) =>
524+ String ( message ) . includes ( "[BrowserBridge] Listening" ) ,
525+ )
526+ expect ( listening ) . toHaveLength ( 1 )
527+ } finally {
528+ logSpy . mockRestore ( )
529+ BrowserBridgeServer . disposeFor ( host )
530+ }
531+ } )
306532} )
307533
308534describe ( "BrowserBridgeServer.registerCommand (dev-only self-gating)" , ( ) => {
0 commit comments