/** * Tests for {@link WebRTCDrivePlugin}. * * Two FakeWebSocket instances share an in-process broker so plugin A's * outbound register message becomes plugin B's inbound message and vice * versa. FakeRTCPeerConnection from the client tests pattern drives ICE * state transitions manually — the offer/answer round trip travels * through the shared signalling bus to prove the wiring end-to-end. No * `vi.mock`, no `vi.stubGlobal`. */ import { afterEach, beforeEach, describe, expect, it, } from "@suaveplan/testing/runner"; import { DRIVE_WEBRTC_CODES, DriveWebRtcError, } from "../../error-codes/error-codes.js"; import type { AdmitDecision, TopologyStrategy, } from "../../peer-manager/topology/index.js"; import type { PeerInfo } from "../../types/peer.types.js"; import { WebRTCDrivePlugin } from "./webrtc-plugin.js"; const READY_STATE_CONNECTING = 0; const READY_STATE_OPEN = 1; const READY_STATE_CLOSED = 3; interface BrokerSocket { readonly id: string; deliver(data: string): void; closed: boolean; } class Broker { private readonly registered = new Map(); public readonly trace: Array<{ from: string; to: string | undefined; type: string; }> = []; public attach(socket: BrokerSocket): void { this.registered.set(socket.id, socket); } public detach(id: string): void { this.registered.delete(id); } public broadcastFrom(senderId: string, data: string): void { let json: { type?: string; from?: string; to?: string; payload?: unknown; }; try { json = JSON.parse(data) as typeof json; } catch { return; } this.trace.push({ from: senderId, to: json.to, type: String(json.type), }); // Inject the sender id so receivers can address replies back. if (json.from === undefined) json.from = senderId; if (json.type === "register") { // Echo a `registered` confirmation back to the sender then // announce the peer to every other registered socket. const me = this.registered.get(senderId); me?.deliver( JSON.stringify({ type: "registered", payload: { peerId: senderId, peers: [] }, }) ); for (const [otherId, sock] of this.registered) { if (otherId === senderId) continue; sock.deliver( JSON.stringify({ type: "peer-joined", from: senderId, payload: { id: senderId, name: senderId, chunks: [], }, }) ); } return; } if (typeof json.to === "string") { const target = this.registered.get(json.to); target?.deliver(JSON.stringify(json)); return; } for (const [otherId, sock] of this.registered) { if (otherId === senderId) continue; sock.deliver(JSON.stringify(json)); } } } function makeBrokerSocketCtor(broker: Broker): typeof WebSocket { class FakeSocket implements BrokerSocket { public readyState: number = READY_STATE_CONNECTING; public readonly id: string; public closed = false; public onopen: ((event: Event) => void) | null = null; public onmessage: ((event: MessageEvent) => void) | null = null; public onclose: ((event: CloseEvent) => void) | null = null; public onerror: ((event: Event) => void) | null = null; public constructor(public readonly url: string) { // Encode the peer id in the URL query so plugins can be told // apart by the broker. const parsed = new URL(url); this.id = parsed.searchParams.get("peerId") ?? url; broker.attach(this); queueMicrotask(() => { this.readyState = READY_STATE_OPEN; this.onopen?.(new Event("open")); }); } public send(data: string): void { if (this.readyState !== READY_STATE_OPEN) { throw new Error("FakeSocket.send: not open"); } broker.broadcastFrom(this.id, data); } public close(code = 1000, reason = "client closed"): void { if (this.closed) return; this.closed = true; this.readyState = READY_STATE_CLOSED; broker.detach(this.id); this.onclose?.({ code, reason, wasClean: true, } as unknown as CloseEvent); } public deliver(data: string): void { this.onmessage?.(new MessageEvent("message", { data })); } } return FakeSocket as unknown as typeof WebSocket; } class FakeRTCDataChannel extends EventTarget { public readyState: RTCDataChannelState = "connecting"; public bufferedAmount = 0; public bufferedAmountLowThreshold = 0; public sent: (ArrayBuffer | string)[] = []; public constructor(public readonly label: string) { super(); } public send(payload: ArrayBuffer | string): void { this.sent.push(payload); } public close(): void { this.readyState = "closed"; this.dispatchEvent(new Event("close")); } public simulateOpen(): void { this.readyState = "open"; this.dispatchEvent(new Event("open")); } } class FakeRTCPeerConnection extends EventTarget { public iceConnectionState: RTCIceConnectionState = "new"; public connectionState: RTCPeerConnectionState = "new"; public localDescription: RTCSessionDescriptionInit | null = null; public remoteDescription: RTCSessionDescriptionInit | null = null; public createdChannels: FakeRTCDataChannel[] = []; public closed = false; public constructor(public readonly config?: RTCConfiguration) { super(); } public async createOffer(): Promise { return { type: "offer", sdp: "fake-offer" }; } public async createAnswer(): Promise { return { type: "answer", sdp: "fake-answer" }; } public async setLocalDescription( d: RTCSessionDescriptionInit ): Promise { this.localDescription = d; } public async setRemoteDescription( d: RTCSessionDescriptionInit ): Promise { this.remoteDescription = d; } public async addIceCandidate(): Promise {} public restartIce(): void {} public close(): void { this.closed = true; this.connectionState = "closed"; } public createDataChannel(label: string): RTCDataChannel { const ch = new FakeRTCDataChannel(label); this.createdChannels.push(ch); return ch as unknown as RTCDataChannel; } public simulateIceState(state: RTCIceConnectionState): void { this.iceConnectionState = state; this.dispatchEvent(new Event("iceconnectionstatechange")); } } function makePlugin( broker: Broker, peerId: string, overrides: { signalingUrl?: string } = {} ): WebRTCDrivePlugin { const url = overrides.signalingUrl ?? `wss://signal.example/?peerId=${peerId}`; return new WebRTCDrivePlugin({ peerId, config: { signaling: { url } }, signalingUrl: url, webSocketCtor: makeBrokerSocketCtor(broker), rtcPeerConnectionCtor: FakeRTCPeerConnection as unknown as typeof RTCPeerConnection, }); } let broker: Broker; beforeEach(() => { broker = new Broker(); }); afterEach(async () => { // No global state to drain — broker is per-test. await Promise.resolve(); }); describe("WebRTCDrivePlugin construction", () => { it("wires every collaborator", { timeout: 5000 }, () => { const plugin = makePlugin(broker, "alice"); expect(plugin.name).toBe("drive-webrtc"); expect(plugin.client).toBeDefined(); expect(plugin.signaling).toBeDefined(); expect(plugin.peers).toBeDefined(); expect(plugin.bandwidth).toBeDefined(); expect(plugin.dataChannels).toBeDefined(); expect(plugin.integrity).toBeDefined(); expect(plugin.discovery).toBeDefined(); expect(plugin.chunkSource).toBeDefined(); }); it("accepts optional topology and maxPeers overrides", { timeout: 5000, }, () => { const plugin = new WebRTCDrivePlugin({ peerId: "alice", config: { signaling: { url: "wss://x?peerId=alice" } }, signalingUrl: "wss://x?peerId=alice", webSocketCtor: makeBrokerSocketCtor(broker), rtcPeerConnectionCtor: FakeRTCPeerConnection as unknown as typeof RTCPeerConnection, maxPeers: 5, topology: plugin0Topology(), defaultPublicKey: new Uint8Array([1, 2, 3]), localChunkProvider: () => null, defaultLabel: "custom", clock: { now: () => 1_000 }, }); expect(plugin.peers.topology).toBeDefined(); }); }); function plugin0Topology(): TopologyStrategy { return { name: "full-mesh", selectConnections(_current, _newPeer): readonly PeerInfo[] { return []; }, admitOrEvict(): AdmitDecision { return { admit: true }; }, }; } describe("WebRTCDrivePlugin.start", () => { it("registers with signaling and sends a register frame", { timeout: 5000, }, async () => { const plugin = makePlugin(broker, "alice"); await plugin.start(); expect(broker.trace.some((t) => t.type === "register")).toBe(true); await plugin.stop(); }); it("is idempotent", { timeout: 5000 }, async () => { const plugin = makePlugin(broker, "alice"); await plugin.start(); await plugin.start(); const registerCount = broker.trace.filter( (t) => t.type === "register" ).length; expect(registerCount).toBe(1); await plugin.stop(); }); it("rejects start after stop", { timeout: 5000 }, async () => { const plugin = makePlugin(broker, "alice"); await plugin.start(); await plugin.stop(); await expect(plugin.start()).rejects.toBeInstanceOf(DriveWebRtcError); }); }); async function settle(times = 25): Promise { for (let i = 0; i < times; i += 1) await Promise.resolve(); } describe("WebRTCDrivePlugin two-plugin handshake", () => { it("discovers peer over signalling and exchanges offer/answer", { timeout: 5000, }, async () => { const alice = makePlugin(broker, "alice"); const bob = makePlugin(broker, "bob"); await alice.start(); await settle(); await bob.start(); await settle(); // Alice should have received a `peer-joined` for bob and // admitted him; bob should have received the offer and // emitted an answer back. const aliceKnowsBob = alice.peers.get("bob") !== undefined; const bobKnowsAlice = bob.peers.get("alice") !== undefined; expect(aliceKnowsBob || bobKnowsAlice).toBe(true); const offers = broker.trace.filter((t) => t.type === "offer"); const answers = broker.trace.filter((t) => t.type === "answer"); expect(offers.length).toBeGreaterThanOrEqual(1); expect(answers.length).toBeGreaterThanOrEqual(1); await alice.stop(); await bob.stop(); }); it("promotes peer to connected on ICE connected and opens a data channel", { timeout: 5000, }, async () => { const alice = makePlugin(broker, "alice"); const bob = makePlugin(broker, "bob"); await alice.start(); await bob.start(); await settle(); // Drive alice's PC for bob into connected state. const pc = alice.client.getPeerConnection("bob") as unknown as | FakeRTCPeerConnection | undefined; expect(pc).toBeDefined(); const events: Array<{ peerId: string }> = []; alice.on("webrtc:connected", (e) => events.push(e)); (pc as FakeRTCPeerConnection).simulateIceState("connected"); expect(events.length).toBe(1); expect(events[0]?.peerId).toBe("bob"); // Data channel manager should now hold a channel for bob. const channel = alice.dataChannels.get("bob"); expect(channel).toBeDefined(); await alice.stop(); await bob.stop(); }); it("evicts a peer on ICE failed", { timeout: 5000 }, async () => { const alice = makePlugin(broker, "alice"); const bob = makePlugin(broker, "bob"); await alice.start(); await bob.start(); await settle(); const pc = alice.client.getPeerConnection( "bob" ) as unknown as FakeRTCPeerConnection; const failures: Array<{ peerId: string }> = []; alice.on("webrtc:failed", (e) => failures.push(e)); pc.simulateIceState("failed"); expect(failures.length).toBeGreaterThanOrEqual(1); expect(alice.peers.get("bob")).toBeUndefined(); await alice.stop(); await bob.stop(); }); }); describe("WebRTCDrivePlugin signalling-driven peer lifecycle", () => { it("admits a peer on peer-joined and evicts on peer-left", { timeout: 5000, }, async () => { const alice = makePlugin(broker, "alice"); const bob = makePlugin(broker, "bob"); await alice.start(); await bob.start(); await settle(); // Now simulate bob leaving via the broker -> alice gets peer-left. // We inject directly into alice's signaling listener path by // sending a peer-left from a separate broker peer. const carol = makePlugin(broker, "carol"); await carol.start(); await settle(); // Force-leave path: synthesize peer-left from bob to alice. const aliceSocket = ( alice.signaling as unknown as { socket: BrokerSocket } ).socket; aliceSocket.deliver( JSON.stringify({ type: "peer-left", from: "bob", payload: { peerId: "bob" }, }) ); expect(alice.peers.get("bob")).toBeUndefined(); await alice.stop(); await bob.stop(); await carol.stop(); }); it("applies announce-chunks and peer-chunks-updated to the peer set", { timeout: 5000, }, async () => { const alice = makePlugin(broker, "alice"); const bob = makePlugin(broker, "bob"); await alice.start(); await bob.start(); await settle(); const aliceSocket = ( alice.signaling as unknown as { socket: BrokerSocket } ).socket; // announce-chunks via the unified message handler. aliceSocket.deliver( JSON.stringify({ type: "announce-chunks", from: "bob", payload: { chunks: ["c1", "c2"] }, }) ); expect(alice.peers.get("bob")?.chunks.has("c1")).toBe(true); aliceSocket.deliver( JSON.stringify({ type: "peer-chunks-updated", payload: { peerId: "bob", chunks: ["c3"] }, }) ); expect(alice.peers.get("bob")?.chunks.has("c3")).toBe(true); await alice.stop(); await bob.stop(); }); it("forwards ice candidates over signalling", { timeout: 5000, }, async () => { const alice = makePlugin(broker, "alice"); const bob = makePlugin(broker, "bob"); await alice.start(); await bob.start(); await settle(); const pc = alice.client.getPeerConnection( "bob" ) as unknown as FakeRTCPeerConnection; // Dispatch a fake ICE candidate event from alice -> the client // re-emits which our plugin forwards to the broker. const candidate = { candidate: "candidate:1", toJSON: () => ({ candidate: "candidate:1" }), } as unknown as RTCIceCandidate; const event = new Event("icecandidate") as RTCPeerConnectionIceEvent; (event as unknown as { candidate: RTCIceCandidate }).candidate = candidate; pc.dispatchEvent(event); await Promise.resolve(); const iceFrames = broker.trace.filter( (t) => t.type === "ice-candidate" ); expect(iceFrames.length).toBeGreaterThanOrEqual(1); await alice.stop(); await bob.stop(); }); }); describe("WebRTCDrivePlugin.stop", () => { it("is idempotent", { timeout: 5000 }, async () => { const plugin = makePlugin(broker, "alice"); await plugin.start(); await plugin.stop(); await plugin.stop(); expect(plugin.signaling.state).toBe("closed"); }); it("is safe to call before start", { timeout: 5000 }, async () => { const plugin = makePlugin(broker, "alice"); await plugin.stop(); expect(plugin.signaling.state).toBe("closed"); }); }); describe("WebRTCDrivePlugin.stats", () => { it("returns finite numbers with a zero baseline", { timeout: 5000, }, async () => { const plugin = makePlugin(broker, "alice"); await plugin.start(); const stats = plugin.stats(); expect(Number.isFinite(stats.activePeers)).toBe(true); expect(Number.isFinite(stats.totalPeers)).toBe(true); expect(stats.bytesDownloadedP2P).toBe(0); await plugin.stop(); }); it("counts connected peers after ICE connected", { timeout: 5000, }, async () => { const alice = makePlugin(broker, "alice"); const bob = makePlugin(broker, "bob"); await alice.start(); await bob.start(); await settle(); const pc = alice.client.getPeerConnection( "bob" ) as unknown as FakeRTCPeerConnection; pc.simulateIceState("connected"); expect(alice.stats().activePeers).toBe(1); await alice.stop(); await bob.stop(); }); }); describe("WebRTCDrivePlugin local chunk provider", () => { it("serves a chunk when localChunkProvider returns bytes", { timeout: 5000, }, async () => { const provider = (id: string): Uint8Array | null => id === "want-this" ? new Uint8Array([42, 43]) : null; const alice = new WebRTCDrivePlugin({ peerId: "alice", config: { signaling: { url: "wss://x?peerId=alice" } }, signalingUrl: "wss://x?peerId=alice", webSocketCtor: makeBrokerSocketCtor(broker), rtcPeerConnectionCtor: FakeRTCPeerConnection as unknown as typeof RTCPeerConnection, localChunkProvider: provider, }); const bob = makePlugin(broker, "bob"); await alice.start(); await bob.start(); await settle(); // Promote alice<->bob to ICE connected so a real RTCDataChannel // exists in alice.dataChannels for delivering the request. const pc = alice.client.getPeerConnection( "bob" ) as unknown as FakeRTCPeerConnection; pc.simulateIceState("connected"); const channel = alice.dataChannels.get("bob") as unknown as | FakeRTCDataChannel | undefined; expect(channel).toBeDefined(); (channel as FakeRTCDataChannel).simulateOpen(); // Drive an incoming request-chunk message from bob over the data // channel by dispatching the message event manually. const messageEvent = new Event("message") as MessageEvent; (messageEvent as unknown as { data: string }).data = JSON.stringify({ type: "request-chunk", requestId: "req-1", fileId: "want-this", chunkId: "want-this", }); (channel as unknown as FakeRTCDataChannel).dispatchEvent(messageEvent); // Allow the async serve path to complete. await settle(); const sent = (channel as FakeRTCDataChannel).sent; expect(sent.length).toBe(1); const wire = JSON.parse(sent[0] as string) as { type: string; payload: { chunkId: string }; }; expect(wire.type).toBe("chunk-data"); expect(wire.payload.chunkId).toBe("want-this"); await alice.stop(); await bob.stop(); }); it("skips when the provider returns null", { timeout: 5000 }, async () => { const alice = new WebRTCDrivePlugin({ peerId: "alice", config: { signaling: { url: "wss://x?peerId=alice" } }, signalingUrl: "wss://x?peerId=alice", webSocketCtor: makeBrokerSocketCtor(broker), rtcPeerConnectionCtor: FakeRTCPeerConnection as unknown as typeof RTCPeerConnection, localChunkProvider: () => null, }); const bob = makePlugin(broker, "bob"); await alice.start(); await bob.start(); await settle(); const pc = alice.client.getPeerConnection( "bob" ) as unknown as FakeRTCPeerConnection; pc.simulateIceState("connected"); const channel = alice.dataChannels.get( "bob" ) as unknown as FakeRTCDataChannel; channel.simulateOpen(); const event = new Event("message") as MessageEvent; (event as unknown as { data: string }).data = JSON.stringify({ type: "request-chunk", requestId: "r", fileId: "missing", chunkId: "missing", }); channel.dispatchEvent(event); await settle(); expect(channel.sent.length).toBe(0); await alice.stop(); await bob.stop(); }); it("swallows provider errors", { timeout: 5000 }, async () => { const alice = new WebRTCDrivePlugin({ peerId: "alice", config: { signaling: { url: "wss://x?peerId=alice" } }, signalingUrl: "wss://x?peerId=alice", webSocketCtor: makeBrokerSocketCtor(broker), rtcPeerConnectionCtor: FakeRTCPeerConnection as unknown as typeof RTCPeerConnection, localChunkProvider: () => { throw new Error("provider boom"); }, }); const bob = makePlugin(broker, "bob"); await alice.start(); await bob.start(); await settle(); const pc = alice.client.getPeerConnection( "bob" ) as unknown as FakeRTCPeerConnection; pc.simulateIceState("connected"); const channel = alice.dataChannels.get( "bob" ) as unknown as FakeRTCDataChannel; channel.simulateOpen(); const event = new Event("message") as MessageEvent; (event as unknown as { data: string }).data = JSON.stringify({ type: "request-chunk", requestId: "r", fileId: "x", chunkId: "x", }); channel.dispatchEvent(event); await settle(); expect(channel.sent.length).toBe(0); await alice.stop(); await bob.stop(); }); it("ignores non-request-chunk and malformed data-channel frames", { timeout: 5000, }, async () => { const alice = makePlugin(broker, "alice"); const bob = makePlugin(broker, "bob"); await alice.start(); await bob.start(); await settle(); const pc = alice.client.getPeerConnection( "bob" ) as unknown as FakeRTCPeerConnection; pc.simulateIceState("connected"); const channel = alice.dataChannels.get( "bob" ) as unknown as FakeRTCDataChannel; channel.simulateOpen(); // Malformed JSON const malformed = new Event("message") as MessageEvent; (malformed as unknown as { data: string }).data = "not-json"; channel.dispatchEvent(malformed); // Non-object payload const nonObject = new Event("message") as MessageEvent; (nonObject as unknown as { data: string }).data = "null"; channel.dispatchEvent(nonObject); // Wrong type const wrongType = new Event("message") as MessageEvent; (wrongType as unknown as { data: string }).data = JSON.stringify({ type: "chunk-data", }); channel.dispatchEvent(wrongType); // Binary (ignored) const binary = new Event("message") as MessageEvent; (binary as unknown as { data: ArrayBuffer }).data = new ArrayBuffer(4); channel.dispatchEvent(binary); // Wrong label (ignored): emit a custom channel:message via // public emitter to exercise the label guard. alice.dataChannels.emit("channel:message", { peerId: "bob", label: "different", data: JSON.stringify({ type: "request-chunk", requestId: "r", fileId: "x", chunkId: "x", }), }); // Missing required string fields const badShape = new Event("message") as MessageEvent; (badShape as unknown as { data: string }).data = JSON.stringify({ type: "request-chunk", requestId: 42, chunkId: "x", }); channel.dispatchEvent(badShape); await settle(); expect(channel.sent.length).toBe(0); await alice.stop(); await bob.stop(); }); }); describe("WebRTCDrivePlugin signalling error surface", () => { it("emits signaling:error on invalid inbound payload", { timeout: 5000, }, async () => { const plugin = makePlugin(broker, "alice"); const errors: Array<{ error: Error }> = []; plugin.on("signaling:error", (e) => errors.push(e)); await plugin.start(); const sock = (plugin.signaling as unknown as { socket: BrokerSocket }) .socket; sock.deliver("not-json"); expect(errors.length).toBeGreaterThanOrEqual(1); await plugin.stop(); }); it("emits signaling:disconnected on close", { timeout: 5000, }, async () => { const plugin = makePlugin(broker, "alice"); const events: Array<{ reason?: string }> = []; plugin.on("signaling:disconnected", (e) => events.push(e)); await plugin.start(); await plugin.stop(); expect(events.length).toBeGreaterThanOrEqual(1); }); }); describe("WebRTCDrivePlugin offer/answer error paths", () => { it("emits webrtc:failed when createOffer throws", { timeout: 5000, }, async () => { const alice = makePlugin(broker, "alice"); await alice.start(); const failures: Array<{ peerId: string; error?: Error }> = []; alice.on("webrtc:failed", (e) => failures.push(e)); // Patch the client to throw on createOffer. const originalCreateOffer = alice.client.createOffer.bind(alice.client); alice.client.createOffer = async () => { throw new Error("offer-failed"); }; const sock = (alice.signaling as unknown as { socket: BrokerSocket }) .socket; sock.deliver( JSON.stringify({ type: "peer-joined", from: "bob", payload: { id: "bob", name: "bob", chunks: [] }, }) ); await settle(); expect(failures.length).toBeGreaterThanOrEqual(1); alice.client.createOffer = originalCreateOffer; await alice.stop(); }); it("emits webrtc:failed when handleOffer throws", { timeout: 5000, }, async () => { const alice = makePlugin(broker, "alice"); await alice.start(); const failures: Array<{ peerId: string }> = []; alice.on("webrtc:failed", (e) => failures.push(e)); const orig = alice.client.handleOffer.bind(alice.client); alice.client.handleOffer = async () => { throw new Error("handle-offer-failed"); }; const sock = (alice.signaling as unknown as { socket: BrokerSocket }) .socket; sock.deliver( JSON.stringify({ type: "offer", from: "bob", payload: { type: "offer", sdp: "x" }, }) ); await settle(); expect(failures.length).toBeGreaterThanOrEqual(1); alice.client.handleOffer = orig; await alice.stop(); }); it("emits webrtc:failed when handleAnswer throws", { timeout: 5000, }, async () => { const alice = makePlugin(broker, "alice"); await alice.start(); const failures: Array<{ peerId: string }> = []; alice.on("webrtc:failed", (e) => failures.push(e)); // First create a peer so handleAnswer has something to look up. const sock = (alice.signaling as unknown as { socket: BrokerSocket }) .socket; sock.deliver( JSON.stringify({ type: "offer", from: "bob", payload: { type: "offer", sdp: "x" }, }) ); await settle(); const orig = alice.client.handleAnswer.bind(alice.client); alice.client.handleAnswer = async () => { throw new Error("answer-failed"); }; sock.deliver( JSON.stringify({ type: "answer", from: "bob", payload: { type: "answer", sdp: "x" }, }) ); await settle(); expect(failures.length).toBeGreaterThanOrEqual(1); alice.client.handleAnswer = orig; await alice.stop(); }); it("ignores answer / ice-candidate without a `from`", { timeout: 5000, }, async () => { const alice = makePlugin(broker, "alice"); await alice.start(); const sock = (alice.signaling as unknown as { socket: BrokerSocket }) .socket; sock.deliver( JSON.stringify({ type: "answer", payload: { type: "answer", sdp: "x" }, }) ); sock.deliver( JSON.stringify({ type: "ice-candidate", payload: { candidate: "x" }, }) ); sock.deliver( JSON.stringify({ type: "offer", payload: { type: "offer", sdp: "x" }, }) ); sock.deliver( JSON.stringify({ type: "announce-chunks", payload: { chunks: ["a"] }, }) ); await Promise.resolve(); await alice.stop(); }); it("tolerates addIceCandidate failures", { timeout: 5000, }, async () => { const alice = makePlugin(broker, "alice"); await alice.start(); const orig = alice.client.addIceCandidate.bind(alice.client); alice.client.addIceCandidate = async () => { throw new Error("bad-candidate"); }; const sock = (alice.signaling as unknown as { socket: BrokerSocket }) .socket; sock.deliver( JSON.stringify({ type: "ice-candidate", from: "bob", payload: { candidate: "c" }, }) ); await Promise.resolve(); alice.client.addIceCandidate = orig; await alice.stop(); }); }); describe("WebRTCDrivePlugin data-channel close + error paths", () => { it("emits datachannel:close and datachannel:error", { timeout: 5000, }, async () => { const alice = makePlugin(broker, "alice"); const bob = makePlugin(broker, "bob"); await alice.start(); await bob.start(); await settle(); const pc = alice.client.getPeerConnection( "bob" ) as unknown as FakeRTCPeerConnection; pc.simulateIceState("connected"); const closes: Array<{ peerId: string }> = []; alice.on("datachannel:close", (e) => closes.push(e)); const errs: Array<{ peerId: string; error: Error }> = []; alice.on("datachannel:error", (e) => errs.push(e)); const channel = alice.dataChannels.get( "bob" ) as unknown as FakeRTCDataChannel; channel.simulateOpen(); // Synthesize an error event. const errEvent = new Event("error") as ErrorEvent; (errEvent as unknown as { error: Error }).error = new Error("oops"); channel.dispatchEvent(errEvent); // Close it. channel.close(); expect(closes.length).toBeGreaterThanOrEqual(1); expect(errs.length).toBeGreaterThanOrEqual(1); await alice.stop(); await bob.stop(); }); }); describe("WebRTCDrivePlugin signaling-send swallows when closed", () => { it("ignores ICE candidates when signaling is closed mid-flight", { timeout: 5000, }, async () => { const alice = makePlugin(broker, "alice"); const bob = makePlugin(broker, "bob"); await alice.start(); await bob.start(); await settle(); const pc = alice.client.getPeerConnection( "bob" ) as unknown as FakeRTCPeerConnection; // Close signaling, then dispatch an ICE event — the plugin // catches the synchronous send error. alice.signaling.close("test"); const candidate = { candidate: "c1", toJSON: () => ({ candidate: "c1" }), } as unknown as RTCIceCandidate; const event = new Event("icecandidate") as RTCPeerConnectionIceEvent; (event as unknown as { candidate: RTCIceCandidate }).candidate = candidate; pc.dispatchEvent(event); await alice.stop(); await bob.stop(); }); }); describe("WebRTCDrivePlugin error code surface", () => { it("uses SIGNALING_UNREACHABLE for post-stop start", { timeout: 5000, }, async () => { const plugin = makePlugin(broker, "alice"); await plugin.stop(); try { await plugin.start(); } catch (err) { expect(err).toBeInstanceOf(DriveWebRtcError); expect((err as DriveWebRtcError).code).toBe( DRIVE_WEBRTC_CODES.SIGNALING_UNREACHABLE ); } }); }); describe("WebRTCDrivePlugin coverage tail", () => { it("invokes the integrity clock through now()", { timeout: 5000, }, async () => { const plugin = makePlugin(broker, "alice"); await plugin.start(); // Call `now()` so the constructor-injected `clock` arrow that // forwards into `this.clock.now()` is exercised. const t = plugin.integrity.now(); expect(typeof t).toBe("number"); await plugin.stop(); }); it("ignores unhandled signalling message variants", { timeout: 5000, }, async () => { const plugin = makePlugin(broker, "alice"); await plugin.start(); const sock = (plugin.signaling as unknown as { socket: BrokerSocket }) .socket; // `chunk-data` is a valid schema variant but not consumed by // the plugin's signalling handler — exercises the `default` // branch. sock.deliver( JSON.stringify({ type: "chunk-data", payload: { requestId: "r", fileId: "1", chunkId: "2", data: "AAAA", }, }) ); await Promise.resolve(); await plugin.stop(); }); it("no-ops on request-chunk when no localChunkProvider is wired", { timeout: 5000, }, async () => { // makePlugin omits localChunkProvider — every request-chunk // hits the `no-provider` branch. const alice = makePlugin(broker, "alice"); const bob = makePlugin(broker, "bob"); await alice.start(); await bob.start(); await settle(); const pc = alice.client.getPeerConnection( "bob" ) as unknown as FakeRTCPeerConnection; pc.simulateIceState("connected"); const channel = alice.dataChannels.get( "bob" ) as unknown as FakeRTCDataChannel; channel.simulateOpen(); const event = new Event("message") as MessageEvent; (event as unknown as { data: string }).data = JSON.stringify({ type: "request-chunk", requestId: "r", fileId: "x", chunkId: "x", }); channel.dispatchEvent(event); await settle(); expect(channel.sent.length).toBe(0); await alice.stop(); await bob.stop(); }); it("swallows data-channel send failures during serveLocalChunk", { timeout: 5000, }, async () => { const alice = new WebRTCDrivePlugin({ peerId: "alice", config: { signaling: { url: "wss://x?peerId=alice" } }, signalingUrl: "wss://x?peerId=alice", webSocketCtor: makeBrokerSocketCtor(broker), rtcPeerConnectionCtor: FakeRTCPeerConnection as unknown as typeof RTCPeerConnection, localChunkProvider: () => new Uint8Array([7, 7, 7]), }); const bob = makePlugin(broker, "bob"); await alice.start(); await bob.start(); await settle(); const pc = alice.client.getPeerConnection( "bob" ) as unknown as FakeRTCPeerConnection; pc.simulateIceState("connected"); const channel = alice.dataChannels.get( "bob" ) as unknown as FakeRTCDataChannel; channel.simulateOpen(); // Patch send to throw so serveLocalChunk hits the catch. channel.send = () => { throw new Error("send-boom"); }; const event = new Event("message") as MessageEvent; (event as unknown as { data: string }).data = JSON.stringify({ type: "request-chunk", requestId: "r", fileId: "x", chunkId: "x", }); channel.dispatchEvent(event); await settle(); await alice.stop(); await bob.stop(); }); it("constructs without injected ctors (falls back to globals)", { timeout: 5000, }, () => { // Provide globals so the default-arm branch is exercised. const origWS = (globalThis as { WebSocket?: unknown }).WebSocket; const origPC = (globalThis as { RTCPeerConnection?: unknown }) .RTCPeerConnection; (globalThis as { WebSocket?: unknown }).WebSocket = makeBrokerSocketCtor(broker); (globalThis as { RTCPeerConnection?: unknown }).RTCPeerConnection = FakeRTCPeerConnection; try { const plugin = new WebRTCDrivePlugin({ peerId: "no-ctors", config: { signaling: { url: "wss://x?peerId=no-ctors" }, }, signalingUrl: "wss://x?peerId=no-ctors", }); expect(plugin.client).toBeDefined(); expect(plugin.signaling).toBeDefined(); } finally { (globalThis as { WebSocket?: unknown }).WebSocket = origWS; (globalThis as { RTCPeerConnection?: unknown }).RTCPeerConnection = origPC; } }); it("forwards ICE candidates without a toJSON method", { timeout: 5000, }, async () => { const alice = makePlugin(broker, "alice"); const bob = makePlugin(broker, "bob"); await alice.start(); await bob.start(); await settle(); const pc = alice.client.getPeerConnection( "bob" ) as unknown as FakeRTCPeerConnection; const candidate = { candidate: "c-no-tojson", } as unknown as RTCIceCandidate; const event = new Event("icecandidate") as RTCPeerConnectionIceEvent; (event as unknown as { candidate: RTCIceCandidate }).candidate = candidate; pc.dispatchEvent(event); await Promise.resolve(); const iceFrames = broker.trace.filter( (t) => t.type === "ice-candidate" ); expect(iceFrames.length).toBeGreaterThanOrEqual(1); await alice.stop(); await bob.stop(); }); it("handles incoming offer when sender is already a known peer", { timeout: 5000, }, async () => { const alice = makePlugin(broker, "alice"); const bob = makePlugin(broker, "bob"); await alice.start(); await bob.start(); await settle(); // Bob is admitted on alice; sending another offer hits the // already-admitted branch. const aliceSock = ( alice.signaling as unknown as { socket: BrokerSocket } ).socket; aliceSock.deliver( JSON.stringify({ type: "offer", from: "bob", payload: { type: "offer", sdp: "again" }, }) ); await settle(); await alice.stop(); await bob.stop(); }); it("maps ICE disconnected and unknown states correctly", { timeout: 5000, }, async () => { const alice = makePlugin(broker, "alice"); const bob = makePlugin(broker, "bob"); await alice.start(); await bob.start(); await settle(); const pc = alice.client.getPeerConnection( "bob" ) as unknown as FakeRTCPeerConnection; pc.simulateIceState("disconnected"); expect(alice.peers.get("bob")?.connectionState).toBe("disconnected"); pc.simulateIceState("checking"); expect(alice.peers.get("bob")?.connectionState).toBe("connecting"); await alice.stop(); await bob.stop(); }); });