Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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';
222 changes: 222 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,225 @@ 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 throw error when both WebRTC and gRPC connections fail', async () => {
// Arrange
const client = new RobotClient();
const webrtcError = new Error('WebRTC connection failed');

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

// Act & Assert
await expect(
client.dial({
...baseDialConfig,
noReconnect: true,
})
).rejects.toThrow('Failed to connect via all methods');
});

it('should emit DISCONNECTED events for both failures before throwing', 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);

// Act
try {
await client.dial({
...baseDialConfig,
noReconnect: true,
});
} catch {
// Expected to throw
}

// 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 when gRPC fails and throw', async () => {
// Arrange
const client = new RobotClient();
const { events, setupListener } = captureDisconnectedEvents();

setupListener(client);

// Act
try {
await client.dial({
host: TEST_HOST,
noReconnect: true,
});
} catch {
// Expected to throw
}

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

it('should include both errors in thrown error cause', async () => {
// Arrange
const client = new RobotClient();
const webrtcError = new Error('WebRTC connection failed');

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

// Act
let caughtError: Error | undefined;
try {
await client.dial({
...baseDialConfig,
noReconnect: true,
});
} catch (error) {
caughtError = error as Error;
}

// Assert
expect(caughtError).toBeDefined();
expect(caughtError).toBeInstanceOf(Error);
expect(caughtError!.message).toBe('Failed to connect via all methods');
expect(caughtError!.cause).toBeDefined();
expect(Array.isArray(caughtError!.cause)).toBe(true);
const causes = caughtError!.cause as Error[];
expect(causes).toHaveLength(2);
expect(causes[0]).toBe(webrtcError);
expect(causes[1]).toBeInstanceOf(Error);
});

it('should convert non-Error objects to Errors before throwing', async () => {
// Arrange
const client = new RobotClient();
const webrtcError = 'string error';

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

// Act
let caughtError: Error | undefined;
try {
await client.dial({
...baseDialConfig,
noReconnect: true,
});
} catch (error) {
caughtError = error as Error;
}

// Assert
expect(caughtError).toBeDefined();
expect(caughtError).toBeInstanceOf(Error);
expect(caughtError!.cause).toBeDefined();
expect(Array.isArray(caughtError!.cause)).toBe(true);
const causes = caughtError!.cause as Error[];
expect(causes.length).toBeGreaterThan(0);
const [firstCause] = causes;
expect(firstCause).toBeInstanceOf(Error);
expect(firstCause?.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();
});

it('should return client instance when only gRPC connection is used', async () => {
// Arrange
const client = new RobotClient();
vi.mocked(rpcModule.dialDirect).mockResolvedValue(
createMockRobotServiceTransport()
);

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

// Assert
expect(result).toBe(client);
});
});
});
24 changes: 20 additions & 4 deletions src/robot/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,14 +631,20 @@ export class RobotClient extends EventDispatcher implements Robot {
: conf.reconnectMaxAttempts;

this.currentRetryAttempt = 0;
let webRTCError: Error | undefined;
let directError: Error | undefined;

// Try to dial via WebRTC first.
if (isDialWebRTCConf(conf) && !conf.reconnectAbortSignal?.abort) {
try {
return await backOff(async () => this.dialWebRTC(conf), backOffOpts);
} catch {
} catch (error) {
webRTCError = 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', webRTCError);
this.emit(MachineConnectionEvent.DISCONNECTED, {
error: webRTCError,
});
}
}

Expand All @@ -647,12 +653,22 @@ export class RobotClient extends EventDispatcher implements Robot {
if (!conf.reconnectAbortSignal?.abort) {
try {
return await backOff(async () => this.dialDirect(conf), backOffOpts);
} catch {
} catch (error) {
directError = 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', directError);
this.emit(MachineConnectionEvent.DISCONNECTED, {
error: directError,
});
}
}

if (webRTCError && directError) {
throw new Error('Failed to connect via all methods', {
cause: [webRTCError, directError],
});
}

return this;
}

Expand Down
Loading