@@ -383,4 +383,239 @@ describe('RobotClient', () => {
383383 expect ( mockResetFn ) . not . toHaveBeenCalled ( ) ;
384384 } ) ;
385385 } ) ;
386+
387+ describe ( 'dial error handling' , ( ) => {
388+ interface DisconnectedEventCapture {
389+ events : unknown [ ] ;
390+ setupListener : ( client : RobotClient ) => void ;
391+ }
392+
393+ const captureDisconnectedEvents = ( ) : DisconnectedEventCapture => {
394+ const events : unknown [ ] = [ ] ;
395+ const setupListener = ( client : RobotClient ) => {
396+ client . on ( 'disconnected' , ( event ) => {
397+ events . push ( event ) ;
398+ } ) ;
399+ } ;
400+ return { events, setupListener } ;
401+ } ;
402+
403+ const findEventWithError = (
404+ events : unknown [ ] ,
405+ errorMessage ?: string
406+ ) : unknown => {
407+ return events . find ( ( event ) => {
408+ if (
409+ typeof event !== 'object' ||
410+ event === null ||
411+ ! ( 'error' in event )
412+ ) {
413+ return false ;
414+ }
415+ if ( errorMessage === undefined || errorMessage === '' ) {
416+ return true ;
417+ }
418+ const { error } = event as { error : Error } ;
419+ return error . message === errorMessage ;
420+ } ) ;
421+ } ;
422+
423+ it ( 'should throw an error when both WebRTC and gRPC connections fail' , async ( ) => {
424+ // Arrange
425+ const client = new RobotClient ( ) ;
426+ const webrtcError = new Error ( 'WebRTC connection failed' ) ;
427+ const grpcError = new Error ( 'gRPC connection failed' ) ;
428+
429+ vi . mocked ( rpcModule . dialWebRTC ) . mockRejectedValue ( webrtcError ) ;
430+ vi . mocked ( rpcModule . dialDirect ) . mockRejectedValue ( grpcError ) ;
431+
432+ // Act & Assert
433+ await expect (
434+ client . dial ( {
435+ ...baseDialConfig ,
436+ noReconnect : true ,
437+ } )
438+ ) . rejects . toThrow ( 'Failed to connect via all methods' ) ;
439+ } ) ;
440+
441+ it ( 'should emit DISCONNECTED event with error when WebRTC fails' , async ( ) => {
442+ // Arrange
443+ const client = new RobotClient ( ) ;
444+ const webrtcError = new Error ( 'WebRTC connection failed' ) ;
445+ const { events, setupListener } = captureDisconnectedEvents ( ) ;
446+
447+ vi . mocked ( rpcModule . dialWebRTC ) . mockRejectedValue ( webrtcError ) ;
448+ setupListener ( client ) ;
449+
450+ // Act
451+ try {
452+ await client . dial ( {
453+ ...baseDialConfig ,
454+ noReconnect : true ,
455+ } ) ;
456+ } catch {
457+ // Expected to throw
458+ }
459+
460+ // Assert
461+ expect ( events . length ) . toBeGreaterThanOrEqual ( 2 ) ;
462+ const webrtcEvent = findEventWithError (
463+ events ,
464+ 'WebRTC connection failed'
465+ ) ;
466+ expect ( webrtcEvent ) . toBeDefined ( ) ;
467+ expect ( webrtcEvent ) . toMatchObject ( { error : webrtcError } ) ;
468+ } ) ;
469+
470+ it ( 'should emit DISCONNECTED event with error when gRPC fails' , async ( ) => {
471+ // Arrange
472+ const client = new RobotClient ( ) ;
473+ const { events, setupListener } = captureDisconnectedEvents ( ) ;
474+
475+ setupListener ( client ) ;
476+
477+ // Act
478+ try {
479+ await client . dial ( {
480+ host : TEST_HOST ,
481+ noReconnect : true ,
482+ } ) ;
483+ } catch {
484+ // Expected to throw
485+ }
486+
487+ // Assert
488+ expect ( events . length ) . toBeGreaterThanOrEqual ( 1 ) ;
489+ const errorEvent = findEventWithError ( events ) ;
490+ expect ( errorEvent ) . toBeDefined ( ) ;
491+ expect ( ( errorEvent as { error : Error } ) . error ) . toBeInstanceOf ( Error ) ;
492+ } ) ;
493+
494+ it ( 'should emit DISCONNECTED events even for non-Error objects' , async ( ) => {
495+ // Arrange
496+ const client = new RobotClient ( ) ;
497+ const webrtcError = 'string error' ;
498+ const { events, setupListener } = captureDisconnectedEvents ( ) ;
499+
500+ vi . mocked ( rpcModule . dialWebRTC ) . mockRejectedValue ( webrtcError ) ;
501+ setupListener ( client ) ;
502+
503+ // Act
504+ try {
505+ await client . dial ( {
506+ ...baseDialConfig ,
507+ noReconnect : true ,
508+ } ) ;
509+ } catch {
510+ // Expected to throw
511+ }
512+
513+ // Assert
514+ expect ( events . length ) . toBeGreaterThanOrEqual ( 2 ) ;
515+ const errorEvent = findEventWithError ( events ) ;
516+ expect ( errorEvent ) . toBeDefined ( ) ;
517+ expect ( ( errorEvent as { error : Error } ) . error ) . toBeInstanceOf ( Error ) ;
518+ } ) ;
519+
520+ it ( 'should include both errors in the thrown error cause' , async ( ) => {
521+ // Arrange
522+ const client = new RobotClient ( ) ;
523+ const webrtcError = new Error ( 'WebRTC connection failed' ) ;
524+
525+ vi . mocked ( rpcModule . dialWebRTC ) . mockRejectedValue ( webrtcError ) ;
526+
527+ // Act
528+ let caughtError : Error | undefined ;
529+ try {
530+ await client . dial ( {
531+ ...baseDialConfig ,
532+ noReconnect : true ,
533+ } ) ;
534+ } catch ( error ) {
535+ caughtError = error as Error ;
536+ }
537+
538+ // Assert
539+ expect ( caughtError ) . toBeDefined ( ) ;
540+ expect ( caughtError ) . toBeInstanceOf ( Error ) ;
541+ expect ( caughtError ! . message ) . toBe ( 'Failed to connect via all methods' ) ;
542+ expect ( caughtError ! . cause ) . toBeDefined ( ) ;
543+ expect ( Array . isArray ( caughtError ! . cause ) ) . toBe ( true ) ;
544+ const causes = caughtError ! . cause as Error [ ] ;
545+ expect ( causes ) . toHaveLength ( 2 ) ;
546+ expect ( causes [ 0 ] ) . toBe ( webrtcError ) ;
547+ expect ( causes [ 1 ] ) . toBeInstanceOf ( Error ) ;
548+ } ) ;
549+
550+ it ( 'should handle non-Error objects thrown from dial methods' , async ( ) => {
551+ // Arrange
552+ const client = new RobotClient ( ) ;
553+ const webrtcError = 'string error' ;
554+ const grpcError = { message : 'object error' } ;
555+
556+ vi . mocked ( rpcModule . dialWebRTC ) . mockRejectedValue ( webrtcError ) ;
557+ vi . mocked ( rpcModule . dialDirect ) . mockRejectedValue ( grpcError ) ;
558+
559+ // Act & Assert
560+ await expect (
561+ client . dial ( {
562+ ...baseDialConfig ,
563+ noReconnect : true ,
564+ } )
565+ ) . rejects . toThrow ( 'Failed to connect via all methods' ) ;
566+ } ) ;
567+
568+ it ( 'should not throw when WebRTC succeeds' , async ( ) => {
569+ // Arrange
570+ const client = setupClientMocks ( ) ;
571+
572+ // Act
573+ const result = await client . dial ( {
574+ ...baseDialConfig ,
575+ noReconnect : true ,
576+ } ) ;
577+
578+ // Assert
579+ expect ( result ) . toBe ( client ) ;
580+ } ) ;
581+
582+ it ( 'should not throw when gRPC succeeds after WebRTC fails' , async ( ) => {
583+ // Arrange
584+ const client = new RobotClient ( ) ;
585+ const webrtcError = new Error ( 'WebRTC connection failed' ) ;
586+
587+ vi . mocked ( rpcModule . dialWebRTC ) . mockRejectedValue ( webrtcError ) ;
588+ vi . mocked ( rpcModule . dialDirect ) . mockResolvedValue (
589+ createMockRobotServiceTransport ( )
590+ ) ;
591+
592+ // Act
593+ // Use a local host so dialDirect validation passes
594+ const result = await client . dial ( {
595+ host : 'localhost:8080' ,
596+ noReconnect : true ,
597+ } ) ;
598+
599+ // Assert
600+ expect ( result ) . toBe ( client ) ;
601+ } ) ;
602+
603+ it ( 'should not throw when only gRPC dial is attempted (no WebRTC config)' , async ( ) => {
604+ // Arrange
605+ const client = new RobotClient ( ) ;
606+ vi . mocked ( rpcModule . dialDirect ) . mockResolvedValue (
607+ createMockRobotServiceTransport ( )
608+ ) ;
609+
610+ // Act
611+ // Use a local host so dialDirect validation passes
612+ const result = await client . dial ( {
613+ host : 'localhost:8080' ,
614+ noReconnect : true ,
615+ } ) ;
616+
617+ // Assert
618+ expect ( result ) . toBe ( client ) ;
619+ } ) ;
620+ } ) ;
386621} ) ;
0 commit comments