11import { afterEach , describe , expect , test } from "bun:test" ;
2- import { mkdirSync , mkdtempSync , readFileSync , rmSync , writeFileSync } from "node:fs" ;
2+ import { mkdirSync , mkdtempSync , readdirSync , readFileSync , rmSync , writeFileSync } from "node:fs" ;
33import { createServer } from "node:http" ;
44import { tmpdir } from "node:os" ;
55import { dirname , join } from "node:path" ;
66import { MagicContextRpcClient } from "./rpc-client" ;
7+ import { drainNotifications , isTuiConnected , pushNotification } from "./rpc-notifications" ;
78import { MagicContextRpcServer } from "./rpc-server" ;
8- import { parseRpcPortFile , rpcPortFilePath } from "./rpc-utils" ;
9+ import { parseRpcPortFile , type RpcPortFileRecord , rpcPortDir , rpcPortFilePath } from "./rpc-utils" ;
910
1011interface TestServer {
1112 port : number ;
@@ -56,6 +57,78 @@ function writePortFileForPid(
5657 writeFileSync ( portFile , JSON . stringify ( { port, pid, started_at : startedAt } ) , "utf-8" ) ;
5758}
5859
60+ function readNewestPortRecord ( storageDir : string , directory : string ) : RpcPortFileRecord | null {
61+ const records : RpcPortFileRecord [ ] = [ ] ;
62+ for ( const entry of readdirSync ( rpcPortDir ( storageDir , directory ) ) ) {
63+ if ( ! entry . startsWith ( "port-" ) || ! entry . endsWith ( ".json" ) ) continue ;
64+ const record = parseRpcPortFile (
65+ readFileSync ( join ( rpcPortDir ( storageDir , directory ) , entry ) , "utf-8" ) ,
66+ ) ;
67+ if ( record ) records . push ( record ) ;
68+ }
69+ records . sort ( ( a , b ) => b . started_at - a . started_at ) ;
70+ return records [ 0 ] ?? null ;
71+ }
72+
73+ async function waitFor ( condition : ( ) => boolean , label : string , timeoutMs = 2_000 ) : Promise < void > {
74+ const start = Date . now ( ) ;
75+ while ( Date . now ( ) - start < timeoutMs ) {
76+ if ( condition ( ) ) return ;
77+ await new Promise ( ( resolve ) => setTimeout ( resolve , 25 ) ) ;
78+ }
79+ throw new Error ( `Timed out waiting for ${ label } ` ) ;
80+ }
81+
82+ async function openSocket ( port : number , token : string ) : Promise < WebSocket > {
83+ const ws = new WebSocket ( `ws://127.0.0.1:${ port } /ws?token=${ encodeURIComponent ( token ) } ` ) ;
84+ await new Promise < void > ( ( resolve , reject ) => {
85+ const timeout = setTimeout ( ( ) => reject ( new Error ( "socket open timed out" ) ) , 2_000 ) ;
86+ ws . addEventListener (
87+ "open" ,
88+ ( ) => {
89+ clearTimeout ( timeout ) ;
90+ resolve ( ) ;
91+ } ,
92+ { once : true } ,
93+ ) ;
94+ ws . addEventListener (
95+ "error" ,
96+ ( ) => {
97+ clearTimeout ( timeout ) ;
98+ reject ( new Error ( "socket open failed" ) ) ;
99+ } ,
100+ { once : true } ,
101+ ) ;
102+ } ) ;
103+ return ws ;
104+ }
105+
106+ function waitForJsonMessage < T extends { type ?: string } > (
107+ ws : WebSocket ,
108+ predicate : ( message : T ) => boolean ,
109+ timeoutMs = 2_000 ,
110+ ) : Promise < T > {
111+ return new Promise ( ( resolve , reject ) => {
112+ const timeout = setTimeout ( ( ) => {
113+ ws . removeEventListener ( "message" , onMessage ) ;
114+ reject ( new Error ( "socket message timed out" ) ) ;
115+ } , timeoutMs ) ;
116+ const onMessage = ( event : MessageEvent ) => {
117+ let message : T ;
118+ try {
119+ message = JSON . parse ( String ( event . data ) ) as T ;
120+ } catch {
121+ return ;
122+ }
123+ if ( ! predicate ( message ) ) return ;
124+ clearTimeout ( timeout ) ;
125+ ws . removeEventListener ( "message" , onMessage ) ;
126+ resolve ( message ) ;
127+ } ;
128+ ws . addEventListener ( "message" , onMessage ) ;
129+ } ) ;
130+ }
131+
59132async function startRpcServer ( handler : ( method : string ) => Response | object ) : Promise < TestServer > {
60133 const server = createServer ( async ( req , res ) => {
61134 if ( req . method === "GET" && req . url === "/health" ) {
@@ -145,9 +218,7 @@ describe("MagicContextRpcClient", () => {
145218 const port = await server . start ( ) ;
146219 try {
147220 // Sanity: the port file carries a non-empty token.
148- const record = parseRpcPortFile (
149- readFileSync ( rpcPortFilePath ( storageDir , directory ) , "utf-8" ) ,
150- ) ;
221+ const record = readNewestPortRecord ( storageDir , directory ) ;
151222 expect ( typeof record ?. token ) . toBe ( "string" ) ;
152223 expect ( ( record ?. token ?? "" ) . length ) . toBeGreaterThan ( 0 ) ;
153224
@@ -167,6 +238,94 @@ describe("MagicContextRpcClient", () => {
167238 }
168239 } ) ;
169240
241+ test ( "websocket upgrade rejects missing bearer token before a socket is created" , async ( ) => {
242+ const storageDir = makeTempDir ( ) ;
243+ const directory = "/repo-ws-auth" ;
244+ const server = new MagicContextRpcServer ( storageDir , directory ) ;
245+ const port = await server . start ( ) ;
246+ try {
247+ const res = await fetch ( `http://127.0.0.1:${ port } /ws` ) ;
248+ expect ( res . status ) . toBe ( 401 ) ;
249+ expect ( isTuiConnected ( ) ) . toBe ( false ) ;
250+ } finally {
251+ server . stop ( ) ;
252+ }
253+ } ) ;
254+
255+ test ( "re-hello replaces the previous websocket notification sink" , async ( ) => {
256+ drainNotifications ( Number . MAX_SAFE_INTEGER ) ;
257+ const storageDir = makeTempDir ( ) ;
258+ const directory = "/repo-ws-rehello" ;
259+ const server = new MagicContextRpcServer ( storageDir , directory ) ;
260+ const port = await server . start ( ) ;
261+ const record = readNewestPortRecord ( storageDir , directory ) ;
262+ expect ( typeof record ?. token ) . toBe ( "string" ) ;
263+
264+ const ws = await openSocket ( port , record ?. token ?? "" ) ;
265+ const notifications : unknown [ ] = [ ] ;
266+ ws . addEventListener ( "message" , ( event ) => {
267+ const message = JSON . parse ( String ( event . data ) ) as {
268+ type ?: string ;
269+ notification ?: unknown ;
270+ } ;
271+ if ( message . type === "notification" ) notifications . push ( message . notification ) ;
272+ } ) ;
273+
274+ try {
275+ ws . send ( JSON . stringify ( { type : "hello" , token : record ?. token , sessionId : "ses_A" } ) ) ;
276+ await waitForJsonMessage ( ws , ( message ) => message . type === "hello-ack" ) ;
277+ expect ( isTuiConnected ( "ses_A" ) ) . toBe ( true ) ;
278+
279+ ws . send ( JSON . stringify ( { type : "hello" , token : record ?. token , sessionId : "ses_B" } ) ) ;
280+ await waitForJsonMessage ( ws , ( message ) => message . type === "hello-ack" ) ;
281+ expect ( isTuiConnected ( "ses_A" ) ) . toBe ( false ) ;
282+ expect ( isTuiConnected ( "ses_B" ) ) . toBe ( true ) ;
283+
284+ ws . send ( JSON . stringify ( { type : "hello" , token : record ?. token , sessionId : "ses_B" } ) ) ;
285+ await waitForJsonMessage ( ws , ( message ) => message . type === "hello-ack" ) ;
286+ pushNotification ( "live" , { ok : true } , "ses_B" ) ;
287+ await waitFor ( ( ) => notifications . length >= 1 , "one live notification" ) ;
288+ await new Promise ( ( resolve ) => setTimeout ( resolve , 50 ) ) ;
289+ expect ( notifications ) . toHaveLength ( 1 ) ;
290+
291+ ws . close ( ) ;
292+ await waitFor ( ( ) => ! isTuiConnected ( ) , "socket sink cleanup" ) ;
293+ } finally {
294+ try {
295+ ws . close ( ) ;
296+ } catch {
297+ // best-effort
298+ }
299+ server . stop ( ) ;
300+ }
301+ } ) ;
302+
303+ test ( "same-process servers keep distinct port files during overlap" , async ( ) => {
304+ const storageDir = makeTempDir ( ) ;
305+ const directory = "/repo-port-collision" ;
306+ const first = new MagicContextRpcServer ( storageDir , directory ) ;
307+ const second = new MagicContextRpcServer ( storageDir , directory ) ;
308+ await first . start ( ) ;
309+ const secondPort = await second . start ( ) ;
310+
311+ try {
312+ const files = readdirSync ( rpcPortDir ( storageDir , directory ) ) . filter (
313+ ( entry ) => entry . startsWith ( "port-" ) && entry . endsWith ( ".json" ) ,
314+ ) ;
315+ expect ( files . length ) . toBeGreaterThanOrEqual ( 2 ) ;
316+
317+ first . stop ( ) ;
318+ const remaining = readNewestPortRecord ( storageDir , directory ) ;
319+ expect ( remaining ?. port ) . toBe ( secondPort ) ;
320+
321+ const client = new MagicContextRpcClient ( storageDir , directory ) ;
322+ expect ( ( await client . resolveEndpoint ( ) ) ?. port ) . toBe ( secondPort ) ;
323+ } finally {
324+ first . stop ( ) ;
325+ second . stop ( ) ;
326+ }
327+ } ) ;
328+
170329 test ( "gives up when the port file points at a dead server" , async ( ) => {
171330 const storageDir = makeTempDir ( ) ;
172331 const directory = "/repo" ;
0 commit comments