518 lines
18 KiB
TypeScript
518 lines
18 KiB
TypeScript
/**
|
|
* Tests for {@link DataChannelManager}. Uses a structural
|
|
* FakeRTCPeerConnection + FakeRTCDataChannel — no `vi.stubGlobal`,
|
|
* no `vi.mock`. The Fakes are real `EventTarget` subclasses; tests
|
|
* drive transitions through `simulate*` helpers.
|
|
*/
|
|
|
|
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import { WebRTCClient } from "../../client/client/client.js";
|
|
import {
|
|
DRIVE_WEBRTC_CODES,
|
|
DriveWebRtcError,
|
|
} from "../../error-codes/error-codes.js";
|
|
import { DataChannelManager } from "./data-channel-manager.js";
|
|
|
|
class FakeRTCDataChannel extends EventTarget {
|
|
public readyState: RTCDataChannelState = "connecting";
|
|
public bufferedAmount = 0;
|
|
public bufferedAmountLowThreshold = 0;
|
|
public closed = false;
|
|
public sentPayloads: (ArrayBuffer | string)[] = [];
|
|
public sendImpl: (payload: ArrayBuffer | string) => void = (payload) => {
|
|
this.sentPayloads.push(payload);
|
|
};
|
|
public closeImpl: () => void = () => {
|
|
this.closed = true;
|
|
this.readyState = "closed";
|
|
};
|
|
|
|
public constructor(
|
|
public readonly label: string,
|
|
_init?: RTCDataChannelInit
|
|
) {
|
|
super();
|
|
}
|
|
|
|
public send(payload: ArrayBuffer | string): void {
|
|
this.sendImpl(payload);
|
|
}
|
|
|
|
public close(): void {
|
|
this.closeImpl();
|
|
this.dispatchEvent(new Event("close"));
|
|
}
|
|
|
|
public simulateOpen(): void {
|
|
this.readyState = "open";
|
|
this.dispatchEvent(new Event("open"));
|
|
}
|
|
public simulateClose(): void {
|
|
this.readyState = "closed";
|
|
this.dispatchEvent(new Event("close"));
|
|
}
|
|
public simulateMessage(data: ArrayBuffer | string): void {
|
|
const e = new Event("message") as MessageEvent;
|
|
(e as unknown as { data: unknown }).data = data;
|
|
this.dispatchEvent(e);
|
|
}
|
|
public simulateError(error?: Error): void {
|
|
const e = new Event("error") as ErrorEvent;
|
|
(e as unknown as { error: Error | undefined }).error = error;
|
|
this.dispatchEvent(e);
|
|
}
|
|
public simulateBufferedAmount(value: number): void {
|
|
this.bufferedAmount = value;
|
|
}
|
|
public simulateBufferedAmountLow(): void {
|
|
this.dispatchEvent(new Event("bufferedamountlow"));
|
|
}
|
|
}
|
|
|
|
class FakeRTCPeerConnection extends EventTarget {
|
|
public connectionState: RTCPeerConnectionState = "new";
|
|
public iceConnectionState: RTCIceConnectionState = "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" };
|
|
}
|
|
public async createAnswer(): Promise<RTCSessionDescriptionInit> {
|
|
return { type: "answer", sdp: "fake" };
|
|
}
|
|
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;
|
|
}
|
|
public createDataChannel(
|
|
label: string,
|
|
init?: RTCDataChannelInit
|
|
): RTCDataChannel {
|
|
const ch = new FakeRTCDataChannel(label, init);
|
|
this.createdChannels.push(ch);
|
|
return ch as unknown as RTCDataChannel;
|
|
}
|
|
}
|
|
|
|
async function makeManager(opts?: {
|
|
highWaterMarkBytes?: number;
|
|
defaultLabel?: string;
|
|
defaultOrdered?: boolean;
|
|
}): Promise<{
|
|
manager: DataChannelManager;
|
|
client: WebRTCClient;
|
|
pc: FakeRTCPeerConnection;
|
|
}> {
|
|
const client = new WebRTCClient({
|
|
peerId: "self",
|
|
config: { signaling: { url: "wss://x" } },
|
|
rtcPeerConnectionCtor:
|
|
FakeRTCPeerConnection as unknown as typeof RTCPeerConnection,
|
|
});
|
|
await client.createOffer("bob");
|
|
const pc = client.getPeerConnection(
|
|
"bob"
|
|
) as unknown as FakeRTCPeerConnection;
|
|
const manager = new DataChannelManager({
|
|
client,
|
|
...(opts?.highWaterMarkBytes !== undefined
|
|
? { highWaterMarkBytes: opts.highWaterMarkBytes }
|
|
: {}),
|
|
...(opts?.defaultLabel !== undefined
|
|
? { defaultLabel: opts.defaultLabel }
|
|
: {}),
|
|
...(opts?.defaultOrdered !== undefined
|
|
? { defaultOrdered: opts.defaultOrdered }
|
|
: {}),
|
|
});
|
|
return { manager, client, pc };
|
|
}
|
|
|
|
describe("DataChannelManager.open", () => {
|
|
it("throws PEER_NOT_FOUND for unknown peer", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const client = new WebRTCClient({
|
|
peerId: "self",
|
|
config: { signaling: { url: "wss://x" } },
|
|
rtcPeerConnectionCtor:
|
|
FakeRTCPeerConnection as unknown as typeof RTCPeerConnection,
|
|
});
|
|
const manager = new DataChannelManager({ client });
|
|
try {
|
|
manager.open("missing");
|
|
throw new Error("expected throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(DriveWebRtcError);
|
|
expect((err as DriveWebRtcError).code).toBe(
|
|
DRIVE_WEBRTC_CODES.PEER_NOT_FOUND
|
|
);
|
|
}
|
|
});
|
|
|
|
it("creates a new channel on first open", { timeout: 5000 }, async () => {
|
|
const { manager, pc } = await makeManager();
|
|
const ch = manager.open("bob");
|
|
expect(pc.createdChannels).toHaveLength(1);
|
|
expect((ch as unknown as FakeRTCDataChannel).label).toBe(
|
|
"drive-chunks"
|
|
);
|
|
});
|
|
|
|
it("reuses an existing channel for the same label", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const { manager, pc } = await makeManager();
|
|
const a = manager.open("bob");
|
|
const b = manager.open("bob");
|
|
expect(a).toBe(b);
|
|
expect(pc.createdChannels).toHaveLength(1);
|
|
});
|
|
|
|
it("emits channel:open when channel opens", { timeout: 5000 }, async () => {
|
|
const { manager } = await makeManager();
|
|
const events: { peerId: string; label: string }[] = [];
|
|
manager.on("channel:open", (e) => events.push(e));
|
|
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
|
|
ch.simulateOpen();
|
|
expect(events).toEqual([{ peerId: "bob", label: "drive-chunks" }]);
|
|
});
|
|
|
|
it("emits channel:open eagerly when channel was already open at register", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const { manager, pc } = await makeManager();
|
|
// Forcefully open the channel before manager.open registers handlers.
|
|
// We achieve this by monkey-patching createDataChannel for one call.
|
|
const originalCreate = pc.createDataChannel.bind(pc);
|
|
pc.createDataChannel = (label, init) => {
|
|
const ch = originalCreate(
|
|
label,
|
|
init
|
|
) as unknown as FakeRTCDataChannel;
|
|
ch.readyState = "open";
|
|
return ch as unknown as RTCDataChannel;
|
|
};
|
|
const events: { peerId: string; label: string }[] = [];
|
|
manager.on("channel:open", (e) => events.push(e));
|
|
manager.open("bob");
|
|
await Promise.resolve();
|
|
expect(events).toEqual([{ peerId: "bob", label: "drive-chunks" }]);
|
|
});
|
|
|
|
it("threads RTCDataChannelInit", { timeout: 5000 }, async () => {
|
|
const { manager, pc } = await makeManager({
|
|
defaultOrdered: false,
|
|
});
|
|
manager.open("bob", "control", { maxRetransmits: 3 });
|
|
// Two channels created? No — only one for "control".
|
|
expect(pc.createdChannels.map((c) => c.label)).toEqual(["control"]);
|
|
});
|
|
});
|
|
|
|
describe("DataChannelManager.send", () => {
|
|
it("throws DATA_CHANNEL_CLOSED if channel is missing", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const { manager } = await makeManager();
|
|
try {
|
|
await manager.send("bob", "drive-chunks", "x");
|
|
throw new Error("expected throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(DriveWebRtcError);
|
|
expect((err as DriveWebRtcError).code).toBe(
|
|
DRIVE_WEBRTC_CODES.DATA_CHANNEL_CLOSED
|
|
);
|
|
}
|
|
});
|
|
|
|
it("throws DATA_CHANNEL_CLOSED if channel not open", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const { manager } = await makeManager();
|
|
manager.open("bob");
|
|
try {
|
|
await manager.send("bob", "drive-chunks", "x");
|
|
throw new Error("expected throw");
|
|
} catch (err) {
|
|
expect((err as DriveWebRtcError).code).toBe(
|
|
DRIVE_WEBRTC_CODES.DATA_CHANNEL_CLOSED
|
|
);
|
|
}
|
|
});
|
|
|
|
it("sends string payload when open and below watermark", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const { manager } = await makeManager({ highWaterMarkBytes: 1_000 });
|
|
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
|
|
ch.simulateOpen();
|
|
await manager.send("bob", "drive-chunks", "hello");
|
|
expect(ch.sentPayloads).toEqual(["hello"]);
|
|
});
|
|
|
|
it("sends ArrayBuffer payload", { timeout: 5000 }, async () => {
|
|
const { manager } = await makeManager();
|
|
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
|
|
ch.simulateOpen();
|
|
const buf = new ArrayBuffer(4);
|
|
await manager.send("bob", "drive-chunks", buf);
|
|
expect(ch.sentPayloads[0]).toBe(buf);
|
|
});
|
|
|
|
it("waits for bufferedamountlow when buffered > watermark", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const { manager } = await makeManager({ highWaterMarkBytes: 100 });
|
|
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
|
|
ch.simulateOpen();
|
|
ch.simulateBufferedAmount(500);
|
|
const pending = manager.send("bob", "drive-chunks", "p");
|
|
// Allow microtasks to run; pending must not yet have resolved.
|
|
let resolved = false;
|
|
pending.then(() => {
|
|
resolved = true;
|
|
});
|
|
await Promise.resolve();
|
|
expect(resolved).toBe(false);
|
|
ch.simulateBufferedAmountLow();
|
|
await pending;
|
|
expect(ch.sentPayloads).toEqual(["p"]);
|
|
});
|
|
|
|
it("rejects with DATA_CHANNEL_CLOSED when AbortSignal aborts before send", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const { manager } = await makeManager();
|
|
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
|
|
ch.simulateOpen();
|
|
const ctrl = new AbortController();
|
|
ctrl.abort(new Error("boom"));
|
|
try {
|
|
await manager.send("bob", "drive-chunks", "p", ctrl.signal);
|
|
throw new Error("expected throw");
|
|
} catch (err) {
|
|
expect((err as DriveWebRtcError).code).toBe(
|
|
DRIVE_WEBRTC_CODES.DATA_CHANNEL_CLOSED
|
|
);
|
|
}
|
|
expect(ch.sentPayloads).toEqual([]);
|
|
});
|
|
|
|
it("rejects waiting send when AbortSignal aborts during drain", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const { manager } = await makeManager({ highWaterMarkBytes: 100 });
|
|
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
|
|
ch.simulateOpen();
|
|
ch.simulateBufferedAmount(500);
|
|
const ctrl = new AbortController();
|
|
const pending = manager.send("bob", "drive-chunks", "p", ctrl.signal);
|
|
let rejection: unknown;
|
|
pending.catch((err) => {
|
|
rejection = err;
|
|
});
|
|
await Promise.resolve();
|
|
ctrl.abort(new Error("user-cancel"));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(rejection).toBeInstanceOf(DriveWebRtcError);
|
|
expect((rejection as DriveWebRtcError).code).toBe(
|
|
DRIVE_WEBRTC_CODES.DATA_CHANNEL_CLOSED
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("DataChannelManager.close / closeAll", () => {
|
|
it("close(peerId, label) detaches and emits", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const { manager } = await makeManager();
|
|
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
|
|
ch.simulateOpen();
|
|
const closes: { peerId: string; label: string; reason?: string }[] = [];
|
|
manager.on("channel:close", (e) => closes.push(e));
|
|
manager.close("bob", "drive-chunks", "test-reason");
|
|
expect(ch.closed).toBe(true);
|
|
// Two close events: one from manager's emit + (NOT) one from the
|
|
// simulateClose dispatch. Because detach happened before
|
|
// channel.close, the close-listener already removed; only the
|
|
// manager's own emit lands.
|
|
expect(closes).toHaveLength(1);
|
|
expect(closes[0]?.reason).toBe("test-reason");
|
|
expect(manager.get("bob")).toBeUndefined();
|
|
});
|
|
|
|
it("close(peerId) closes every label", { timeout: 5000 }, async () => {
|
|
const { manager } = await makeManager();
|
|
const a = manager.open("bob", "a") as unknown as FakeRTCDataChannel;
|
|
const b = manager.open("bob", "b") as unknown as FakeRTCDataChannel;
|
|
a.simulateOpen();
|
|
b.simulateOpen();
|
|
manager.close("bob");
|
|
expect(a.closed).toBe(true);
|
|
expect(b.closed).toBe(true);
|
|
expect(manager.get("bob", "a")).toBeUndefined();
|
|
expect(manager.get("bob", "b")).toBeUndefined();
|
|
});
|
|
|
|
it("close is idempotent on unknown peer/label", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const { manager } = await makeManager();
|
|
manager.close("nobody");
|
|
manager.close("nobody", "x");
|
|
});
|
|
|
|
it("close with unknown label on known peer is a silent no-op", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const { manager } = await makeManager();
|
|
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
|
|
ch.simulateOpen();
|
|
let closes = 0;
|
|
manager.on("channel:close", () => closes++);
|
|
manager.close("bob", "no-such-label");
|
|
expect(closes).toBe(0);
|
|
// The known label is still alive.
|
|
expect(manager.get("bob")).toBeDefined();
|
|
});
|
|
|
|
it("aborts drain wait with no signal reason", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const { manager } = await makeManager({ highWaterMarkBytes: 100 });
|
|
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
|
|
ch.simulateOpen();
|
|
ch.simulateBufferedAmount(500);
|
|
const ctrl = new AbortController();
|
|
const pending = manager.send("bob", "drive-chunks", "p", ctrl.signal);
|
|
let rejection: unknown;
|
|
pending.catch((err) => {
|
|
rejection = err;
|
|
});
|
|
await Promise.resolve();
|
|
ctrl.abort(); // no reason
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(rejection).toBeInstanceOf(DriveWebRtcError);
|
|
});
|
|
|
|
it("closeAll closes every peer", { timeout: 5000 }, async () => {
|
|
const client = new WebRTCClient({
|
|
peerId: "self",
|
|
config: { signaling: { url: "wss://x" } },
|
|
rtcPeerConnectionCtor:
|
|
FakeRTCPeerConnection as unknown as typeof RTCPeerConnection,
|
|
});
|
|
await client.createOffer("bob");
|
|
await client.createOffer("carol");
|
|
const manager = new DataChannelManager({ client });
|
|
const a = manager.open("bob") as unknown as FakeRTCDataChannel;
|
|
const b = manager.open("carol") as unknown as FakeRTCDataChannel;
|
|
a.simulateOpen();
|
|
b.simulateOpen();
|
|
manager.closeAll("shutdown");
|
|
expect(a.closed).toBe(true);
|
|
expect(b.closed).toBe(true);
|
|
});
|
|
|
|
it("swallows close() throws", { timeout: 5000 }, async () => {
|
|
const { manager } = await makeManager();
|
|
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
|
|
ch.simulateOpen();
|
|
ch.closeImpl = () => {
|
|
throw new Error("bad");
|
|
};
|
|
manager.close("bob");
|
|
});
|
|
});
|
|
|
|
describe("DataChannelManager remote events", () => {
|
|
it("forwards messages", { timeout: 5000 }, async () => {
|
|
const { manager } = await makeManager();
|
|
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
|
|
ch.simulateOpen();
|
|
const messages: {
|
|
peerId: string;
|
|
label: string;
|
|
data: ArrayBuffer | string;
|
|
}[] = [];
|
|
manager.on("channel:message", (e) => messages.push(e));
|
|
ch.simulateMessage("hi");
|
|
const buf = new ArrayBuffer(2);
|
|
ch.simulateMessage(buf);
|
|
expect(messages).toHaveLength(2);
|
|
expect(messages[0]?.data).toBe("hi");
|
|
expect(messages[1]?.data).toBe(buf);
|
|
});
|
|
|
|
it("forwards errors with DriveWebRtcError(DATA_CHANNEL_CLOSED)", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const { manager } = await makeManager();
|
|
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
|
|
ch.simulateOpen();
|
|
const errors: { error: DriveWebRtcError }[] = [];
|
|
manager.on("channel:error", (e) => errors.push(e));
|
|
ch.simulateError(new Error("boom"));
|
|
ch.simulateError(undefined);
|
|
expect(errors).toHaveLength(2);
|
|
expect(errors[0]?.error.code).toBe(
|
|
DRIVE_WEBRTC_CODES.DATA_CHANNEL_CLOSED
|
|
);
|
|
expect(errors[0]?.error.cause).toBeInstanceOf(Error);
|
|
expect(errors[1]?.error.cause).toBeUndefined();
|
|
});
|
|
|
|
it("emits channel:close when channel closes remotely", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const { manager } = await makeManager();
|
|
const ch = manager.open("bob") as unknown as FakeRTCDataChannel;
|
|
ch.simulateOpen();
|
|
const closes: { peerId: string; label: string }[] = [];
|
|
manager.on("channel:close", (e) => closes.push(e));
|
|
ch.simulateClose();
|
|
expect(closes).toHaveLength(1);
|
|
expect(manager.get("bob")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("DataChannelManager.get", () => {
|
|
it("returns the active channel by default label", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const { manager } = await makeManager();
|
|
const ch = manager.open("bob");
|
|
expect(manager.get("bob")).toBe(ch);
|
|
});
|
|
|
|
it("returns undefined for unknown peer/label", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const { manager } = await makeManager();
|
|
expect(manager.get("nobody")).toBeUndefined();
|
|
manager.open("bob");
|
|
expect(manager.get("bob", "other")).toBeUndefined();
|
|
});
|
|
});
|