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