Skip to content

Commit 892b0c0

Browse files
committed
emit disconnected event and throw on dial error
1 parent f1abc83 commit 892b0c0

File tree

2 files changed

+258
-4
lines changed

2 files changed

+258
-4
lines changed

src/robot/__tests__/client.spec.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
});

src/robot/client.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -632,13 +632,21 @@ export class RobotClient extends EventDispatcher implements Robot {
632632

633633
this.currentRetryAttempt = 0;
634634

635+
let dialWebRTCError: Error | undefined;
636+
let dialDirectError: Error | undefined;
637+
635638
// Try to dial via WebRTC first.
636639
if (isDialWebRTCConf(conf) && !conf.reconnectAbortSignal?.abort) {
637640
try {
638641
return await backOff(async () => this.dialWebRTC(conf), backOffOpts);
639-
} catch {
642+
} catch (error) {
643+
dialWebRTCError =
644+
error instanceof Error ? error : new Error(String(error));
640645
// eslint-disable-next-line no-console
641-
console.debug('Failed to connect via WebRTC');
646+
console.debug('Failed to connect via WebRTC', dialWebRTCError);
647+
this.emit(MachineConnectionEvent.DISCONNECTED, {
648+
error: dialWebRTCError,
649+
});
642650
}
643651
}
644652

@@ -647,12 +655,23 @@ export class RobotClient extends EventDispatcher implements Robot {
647655
if (!conf.reconnectAbortSignal?.abort) {
648656
try {
649657
return await backOff(async () => this.dialDirect(conf), backOffOpts);
650-
} catch {
658+
} catch (error) {
659+
dialDirectError =
660+
error instanceof Error ? error : new Error(String(error));
651661
// eslint-disable-next-line no-console
652-
console.debug('Failed to connect via gRPC');
662+
console.debug('Failed to connect via gRPC', dialDirectError);
663+
this.emit(MachineConnectionEvent.DISCONNECTED, {
664+
error: dialDirectError,
665+
});
653666
}
654667
}
655668

669+
if (dialWebRTCError && dialDirectError) {
670+
throw new Error('Failed to connect via all methods', {
671+
cause: [dialWebRTCError, dialDirectError],
672+
});
673+
}
674+
656675
return this;
657676
}
658677

0 commit comments

Comments
 (0)