Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/__fixtures__/test-constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const TEST_URL = 'http://base.test';
export const TEST_HOST = 'test-host';
export const TEST_LOCAL_HOST = 'localhost:8080';
export const TEST_AUTH_ENTITY = 'test-entity';
export const TEST_SIGNALING_ADDRESS = 'https://signaling.test';
192 changes: 192 additions & 0 deletions src/robot/__tests__/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '../../__fixtures__/credentials';
import {
TEST_HOST,
TEST_LOCAL_HOST,
TEST_SIGNALING_ADDRESS,
} from '../../__fixtures__/test-constants';
import { baseDialConfig } from '../__fixtures__/dial-configs';
Expand Down Expand Up @@ -383,4 +384,195 @@ describe('RobotClient', () => {
expect(mockResetFn).not.toHaveBeenCalled();
});
});

describe('dial error handling', () => {
const captureDisconnectedEvents = () => {
const events: unknown[] = [];
const setupListener = (client: RobotClient) => {
client.on('disconnected', (event) => {
events.push(event);
});
};
return { events, setupListener };
};

const findEventWithError = (
events: unknown[],
errorMessage?: string
): unknown => {
return events.find((event) => {
if (
typeof event !== 'object' ||
event === null ||
!('error' in event)
) {
return false;
}
if (errorMessage === undefined || errorMessage === '') {
return true;
}
const { error } = event as { error: Error };
return error.message === errorMessage;
});
};

it('should return client instance when WebRTC connection succeeds', async () => {
// Arrange
const client = setupClientMocks();

// Act
const result = await client.dial({
...baseDialConfig,
noReconnect: true,
});

// Assert
expect(result).toBe(client);
});

it('should return client instance even when both WebRTC and gRPC connections fail', async () => {
// Arrange
const client = new RobotClient();
const webrtcError = new Error('WebRTC connection failed');
const { events, setupListener } = captureDisconnectedEvents();

vi.mocked(rpcModule.dialWebRTC).mockRejectedValue(webrtcError);
setupListener(client);

// Act
const result = await client.dial({
...baseDialConfig,
noReconnect: true,
});

// Assert
expect(result).toBe(client);
expect(events.length).toBeGreaterThanOrEqual(2);
});

it('should emit DISCONNECTED event with error when WebRTC fails', async () => {
// Arrange
const client = new RobotClient();
const webrtcError = new Error('WebRTC connection failed');
const { events, setupListener } = captureDisconnectedEvents();

vi.mocked(rpcModule.dialWebRTC).mockRejectedValue(webrtcError);
setupListener(client);

// Act
await client.dial({
...baseDialConfig,
noReconnect: true,
});

// Assert
expect(events.length).toBeGreaterThanOrEqual(2);
const webrtcEvent = findEventWithError(
events,
'WebRTC connection failed'
);
expect(webrtcEvent).toBeDefined();
expect(webrtcEvent).toMatchObject({ error: webrtcError });
});

it('should emit DISCONNECTED event with error when gRPC fails', async () => {
// Arrange
const client = new RobotClient();
const { events, setupListener } = captureDisconnectedEvents();

setupListener(client);

// Act
await client.dial({
host: TEST_HOST,
noReconnect: true,
});

// Assert
expect(events.length).toBeGreaterThanOrEqual(1);
const errorEvent = findEventWithError(events);
expect(errorEvent).toBeDefined();
expect((errorEvent as { error: Error }).error).toBeInstanceOf(Error);
});

it('should emit both errors as DISCONNECTED events when both connections fail', async () => {
// Arrange
const client = new RobotClient();
const webrtcError = new Error('WebRTC connection failed');
const { events, setupListener } = captureDisconnectedEvents();

vi.mocked(rpcModule.dialWebRTC).mockRejectedValue(webrtcError);
setupListener(client);

// Act
await client.dial({
...baseDialConfig,
noReconnect: true,
});

// Assert
expect(events.length).toBeGreaterThanOrEqual(2);
const webrtcEvent = findEventWithError(
events,
'WebRTC connection failed'
);
expect(webrtcEvent).toBeDefined();
expect(webrtcEvent).toMatchObject({ error: webrtcError });
});

it('should convert non-Error objects to Errors and emit them', async () => {
// Arrange
const client = new RobotClient();
const webrtcError = 'string error';
const { events, setupListener } = captureDisconnectedEvents();

vi.mocked(rpcModule.dialWebRTC).mockRejectedValue(webrtcError);
setupListener(client);

// Act
const result = await client.dial({
...baseDialConfig,
noReconnect: true,
});

// Assert
expect(result).toBe(client);
expect(events.length).toBeGreaterThanOrEqual(1);
const errorEvent = findEventWithError(events);
expect(errorEvent).toBeDefined();
expect((errorEvent as { error: Error }).error).toBeInstanceOf(Error);
expect((errorEvent as { error: Error }).error.message).toBe(
'string error'
);
});

it('should fallback to gRPC when WebRTC fails and emit WebRTC error', async () => {
// Arrange
const client = new RobotClient();
const webrtcError = new Error('WebRTC connection failed');
const { events, setupListener } = captureDisconnectedEvents();

setupListener(client);
vi.mocked(rpcModule.dialWebRTC).mockRejectedValue(webrtcError);
vi.mocked(rpcModule.dialDirect).mockResolvedValue(
createMockRobotServiceTransport()
);

// Act
const result = await client.dial({
...baseDialConfig,
host: TEST_LOCAL_HOST,
noReconnect: true,
});

// Assert
expect(result).toBe(client);
expect(events.length).toBeGreaterThanOrEqual(1);
const webrtcEvent = findEventWithError(
events,
'WebRTC connection failed'
);
expect(webrtcEvent).toBeDefined();
});
});
});
18 changes: 14 additions & 4 deletions src/robot/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,9 +636,14 @@ export class RobotClient extends EventDispatcher implements Robot {
if (isDialWebRTCConf(conf) && !conf.reconnectAbortSignal?.abort) {
try {
return await backOff(async () => this.dialWebRTC(conf), backOffOpts);
} catch {
} catch (error) {
const dialWebRTCError =
error instanceof Error ? error : new Error(String(error));
// eslint-disable-next-line no-console
console.debug('Failed to connect via WebRTC');
console.debug('Failed to connect via WebRTC', dialWebRTCError);
this.emit(MachineConnectionEvent.DISCONNECTED, {
error: dialWebRTCError,
});
}
}

Expand All @@ -647,9 +652,14 @@ export class RobotClient extends EventDispatcher implements Robot {
if (!conf.reconnectAbortSignal?.abort) {
try {
return await backOff(async () => this.dialDirect(conf), backOffOpts);
} catch {
} catch (error) {
const dialDirectError =
error instanceof Error ? error : new Error(String(error));
// eslint-disable-next-line no-console
console.debug('Failed to connect via gRPC');
console.debug('Failed to connect via gRPC', dialDirectError);
this.emit(MachineConnectionEvent.DISCONNECTED, {
error: dialDirectError,
});
}
}

Expand Down
Loading