import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createPeer } from '../src/client/peer.js'; import { PROTOCOL_VERSION } from '../constants/webrtc.js'; import { RULES_VERSION } from '../constants/gameplay.js'; class Signal { handlers = new Map(); peer?: Signal; connected = false; disconnected = false; on(name: string, fn: Function) { this.handlers.set(name, [...this.handlers.get(name) ?? [], fn]); return this; } once(name: string, fn: Function) { return this.on(name, fn); } fire(name: string, data?: any) { for (const fn of this.handlers.get(name) ?? []) fn(data); } connect() { this.connected = true; queueMicrotask(() => { this.fire('connect'); if (this.peer?.connected) { this.fire('signal:peers', { count: 2 }); this.peer.fire('signal:peers', { count: 2 }); } }); } disconnect() { this.disconnected = true; this.connected = false; return this; } timeout() { return this; } async emitWithAck(_event: string, packet: any) { queueMicrotask(() => this.peer?.fire('signal', packet)); return { ok: true }; } removeAllListeners() { this.handlers.clear(); return this; } } class Channel { readyState = 'connecting'; onopen?: () => void; onmessage?: (event: { data: string }) => void; onclose?: () => void; peer?: Channel; send(data: string) { queueMicrotask(() => this.peer?.onmessage?.({ data })); } close() { this.readyState = 'closed'; this.onclose?.(); } } class PC { connectionState = 'new'; localDescription: any; remoteDescription: any; channel?: Channel; peer?: PC; ondatachannel?: (event: any) => void; onicecandidate?: (event: any) => void; onconnectionstatechange?: () => void; createDataChannel(_label: string, options: any) { assert.equal(options.ordered, true); assert.equal(options.maxRetransmits, undefined); return this.channel = new Channel(); } async createOffer() { return { type: 'offer', sdp: 'offer' }; } async createAnswer() { return { type: 'answer', sdp: 'answer' }; } async setLocalDescription(value: any) { this.localDescription = value; } async setRemoteDescription(value: any) { this.remoteDescription = value; if (value.type === 'answer') { const remote = new Channel(); this.channel!.peer = remote; remote.peer = this.channel; this.peer!.channel = remote; this.peer!.ondatachannel?.({ channel: remote }); this.connectionState = this.peer!.connectionState = 'connected'; this.channel!.readyState = remote.readyState = 'open'; this.channel!.onopen?.(); remote.onopen?.(); } } async addIceCandidate() {} restartIce() {} close() { this.connectionState = 'closed'; } async getStats() { return new Map(); } getConfiguration() { return { iceServers: [], bundlePolicy: 'max-bundle', rtcpMuxPolicy: 'require' }; } setConfiguration(value: any) { if (value.bundlePolicy !== 'max-bundle' || value.rtcpMuxPolicy !== 'require') throw new Error('INVALID_MODIFICATION'); } } test('peer connects reliable channel, closes signaling after mutual ready and keeps delivering data', async () => { const sa = new Signal(), sb = new Signal(); sa.peer = sb; sb.peer = sa; const pa = new PC(), pb = new PC(); pa.peer = pb; pb.peer = pa; const config = { matchId: 'm1', roomId: 'r1', epoch: 0, iceServers: [] }; const a = createPeer({ ...config, slot: 0, createSignaling: () => sa as any, createConnection: () => pa as any }); const b = createPeer({ ...config, slot: 1, createSignaling: () => sb as any, createConnection: () => pb as any }); try { const messages: any[] = []; b.onMessage(m => messages.push(m)); await Promise.all([a.connect(), b.connect()]); assert.equal(sa.disconnected, true); assert.equal(sb.disconnected, true); const message = { protocolVersion: PROTOCOL_VERSION, rulesVersion: RULES_VERSION, matchId: 'm1', connectionEpoch: 0, turnId: 1, type: 'turn:start' as const, startsAt: 1000 }; a.send(message); await new Promise(r => setImmediate(r)); assert.deepEqual(messages, [message]); assert.equal(pa.connectionState, 'connected'); } finally { a.close(); b.close(); } }); test('late ICE rejection after mutual ready does not invalidate a working data channel', async () => { const sa = new Signal(), sb = new Signal(); sa.peer = sb; sb.peer = sa; const pa = new PC(), pb = new PC(); pa.peer = pb; pb.peer = pa; const normal = sa.emitWithAck.bind(sa); let rejectIce!: (error: Error) => void; sa.emitWithAck = (event, packet) => packet.type === 'ice' ? new Promise((_resolve, reject) => { rejectIce = reject; }) : normal(event, packet); const a = createPeer({ matchId: 'late', roomId: 'r', slot: 0, iceServers: [], createSignaling: () => sa as any, createConnection: () => pa as any }); const b = createPeer({ matchId: 'late', roomId: 'r', slot: 1, iceServers: [], createSignaling: () => sb as any, createConnection: () => pb as any }); try { const connecting = a.connect(); pa.onicecandidate?.({ candidate: { toJSON: () => ({ candidate: 'test' }) } }); await Promise.all([connecting, b.connect()]); rejectIce(new Error('PEER_NOT_READY')); await new Promise(r => setImmediate(r)); assert.equal(a.connected, true); assert.equal(b.connected, true); } finally { a.close(); b.close(); } }); test('an ICE failure from the previous epoch cannot reject the new connection attempt', async () => { const sa = new Signal(), sb = new Signal(); sa.peer = sb; sb.peer = sa; const pa = new PC(), pb = new PC(); pa.peer = pb; pb.peer = pa; const normal = sa.emitWithAck.bind(sa); let rejectIce!: (error: Error) => void; sa.emitWithAck = (event, packet) => packet.type === 'ice' ? new Promise((_resolve, reject) => { rejectIce = reject; }) : normal(event, packet); const a = createPeer({ matchId: 'generation', roomId: 'r', slot: 0, iceServers: [], createSignaling: () => sa as any, createConnection: () => pa as any }); const b = createPeer({ matchId: 'generation', roomId: 'r', slot: 1, iceServers: [], createSignaling: () => sb as any, createConnection: () => pb as any }); try { const first = a.connect(); pa.onicecandidate?.({ candidate: { toJSON: () => ({ candidate: 'old' }) } }); await Promise.all([first, b.connect()]); const recovery = a.restart(); let failed = false; void recovery.catch(() => { failed = true; }); rejectIce(new Error('OLD_ICE')); await new Promise(r => setImmediate(r)); assert.equal(failed, false); assert.equal(sa.connected, true); await Promise.all([recovery, b.restart()]); assert.equal(a.connected, true); } finally { a.close(); b.close(); } }); test('ICE restart refreshes servers without changing immutable connection configuration', async () => { const sa = new Signal(), sb = new Signal(); sa.peer = sb; sb.peer = sa; const pa = new PC(), pb = new PC(); pa.peer = pb; pb.peer = pa; const a = createPeer({ matchId: 'restart', roomId: 'r', slot: 0, iceServers: [], refreshIce: async () => [{ urls: 'stun:example.test' }], createSignaling: () => sa as any, createConnection: () => pa as any }); const b = createPeer({ matchId: 'restart', roomId: 'r', slot: 1, iceServers: [], refreshIce: async () => [], createSignaling: () => sb as any, createConnection: () => pb as any }); try { await Promise.all([a.connect(), b.connect()]); await Promise.all([a.restart(), b.restart()]); assert.equal(a.connected, true); assert.equal(b.connected, true); assert.equal(a.epoch, 1); } finally { a.close(); b.close(); } }); test('recovery replaces a closed data channel and can replace a connection that rejects ICE reconfiguration', async () => { const sa = new Signal(), sb = new Signal(); sa.peer = sb; sb.peer = sa; const pairs = Array.from({ length: 2 }, () => { const a = new PC(), b = new PC(); a.peer = b; b.peer = a; return [a, b]; }); let ai = 0, bi = 0; pairs[0][0].setConfiguration = () => { throw new Error('InvalidModificationError'); }; const a = createPeer({ matchId: 'fallback', roomId: 'r', slot: 0, iceServers: [], refreshIce: async () => [], createSignaling: () => sa as any, createConnection: () => pairs[ai++][0] as any }); const b = createPeer({ matchId: 'fallback', roomId: 'r', slot: 1, iceServers: [], refreshIce: async () => [], createSignaling: () => sb as any, createConnection: () => pairs[bi++][1] as any }); try { await Promise.all([a.connect(), b.connect()]); pairs[0][1].channel!.close(); await Promise.all([a.restart(), b.restart()]); assert.equal(a.connected, true); assert.equal(b.connected, true); assert.equal(pairs[0][0].connectionState, 'closed'); assert.equal(pairs[0][1].connectionState, 'closed'); assert.equal(ai, 2); assert.equal(bi, 2); } finally { a.close(); b.close(); } });