Skip to content

Commit e487963

Browse files
authored
emit disconnected event and throw on dial error (#675)
1 parent a6c9381 commit e487963

File tree

3 files changed

+243
-4
lines changed

3 files changed

+243
-4
lines changed

src/__fixtures__/test-constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export const TEST_URL = 'http://base.test';
22
export const TEST_HOST = 'test-host';
3+
export const TEST_LOCAL_HOST = 'localhost:8080';
34
export const TEST_AUTH_ENTITY = 'test-entity';
45
export const TEST_SIGNALING_ADDRESS = 'https://signaling.test';

src/robot/__tests__/client.spec.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from '../../__fixtures__/credentials';
1313
import {
1414
TEST_HOST,
15+
TEST_LOCAL_HOST,
1516
TEST_SIGNALING_ADDRESS,
1617
} from '../../__fixtures__/test-constants';
1718
import { 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
});

src/robot/client.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -631,14 +631,20 @@ export class RobotClient extends EventDispatcher implements Robot {
631631
: conf.reconnectMaxAttempts;
632632

633633
this.currentRetryAttempt = 0;
634+
let webRTCError: Error | undefined;
635+
let directError: Error | undefined;
634636

635637
// Try to dial via WebRTC first.
636638
if (isDialWebRTCConf(conf) && !conf.reconnectAbortSignal?.abort) {
637639
try {
638640
return await backOff(async () => this.dialWebRTC(conf), backOffOpts);
639-
} catch {
641+
} catch (error) {
642+
webRTCError = error instanceof Error ? error : new Error(String(error));
640643
// eslint-disable-next-line no-console
641-
console.debug('Failed to connect via WebRTC');
644+
console.debug('Failed to connect via WebRTC', webRTCError);
645+
this.emit(MachineConnectionEvent.DISCONNECTED, {
646+
error: webRTCError,
647+
});
642648
}
643649
}
644650

@@ -647,12 +653,22 @@ export class RobotClient extends EventDispatcher implements Robot {
647653
if (!conf.reconnectAbortSignal?.abort) {
648654
try {
649655
return await backOff(async () => this.dialDirect(conf), backOffOpts);
650-
} catch {
656+
} catch (error) {
657+
directError = error instanceof Error ? error : new Error(String(error));
651658
// eslint-disable-next-line no-console
652-
console.debug('Failed to connect via gRPC');
659+
console.debug('Failed to connect via gRPC', directError);
660+
this.emit(MachineConnectionEvent.DISCONNECTED, {
661+
error: directError,
662+
});
653663
}
654664
}
655665

666+
if (webRTCError && directError) {
667+
throw new Error('Failed to connect via all methods', {
668+
cause: [webRTCError, directError],
669+
});
670+
}
671+
656672
return this;
657673
}
658674

0 commit comments

Comments
 (0)