feat: signal server tag
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import {
|
||||
SIGNALING_CONNECT_TIMEOUT_MS,
|
||||
SIGNALING_HEALTH_PROBE_INTERVAL_MS,
|
||||
SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS,
|
||||
SIGNALING_KEEPALIVE_INTERVAL_MS,
|
||||
SIGNALING_RECONNECT_BASE_DELAY_MS,
|
||||
STATE_HEARTBEAT_INTERVAL_MS
|
||||
} from '../realtime.constants';
|
||||
import { WebRTCLogger } from '../logging/webrtc-logger';
|
||||
import { SignalingManager } from './signaling.manager';
|
||||
import { probeSignalingEndpointHealth } from './signaling-endpoint-health.rules';
|
||||
|
||||
vi.mock('./signaling-endpoint-health.rules', () => ({
|
||||
probeSignalingEndpointHealth: vi.fn(async () => ({ ok: true, serverInstanceId: 'instance-a' }))
|
||||
}));
|
||||
|
||||
type MockSocketHandler = (event?: { code?: number; reason?: string; wasClean?: boolean }) => void;
|
||||
|
||||
class MockWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
static instances: MockWebSocket[] = [];
|
||||
|
||||
shouldFailSend = false;
|
||||
autoAckKeepalive = true;
|
||||
readonly url: string;
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
onopen: MockSocketHandler | null = null;
|
||||
onclose: MockSocketHandler | null = null;
|
||||
onerror: MockSocketHandler | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.readyState === MockWebSocket.CLOSED) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
this.onclose?.({ code: 1000, reason: 'closed', wasClean: true });
|
||||
}
|
||||
|
||||
send(payload: string): void {
|
||||
if (this.shouldFailSend) {
|
||||
throw new Error('mock send failure');
|
||||
}
|
||||
|
||||
if (!this.autoAckKeepalive) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const message = JSON.parse(payload) as { type?: string };
|
||||
|
||||
if (message.type === 'keepalive') {
|
||||
this.onmessage?.({ data: JSON.stringify({ type: 'keepalive_ack' }) });
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed payloads in tests
|
||||
}
|
||||
}
|
||||
|
||||
simulateOpen(): void {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
this.onopen?.();
|
||||
}
|
||||
|
||||
simulateError(): void {
|
||||
this.onerror?.(new Event('error'));
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
describe('SignalingManager reconnection', () => {
|
||||
const serverUrl = 'ws://localhost:3001';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
MockWebSocket.instances = [];
|
||||
vi.stubGlobal('WebSocket', MockWebSocket);
|
||||
vi.mocked(probeSignalingEndpointHealth).mockResolvedValue({ ok: true, serverInstanceId: 'instance-a' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function createManager(
|
||||
identifyCredentials: {
|
||||
oderId: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
profileUpdatedAt?: number;
|
||||
homeSignalServerUrl?: string;
|
||||
} = {
|
||||
oderId: 'peer-a',
|
||||
displayName: 'Peer A',
|
||||
description: 'hello',
|
||||
profileUpdatedAt: 42,
|
||||
homeSignalServerUrl: 'https://signal.example'
|
||||
}
|
||||
): SignalingManager {
|
||||
const sentMessages: Record<string, unknown>[] = [];
|
||||
const manager = new SignalingManager(
|
||||
new WebRTCLogger(false),
|
||||
() => identifyCredentials,
|
||||
() => ({ serverId: 'server-1', userId: 'peer-a' }),
|
||||
() => new Set(['server-1'])
|
||||
);
|
||||
const originalSendRawMessage = manager.sendRawMessage.bind(manager);
|
||||
|
||||
manager.sendRawMessage = (message: Record<string, unknown>) => {
|
||||
sentMessages.push(message);
|
||||
originalSendRawMessage(message);
|
||||
};
|
||||
|
||||
Object.assign(manager, { sentMessages });
|
||||
|
||||
return manager;
|
||||
}
|
||||
|
||||
it('schedules reconnect after an established socket closes', async () => {
|
||||
const manager = createManager();
|
||||
const connected = vi.fn();
|
||||
|
||||
manager.connect(serverUrl).subscribe({ next: connected });
|
||||
MockWebSocket.instances[0]?.simulateOpen();
|
||||
expect(connected).toHaveBeenCalledWith(true);
|
||||
|
||||
MockWebSocket.instances[0]?.close();
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
expect(MockWebSocket.instances[1]?.readyState).toBe(MockWebSocket.CONNECTING);
|
||||
});
|
||||
|
||||
it('keeps retrying after a failed reconnect attempt until the server accepts the socket', async () => {
|
||||
const manager = createManager();
|
||||
|
||||
manager.connect(serverUrl).subscribe();
|
||||
MockWebSocket.instances[0]?.simulateOpen();
|
||||
MockWebSocket.instances[0]?.close();
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
||||
MockWebSocket.instances[1]?.simulateError();
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS * 2);
|
||||
expect(MockWebSocket.instances).toHaveLength(3);
|
||||
|
||||
MockWebSocket.instances[2]?.simulateOpen();
|
||||
expect(manager.isSocketOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('discards a stale connecting socket and retries after the connect timeout', async () => {
|
||||
const manager = createManager();
|
||||
|
||||
manager.connect(serverUrl).subscribe();
|
||||
MockWebSocket.instances[0]?.simulateOpen();
|
||||
MockWebSocket.instances[0]?.close();
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_CONNECT_TIMEOUT_MS);
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS * 2);
|
||||
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('does not treat a timed-out waitForOpen result as a successful reconnect', async () => {
|
||||
const manager = createManager();
|
||||
|
||||
manager.connect(serverUrl).subscribe();
|
||||
MockWebSocket.instances[0]?.simulateOpen();
|
||||
MockWebSocket.instances[0]?.close();
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
|
||||
const secondAttempt = vi.fn();
|
||||
|
||||
manager.connect(serverUrl).subscribe({ next: secondAttempt });
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_CONNECT_TIMEOUT_MS);
|
||||
expect(secondAttempt).toHaveBeenCalledWith(false);
|
||||
expect(manager.isSocketOpen()).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS * 2);
|
||||
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('replays the full identify payload after reconnect', () => {
|
||||
const manager = createManager() as SignalingManager & { sentMessages: Record<string, unknown>[] };
|
||||
|
||||
manager.connect(serverUrl).subscribe();
|
||||
MockWebSocket.instances[0]?.simulateOpen();
|
||||
manager.sentMessages.length = 0;
|
||||
MockWebSocket.instances[0]?.close();
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
||||
MockWebSocket.instances[1]?.simulateOpen();
|
||||
|
||||
const identifyMessage = manager.sentMessages.find((message) => message['type'] === 'identify');
|
||||
|
||||
expect(identifyMessage).toMatchObject({
|
||||
type: 'identify',
|
||||
oderId: 'peer-a',
|
||||
displayName: 'Peer A',
|
||||
description: 'hello',
|
||||
profileUpdatedAt: 42,
|
||||
homeSignalServerUrl: 'https://signal.example'
|
||||
});
|
||||
});
|
||||
|
||||
it('does not force reconnect on servers that never send keepalive acknowledgements', () => {
|
||||
const manager = createManager();
|
||||
|
||||
manager.connect(serverUrl).subscribe();
|
||||
const socket = MockWebSocket.instances[0];
|
||||
|
||||
if (socket) {
|
||||
socket.autoAckKeepalive = false;
|
||||
}
|
||||
|
||||
socket?.simulateOpen();
|
||||
|
||||
vi.advanceTimersByTime(STATE_HEARTBEAT_INTERVAL_MS);
|
||||
vi.advanceTimersByTime(SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS);
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
expect(manager.isSocketOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('forces reconnect when keepalive acknowledgements stop arriving on ack-capable servers', () => {
|
||||
const manager = createManager();
|
||||
|
||||
manager.connect(serverUrl).subscribe();
|
||||
const socket = MockWebSocket.instances[0];
|
||||
|
||||
socket?.simulateOpen();
|
||||
vi.advanceTimersByTime(STATE_HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
if (socket) {
|
||||
socket.autoAckKeepalive = false;
|
||||
}
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_KEEPALIVE_INTERVAL_MS + STATE_HEARTBEAT_INTERVAL_MS);
|
||||
vi.advanceTimersByTime(SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS);
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
||||
|
||||
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
|
||||
expect(manager.isSocketOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('silently drops transient negotiation messages while the socket is reconnecting', () => {
|
||||
const logger = new WebRTCLogger(false);
|
||||
const errorSpy = vi.spyOn(logger, 'error');
|
||||
const manager = new SignalingManager(
|
||||
logger,
|
||||
() => ({ oderId: 'peer-a', displayName: 'Peer A' }),
|
||||
() => ({ serverId: 'server-1', userId: 'peer-a' }),
|
||||
() => new Set(['server-1'])
|
||||
);
|
||||
|
||||
expect(manager.sendRawMessage({
|
||||
type: 'ice_candidate',
|
||||
targetUserId: 'peer-b',
|
||||
payload: { candidate: { candidate: 'candidate:1' } }
|
||||
})).toBe(false);
|
||||
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forces reconnect when the signaling health probe fails on an open socket', async () => {
|
||||
vi.mocked(probeSignalingEndpointHealth).mockResolvedValue({ ok: false });
|
||||
const manager = createManager();
|
||||
|
||||
manager.connect(serverUrl).subscribe();
|
||||
MockWebSocket.instances[0]?.simulateOpen();
|
||||
await Promise.resolve();
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
||||
|
||||
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
|
||||
expect(manager.isSocketOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('forces reconnect when the signaling server instance changes under an open socket', async () => {
|
||||
const manager = createManager();
|
||||
|
||||
manager.connect(serverUrl).subscribe();
|
||||
MockWebSocket.instances[0]?.simulateOpen();
|
||||
|
||||
vi.advanceTimersByTime(STATE_HEARTBEAT_INTERVAL_MS);
|
||||
await Promise.resolve();
|
||||
|
||||
vi.mocked(probeSignalingEndpointHealth).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
serverInstanceId: 'instance-b'
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(STATE_HEARTBEAT_INTERVAL_MS);
|
||||
await Promise.resolve();
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
||||
|
||||
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
|
||||
expect(manager.isSocketOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('forces reconnect when outbound signaling sends fail on a stale socket', () => {
|
||||
const manager = createManager();
|
||||
|
||||
manager.connect(serverUrl).subscribe();
|
||||
const socket = MockWebSocket.instances[0];
|
||||
|
||||
socket?.simulateOpen();
|
||||
|
||||
if (socket) {
|
||||
socket.shouldFailSend = true;
|
||||
}
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_KEEPALIVE_INTERVAL_MS + STATE_HEARTBEAT_INTERVAL_MS);
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
||||
|
||||
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
|
||||
expect(manager.isSocketOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user