@@ -110,6 +110,7 @@ vi.mock('./gmcp', async () => {
110110 ...actual ,
111111 GMCPClientFileTransfer : class {
112112 packageName = 'Client.FileTransfer' ;
113+ reset = vi . fn ( ) ;
113114 sendReject = vi . fn ( ) ;
114115 shutdown = vi . fn ( ) ;
115116 } ,
@@ -144,11 +145,8 @@ vi.mock('./mcp', () => ({
144145
145146import MudClient from './client' ;
146147import { GMCPClientFileTransfer } from './gmcp' ;
147- import { useItemsStore } from './stores/itemsStore' ;
148148import { useOutputStore } from './stores/outputStore' ;
149- import { useSessionStore } from './stores/sessionStore' ;
150- import { useSkillsStore } from './stores/skillsStore' ;
151- import { useUserlistStore } from './stores/userlistStore' ;
149+ import type { Stream } from './telnet' ;
152150
153151class MockWebSocket {
154152 static CONNECTING = 0 ;
@@ -215,13 +213,10 @@ describe('MudClient lifecycle cleanup', () => {
215213 mockFileTransferManagerInstances . length = 0 ;
216214 mockPreferenceListeners . clear ( ) ;
217215 mockPreferenceSubscribe . mockClear ( ) ;
216+ mockPreferencesState . general . localEcho = false ;
218217 mockPreferencesState . sound . muteInBackground = false ;
219218 mockWebSocketInstances . length = 0 ;
220- useItemsStore . getState ( ) . reset ( ) ;
221219 useOutputStore . getState ( ) . reset ( ) ;
222- useSessionStore . getState ( ) . reset ( ) ;
223- useSkillsStore . getState ( ) . reset ( ) ;
224- useUserlistStore . getState ( ) . reset ( ) ;
225220 vi . stubGlobal ( 'WebSocket' , MockWebSocket ) ;
226221 Object . defineProperty ( window , 'WebSocket' , {
227222 configurable : true ,
@@ -292,43 +287,136 @@ describe('MudClient lifecycle cleanup', () => {
292287 expect ( cleanupOrder ) . toEqual ( [ 'fileTransferManager.cleanup' , 'gmcp.reset' ] ) ;
293288 } ) ;
294289
295- it ( 'clears item state during connection cleanup ' , ( ) => {
290+ it ( 'runs disconnect resets in registration order and isolates failures ' , ( ) => {
296291 const client = new MudClient ( 'example.test' , 443 ) ;
292+ const resetOrder : string [ ] = [ ] ;
293+ const resetError = new Error ( 'reset failed' ) ;
294+ const consoleError = vi . spyOn ( console , 'error' ) . mockImplementation ( ( ) => { } ) ;
295+ client . registerDisconnectReset ( ( ) => resetOrder . push ( 'first' ) ) ;
296+ client . registerDisconnectReset ( ( ) => {
297+ resetOrder . push ( 'second' ) ;
298+ throw resetError ;
299+ } ) ;
300+ client . registerDisconnectReset ( ( ) => resetOrder . push ( 'third' ) ) ;
297301 client . connect ( ) ;
298- useItemsStore . getState ( ) . setLocationItems ( 'room' , [
299- { id : 'lantern' , name : 'Lantern' } ,
300- ] ) ;
301- useItemsStore . getState ( ) . setLocationItems ( 'inv' , [
302- { id : 'coin' , name : 'Coin' } ,
303- ] ) ;
304302
305303 client . close ( ) ;
306304
307- expect ( useItemsStore . getState ( ) . itemsByLocation ) . toEqual ( { } ) ;
308- expect ( useItemsStore . getState ( ) . hasReceivedList ) . toBe ( false ) ;
305+ expect ( resetOrder ) . toEqual ( [ 'first' , 'second' , 'third' ] ) ;
306+ expect ( consoleError ) . toHaveBeenCalledWith ( 'Disconnect reset failed:' , resetError ) ;
309307 } ) ;
310308
311- it ( 'clears session, skills, and userlist state during connection cleanup ' , ( ) => {
309+ it ( 'runs disconnect resets once per disconnect and again after reconnect ' , ( ) => {
312310 const client = new MudClient ( 'example.test' , 443 ) ;
311+ const reset = vi . fn ( ) ;
312+ client . registerDisconnectReset ( reset ) ;
313313 client . connect ( ) ;
314- useSessionStore . getState ( ) . setPlayer ( 'q' , 'Q the Mongoose' ) ;
315- useSessionStore . getState ( ) . setRoomId ( '101' ) ;
316- useSkillsStore . getState ( ) . setGroups ( [ { name : 'Combat' , rank : 'Adept' } ] ) ;
317- useSkillsStore . getState ( ) . setList ( { group : 'combat' , list : [ 'slash' ] } ) ;
318- useUserlistStore . getState ( ) . setPlayers ( [
319- { Object : 'q' , Name : 'Q' , Icon : 0 , away : false , idle : false } ,
320- ] ) ;
314+ const firstSocket = mockWebSocketInstances [ 0 ] ;
315+
316+ client . close ( ) ;
317+ firstSocket . onclose ?.( new Event ( 'close' ) ) ;
318+
319+ expect ( reset ) . toHaveBeenCalledTimes ( 1 ) ;
320+
321+ client . connect ( ) ;
322+ client . close ( ) ;
323+
324+ expect ( reset ) . toHaveBeenCalledTimes ( 2 ) ;
325+ } ) ;
326+
327+ it ( 'automatically registers MCP package disconnect resets' , ( ) => {
328+ const client = new MudClient ( 'example.test' , 443 ) ;
329+ const mcpPackage = client . registerMcpPackage (
330+ class {
331+ packageName = 'test-package' ;
332+ reset = vi . fn ( ) ;
333+ } as never ,
334+ ) as unknown as { reset : ReturnType < typeof vi . fn > } ;
335+ client . connect ( ) ;
336+
337+ client . close ( ) ;
338+
339+ expect ( mcpPackage . reset ) . toHaveBeenCalledOnce ( ) ;
340+ } ) ;
341+
342+ it ( 'clears a closed local transport and rejects later sends' , ( ) => {
343+ const client = new MudClient ( 'example.test' , 443 ) ;
344+ const closeListeners : Array < ( ) => void > = [ ] ;
345+ const stream = {
346+ close : vi . fn ( ( ) => {
347+ closeListeners . forEach ( ( listener ) => {
348+ listener ( ) ;
349+ } ) ;
350+ } ) ,
351+ on : vi . fn ( ( event : string , callback : ( ) => void ) => {
352+ if ( event === 'close' ) closeListeners . push ( callback ) ;
353+ } ) ,
354+ write : vi . fn ( ) ,
355+ } as unknown as Stream & { close ( ) : void } ;
356+ client . connectLocal ( stream ) ;
357+
358+ client . send ( 'look\r\n' ) ;
359+ expect ( stream . write ) . toHaveBeenCalledOnce ( ) ;
360+
361+ client . close ( ) ;
362+
363+ expect ( stream . close ) . toHaveBeenCalledOnce ( ) ;
364+ expect ( client . connected ) . toBe ( false ) ;
365+ expect (
366+ client as unknown as { localMode : boolean ; localStream ?: Stream } ,
367+ ) . toMatchObject ( {
368+ localMode : false ,
369+ localStream : undefined ,
370+ } ) ;
371+ expect ( ( ) => client . send ( 'look\r\n' ) ) . toThrow (
372+ new Error ( 'Cannot send while disconnected' ) ,
373+ ) ;
374+ expect ( stream . write ) . toHaveBeenCalledOnce ( ) ;
375+ } ) ;
376+
377+ it . each ( [
378+ [ 'CONNECTING' , MockWebSocket . CONNECTING ] ,
379+ [ 'CLOSING' , MockWebSocket . CLOSING ] ,
380+ [ 'CLOSED' , MockWebSocket . CLOSED ] ,
381+ ] ) ( 'rejects sends while the WebSocket is %s' , ( _label , readyState ) => {
382+ const client = new MudClient ( 'example.test' , 443 ) ;
383+ client . connect ( ) ;
384+ const socket = mockWebSocketInstances [ 0 ] ;
385+ socket . onopen ?.( new Event ( 'open' ) ) ;
386+ socket . readyState = readyState ;
321387
388+ expect ( ( ) => client . send ( 'look\r\n' ) ) . toThrow (
389+ new Error ( 'Cannot send while disconnected' ) ,
390+ ) ;
391+ expect ( socket . send ) . not . toHaveBeenCalled ( ) ;
322392 client . close ( ) ;
393+ } ) ;
394+
395+ it ( 'sends through a connected open WebSocket' , ( ) => {
396+ const client = new MudClient ( 'example.test' , 443 ) ;
397+ client . connect ( ) ;
398+ const socket = mockWebSocketInstances [ 0 ] ;
399+ socket . onopen ?.( new Event ( 'open' ) ) ;
400+
401+ client . send ( 'look\r\n' ) ;
402+
403+ expect ( socket . send ) . toHaveBeenCalledWith ( 'look\r\n' ) ;
404+ } ) ;
405+
406+ it ( 'reports a disconnected command without adding local echo' , ( ) => {
407+ mockPreferencesState . general . localEcho = true ;
408+ const client = new MudClient ( 'example.test' , 443 ) ;
323409
324- expect ( useSessionStore . getState ( ) . playerId ) . toBe ( '' ) ;
325- expect ( useSessionStore . getState ( ) . playerName ) . toBe ( '' ) ;
326- expect ( useSessionStore . getState ( ) . roomId ) . toBe ( '' ) ;
327- expect ( useSkillsStore . getState ( ) . groups ) . toEqual ( [ ] ) ;
328- expect ( useSkillsStore . getState ( ) . skillsByGroup ) . toEqual ( { } ) ;
329- expect ( useSkillsStore . getState ( ) . infoBySkill ) . toEqual ( { } ) ;
330- expect ( useUserlistStore . getState ( ) . players ) . toEqual ( [ ] ) ;
331- expect ( useUserlistStore . getState ( ) . hasReceivedList ) . toBe ( false ) ;
410+ expect ( ( ) => client . sendCommand ( 'look' ) ) . not . toThrow ( ) ;
411+
412+ expect ( useOutputStore . getState ( ) . entries ) . toEqual ( [
413+ {
414+ id : 1 ,
415+ type : 'error' ,
416+ error : new Error ( 'Cannot send while disconnected' ) ,
417+ } ,
418+ ] ) ;
419+ expect ( mockWebSocketInstances ) . toEqual ( [ ] ) ;
332420 } ) ;
333421
334422 it ( 'buffers text split across frames until the line is complete' , async ( ) => {
0 commit comments