Files
suaveplan/packages/networking/drive/drive-webrtc/src/plugin/webrtc-plugin/webrtc-plugin.test.ts
2026-05-27 23:47:53 +01:00

1195 lines
42 KiB
TypeScript

/**
* 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<string, BrokerSocket>();
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<RTCSessionDescriptionInit> {
return { type: "offer", sdp: "fake-offer" };
}
public async createAnswer(): Promise<RTCSessionDescriptionInit> {
return { type: "answer", sdp: "fake-answer" };
}
public async setLocalDescription(
d: RTCSessionDescriptionInit
): Promise<void> {
this.localDescription = d;
}
public async setRemoteDescription(
d: RTCSessionDescriptionInit
): Promise<void> {
this.remoteDescription = d;
}
public async addIceCandidate(): Promise<void> {}
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<void> {
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();
});
});