1195 lines
42 KiB
TypeScript
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();
|
|
});
|
|
});
|