fixed tests
This commit is contained in:
146
tests/ClientManager.test.ts
Normal file
146
tests/ClientManager.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { test, expect, describe, beforeEach } from "bun:test";
|
||||
import { ClientManager } from "../src/services/ClientManager.ts";
|
||||
import type { IWebSocketClient } from "../src/interfaces/ISignalingMessage.ts";
|
||||
|
||||
class MockWebSocket {
|
||||
sentMessages: string[] = [];
|
||||
closed = false;
|
||||
|
||||
send(data: string) {
|
||||
this.sentMessages.push(data);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
describe("ClientManager", () => {
|
||||
let clientManager: ClientManager;
|
||||
let mockWs: MockWebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
clientManager = new ClientManager();
|
||||
mockWs = new MockWebSocket();
|
||||
});
|
||||
|
||||
test("should add a publisher client", () => {
|
||||
const client: IWebSocketClient = {
|
||||
id: "publisher-1",
|
||||
ws: mockWs,
|
||||
role: "publisher"
|
||||
};
|
||||
|
||||
clientManager.addClient(client);
|
||||
|
||||
expect(clientManager.getClient("publisher-1")).toBe(client);
|
||||
expect(clientManager.getPublisher()).toBe(client);
|
||||
});
|
||||
|
||||
test("should add subscriber clients", () => {
|
||||
const subscriber1: IWebSocketClient = {
|
||||
id: "subscriber-1",
|
||||
ws: mockWs,
|
||||
role: "subscriber"
|
||||
};
|
||||
|
||||
const subscriber2: IWebSocketClient = {
|
||||
id: "subscriber-2",
|
||||
ws: new MockWebSocket(),
|
||||
role: "subscriber"
|
||||
};
|
||||
|
||||
clientManager.addClient(subscriber1);
|
||||
clientManager.addClient(subscriber2);
|
||||
|
||||
const subscribers = clientManager.getSubscribers();
|
||||
expect(subscribers).toHaveLength(2);
|
||||
expect(subscribers).toContain(subscriber1);
|
||||
expect(subscribers).toContain(subscriber2);
|
||||
});
|
||||
|
||||
test("should notify subscribers when publisher joins", () => {
|
||||
const subscriber1: IWebSocketClient = {
|
||||
id: "subscriber-1",
|
||||
ws: mockWs,
|
||||
role: "subscriber"
|
||||
};
|
||||
|
||||
const subscriber2: IWebSocketClient = {
|
||||
id: "subscriber-2",
|
||||
ws: new MockWebSocket(),
|
||||
role: "subscriber"
|
||||
};
|
||||
|
||||
clientManager.addClient(subscriber1);
|
||||
clientManager.addClient(subscriber2);
|
||||
|
||||
const publisher: IWebSocketClient = {
|
||||
id: "publisher-1",
|
||||
ws: new MockWebSocket(),
|
||||
role: "publisher"
|
||||
};
|
||||
|
||||
clientManager.addClient(publisher);
|
||||
|
||||
expect(mockWs.sentMessages).toContain(
|
||||
JSON.stringify({ type: 'publisher-joined' })
|
||||
);
|
||||
expect((subscriber2.ws as MockWebSocket).sentMessages).toContain(
|
||||
JSON.stringify({ type: 'publisher-joined' })
|
||||
);
|
||||
});
|
||||
|
||||
test("should remove client and notify subscribers when publisher leaves", () => {
|
||||
const subscriber: IWebSocketClient = {
|
||||
id: "subscriber-1",
|
||||
ws: mockWs,
|
||||
role: "subscriber"
|
||||
};
|
||||
|
||||
const publisher: IWebSocketClient = {
|
||||
id: "publisher-1",
|
||||
ws: new MockWebSocket(),
|
||||
role: "publisher"
|
||||
};
|
||||
|
||||
clientManager.addClient(subscriber);
|
||||
clientManager.addClient(publisher);
|
||||
|
||||
clientManager.removeClient("publisher-1");
|
||||
|
||||
expect(clientManager.getClient("publisher-1")).toBeUndefined();
|
||||
expect(clientManager.getPublisher()).toBeNull();
|
||||
expect(mockWs.sentMessages).toContain(
|
||||
JSON.stringify({ type: 'publisher-left' })
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle removing non-existent client", () => {
|
||||
expect(() => {
|
||||
clientManager.removeClient("non-existent");
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test("should get all clients", () => {
|
||||
const publisher: IWebSocketClient = {
|
||||
id: "publisher-1",
|
||||
ws: mockWs,
|
||||
role: "publisher"
|
||||
};
|
||||
|
||||
const subscriber: IWebSocketClient = {
|
||||
id: "subscriber-1",
|
||||
ws: new MockWebSocket(),
|
||||
role: "subscriber"
|
||||
};
|
||||
|
||||
clientManager.addClient(publisher);
|
||||
clientManager.addClient(subscriber);
|
||||
|
||||
const allClients = clientManager.getAllClients();
|
||||
expect(allClients).toHaveLength(2);
|
||||
expect(allClients).toContain(publisher);
|
||||
expect(allClients).toContain(subscriber);
|
||||
});
|
||||
});
|
||||
176
tests/SignalingService.test.ts
Normal file
176
tests/SignalingService.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { test, expect, describe, beforeEach } from "bun:test";
|
||||
import { SignalingService } from "../src/services/SignalingService.ts";
|
||||
import { ClientManager } from "../src/services/ClientManager.ts";
|
||||
import type { ISignalingMessage } from "../src/interfaces/ISignalingMessage.ts";
|
||||
|
||||
class MockWebSocket {
|
||||
sentMessages: string[] = [];
|
||||
closed = false;
|
||||
|
||||
send(data: string) {
|
||||
this.sentMessages.push(data);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
describe("SignalingService", () => {
|
||||
let signalingService: SignalingService;
|
||||
let clientManager: ClientManager;
|
||||
let mockWs: MockWebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
clientManager = new ClientManager();
|
||||
signalingService = new SignalingService(clientManager);
|
||||
mockWs = new MockWebSocket();
|
||||
});
|
||||
|
||||
test("should handle publisher connection", () => {
|
||||
const clientId = signalingService.handleConnection(mockWs, "publisher");
|
||||
|
||||
expect(typeof clientId).toBe("string");
|
||||
expect(clientId.length).toBeGreaterThan(0);
|
||||
expect(mockWs.sentMessages[0]).toBe(
|
||||
JSON.stringify({
|
||||
type: 'join',
|
||||
data: { clientId, role: 'publisher' }
|
||||
})
|
||||
);
|
||||
expect(clientManager.getPublisher()?.id).toBe(clientId);
|
||||
});
|
||||
|
||||
test("should handle subscriber connection", () => {
|
||||
const clientId = signalingService.handleConnection(mockWs, "subscriber");
|
||||
|
||||
expect(typeof clientId).toBe("string");
|
||||
expect(clientId.length).toBeGreaterThan(0);
|
||||
expect(mockWs.sentMessages[0]).toBe(
|
||||
JSON.stringify({
|
||||
type: 'join',
|
||||
data: { clientId, role: 'subscriber' }
|
||||
})
|
||||
);
|
||||
|
||||
const subscribers = clientManager.getSubscribers();
|
||||
expect(subscribers).toHaveLength(1);
|
||||
expect(subscribers[0].id).toBe(clientId);
|
||||
});
|
||||
|
||||
test("should handle disconnection", () => {
|
||||
const clientId = signalingService.handleConnection(mockWs, "publisher");
|
||||
expect(clientManager.getPublisher()).not.toBeNull();
|
||||
|
||||
signalingService.handleDisconnection(clientId);
|
||||
expect(clientManager.getPublisher()).toBeNull();
|
||||
});
|
||||
|
||||
test("should handle offer from publisher to subscribers", () => {
|
||||
// Add publisher
|
||||
const publisherId = signalingService.handleConnection(mockWs, "publisher");
|
||||
|
||||
// Add subscribers
|
||||
const subscriber1Ws = new MockWebSocket();
|
||||
const subscriber2Ws = new MockWebSocket();
|
||||
signalingService.handleConnection(subscriber1Ws, "subscriber");
|
||||
signalingService.handleConnection(subscriber2Ws, "subscriber");
|
||||
|
||||
const offerMessage: ISignalingMessage = {
|
||||
type: "offer",
|
||||
data: { sdp: "fake-offer-sdp", type: "offer" }
|
||||
};
|
||||
|
||||
signalingService.handleMessage(publisherId, offerMessage);
|
||||
|
||||
expect(subscriber1Ws.sentMessages).toContain(
|
||||
JSON.stringify({
|
||||
...offerMessage,
|
||||
senderId: publisherId
|
||||
})
|
||||
);
|
||||
expect(subscriber2Ws.sentMessages).toContain(
|
||||
JSON.stringify({
|
||||
...offerMessage,
|
||||
senderId: publisherId
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle answer from subscriber to publisher", () => {
|
||||
// Add publisher
|
||||
const publisherWs = new MockWebSocket();
|
||||
const publisherId = signalingService.handleConnection(publisherWs, "publisher");
|
||||
|
||||
// Add subscriber
|
||||
const subscriberWs = new MockWebSocket();
|
||||
const subscriberId = signalingService.handleConnection(subscriberWs, "subscriber");
|
||||
|
||||
const answerMessage: ISignalingMessage = {
|
||||
type: "answer",
|
||||
data: { sdp: "fake-answer-sdp", type: "answer" }
|
||||
};
|
||||
|
||||
signalingService.handleMessage(subscriberId, answerMessage);
|
||||
|
||||
expect(publisherWs.sentMessages).toContain(
|
||||
JSON.stringify({
|
||||
...answerMessage,
|
||||
senderId: subscriberId
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle ice candidates from publisher to subscribers", () => {
|
||||
const publisherId = signalingService.handleConnection(mockWs, "publisher");
|
||||
|
||||
const subscriberWs = new MockWebSocket();
|
||||
signalingService.handleConnection(subscriberWs, "subscriber");
|
||||
|
||||
const iceCandidateMessage: ISignalingMessage = {
|
||||
type: "ice-candidate",
|
||||
data: { candidate: "fake-ice-candidate" }
|
||||
};
|
||||
|
||||
signalingService.handleMessage(publisherId, iceCandidateMessage);
|
||||
|
||||
expect(subscriberWs.sentMessages).toContain(
|
||||
JSON.stringify({
|
||||
...iceCandidateMessage,
|
||||
senderId: publisherId
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle ice candidates from subscriber to publisher", () => {
|
||||
const publisherWs = new MockWebSocket();
|
||||
const publisherId = signalingService.handleConnection(publisherWs, "publisher");
|
||||
|
||||
const subscriberId = signalingService.handleConnection(mockWs, "subscriber");
|
||||
|
||||
const iceCandidateMessage: ISignalingMessage = {
|
||||
type: "ice-candidate",
|
||||
data: { candidate: "fake-ice-candidate" }
|
||||
};
|
||||
|
||||
signalingService.handleMessage(subscriberId, iceCandidateMessage);
|
||||
|
||||
expect(publisherWs.sentMessages).toContain(
|
||||
JSON.stringify({
|
||||
...iceCandidateMessage,
|
||||
senderId: subscriberId
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should ignore messages from non-existent clients", () => {
|
||||
const message: ISignalingMessage = {
|
||||
type: "offer",
|
||||
data: { sdp: "fake-sdp" }
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
signalingService.handleMessage("non-existent-id", message);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
130
tests/basic.test.ts
Normal file
130
tests/basic.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { ClientManager } from "../src/services/ClientManager.ts";
|
||||
import { SignalingService } from "../src/services/SignalingService.ts";
|
||||
import type { IWebSocketClient } from "../src/interfaces/ISignalingMessage.ts";
|
||||
|
||||
class MockWebSocket {
|
||||
sentMessages: string[] = [];
|
||||
send(data: string) {
|
||||
this.sentMessages.push(data);
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
|
||||
describe("Basic WebRTC Broadcasting Tests", () => {
|
||||
test("ClientManager should add and retrieve clients", () => {
|
||||
const clientManager = new ClientManager();
|
||||
const mockWs = new MockWebSocket();
|
||||
|
||||
const client: IWebSocketClient = {
|
||||
id: "test-client",
|
||||
ws: mockWs,
|
||||
role: "publisher"
|
||||
};
|
||||
|
||||
clientManager.addClient(client);
|
||||
|
||||
expect(clientManager.getClient("test-client")).toBe(client);
|
||||
expect(clientManager.getPublisher()).toBe(client);
|
||||
});
|
||||
|
||||
test("ClientManager should track subscribers separately", () => {
|
||||
const clientManager = new ClientManager();
|
||||
|
||||
const subscriber1: IWebSocketClient = {
|
||||
id: "sub-1",
|
||||
ws: new MockWebSocket(),
|
||||
role: "subscriber"
|
||||
};
|
||||
|
||||
const subscriber2: IWebSocketClient = {
|
||||
id: "sub-2",
|
||||
ws: new MockWebSocket(),
|
||||
role: "subscriber"
|
||||
};
|
||||
|
||||
clientManager.addClient(subscriber1);
|
||||
clientManager.addClient(subscriber2);
|
||||
|
||||
const subscribers = clientManager.getSubscribers();
|
||||
expect(subscribers.length).toBe(2);
|
||||
expect(subscribers).toContain(subscriber1);
|
||||
expect(subscribers).toContain(subscriber2);
|
||||
});
|
||||
|
||||
test("SignalingService should create client connections", () => {
|
||||
const clientManager = new ClientManager();
|
||||
const signalingService = new SignalingService(clientManager);
|
||||
const mockWs = new MockWebSocket();
|
||||
|
||||
const clientId = signalingService.handleConnection(mockWs, "publisher");
|
||||
|
||||
expect(typeof clientId).toBe("string");
|
||||
expect(clientId.length).toBeGreaterThan(0);
|
||||
expect(mockWs.sentMessages.length).toBe(1);
|
||||
|
||||
const message = JSON.parse(mockWs.sentMessages[0]);
|
||||
expect(message.type).toBe("join");
|
||||
expect(message.data.clientId).toBe(clientId);
|
||||
expect(message.data.role).toBe("publisher");
|
||||
});
|
||||
|
||||
test("SignalingService should handle disconnections", () => {
|
||||
const clientManager = new ClientManager();
|
||||
const signalingService = new SignalingService(clientManager);
|
||||
const mockWs = new MockWebSocket();
|
||||
|
||||
const clientId = signalingService.handleConnection(mockWs, "publisher");
|
||||
expect(clientManager.getPublisher()).toBeTruthy();
|
||||
|
||||
signalingService.handleDisconnection(clientId);
|
||||
expect(clientManager.getPublisher()).toBeNull();
|
||||
});
|
||||
|
||||
test("Complete publisher-subscriber flow", () => {
|
||||
const clientManager = new ClientManager();
|
||||
const signalingService = new SignalingService(clientManager);
|
||||
|
||||
// Add publisher
|
||||
const publisherWs = new MockWebSocket();
|
||||
const publisherId = signalingService.handleConnection(publisherWs, "publisher");
|
||||
|
||||
// Add subscriber
|
||||
const subscriberWs = new MockWebSocket();
|
||||
const subscriberId = signalingService.handleConnection(subscriberWs, "subscriber");
|
||||
|
||||
// Clear initial connection messages
|
||||
publisherWs.sentMessages = [];
|
||||
subscriberWs.sentMessages = [];
|
||||
|
||||
// Send offer from publisher
|
||||
const offerMessage = {
|
||||
type: "offer" as const,
|
||||
data: { sdp: "test-offer" }
|
||||
};
|
||||
|
||||
signalingService.handleMessage(publisherId, offerMessage);
|
||||
|
||||
// Check subscriber received the offer
|
||||
expect(subscriberWs.sentMessages.length).toBe(1);
|
||||
const receivedMessage = JSON.parse(subscriberWs.sentMessages[0]);
|
||||
expect(receivedMessage.type).toBe("offer");
|
||||
expect(receivedMessage.data.sdp).toBe("test-offer");
|
||||
expect(receivedMessage.senderId).toBe(publisherId);
|
||||
|
||||
// Send answer from subscriber
|
||||
const answerMessage = {
|
||||
type: "answer" as const,
|
||||
data: { sdp: "test-answer" }
|
||||
};
|
||||
|
||||
signalingService.handleMessage(subscriberId, answerMessage);
|
||||
|
||||
// Check publisher received the answer
|
||||
expect(publisherWs.sentMessages.length).toBe(1);
|
||||
const publisherMessage = JSON.parse(publisherWs.sentMessages[0]);
|
||||
expect(publisherMessage.type).toBe("answer");
|
||||
expect(publisherMessage.data.sdp).toBe("test-answer");
|
||||
expect(publisherMessage.senderId).toBe(subscriberId);
|
||||
});
|
||||
});
|
||||
288
tests/frontend/MediaHandler.test.ts
Normal file
288
tests/frontend/MediaHandler.test.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { test, expect, describe, beforeEach, afterEach } from "bun:test";
|
||||
|
||||
// Mock MediaStream and related APIs
|
||||
class MockMediaStreamTrack {
|
||||
kind: string;
|
||||
id: string;
|
||||
label: string = '';
|
||||
enabled: boolean = true;
|
||||
muted: boolean = false;
|
||||
readyState: 'live' | 'ended' = 'live';
|
||||
|
||||
constructor(kind: string) {
|
||||
this.kind = kind;
|
||||
this.id = Math.random().toString(36);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.readyState = 'ended';
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new MockMediaStreamTrack(this.kind);
|
||||
}
|
||||
}
|
||||
|
||||
class MockMediaStream {
|
||||
id: string;
|
||||
active: boolean = true;
|
||||
private tracks: MockMediaStreamTrack[] = [];
|
||||
|
||||
constructor(tracks?: MockMediaStreamTrack[]) {
|
||||
this.id = Math.random().toString(36);
|
||||
if (tracks) {
|
||||
this.tracks = [...tracks];
|
||||
}
|
||||
}
|
||||
|
||||
getTracks() {
|
||||
return [...this.tracks];
|
||||
}
|
||||
|
||||
getVideoTracks() {
|
||||
return this.tracks.filter(track => track.kind === 'video');
|
||||
}
|
||||
|
||||
getAudioTracks() {
|
||||
return this.tracks.filter(track => track.kind === 'audio');
|
||||
}
|
||||
|
||||
addTrack(track: MockMediaStreamTrack) {
|
||||
this.tracks.push(track);
|
||||
}
|
||||
|
||||
removeTrack(track: MockMediaStreamTrack) {
|
||||
const index = this.tracks.indexOf(track);
|
||||
if (index > -1) {
|
||||
this.tracks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MockHTMLVideoElement {
|
||||
srcObject: MockMediaStream | null = null;
|
||||
autoplay: boolean = false;
|
||||
muted: boolean = false;
|
||||
|
||||
play() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
pause() {}
|
||||
}
|
||||
|
||||
// Mock navigator.mediaDevices
|
||||
let mockGetUserMediaCalls: any[] = [];
|
||||
const mockGetUserMedia = (constraints: any) => {
|
||||
mockGetUserMediaCalls.push(constraints);
|
||||
const tracks = [];
|
||||
if (constraints.video) tracks.push(new MockMediaStreamTrack('video'));
|
||||
if (constraints.audio) tracks.push(new MockMediaStreamTrack('audio'));
|
||||
return Promise.resolve(new MockMediaStream(tracks));
|
||||
};
|
||||
|
||||
(global as any).navigator = {
|
||||
mediaDevices: {
|
||||
getUserMedia: mockGetUserMedia
|
||||
}
|
||||
};
|
||||
|
||||
// Mock document.getElementById
|
||||
let getElementByIdReturn: any = null;
|
||||
(global as any).document = {
|
||||
getElementById: () => getElementByIdReturn
|
||||
};
|
||||
|
||||
// Import after mocking
|
||||
import { MediaHandler } from "../../public/js/services/MediaHandler.ts";
|
||||
|
||||
describe("MediaHandler", () => {
|
||||
let mediaHandler: MediaHandler;
|
||||
let mockVideoElement: MockHTMLVideoElement;
|
||||
|
||||
beforeEach(() => {
|
||||
mockVideoElement = new MockHTMLVideoElement();
|
||||
getElementByIdReturn = mockVideoElement;
|
||||
mockGetUserMediaCalls.length = 0; // Clear calls array
|
||||
});
|
||||
|
||||
test("should initialize with video element", () => {
|
||||
mediaHandler = new MediaHandler('testVideo');
|
||||
|
||||
// The MediaHandler should have found and stored the video element
|
||||
expect(mediaHandler.getLocalVideo()).toBe(mockVideoElement);
|
||||
});
|
||||
|
||||
test("should initialize without video element", () => {
|
||||
mediaHandler = new MediaHandler();
|
||||
|
||||
expect(mediaHandler.getLocalVideo()).toBeNull();
|
||||
});
|
||||
|
||||
test("should get local stream successfully", async () => {
|
||||
mediaHandler = new MediaHandler('testVideo');
|
||||
|
||||
const mockTracks = [
|
||||
new MockMediaStreamTrack('video'),
|
||||
new MockMediaStreamTrack('audio')
|
||||
];
|
||||
const mockStream = new MockMediaStream(mockTracks);
|
||||
|
||||
// Override mock to return this specific stream and track calls
|
||||
const originalGetUserMedia = (global as any).navigator.mediaDevices.getUserMedia;
|
||||
const getUserMediaCalls: any[] = [];
|
||||
(global as any).navigator.mediaDevices.getUserMedia = (constraints: any) => {
|
||||
getUserMediaCalls.push(constraints);
|
||||
return Promise.resolve(mockStream);
|
||||
};
|
||||
|
||||
const stream = await mediaHandler.getLocalStream();
|
||||
|
||||
expect(getUserMediaCalls).toHaveLength(1);
|
||||
expect(getUserMediaCalls[0]).toEqual({
|
||||
video: {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
frameRate: { ideal: 30 }
|
||||
},
|
||||
audio: true
|
||||
});
|
||||
|
||||
expect(stream).toBe(mockStream);
|
||||
expect(mockVideoElement.srcObject).toBe(mockStream);
|
||||
|
||||
// Restore original
|
||||
(global as any).navigator.mediaDevices.getUserMedia = originalGetUserMedia;
|
||||
});
|
||||
|
||||
test("should handle getUserMedia error", async () => {
|
||||
mediaHandler = new MediaHandler('testVideo');
|
||||
|
||||
const error = new Error('Camera access denied');
|
||||
|
||||
// Override mock to reject with error
|
||||
const originalGetUserMedia = (global as any).navigator.mediaDevices.getUserMedia;
|
||||
const originalConsoleError = console.error;
|
||||
const errorLogs: any[] = [];
|
||||
console.error = (...args: any[]) => errorLogs.push(args);
|
||||
|
||||
(global as any).navigator.mediaDevices.getUserMedia = () => Promise.reject(error);
|
||||
|
||||
await expect(mediaHandler.getLocalStream()).rejects.toThrow('Camera access denied');
|
||||
|
||||
expect(errorLogs.length).toBeGreaterThan(0);
|
||||
expect(errorLogs[0][0]).toBe('Error accessing media devices:');
|
||||
expect(errorLogs[0][1]).toBe(error);
|
||||
|
||||
// Restore originals
|
||||
console.error = originalConsoleError;
|
||||
(global as any).navigator.mediaDevices.getUserMedia = originalGetUserMedia;
|
||||
});
|
||||
|
||||
test("should stop local stream", async () => {
|
||||
mediaHandler = new MediaHandler('testVideo');
|
||||
|
||||
const mockTracks = [
|
||||
new MockMediaStreamTrack('video'),
|
||||
new MockMediaStreamTrack('audio')
|
||||
];
|
||||
const mockStream = new MockMediaStream(mockTracks);
|
||||
|
||||
// Override mock to return this specific stream
|
||||
const originalGetUserMedia = (global as any).navigator.mediaDevices.getUserMedia;
|
||||
(global as any).navigator.mediaDevices.getUserMedia = () => Promise.resolve(mockStream);
|
||||
|
||||
await mediaHandler.getLocalStream();
|
||||
|
||||
// Track the initial state of the tracks
|
||||
const initialStates = mockTracks.map(track => track.readyState);
|
||||
|
||||
mediaHandler.stopLocalStream();
|
||||
|
||||
// Check that all tracks were stopped (readyState should be 'ended')
|
||||
mockTracks.forEach(track => {
|
||||
expect(track.readyState).toBe('ended');
|
||||
});
|
||||
|
||||
expect(mockVideoElement.srcObject).toBeNull();
|
||||
expect(mediaHandler.getCurrentStream()).toBeNull();
|
||||
|
||||
// Restore original
|
||||
(global as any).navigator.mediaDevices.getUserMedia = originalGetUserMedia;
|
||||
});
|
||||
|
||||
test("should handle stopping stream when no stream exists", () => {
|
||||
mediaHandler = new MediaHandler('testVideo');
|
||||
|
||||
expect(() => {
|
||||
mediaHandler.stopLocalStream();
|
||||
}).not.toThrow();
|
||||
|
||||
expect(mockVideoElement.srcObject).toBeNull();
|
||||
});
|
||||
|
||||
test("should get current stream", async () => {
|
||||
mediaHandler = new MediaHandler('testVideo');
|
||||
|
||||
expect(mediaHandler.getCurrentStream()).toBeNull();
|
||||
|
||||
const mockStream = new MockMediaStream([new MockMediaStreamTrack('video')]);
|
||||
|
||||
// Override mock to return this specific stream
|
||||
const originalGetUserMedia = (global as any).navigator.mediaDevices.getUserMedia;
|
||||
(global as any).navigator.mediaDevices.getUserMedia = () => Promise.resolve(mockStream);
|
||||
|
||||
await mediaHandler.getLocalStream();
|
||||
|
||||
// Restore original
|
||||
(global as any).navigator.mediaDevices.getUserMedia = originalGetUserMedia;
|
||||
|
||||
expect(mediaHandler.getCurrentStream()).toBe(mockStream);
|
||||
});
|
||||
|
||||
test("should work without video element", async () => {
|
||||
getElementByIdReturn = null;
|
||||
mediaHandler = new MediaHandler('nonExistentVideo');
|
||||
|
||||
const mockStream = new MockMediaStream([new MockMediaStreamTrack('video')]);
|
||||
|
||||
// Override mock to return this specific stream
|
||||
const originalGetUserMedia = (global as any).navigator.mediaDevices.getUserMedia;
|
||||
(global as any).navigator.mediaDevices.getUserMedia = () => Promise.resolve(mockStream);
|
||||
|
||||
const stream = await mediaHandler.getLocalStream();
|
||||
|
||||
expect(stream).toBe(mockStream);
|
||||
expect(mediaHandler.getLocalVideo()).toBeNull();
|
||||
|
||||
// Restore original
|
||||
(global as any).navigator.mediaDevices.getUserMedia = originalGetUserMedia;
|
||||
});
|
||||
|
||||
test("should handle multiple calls to getLocalStream", async () => {
|
||||
mediaHandler = new MediaHandler('testVideo');
|
||||
|
||||
const mockStream1 = new MockMediaStream([new MockMediaStreamTrack('video')]);
|
||||
const mockStream2 = new MockMediaStream([new MockMediaStreamTrack('video')]);
|
||||
|
||||
let callCount = 0;
|
||||
const originalGetUserMedia = (global as any).navigator.mediaDevices.getUserMedia;
|
||||
(global as any).navigator.mediaDevices.getUserMedia = () => {
|
||||
callCount++;
|
||||
return Promise.resolve(callCount === 1 ? mockStream1 : mockStream2);
|
||||
};
|
||||
|
||||
const stream1 = await mediaHandler.getLocalStream();
|
||||
const stream2 = await mediaHandler.getLocalStream();
|
||||
|
||||
expect(stream1).toBe(mockStream1);
|
||||
expect(stream2).toBe(mockStream2);
|
||||
expect(callCount).toBe(2);
|
||||
|
||||
// The video element should have the latest stream
|
||||
expect(mockVideoElement.srcObject).toBe(mockStream2);
|
||||
|
||||
// Restore original
|
||||
(global as any).navigator.mediaDevices.getUserMedia = originalGetUserMedia;
|
||||
});
|
||||
});
|
||||
198
tests/frontend/UIController.test.ts
Normal file
198
tests/frontend/UIController.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { test, expect, describe, beforeEach } from "bun:test";
|
||||
|
||||
// Simple mock function for tests
|
||||
function mockFn() {
|
||||
let callCount = 0;
|
||||
const fn = () => { callCount++; };
|
||||
|
||||
Object.defineProperty(fn, 'toHaveBeenCalledTimes', {
|
||||
value: (expected: number) => {
|
||||
if (callCount !== expected) {
|
||||
throw new Error(`Expected ${expected} calls, got ${callCount}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
return fn;
|
||||
}
|
||||
|
||||
// Mock HTML elements
|
||||
class MockHTMLElement {
|
||||
public textContent: string = '';
|
||||
public className: string = '';
|
||||
public disabled: boolean = false;
|
||||
private eventListeners: { [key: string]: (() => void)[] } = {};
|
||||
|
||||
addEventListener(event: string, handler: () => void) {
|
||||
if (!this.eventListeners[event]) {
|
||||
this.eventListeners[event] = [];
|
||||
}
|
||||
this.eventListeners[event].push(handler);
|
||||
}
|
||||
|
||||
click() {
|
||||
const handlers = this.eventListeners['click'] || [];
|
||||
handlers.forEach(handler => handler());
|
||||
}
|
||||
}
|
||||
|
||||
// Mock document.getElementById
|
||||
const mockElements: { [id: string]: MockHTMLElement } = {};
|
||||
const getElementByIdCalls: string[] = [];
|
||||
(global as any).document = {
|
||||
getElementById: (id: string) => {
|
||||
getElementByIdCalls.push(id);
|
||||
return mockElements[id] || null;
|
||||
}
|
||||
};
|
||||
|
||||
// Import after mocking
|
||||
import { UIController } from "../../public/js/services/UIController.ts";
|
||||
|
||||
describe("UIController", () => {
|
||||
let uiController: UIController;
|
||||
let statusElement: MockHTMLElement;
|
||||
let subscribersElement: MockHTMLElement;
|
||||
let startButton: MockHTMLElement;
|
||||
let stopButton: MockHTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mock elements
|
||||
statusElement = new MockHTMLElement();
|
||||
subscribersElement = new MockHTMLElement();
|
||||
startButton = new MockHTMLElement();
|
||||
stopButton = new MockHTMLElement();
|
||||
|
||||
mockElements['status'] = statusElement;
|
||||
mockElements['subscribers'] = subscribersElement;
|
||||
mockElements['startBtn'] = startButton;
|
||||
mockElements['stopBtn'] = stopButton;
|
||||
|
||||
// Clear call history
|
||||
getElementByIdCalls.length = 0;
|
||||
|
||||
uiController = new UIController('status', 'subscribers', 'startBtn', 'stopBtn');
|
||||
});
|
||||
|
||||
test("should initialize with all elements", () => {
|
||||
expect(getElementByIdCalls).toContain('status');
|
||||
expect(getElementByIdCalls).toContain('subscribers');
|
||||
expect(getElementByIdCalls).toContain('startBtn');
|
||||
expect(getElementByIdCalls).toContain('stopBtn');
|
||||
});
|
||||
|
||||
test("should initialize with minimal elements", () => {
|
||||
getElementByIdCalls.length = 0; // Clear previous calls
|
||||
const minimalController = new UIController('status');
|
||||
expect(getElementByIdCalls).toContain('status');
|
||||
});
|
||||
|
||||
test("should update status correctly", () => {
|
||||
uiController.updateStatus('Connected', 'connected');
|
||||
|
||||
expect(statusElement.textContent).toBe('Connected');
|
||||
expect(statusElement.className).toBe('status connected');
|
||||
});
|
||||
|
||||
test("should update subscribers count", () => {
|
||||
uiController.updateSubscribersCount(5);
|
||||
|
||||
expect(subscribersElement.textContent).toBe('Subscribers: 5');
|
||||
});
|
||||
|
||||
test("should handle missing subscribers element", () => {
|
||||
const controllerWithoutSubs = new UIController('status');
|
||||
|
||||
expect(() => {
|
||||
controllerWithoutSubs.updateSubscribersCount(5);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test("should set button states", () => {
|
||||
uiController.setButtonStates(false, true);
|
||||
|
||||
expect(startButton.disabled).toBe(true);
|
||||
expect(stopButton.disabled).toBe(false);
|
||||
|
||||
uiController.setButtonStates(true, false);
|
||||
|
||||
expect(startButton.disabled).toBe(false);
|
||||
expect(stopButton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle missing buttons", () => {
|
||||
const controllerWithoutButtons = new UIController('status');
|
||||
|
||||
expect(() => {
|
||||
controllerWithoutButtons.setButtonStates(true, false);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test("should add button click handlers", () => {
|
||||
const startHandler = mockFn();
|
||||
const stopHandler = mockFn();
|
||||
|
||||
uiController.onButtonClick('startBtn', startHandler);
|
||||
uiController.onButtonClick('stopBtn', stopHandler);
|
||||
|
||||
startButton.click();
|
||||
expect(startHandler.toHaveBeenCalledTimes(1)).toBe(true);
|
||||
|
||||
stopButton.click();
|
||||
expect(stopHandler.toHaveBeenCalledTimes(1)).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle non-existent button click handlers", () => {
|
||||
const handler = mockFn();
|
||||
|
||||
expect(() => {
|
||||
uiController.onButtonClick('nonExistentBtn', handler);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test("should handle multiple clicks", () => {
|
||||
const handler = mockFn();
|
||||
uiController.onButtonClick('startBtn', handler);
|
||||
|
||||
startButton.click();
|
||||
startButton.click();
|
||||
startButton.click();
|
||||
|
||||
expect(handler.toHaveBeenCalledTimes(3)).toBe(true);
|
||||
});
|
||||
|
||||
test("should support multiple handlers on same button", () => {
|
||||
const handler1 = mockFn();
|
||||
const handler2 = mockFn();
|
||||
|
||||
uiController.onButtonClick('startBtn', handler1);
|
||||
uiController.onButtonClick('startBtn', handler2);
|
||||
|
||||
startButton.click();
|
||||
|
||||
expect(handler1.toHaveBeenCalledTimes(1)).toBe(true);
|
||||
expect(handler2.toHaveBeenCalledTimes(1)).toBe(true);
|
||||
});
|
||||
|
||||
test("should update status with different classes", () => {
|
||||
uiController.updateStatus('Connecting...', 'waiting');
|
||||
expect(statusElement.className).toBe('status waiting');
|
||||
|
||||
uiController.updateStatus('Connected', 'connected');
|
||||
expect(statusElement.className).toBe('status connected');
|
||||
|
||||
uiController.updateStatus('Error occurred', 'error');
|
||||
expect(statusElement.className).toBe('status error');
|
||||
});
|
||||
|
||||
test("should update subscribers count with zero", () => {
|
||||
uiController.updateSubscribersCount(0);
|
||||
expect(subscribersElement.textContent).toBe('Subscribers: 0');
|
||||
});
|
||||
|
||||
test("should update subscribers count with large numbers", () => {
|
||||
uiController.updateSubscribersCount(1000);
|
||||
expect(subscribersElement.textContent).toBe('Subscribers: 1000');
|
||||
});
|
||||
});
|
||||
205
tests/frontend/WebSocketClient.test.ts
Normal file
205
tests/frontend/WebSocketClient.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { test, expect, describe, beforeEach, afterEach } from "bun:test";
|
||||
|
||||
// Mock WebSocket for testing
|
||||
class MockWebSocket {
|
||||
public readyState = WebSocket.CONNECTING;
|
||||
public url: string;
|
||||
public sentMessages: string[] = [];
|
||||
|
||||
public onopen: (() => void) | null = null;
|
||||
public onclose: (() => void) | null = null;
|
||||
public onmessage: ((event: { data: string }) => void) | null = null;
|
||||
public onerror: ((error: any) => void) | null = null;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
// Simulate successful connection
|
||||
setTimeout(() => {
|
||||
this.readyState = WebSocket.OPEN;
|
||||
if (this.onopen) {
|
||||
this.onopen();
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
if (this.readyState === WebSocket.OPEN) {
|
||||
this.sentMessages.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
if (this.onclose) {
|
||||
this.onclose();
|
||||
}
|
||||
}
|
||||
|
||||
simulateMessage(data: any) {
|
||||
if (this.onmessage) {
|
||||
this.onmessage({ data: JSON.stringify(data) });
|
||||
}
|
||||
}
|
||||
|
||||
simulateError(error: any) {
|
||||
if (this.onerror) {
|
||||
this.onerror(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mock the global WebSocket
|
||||
(global as any).WebSocket = MockWebSocket;
|
||||
|
||||
// Import after mocking
|
||||
import { WebSocketClient } from "../../public/js/services/WebSocketClient.ts";
|
||||
import type { ISignalingMessage } from "../../public/js/interfaces/IWebRTCClient.ts";
|
||||
|
||||
describe("WebSocketClient", () => {
|
||||
let wsClient: WebSocketClient;
|
||||
let mockWs: MockWebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
wsClient = new WebSocketClient('publisher');
|
||||
// Access the internal WebSocket instance for testing
|
||||
});
|
||||
|
||||
test("should connect as publisher", async () => {
|
||||
const connectPromise = wsClient.connect();
|
||||
|
||||
// Wait for connection
|
||||
await connectPromise;
|
||||
|
||||
expect(wsClient.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
test("should connect as subscriber", async () => {
|
||||
const subscriberClient = new WebSocketClient('subscriber');
|
||||
await subscriberClient.connect();
|
||||
|
||||
expect(subscriberClient.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle connection error", async () => {
|
||||
const errorClient = new WebSocketClient('publisher');
|
||||
|
||||
// Start connection but simulate error before it completes
|
||||
const connectPromise = errorClient.connect();
|
||||
|
||||
// Simulate error during connection
|
||||
setTimeout(() => {
|
||||
// Access the internal WebSocket and trigger error
|
||||
const ws = (errorClient as any).ws;
|
||||
if (ws && ws.onerror) {
|
||||
ws.onerror(new Error('Connection failed'));
|
||||
}
|
||||
}, 5);
|
||||
|
||||
await expect(connectPromise).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should send messages when connected", async () => {
|
||||
await wsClient.connect();
|
||||
|
||||
const message: ISignalingMessage = {
|
||||
type: 'offer',
|
||||
data: { sdp: 'test-sdp' }
|
||||
};
|
||||
|
||||
wsClient.sendMessage(message);
|
||||
|
||||
const ws = (wsClient as any).ws as MockWebSocket;
|
||||
expect(ws.sentMessages).toHaveLength(1);
|
||||
expect(JSON.parse(ws.sentMessages[0])).toEqual(message);
|
||||
});
|
||||
|
||||
test("should not send messages when disconnected", () => {
|
||||
const message: ISignalingMessage = {
|
||||
type: 'offer',
|
||||
data: { sdp: 'test-sdp' }
|
||||
};
|
||||
|
||||
wsClient.sendMessage(message);
|
||||
|
||||
// Should not have sent anything since not connected
|
||||
const ws = (wsClient as any).ws;
|
||||
expect(ws).toBeNull();
|
||||
});
|
||||
|
||||
test("should handle incoming messages", async () => {
|
||||
await wsClient.connect();
|
||||
|
||||
const receivedMessages: ISignalingMessage[] = [];
|
||||
wsClient.onMessage('join', (message) => {
|
||||
receivedMessages.push(message);
|
||||
});
|
||||
|
||||
const testMessage: ISignalingMessage = {
|
||||
type: 'join',
|
||||
data: { clientId: 'test-id', role: 'publisher' }
|
||||
};
|
||||
|
||||
const ws = (wsClient as any).ws as MockWebSocket;
|
||||
ws.simulateMessage(testMessage);
|
||||
|
||||
expect(receivedMessages).toHaveLength(1);
|
||||
expect(receivedMessages[0]).toEqual(testMessage);
|
||||
});
|
||||
|
||||
test("should handle multiple message types", async () => {
|
||||
await wsClient.connect();
|
||||
|
||||
const joinMessages: ISignalingMessage[] = [];
|
||||
const offerMessages: ISignalingMessage[] = [];
|
||||
|
||||
wsClient.onMessage('join', (message) => joinMessages.push(message));
|
||||
wsClient.onMessage('offer', (message) => offerMessages.push(message));
|
||||
|
||||
const ws = (wsClient as any).ws as MockWebSocket;
|
||||
|
||||
ws.simulateMessage({ type: 'join', data: { clientId: 'test' } });
|
||||
ws.simulateMessage({ type: 'offer', data: { sdp: 'test-sdp' } });
|
||||
ws.simulateMessage({ type: 'join', data: { clientId: 'test2' } });
|
||||
|
||||
expect(joinMessages).toHaveLength(2);
|
||||
expect(offerMessages).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("should disconnect properly", async () => {
|
||||
await wsClient.connect();
|
||||
expect(wsClient.isConnected()).toBe(true);
|
||||
|
||||
wsClient.disconnect();
|
||||
expect(wsClient.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle malformed JSON messages gracefully", async () => {
|
||||
await wsClient.connect();
|
||||
|
||||
// Mock console.error to verify error logging
|
||||
const originalConsoleError = console.error;
|
||||
const errorLogs: any[] = [];
|
||||
console.error = (...args: any[]) => errorLogs.push(args);
|
||||
|
||||
const ws = (wsClient as any).ws as MockWebSocket;
|
||||
if (ws.onmessage) {
|
||||
ws.onmessage({ data: 'invalid-json' });
|
||||
}
|
||||
|
||||
expect(errorLogs.length).toBeGreaterThan(0);
|
||||
expect(errorLogs[0][0]).toBe('Failed to parse WebSocket message:');
|
||||
|
||||
// Restore console.error
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
test("should handle close event", async () => {
|
||||
await wsClient.connect();
|
||||
expect(wsClient.isConnected()).toBe(true);
|
||||
|
||||
const ws = (wsClient as any).ws as MockWebSocket;
|
||||
ws.close();
|
||||
|
||||
expect(wsClient.isConnected()).toBe(false);
|
||||
});
|
||||
});
|
||||
233
tests/integration.test.ts
Normal file
233
tests/integration.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { test, expect, describe, beforeEach, afterEach } from "bun:test";
|
||||
import { ClientManager } from "../src/services/ClientManager.ts";
|
||||
import { SignalingService } from "../src/services/SignalingService.ts";
|
||||
|
||||
interface MockWebSocketMessage {
|
||||
type: string;
|
||||
data?: any;
|
||||
senderId?: string;
|
||||
}
|
||||
|
||||
class MockWebSocket {
|
||||
private messageHandler: ((event: { data: string }) => void) | null = null;
|
||||
private openHandler: (() => void) | null = null;
|
||||
private closeHandler: (() => void) | null = null;
|
||||
|
||||
public sentMessages: string[] = [];
|
||||
public readyState = WebSocket.OPEN;
|
||||
public url: string;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
// Simulate connection opening
|
||||
setTimeout(() => {
|
||||
if (this.openHandler) {
|
||||
this.openHandler();
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sentMessages.push(data);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
if (this.closeHandler) {
|
||||
this.closeHandler();
|
||||
}
|
||||
}
|
||||
|
||||
set onmessage(handler: (event: { data: string }) => void) {
|
||||
this.messageHandler = handler;
|
||||
}
|
||||
|
||||
set onopen(handler: () => void) {
|
||||
this.openHandler = handler;
|
||||
}
|
||||
|
||||
set onclose(handler: () => void) {
|
||||
this.closeHandler = handler;
|
||||
}
|
||||
|
||||
set onerror(handler: (error: any) => void) {
|
||||
// Mock implementation
|
||||
}
|
||||
|
||||
simulateMessage(message: MockWebSocketMessage) {
|
||||
if (this.messageHandler) {
|
||||
this.messageHandler({ data: JSON.stringify(message) });
|
||||
}
|
||||
}
|
||||
|
||||
getLastMessage(): MockWebSocketMessage | null {
|
||||
if (this.sentMessages.length === 0) return null;
|
||||
return JSON.parse(this.sentMessages[this.sentMessages.length - 1]);
|
||||
}
|
||||
|
||||
getAllMessages(): MockWebSocketMessage[] {
|
||||
return this.sentMessages.map(msg => JSON.parse(msg));
|
||||
}
|
||||
}
|
||||
|
||||
describe("WebSocket Signaling Integration", () => {
|
||||
let clientManager: ClientManager;
|
||||
let signalingService: SignalingService;
|
||||
|
||||
beforeEach(() => {
|
||||
clientManager = new ClientManager();
|
||||
signalingService = new SignalingService(clientManager);
|
||||
});
|
||||
|
||||
test("should handle complete publisher-subscriber flow", () => {
|
||||
// Setup subscribers first
|
||||
const subscriber1Ws = new MockWebSocket("ws://test?role=subscriber");
|
||||
const subscriber1Id = signalingService.handleConnection(subscriber1Ws, "subscriber");
|
||||
|
||||
const subscriber2Ws = new MockWebSocket("ws://test?role=subscriber");
|
||||
const subscriber2Id = signalingService.handleConnection(subscriber2Ws, "subscriber");
|
||||
|
||||
// Setup publisher (this should notify existing subscribers)
|
||||
const publisherWs = new MockWebSocket("ws://test?role=publisher");
|
||||
const publisherId = signalingService.handleConnection(publisherWs, "publisher");
|
||||
|
||||
// Verify initial connections
|
||||
expect(publisherWs.getLastMessage()).toEqual({
|
||||
type: 'join',
|
||||
data: { clientId: publisherId, role: 'publisher' }
|
||||
});
|
||||
|
||||
// Verify subscribers get notified about publisher joining
|
||||
const sub1Messages = subscriber1Ws.getAllMessages();
|
||||
const sub2Messages = subscriber2Ws.getAllMessages();
|
||||
|
||||
expect(sub1Messages).toHaveLength(2); // join + publisher-joined
|
||||
expect(sub1Messages.some(msg => msg.type === 'publisher-joined')).toBe(true);
|
||||
expect(sub2Messages).toHaveLength(2); // join + publisher-joined
|
||||
expect(sub2Messages.some(msg => msg.type === 'publisher-joined')).toBe(true);
|
||||
|
||||
// Test offer flow
|
||||
const offerMessage = {
|
||||
type: "offer" as const,
|
||||
data: { sdp: "fake-offer-sdp", type: "offer" }
|
||||
};
|
||||
|
||||
signalingService.handleMessage(publisherId, offerMessage);
|
||||
|
||||
// Verify subscribers received the offer
|
||||
const sub1LastMessage = subscriber1Ws.getLastMessage();
|
||||
const sub2LastMessage = subscriber2Ws.getLastMessage();
|
||||
|
||||
expect(sub1LastMessage).toEqual({
|
||||
...offerMessage,
|
||||
senderId: publisherId
|
||||
});
|
||||
expect(sub2LastMessage).toEqual({
|
||||
...offerMessage,
|
||||
senderId: publisherId
|
||||
});
|
||||
|
||||
// Test answer flow
|
||||
const answerMessage = {
|
||||
type: "answer" as const,
|
||||
data: { sdp: "fake-answer-sdp", type: "answer" }
|
||||
};
|
||||
|
||||
signalingService.handleMessage(subscriber1Id, answerMessage);
|
||||
|
||||
// Verify publisher received the answer
|
||||
const publisherLastMessage = publisherWs.getLastMessage();
|
||||
expect(publisherLastMessage).toEqual({
|
||||
...answerMessage,
|
||||
senderId: subscriber1Id
|
||||
});
|
||||
|
||||
// Test ICE candidate exchange
|
||||
const iceCandidateMessage = {
|
||||
type: "ice-candidate" as const,
|
||||
data: { candidate: "fake-ice-candidate" }
|
||||
};
|
||||
|
||||
// Publisher to subscribers
|
||||
signalingService.handleMessage(publisherId, iceCandidateMessage);
|
||||
|
||||
expect(subscriber1Ws.getLastMessage()).toEqual({
|
||||
...iceCandidateMessage,
|
||||
senderId: publisherId
|
||||
});
|
||||
expect(subscriber2Ws.getLastMessage()).toEqual({
|
||||
...iceCandidateMessage,
|
||||
senderId: publisherId
|
||||
});
|
||||
|
||||
// Subscriber to publisher
|
||||
signalingService.handleMessage(subscriber1Id, iceCandidateMessage);
|
||||
|
||||
expect(publisherWs.getLastMessage()).toEqual({
|
||||
...iceCandidateMessage,
|
||||
senderId: subscriber1Id
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle publisher disconnect gracefully", () => {
|
||||
// Setup
|
||||
const publisherWs = new MockWebSocket("ws://test?role=publisher");
|
||||
const publisherId = signalingService.handleConnection(publisherWs, "publisher");
|
||||
|
||||
const subscriberWs = new MockWebSocket("ws://test?role=subscriber");
|
||||
signalingService.handleConnection(subscriberWs, "subscriber");
|
||||
|
||||
// Disconnect publisher
|
||||
signalingService.handleDisconnection(publisherId);
|
||||
|
||||
// Verify subscriber gets publisher-left notification
|
||||
const subscriberMessages = subscriberWs.getAllMessages();
|
||||
expect(subscriberMessages).toHaveLength(2); // join + publisher-left
|
||||
expect(subscriberMessages.some(msg => msg.type === 'publisher-left')).toBe(true);
|
||||
|
||||
// Verify publisher is removed
|
||||
expect(clientManager.getPublisher()).toBeNull();
|
||||
});
|
||||
|
||||
test("should handle subscriber disconnect gracefully", () => {
|
||||
const publisherWs = new MockWebSocket("ws://test?role=publisher");
|
||||
signalingService.handleConnection(publisherWs, "publisher");
|
||||
|
||||
const subscriberWs = new MockWebSocket("ws://test?role=subscriber");
|
||||
const subscriberId = signalingService.handleConnection(subscriberWs, "subscriber");
|
||||
|
||||
// Verify subscriber was added
|
||||
expect(clientManager.getSubscribers()).toHaveLength(1);
|
||||
|
||||
// Disconnect subscriber
|
||||
signalingService.handleDisconnection(subscriberId);
|
||||
|
||||
// Verify subscriber was removed
|
||||
expect(clientManager.getSubscribers()).toHaveLength(0);
|
||||
expect(clientManager.getClient(subscriberId)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should handle multiple subscribers connecting and disconnecting", () => {
|
||||
const publisherWs = new MockWebSocket("ws://test?role=publisher");
|
||||
signalingService.handleConnection(publisherWs, "publisher");
|
||||
|
||||
// Add 3 subscribers
|
||||
const subscribers = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const ws = new MockWebSocket(`ws://test?role=subscriber&id=${i}`);
|
||||
const id = signalingService.handleConnection(ws, "subscriber");
|
||||
subscribers.push({ ws, id });
|
||||
}
|
||||
|
||||
expect(clientManager.getSubscribers()).toHaveLength(3);
|
||||
|
||||
// Disconnect middle subscriber
|
||||
signalingService.handleDisconnection(subscribers[1].id);
|
||||
expect(clientManager.getSubscribers()).toHaveLength(2);
|
||||
|
||||
// Verify remaining subscribers are still connected
|
||||
expect(clientManager.getClient(subscribers[0].id)).toBeDefined();
|
||||
expect(clientManager.getClient(subscribers[2].id)).toBeDefined();
|
||||
expect(clientManager.getClient(subscribers[1].id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
344
tests/sfu/SFUClientManager.test.ts
Normal file
344
tests/sfu/SFUClientManager.test.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import { test, expect, describe, beforeEach } from "bun:test";
|
||||
import { SFUClientManager } from "../../src/services/SFUClientManager.ts";
|
||||
import type { ISFUClient } from "../../src/interfaces/ISFUTypes.ts";
|
||||
|
||||
// Mock WebRTC Transport
|
||||
class MockWebRtcTransport {
|
||||
id: string;
|
||||
closed = false;
|
||||
|
||||
constructor(id: string) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Mock Producer
|
||||
class MockProducer {
|
||||
id: string;
|
||||
closed = false;
|
||||
|
||||
constructor(id: string) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Mock Consumer
|
||||
class MockConsumer {
|
||||
id: string;
|
||||
closed = false;
|
||||
|
||||
constructor(id: string) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Mock WebSocket
|
||||
class MockWebSocket {
|
||||
sentMessages: string[] = [];
|
||||
|
||||
send(data: string) {
|
||||
this.sentMessages.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
describe("SFUClientManager", () => {
|
||||
let clientManager: SFUClientManager;
|
||||
|
||||
beforeEach(() => {
|
||||
clientManager = new SFUClientManager();
|
||||
});
|
||||
|
||||
test("should add and retrieve clients", () => {
|
||||
const mockWs = new MockWebSocket();
|
||||
const client: ISFUClient = {
|
||||
id: "client-1",
|
||||
role: "publisher",
|
||||
ws: mockWs,
|
||||
consumers: new Map()
|
||||
};
|
||||
|
||||
clientManager.addClient(client);
|
||||
|
||||
const retrievedClient = clientManager.getClient("client-1");
|
||||
expect(retrievedClient).toBe(client);
|
||||
});
|
||||
|
||||
test("should set and manage client transport", () => {
|
||||
const mockWs = new MockWebSocket();
|
||||
const client: ISFUClient = {
|
||||
id: "client-1",
|
||||
role: "publisher",
|
||||
ws: mockWs,
|
||||
consumers: new Map()
|
||||
};
|
||||
|
||||
clientManager.addClient(client);
|
||||
|
||||
const transport = new MockWebRtcTransport("transport-1") as any;
|
||||
clientManager.setClientTransport("client-1", transport);
|
||||
|
||||
const retrievedClient = clientManager.getClient("client-1");
|
||||
expect(retrievedClient?.transport).toBe(transport);
|
||||
});
|
||||
|
||||
test("should manage client producer", () => {
|
||||
const mockWs = new MockWebSocket();
|
||||
const client: ISFUClient = {
|
||||
id: "publisher-1",
|
||||
role: "publisher",
|
||||
ws: mockWs,
|
||||
consumers: new Map()
|
||||
};
|
||||
|
||||
clientManager.addClient(client);
|
||||
|
||||
const producer = new MockProducer("producer-1") as any;
|
||||
clientManager.setClientProducer("publisher-1", producer);
|
||||
|
||||
const retrievedClient = clientManager.getClient("publisher-1");
|
||||
expect(retrievedClient?.producer).toBe(producer);
|
||||
|
||||
const retrievedProducer = clientManager.getProducer("producer-1");
|
||||
expect(retrievedProducer).toBe(producer);
|
||||
});
|
||||
|
||||
test("should manage client consumers", () => {
|
||||
const mockWs = new MockWebSocket();
|
||||
const client: ISFUClient = {
|
||||
id: "subscriber-1",
|
||||
role: "subscriber",
|
||||
ws: mockWs,
|
||||
consumers: new Map()
|
||||
};
|
||||
|
||||
clientManager.addClient(client);
|
||||
|
||||
const consumer = new MockConsumer("consumer-1") as any;
|
||||
clientManager.addClientConsumer("subscriber-1", consumer);
|
||||
|
||||
const retrievedClient = clientManager.getClient("subscriber-1");
|
||||
expect(retrievedClient?.consumers.has("consumer-1")).toBe(true);
|
||||
expect(retrievedClient?.consumers.get("consumer-1")).toBe(consumer);
|
||||
|
||||
const retrievedConsumer = clientManager.getConsumer("consumer-1");
|
||||
expect(retrievedConsumer).toBe(consumer);
|
||||
});
|
||||
|
||||
test("should remove client consumer", () => {
|
||||
const mockWs = new MockWebSocket();
|
||||
const client: ISFUClient = {
|
||||
id: "subscriber-1",
|
||||
role: "subscriber",
|
||||
ws: mockWs,
|
||||
consumers: new Map()
|
||||
};
|
||||
|
||||
clientManager.addClient(client);
|
||||
|
||||
const consumer = new MockConsumer("consumer-1") as any;
|
||||
clientManager.addClientConsumer("subscriber-1", consumer);
|
||||
|
||||
// Verify consumer is added
|
||||
expect(clientManager.getConsumer("consumer-1")).toBe(consumer);
|
||||
|
||||
// Remove consumer
|
||||
clientManager.removeClientConsumer("subscriber-1", "consumer-1");
|
||||
|
||||
// Verify consumer is removed and closed
|
||||
expect(clientManager.getConsumer("consumer-1")).toBeUndefined();
|
||||
expect(consumer.closed).toBe(true);
|
||||
|
||||
const retrievedClient = clientManager.getClient("subscriber-1");
|
||||
expect(retrievedClient?.consumers.has("consumer-1")).toBe(false);
|
||||
});
|
||||
|
||||
test("should get publishers and subscribers separately", () => {
|
||||
const publisherWs = new MockWebSocket();
|
||||
const subscriberWs1 = new MockWebSocket();
|
||||
const subscriberWs2 = new MockWebSocket();
|
||||
|
||||
const publisher: ISFUClient = {
|
||||
id: "publisher-1",
|
||||
role: "publisher",
|
||||
ws: publisherWs,
|
||||
consumers: new Map(),
|
||||
producer: new MockProducer("producer-1") as any
|
||||
};
|
||||
|
||||
const subscriber1: ISFUClient = {
|
||||
id: "subscriber-1",
|
||||
role: "subscriber",
|
||||
ws: subscriberWs1,
|
||||
consumers: new Map()
|
||||
};
|
||||
|
||||
const subscriber2: ISFUClient = {
|
||||
id: "subscriber-2",
|
||||
role: "subscriber",
|
||||
ws: subscriberWs2,
|
||||
consumers: new Map()
|
||||
};
|
||||
|
||||
clientManager.addClient(publisher);
|
||||
clientManager.addClient(subscriber1);
|
||||
clientManager.addClient(subscriber2);
|
||||
|
||||
const publishers = clientManager.getPublishers();
|
||||
const subscribers = clientManager.getSubscribers();
|
||||
|
||||
expect(publishers).toHaveLength(1);
|
||||
expect(publishers[0]).toBe(publisher);
|
||||
|
||||
expect(subscribers).toHaveLength(2);
|
||||
expect(subscribers).toContain(subscriber1);
|
||||
expect(subscribers).toContain(subscriber2);
|
||||
});
|
||||
|
||||
test("should get all producers", () => {
|
||||
const publisherWs1 = new MockWebSocket();
|
||||
const publisherWs2 = new MockWebSocket();
|
||||
|
||||
const publisher1: ISFUClient = {
|
||||
id: "publisher-1",
|
||||
role: "publisher",
|
||||
ws: publisherWs1,
|
||||
consumers: new Map()
|
||||
};
|
||||
|
||||
const publisher2: ISFUClient = {
|
||||
id: "publisher-2",
|
||||
role: "publisher",
|
||||
ws: publisherWs2,
|
||||
consumers: new Map()
|
||||
};
|
||||
|
||||
clientManager.addClient(publisher1);
|
||||
clientManager.addClient(publisher2);
|
||||
|
||||
const producer1 = new MockProducer("producer-1") as any;
|
||||
const producer2 = new MockProducer("producer-2") as any;
|
||||
|
||||
clientManager.setClientProducer("publisher-1", producer1);
|
||||
clientManager.setClientProducer("publisher-2", producer2);
|
||||
|
||||
const allProducers = clientManager.getAllProducers();
|
||||
expect(allProducers).toHaveLength(2);
|
||||
expect(allProducers).toContain(producer1);
|
||||
expect(allProducers).toContain(producer2);
|
||||
});
|
||||
|
||||
test("should remove client and cleanup resources", () => {
|
||||
const mockWs = new MockWebSocket();
|
||||
const client: ISFUClient = {
|
||||
id: "client-1",
|
||||
role: "publisher",
|
||||
ws: mockWs,
|
||||
consumers: new Map()
|
||||
};
|
||||
|
||||
clientManager.addClient(client);
|
||||
|
||||
// Add transport, producer, and consumer
|
||||
const transport = new MockWebRtcTransport("transport-1") as any;
|
||||
const producer = new MockProducer("producer-1") as any;
|
||||
const consumer = new MockConsumer("consumer-1") as any;
|
||||
|
||||
clientManager.setClientTransport("client-1", transport);
|
||||
clientManager.setClientProducer("client-1", producer);
|
||||
clientManager.addClientConsumer("client-1", consumer);
|
||||
|
||||
// Remove client
|
||||
clientManager.removeClient("client-1");
|
||||
|
||||
// Verify client is removed
|
||||
expect(clientManager.getClient("client-1")).toBeUndefined();
|
||||
|
||||
// Verify resources are cleaned up
|
||||
expect(transport.closed).toBe(true);
|
||||
expect(producer.closed).toBe(true);
|
||||
expect(consumer.closed).toBe(true);
|
||||
|
||||
// Verify global maps are cleaned up
|
||||
expect(clientManager.getProducer("producer-1")).toBeUndefined();
|
||||
expect(clientManager.getConsumer("consumer-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should provide correct statistics", () => {
|
||||
const publisherWs = new MockWebSocket();
|
||||
const subscriberWs1 = new MockWebSocket();
|
||||
const subscriberWs2 = new MockWebSocket();
|
||||
|
||||
const publisher: ISFUClient = {
|
||||
id: "publisher-1",
|
||||
role: "publisher",
|
||||
ws: publisherWs,
|
||||
consumers: new Map(),
|
||||
producer: new MockProducer("producer-1") as any
|
||||
};
|
||||
|
||||
const subscriber1: ISFUClient = {
|
||||
id: "subscriber-1",
|
||||
role: "subscriber",
|
||||
ws: subscriberWs1,
|
||||
consumers: new Map()
|
||||
};
|
||||
|
||||
const subscriber2: ISFUClient = {
|
||||
id: "subscriber-2",
|
||||
role: "subscriber",
|
||||
ws: subscriberWs2,
|
||||
consumers: new Map()
|
||||
};
|
||||
|
||||
clientManager.addClient(publisher);
|
||||
clientManager.addClient(subscriber1);
|
||||
clientManager.addClient(subscriber2);
|
||||
|
||||
const producer = new MockProducer("producer-1") as any;
|
||||
const consumer1 = new MockConsumer("consumer-1") as any;
|
||||
const consumer2 = new MockConsumer("consumer-2") as any;
|
||||
|
||||
clientManager.setClientProducer("publisher-1", producer);
|
||||
clientManager.addClientConsumer("subscriber-1", consumer1);
|
||||
clientManager.addClientConsumer("subscriber-2", consumer2);
|
||||
|
||||
const stats = clientManager.getStats();
|
||||
|
||||
expect(stats.clients).toBe(3);
|
||||
expect(stats.publishers).toBe(1);
|
||||
expect(stats.subscribers).toBe(2);
|
||||
expect(stats.producers).toBe(1);
|
||||
expect(stats.consumers).toBe(2);
|
||||
});
|
||||
|
||||
test("should handle removing non-existent client gracefully", () => {
|
||||
expect(() => {
|
||||
clientManager.removeClient("non-existent");
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test("should handle operations on non-existent clients gracefully", () => {
|
||||
const transport = new MockWebRtcTransport("transport-1") as any;
|
||||
const producer = new MockProducer("producer-1") as any;
|
||||
const consumer = new MockConsumer("consumer-1") as any;
|
||||
|
||||
expect(() => {
|
||||
clientManager.setClientTransport("non-existent", transport);
|
||||
clientManager.setClientProducer("non-existent", producer);
|
||||
clientManager.addClientConsumer("non-existent", consumer);
|
||||
clientManager.removeClientConsumer("non-existent", "consumer-1");
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
335
tests/sfu/SFUDemoIntegration.test.ts
Normal file
335
tests/sfu/SFUDemoIntegration.test.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
|
||||
// Mock WebSocket for testing SFU Demo Server functionality
|
||||
class MockWebSocket {
|
||||
public readyState = WebSocket.OPEN;
|
||||
public url: string;
|
||||
public sentMessages: string[] = [];
|
||||
|
||||
public onopen: (() => void) | null = null;
|
||||
public onclose: (() => void) | null = null;
|
||||
public onmessage: ((event: { data: string }) => void) | null = null;
|
||||
public onerror: ((error: any) => void) | null = null;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
setTimeout(() => {
|
||||
if (this.onopen) {
|
||||
this.onopen();
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
if (this.readyState === WebSocket.OPEN) {
|
||||
this.sentMessages.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
if (this.onclose) {
|
||||
this.onclose();
|
||||
}
|
||||
}
|
||||
|
||||
simulateMessage(data: any) {
|
||||
if (this.onmessage) {
|
||||
this.onmessage({ data: JSON.stringify(data) });
|
||||
}
|
||||
}
|
||||
|
||||
getLastMessage() {
|
||||
if (this.sentMessages.length === 0) return null;
|
||||
return JSON.parse(this.sentMessages[this.sentMessages.length - 1]);
|
||||
}
|
||||
|
||||
getAllMessages() {
|
||||
return this.sentMessages.map(msg => JSON.parse(msg));
|
||||
}
|
||||
}
|
||||
|
||||
describe("SFU Demo Server Integration", () => {
|
||||
test("should simulate SFU publisher connection flow", () => {
|
||||
const publisherWs = new MockWebSocket("ws://localhost:3001?role=publisher");
|
||||
|
||||
// Simulate server responses for publisher flow
|
||||
const messages: any[] = [];
|
||||
|
||||
publisherWs.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
messages.push(message);
|
||||
|
||||
// Simulate server responses
|
||||
if (message.type === 'join') {
|
||||
expect(message.data.role).toBe('publisher');
|
||||
expect(typeof message.data.clientId).toBe('string');
|
||||
}
|
||||
};
|
||||
|
||||
// Simulate join message from server
|
||||
publisherWs.simulateMessage({
|
||||
type: 'join',
|
||||
data: { clientId: 'publisher-123', role: 'publisher' }
|
||||
});
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].type).toBe('join');
|
||||
expect(messages[0].data.role).toBe('publisher');
|
||||
});
|
||||
|
||||
test("should simulate SFU subscriber connection flow", () => {
|
||||
const subscriberWs = new MockWebSocket("ws://localhost:3001?role=subscriber");
|
||||
|
||||
const messages: any[] = [];
|
||||
|
||||
subscriberWs.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
messages.push(message);
|
||||
|
||||
if (message.type === 'join') {
|
||||
expect(message.data.role).toBe('subscriber');
|
||||
expect(typeof message.data.clientId).toBe('string');
|
||||
}
|
||||
};
|
||||
|
||||
// Simulate join message from server
|
||||
subscriberWs.simulateMessage({
|
||||
type: 'join',
|
||||
data: { clientId: 'subscriber-123', role: 'subscriber' }
|
||||
});
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].type).toBe('join');
|
||||
expect(messages[0].data.role).toBe('subscriber');
|
||||
});
|
||||
|
||||
test("should simulate SFU signaling message exchange", () => {
|
||||
const publisherWs = new MockWebSocket("ws://localhost:3001?role=publisher");
|
||||
const subscriberWs = new MockWebSocket("ws://localhost:3001?role=subscriber");
|
||||
|
||||
const publisherMessages: any[] = [];
|
||||
const subscriberMessages: any[] = [];
|
||||
|
||||
publisherWs.onmessage = (event) => {
|
||||
publisherMessages.push(JSON.parse(event.data));
|
||||
};
|
||||
|
||||
subscriberWs.onmessage = (event) => {
|
||||
subscriberMessages.push(JSON.parse(event.data));
|
||||
};
|
||||
|
||||
// Simulate SFU RTC capabilities exchange
|
||||
publisherWs.simulateMessage({
|
||||
type: 'routerRtpCapabilities',
|
||||
data: {
|
||||
rtpCapabilities: {
|
||||
codecs: [
|
||||
{ mimeType: 'video/VP8', clockRate: 90000 },
|
||||
{ mimeType: 'audio/opus', clockRate: 48000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Simulate transport creation
|
||||
publisherWs.simulateMessage({
|
||||
type: 'webRtcTransportCreated',
|
||||
data: {
|
||||
id: 'transport-123',
|
||||
iceParameters: { usernameFragment: 'test' },
|
||||
iceCandidates: [],
|
||||
dtlsParameters: { fingerprints: [] }
|
||||
}
|
||||
});
|
||||
|
||||
// Simulate producer created
|
||||
publisherWs.simulateMessage({
|
||||
type: 'produced',
|
||||
data: { producerId: 'producer-123' }
|
||||
});
|
||||
|
||||
// Notify subscriber of new producer
|
||||
subscriberWs.simulateMessage({
|
||||
type: 'newProducer',
|
||||
data: { producerId: 'producer-123' }
|
||||
});
|
||||
|
||||
// Simulate consumer created for subscriber
|
||||
subscriberWs.simulateMessage({
|
||||
type: 'consumed',
|
||||
data: {
|
||||
consumerId: 'consumer-123',
|
||||
producerId: 'producer-123',
|
||||
kind: 'video',
|
||||
rtpParameters: {}
|
||||
}
|
||||
});
|
||||
|
||||
// Verify message flow
|
||||
expect(publisherMessages).toHaveLength(3);
|
||||
expect(publisherMessages[0].type).toBe('routerRtpCapabilities');
|
||||
expect(publisherMessages[1].type).toBe('webRtcTransportCreated');
|
||||
expect(publisherMessages[2].type).toBe('produced');
|
||||
|
||||
expect(subscriberMessages).toHaveLength(2);
|
||||
expect(subscriberMessages[0].type).toBe('newProducer');
|
||||
expect(subscriberMessages[1].type).toBe('consumed');
|
||||
expect(subscriberMessages[1].data.producerId).toBe('producer-123');
|
||||
});
|
||||
|
||||
test("should simulate SFU scaling advantages", () => {
|
||||
// Simulate 1 publisher and multiple subscribers
|
||||
const publisher = new MockWebSocket("ws://localhost:3001?role=publisher");
|
||||
const subscribers = [
|
||||
new MockWebSocket("ws://localhost:3001?role=subscriber"),
|
||||
new MockWebSocket("ws://localhost:3001?role=subscriber"),
|
||||
new MockWebSocket("ws://localhost:3001?role=subscriber"),
|
||||
new MockWebSocket("ws://localhost:3001?role=subscriber"),
|
||||
new MockWebSocket("ws://localhost:3001?role=subscriber")
|
||||
];
|
||||
|
||||
// In mesh architecture, publisher would need 5 connections
|
||||
// In SFU architecture, publisher needs only 1 connection to server
|
||||
|
||||
const publisherConnections = 1; // SFU: constant regardless of subscribers
|
||||
const meshConnections = subscribers.length; // Mesh: scales with subscribers
|
||||
|
||||
const bandwidthSaved = (meshConnections - publisherConnections) / meshConnections;
|
||||
|
||||
expect(publisherConnections).toBe(1);
|
||||
expect(meshConnections).toBe(5);
|
||||
expect(bandwidthSaved).toBe(0.8); // 80% bandwidth saved for publisher
|
||||
});
|
||||
|
||||
test("should simulate SFU adaptive bitrate", () => {
|
||||
const subscribers = [
|
||||
{ quality: 'low', expectedBitrate: 800 },
|
||||
{ quality: 'medium', expectedBitrate: 1500 },
|
||||
{ quality: 'high', expectedBitrate: 2500 }
|
||||
];
|
||||
|
||||
// SFU can adapt bitrate per subscriber based on their capabilities
|
||||
subscribers.forEach(subscriber => {
|
||||
const ws = new MockWebSocket(`ws://localhost:3001?role=subscriber&quality=${subscriber.quality}`);
|
||||
const messages: any[] = [];
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
messages.push(JSON.parse(event.data));
|
||||
};
|
||||
|
||||
ws.simulateMessage({
|
||||
type: 'consumed',
|
||||
data: {
|
||||
consumerId: 'consumer-' + subscriber.quality,
|
||||
bitrate: subscriber.expectedBitrate,
|
||||
quality: subscriber.quality
|
||||
}
|
||||
});
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].data.quality).toBe(subscriber.quality);
|
||||
expect(messages[0].data.bitrate).toBe(subscriber.expectedBitrate);
|
||||
});
|
||||
});
|
||||
|
||||
test("should simulate SFU error handling", () => {
|
||||
const client = new MockWebSocket("ws://localhost:3001?role=subscriber");
|
||||
const messages: any[] = [];
|
||||
|
||||
client.onmessage = (event) => {
|
||||
messages.push(JSON.parse(event.data));
|
||||
};
|
||||
|
||||
// Simulate server error
|
||||
client.simulateMessage({
|
||||
type: 'error',
|
||||
data: { message: 'Producer not found' }
|
||||
});
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].type).toBe('error');
|
||||
expect(messages[0].data.message).toContain('Producer not found');
|
||||
});
|
||||
|
||||
test("should simulate SFU statistics tracking", () => {
|
||||
// Mock server statistics
|
||||
const stats = {
|
||||
totalClients: 6,
|
||||
publishers: 1,
|
||||
subscribers: 5,
|
||||
streamsForwarded: 1,
|
||||
totalBandwidth: 8500, // 1 publisher (2500) + 5 subscribers (1200 each)
|
||||
uptime: 120
|
||||
};
|
||||
|
||||
// Verify SFU efficiency metrics
|
||||
expect(stats.publishers).toBe(1);
|
||||
expect(stats.subscribers).toBe(5);
|
||||
expect(stats.streamsForwarded).toBe(1); // Server handles 1 stream, forwards to 5
|
||||
|
||||
// Calculate bandwidth efficiency
|
||||
const publisherBandwidth = 2500;
|
||||
const meshTotalBandwidth = publisherBandwidth * stats.subscribers; // 12,500
|
||||
const sfuTotalBandwidth = stats.totalBandwidth; // 8,500
|
||||
const bandwidthSaved = (meshTotalBandwidth - sfuTotalBandwidth) / meshTotalBandwidth;
|
||||
|
||||
expect(bandwidthSaved).toBeGreaterThan(0.3); // At least 30% bandwidth saved
|
||||
});
|
||||
|
||||
test("should simulate SFU reconnection handling", () => {
|
||||
const client = new MockWebSocket("ws://localhost:3001?role=publisher");
|
||||
let reconnectAttempted = false;
|
||||
|
||||
client.onclose = () => {
|
||||
reconnectAttempted = true;
|
||||
};
|
||||
|
||||
// Simulate connection loss
|
||||
client.close();
|
||||
|
||||
expect(reconnectAttempted).toBe(true);
|
||||
expect(client.readyState).toBe(WebSocket.CLOSED);
|
||||
|
||||
// In a real SFU implementation, the client would reconnect
|
||||
// and the server would maintain stream state
|
||||
const reconnectedClient = new MockWebSocket("ws://localhost:3001?role=publisher");
|
||||
expect(reconnectedClient.readyState).toBe(WebSocket.OPEN);
|
||||
});
|
||||
|
||||
test("should validate SFU message types", () => {
|
||||
const validSFUMessageTypes = [
|
||||
'join',
|
||||
'routerRtpCapabilities',
|
||||
'webRtcTransportCreated',
|
||||
'webRtcTransportConnected',
|
||||
'produced',
|
||||
'consumed',
|
||||
'resumed',
|
||||
'paused',
|
||||
'newProducer',
|
||||
'producers',
|
||||
'error'
|
||||
];
|
||||
|
||||
validSFUMessageTypes.forEach(messageType => {
|
||||
expect(typeof messageType).toBe('string');
|
||||
expect(messageType.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Test message structure
|
||||
const sampleMessage = {
|
||||
type: 'consumed',
|
||||
data: {
|
||||
consumerId: 'consumer-123',
|
||||
producerId: 'producer-123',
|
||||
kind: 'video'
|
||||
}
|
||||
};
|
||||
|
||||
expect(sampleMessage.type).toBe('consumed');
|
||||
expect(sampleMessage.data.consumerId).toBe('consumer-123');
|
||||
expect(sampleMessage.data.producerId).toBe('producer-123');
|
||||
expect(sampleMessage.data.kind).toBe('video');
|
||||
});
|
||||
});
|
||||
441
tests/sfu/SFUSignalingService.test.ts
Normal file
441
tests/sfu/SFUSignalingService.test.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import { test, expect, describe, beforeEach } from "bun:test";
|
||||
import { SFUSignalingService } from "../../src/services/SFUSignalingService.ts";
|
||||
import { SFUClientManager } from "../../src/services/SFUClientManager.ts";
|
||||
import type { ISFUSignalingMessage } from "../../src/interfaces/ISFUTypes.ts";
|
||||
|
||||
// Mock MediaServerManager
|
||||
class MockMediaServerManager {
|
||||
private mockRtpCapabilities = {
|
||||
codecs: [
|
||||
{ mimeType: 'video/VP8', clockRate: 90000 },
|
||||
{ mimeType: 'audio/opus', clockRate: 48000 }
|
||||
]
|
||||
};
|
||||
|
||||
private mockTransport = {
|
||||
id: 'transport-123',
|
||||
iceParameters: { usernameFragment: 'test', password: 'test' },
|
||||
iceCandidates: [],
|
||||
dtlsParameters: { fingerprints: [{ algorithm: 'sha-256', value: 'test' }] },
|
||||
connect: () => Promise.resolve(),
|
||||
produce: () => Promise.resolve({ id: 'producer-123', kind: 'video' }),
|
||||
consume: () => Promise.resolve({ id: 'consumer-123', kind: 'video', rtpParameters: {} }),
|
||||
close: () => {}
|
||||
};
|
||||
|
||||
private mockRouter = {
|
||||
canConsume: () => true
|
||||
};
|
||||
|
||||
getRtpCapabilities() {
|
||||
return this.mockRtpCapabilities;
|
||||
}
|
||||
|
||||
async createWebRtcTransport() {
|
||||
return this.mockTransport;
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.mockRouter;
|
||||
}
|
||||
|
||||
getWorker() {
|
||||
return { pid: 12345 };
|
||||
}
|
||||
}
|
||||
|
||||
// Mock WebSocket
|
||||
class MockWebSocket {
|
||||
sentMessages: string[] = [];
|
||||
|
||||
send(data: string) {
|
||||
this.sentMessages.push(data);
|
||||
}
|
||||
|
||||
getLastMessage() {
|
||||
return this.sentMessages.length > 0 ?
|
||||
JSON.parse(this.sentMessages[this.sentMessages.length - 1]) : null;
|
||||
}
|
||||
|
||||
getAllMessages() {
|
||||
return this.sentMessages.map(msg => JSON.parse(msg));
|
||||
}
|
||||
}
|
||||
|
||||
describe("SFUSignalingService", () => {
|
||||
let signalingService: SFUSignalingService;
|
||||
let clientManager: SFUClientManager;
|
||||
let mediaServer: MockMediaServerManager;
|
||||
|
||||
beforeEach(() => {
|
||||
clientManager = new SFUClientManager();
|
||||
mediaServer = new MockMediaServerManager();
|
||||
signalingService = new SFUSignalingService(mediaServer as any, clientManager);
|
||||
});
|
||||
|
||||
test("should handle client connections", () => {
|
||||
const mockWs = new MockWebSocket();
|
||||
|
||||
const clientId = signalingService.handleConnection(mockWs, "publisher");
|
||||
|
||||
expect(typeof clientId).toBe("string");
|
||||
expect(clientId.length).toBeGreaterThan(0);
|
||||
|
||||
const client = clientManager.getClient(clientId);
|
||||
expect(client).toBeTruthy();
|
||||
expect(client?.role).toBe("publisher");
|
||||
expect(client?.ws).toBe(mockWs);
|
||||
|
||||
const lastMessage = mockWs.getLastMessage();
|
||||
expect(lastMessage.type).toBe("join");
|
||||
expect(lastMessage.data.clientId).toBe(clientId);
|
||||
expect(lastMessage.data.role).toBe("publisher");
|
||||
});
|
||||
|
||||
test("should handle client disconnection", () => {
|
||||
const mockWs = new MockWebSocket();
|
||||
|
||||
const clientId = signalingService.handleConnection(mockWs, "subscriber");
|
||||
expect(clientManager.getClient(clientId)).toBeTruthy();
|
||||
|
||||
signalingService.handleDisconnection(clientId);
|
||||
expect(clientManager.getClient(clientId)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should handle getRouterRtpCapabilities message", async () => {
|
||||
const mockWs = new MockWebSocket();
|
||||
const clientId = signalingService.handleConnection(mockWs, "publisher");
|
||||
|
||||
const message: ISFUSignalingMessage = {
|
||||
type: "getRouterRtpCapabilities"
|
||||
};
|
||||
|
||||
await signalingService.handleMessage(clientId, message);
|
||||
|
||||
const lastMessage = mockWs.getLastMessage();
|
||||
expect(lastMessage.type).toBe("routerRtpCapabilities");
|
||||
expect(lastMessage.data.rtpCapabilities).toBeTruthy();
|
||||
expect(lastMessage.data.rtpCapabilities.codecs).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("should handle createWebRtcTransport message", async () => {
|
||||
const mockWs = new MockWebSocket();
|
||||
const clientId = signalingService.handleConnection(mockWs, "publisher");
|
||||
|
||||
const message: ISFUSignalingMessage = {
|
||||
type: "createWebRtcTransport"
|
||||
};
|
||||
|
||||
await signalingService.handleMessage(clientId, message);
|
||||
|
||||
const lastMessage = mockWs.getLastMessage();
|
||||
expect(lastMessage.type).toBe("webRtcTransportCreated");
|
||||
expect(lastMessage.data.id).toBe("transport-123");
|
||||
expect(lastMessage.data.iceParameters).toBeTruthy();
|
||||
expect(lastMessage.data.dtlsParameters).toBeTruthy();
|
||||
|
||||
// Verify transport was set on client
|
||||
const client = clientManager.getClient(clientId);
|
||||
expect(client?.transport).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should handle connectWebRtcTransport message", async () => {
|
||||
const mockWs = new MockWebSocket();
|
||||
const clientId = signalingService.handleConnection(mockWs, "publisher");
|
||||
|
||||
// First create transport
|
||||
await signalingService.handleMessage(clientId, { type: "createWebRtcTransport" });
|
||||
|
||||
// Then connect it
|
||||
const connectMessage: ISFUSignalingMessage = {
|
||||
type: "connectWebRtcTransport",
|
||||
data: {
|
||||
dtlsParameters: { fingerprints: [{ algorithm: 'sha-256', value: 'test' }] }
|
||||
}
|
||||
};
|
||||
|
||||
await signalingService.handleMessage(clientId, connectMessage);
|
||||
|
||||
const lastMessage = mockWs.getLastMessage();
|
||||
expect(lastMessage.type).toBe("webRtcTransportConnected");
|
||||
});
|
||||
|
||||
test("should handle produce message for publisher", async () => {
|
||||
const mockWs = new MockWebSocket();
|
||||
const clientId = signalingService.handleConnection(mockWs, "publisher");
|
||||
|
||||
// Create transport first
|
||||
await signalingService.handleMessage(clientId, { type: "createWebRtcTransport" });
|
||||
|
||||
// Mock producer creation
|
||||
const mockProducer = { id: 'producer-123', kind: 'video' };
|
||||
const client = clientManager.getClient(clientId);
|
||||
if (client?.transport) {
|
||||
(client.transport as any).produce = () => Promise.resolve(mockProducer);
|
||||
}
|
||||
|
||||
const produceMessage: ISFUSignalingMessage = {
|
||||
type: "produce",
|
||||
data: {
|
||||
kind: "video",
|
||||
rtpParameters: { codecs: [], encodings: [] }
|
||||
}
|
||||
};
|
||||
|
||||
await signalingService.handleMessage(clientId, produceMessage);
|
||||
|
||||
const messages = mockWs.getAllMessages();
|
||||
const producedMessage = messages.find(msg => msg.type === 'produced');
|
||||
expect(producedMessage).toBeTruthy();
|
||||
expect(producedMessage.data.producerId).toBe('producer-123');
|
||||
});
|
||||
|
||||
test("should reject produce message for subscriber", async () => {
|
||||
const mockWs = new MockWebSocket();
|
||||
const clientId = signalingService.handleConnection(mockWs, "subscriber");
|
||||
|
||||
await signalingService.handleMessage(clientId, { type: "createWebRtcTransport" });
|
||||
|
||||
const produceMessage: ISFUSignalingMessage = {
|
||||
type: "produce",
|
||||
data: {
|
||||
kind: "video",
|
||||
rtpParameters: { codecs: [], encodings: [] }
|
||||
}
|
||||
};
|
||||
|
||||
await signalingService.handleMessage(clientId, produceMessage);
|
||||
|
||||
const lastMessage = mockWs.getLastMessage();
|
||||
expect(lastMessage.type).toBe("error");
|
||||
expect(lastMessage.data.message).toContain("Only publishers can produce");
|
||||
});
|
||||
|
||||
test("should handle consume message for subscriber", async () => {
|
||||
// First create a publisher with a producer
|
||||
const publisherWs = new MockWebSocket();
|
||||
const publisherId = signalingService.handleConnection(publisherWs, "publisher");
|
||||
await signalingService.handleMessage(publisherId, { type: "createWebRtcTransport" });
|
||||
|
||||
// Mock producer
|
||||
const mockProducer = { id: 'producer-123', kind: 'video' };
|
||||
clientManager.setClientProducer(publisherId, mockProducer as any);
|
||||
|
||||
// Now create subscriber
|
||||
const subscriberWs = new MockWebSocket();
|
||||
const subscriberId = signalingService.handleConnection(subscriberWs, "subscriber");
|
||||
await signalingService.handleMessage(subscriberId, { type: "createWebRtcTransport" });
|
||||
|
||||
// Mock consumer creation
|
||||
const mockConsumer = {
|
||||
id: 'consumer-123',
|
||||
kind: 'video',
|
||||
rtpParameters: { codecs: [], encodings: [] }
|
||||
};
|
||||
const subscriberClient = clientManager.getClient(subscriberId);
|
||||
if (subscriberClient?.transport) {
|
||||
(subscriberClient.transport as any).consume = () => Promise.resolve(mockConsumer);
|
||||
}
|
||||
|
||||
const consumeMessage: ISFUSignalingMessage = {
|
||||
type: "consume",
|
||||
data: {
|
||||
producerId: "producer-123",
|
||||
rtpCapabilities: { codecs: [] }
|
||||
}
|
||||
};
|
||||
|
||||
await signalingService.handleMessage(subscriberId, consumeMessage);
|
||||
|
||||
const lastMessage = subscriberWs.getLastMessage();
|
||||
expect(lastMessage.type).toBe("consumed");
|
||||
expect(lastMessage.data.consumerId).toBe('consumer-123');
|
||||
expect(lastMessage.data.producerId).toBe('producer-123');
|
||||
});
|
||||
|
||||
test("should reject consume message for publisher", async () => {
|
||||
const mockWs = new MockWebSocket();
|
||||
const clientId = signalingService.handleConnection(mockWs, "publisher");
|
||||
|
||||
await signalingService.handleMessage(clientId, { type: "createWebRtcTransport" });
|
||||
|
||||
const consumeMessage: ISFUSignalingMessage = {
|
||||
type: "consume",
|
||||
data: {
|
||||
producerId: "producer-123",
|
||||
rtpCapabilities: { codecs: [] }
|
||||
}
|
||||
};
|
||||
|
||||
await signalingService.handleMessage(clientId, consumeMessage);
|
||||
|
||||
const lastMessage = mockWs.getLastMessage();
|
||||
expect(lastMessage.type).toBe("error");
|
||||
expect(lastMessage.data.message).toContain("Only subscribers can consume");
|
||||
});
|
||||
|
||||
test("should handle resume message", async () => {
|
||||
const mockWs = new MockWebSocket();
|
||||
const clientId = signalingService.handleConnection(mockWs, "subscriber");
|
||||
|
||||
// Mock consumer
|
||||
const mockConsumer = {
|
||||
id: 'consumer-123',
|
||||
resume: () => Promise.resolve()
|
||||
};
|
||||
clientManager.addClientConsumer(clientId, mockConsumer as any);
|
||||
|
||||
const resumeMessage: ISFUSignalingMessage = {
|
||||
type: "resume",
|
||||
data: { consumerId: "consumer-123" }
|
||||
};
|
||||
|
||||
await signalingService.handleMessage(clientId, resumeMessage);
|
||||
|
||||
const lastMessage = mockWs.getLastMessage();
|
||||
expect(lastMessage.type).toBe("resumed");
|
||||
expect(lastMessage.data.consumerId).toBe("consumer-123");
|
||||
});
|
||||
|
||||
test("should handle pause message", async () => {
|
||||
const mockWs = new MockWebSocket();
|
||||
const clientId = signalingService.handleConnection(mockWs, "subscriber");
|
||||
|
||||
// Mock consumer
|
||||
const mockConsumer = {
|
||||
id: 'consumer-123',
|
||||
pause: () => Promise.resolve()
|
||||
};
|
||||
clientManager.addClientConsumer(clientId, mockConsumer as any);
|
||||
|
||||
const pauseMessage: ISFUSignalingMessage = {
|
||||
type: "pause",
|
||||
data: { consumerId: "consumer-123" }
|
||||
};
|
||||
|
||||
await signalingService.handleMessage(clientId, pauseMessage);
|
||||
|
||||
const lastMessage = mockWs.getLastMessage();
|
||||
expect(lastMessage.type).toBe("paused");
|
||||
expect(lastMessage.data.consumerId).toBe("consumer-123");
|
||||
});
|
||||
|
||||
test("should handle getProducers message", async () => {
|
||||
// Create publisher with producer
|
||||
const publisherWs = new MockWebSocket();
|
||||
const publisherId = signalingService.handleConnection(publisherWs, "publisher");
|
||||
|
||||
const mockProducer = { id: 'producer-123', kind: 'video' };
|
||||
clientManager.setClientProducer(publisherId, mockProducer as any);
|
||||
|
||||
// Create subscriber requesting producers
|
||||
const subscriberWs = new MockWebSocket();
|
||||
const subscriberId = signalingService.handleConnection(subscriberWs, "subscriber");
|
||||
|
||||
const getProducersMessage: ISFUSignalingMessage = {
|
||||
type: "getProducers"
|
||||
};
|
||||
|
||||
await signalingService.handleMessage(subscriberId, getProducersMessage);
|
||||
|
||||
const lastMessage = subscriberWs.getLastMessage();
|
||||
expect(lastMessage.type).toBe("producers");
|
||||
expect(lastMessage.data.producers).toHaveLength(1);
|
||||
expect(lastMessage.data.producers[0].id).toBe('producer-123');
|
||||
expect(lastMessage.data.producers[0].kind).toBe('video');
|
||||
});
|
||||
|
||||
test("should notify subscribers when new producer joins", async () => {
|
||||
// Create subscribers first
|
||||
const subscriber1Ws = new MockWebSocket();
|
||||
const subscriber1Id = signalingService.handleConnection(subscriber1Ws, "subscriber");
|
||||
|
||||
const subscriber2Ws = new MockWebSocket();
|
||||
const subscriber2Id = signalingService.handleConnection(subscriber2Ws, "subscriber");
|
||||
|
||||
// Clear initial join messages
|
||||
subscriber1Ws.sentMessages = [];
|
||||
subscriber2Ws.sentMessages = [];
|
||||
|
||||
// Create publisher and produce
|
||||
const publisherWs = new MockWebSocket();
|
||||
const publisherId = signalingService.handleConnection(publisherWs, "publisher");
|
||||
await signalingService.handleMessage(publisherId, { type: "createWebRtcTransport" });
|
||||
|
||||
// Mock producer creation and produce
|
||||
const mockProducer = { id: 'producer-123', kind: 'video' };
|
||||
const publisherClient = clientManager.getClient(publisherId);
|
||||
if (publisherClient?.transport) {
|
||||
(publisherClient.transport as any).produce = () => Promise.resolve(mockProducer);
|
||||
}
|
||||
|
||||
await signalingService.handleMessage(publisherId, {
|
||||
type: "produce",
|
||||
data: { kind: "video", rtpParameters: { codecs: [], encodings: [] } }
|
||||
});
|
||||
|
||||
// Check that subscribers were notified
|
||||
const sub1LastMessage = subscriber1Ws.getLastMessage();
|
||||
const sub2LastMessage = subscriber2Ws.getLastMessage();
|
||||
|
||||
expect(sub1LastMessage?.type).toBe("newProducer");
|
||||
expect(sub1LastMessage?.data.producerId).toBe('producer-123');
|
||||
|
||||
expect(sub2LastMessage?.type).toBe("newProducer");
|
||||
expect(sub2LastMessage?.data.producerId).toBe('producer-123');
|
||||
});
|
||||
|
||||
test("should handle messages from unknown clients gracefully", async () => {
|
||||
const message: ISFUSignalingMessage = {
|
||||
type: "getRouterRtpCapabilities"
|
||||
};
|
||||
|
||||
expect(async () => {
|
||||
await signalingService.handleMessage("unknown-client-id", message);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test("should handle unknown message types", async () => {
|
||||
const mockWs = new MockWebSocket();
|
||||
const clientId = signalingService.handleConnection(mockWs, "publisher");
|
||||
|
||||
const unknownMessage: any = {
|
||||
type: "unknownMessageType"
|
||||
};
|
||||
|
||||
await signalingService.handleMessage(clientId, unknownMessage);
|
||||
|
||||
// Should not crash, just log a warning
|
||||
expect(true).toBe(true); // Test passes if no exception thrown
|
||||
});
|
||||
|
||||
test("should provide service statistics", () => {
|
||||
// Create some clients
|
||||
const publisher1Ws = new MockWebSocket();
|
||||
const publisher1Id = signalingService.handleConnection(publisher1Ws, "publisher");
|
||||
|
||||
const subscriber1Ws = new MockWebSocket();
|
||||
const subscriber1Id = signalingService.handleConnection(subscriber1Ws, "subscriber");
|
||||
|
||||
const subscriber2Ws = new MockWebSocket();
|
||||
const subscriber2Id = signalingService.handleConnection(subscriber2Ws, "subscriber");
|
||||
|
||||
// Add some producers and consumers
|
||||
const mockProducer = { id: 'producer-123', kind: 'video' };
|
||||
const mockConsumer1 = { id: 'consumer-123', kind: 'video' };
|
||||
const mockConsumer2 = { id: 'consumer-456', kind: 'video' };
|
||||
|
||||
clientManager.setClientProducer(publisher1Id, mockProducer as any);
|
||||
clientManager.addClientConsumer(subscriber1Id, mockConsumer1 as any);
|
||||
clientManager.addClientConsumer(subscriber2Id, mockConsumer2 as any);
|
||||
|
||||
const stats = signalingService.getStats();
|
||||
|
||||
expect(stats.clients).toBe(3);
|
||||
expect(stats.publishers).toBe(1);
|
||||
expect(stats.subscribers).toBe(2);
|
||||
expect(stats.producers).toBe(1);
|
||||
expect(stats.consumers).toBe(2);
|
||||
expect(stats.mediaServer.workerId).toBe(12345);
|
||||
});
|
||||
});
|
||||
153
tests/websocket.test.ts
Normal file
153
tests/websocket.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { test, expect, describe, beforeAll, afterAll } from "bun:test";
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { ClientManager } from '../src/services/ClientManager.ts';
|
||||
import { SignalingService } from '../src/services/SignalingService.ts';
|
||||
|
||||
let server: any;
|
||||
let clientManager: ClientManager;
|
||||
let signalingService: SignalingService;
|
||||
const clientSessions = new Map<object, string>();
|
||||
|
||||
beforeAll(async () => {
|
||||
clientManager = new ClientManager();
|
||||
signalingService = new SignalingService(clientManager);
|
||||
|
||||
server = Bun.serve({
|
||||
port: 0, // Use random available port
|
||||
fetch(req, server) {
|
||||
// Handle WebSocket upgrade
|
||||
const url = new URL(req.url);
|
||||
const role = url.searchParams.get('role');
|
||||
|
||||
// Reject invalid roles before upgrade
|
||||
if (role === 'invalid') {
|
||||
return new Response('Invalid role', { status: 400 });
|
||||
}
|
||||
|
||||
if (server.upgrade(req, { data: { role } })) {
|
||||
return; // do not return a Response
|
||||
}
|
||||
return new Response('Test Server');
|
||||
},
|
||||
websocket: {
|
||||
open(ws: ServerWebSocket<{ role?: string }>) {
|
||||
const role = ws.data?.role;
|
||||
|
||||
if (role === 'invalid') {
|
||||
ws.close(1002, 'Invalid role parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
const clientRole = (role === 'publisher' || role === 'subscriber') ? role : 'unknown';
|
||||
const clientId = signalingService.handleConnection(ws, clientRole);
|
||||
clientSessions.set(ws, clientId);
|
||||
|
||||
// Send join message immediately for valid roles
|
||||
if (clientRole !== 'unknown') {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'join',
|
||||
data: { clientId, role: clientRole }
|
||||
}));
|
||||
}
|
||||
},
|
||||
message(ws: ServerWebSocket<unknown>, message: string | Buffer) {
|
||||
const clientId = clientSessions.get(ws);
|
||||
if (!clientId) return;
|
||||
// Message handling logic would go here
|
||||
},
|
||||
close(ws: ServerWebSocket<unknown>) {
|
||||
const clientId = clientSessions.get(ws);
|
||||
if (clientId) {
|
||||
signalingService.handleDisconnection(clientId);
|
||||
clientSessions.delete(ws);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait a bit for server to start
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (server) {
|
||||
server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
describe("WebSocket Connection Test", () => {
|
||||
test("should connect to WebSocket server", async () => {
|
||||
// This test verifies the WebSocket endpoint is working
|
||||
const ws = new WebSocket(`ws://localhost:${server.port}?role=publisher`);
|
||||
|
||||
const connectionPromise = new Promise((resolve, reject) => {
|
||||
ws.onopen = () => resolve("connected");
|
||||
ws.onerror = (error) => reject(error);
|
||||
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => reject(new Error("Connection timeout")), 5000);
|
||||
});
|
||||
|
||||
const result = await connectionPromise;
|
||||
expect(result).toBe("connected");
|
||||
|
||||
// Test sending a message
|
||||
const messagePromise = new Promise((resolve) => {
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
resolve(message);
|
||||
};
|
||||
});
|
||||
|
||||
// Should receive a join message
|
||||
const joinMessage = await messagePromise;
|
||||
expect(joinMessage.type).toBe("join");
|
||||
expect(joinMessage.data.role).toBe("publisher");
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("should handle subscriber connection", async () => {
|
||||
const ws = new WebSocket(`ws://localhost:${server.port}?role=subscriber`);
|
||||
|
||||
const connectionPromise = new Promise((resolve, reject) => {
|
||||
ws.onopen = () => resolve("connected");
|
||||
ws.onerror = (error) => reject(error);
|
||||
setTimeout(() => reject(new Error("Connection timeout")), 5000);
|
||||
});
|
||||
|
||||
await connectionPromise;
|
||||
|
||||
const messagePromise = new Promise((resolve) => {
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
resolve(message);
|
||||
};
|
||||
});
|
||||
|
||||
const joinMessage = await messagePromise;
|
||||
expect(joinMessage.type).toBe("join");
|
||||
expect(joinMessage.data.role).toBe("subscriber");
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("should reject invalid role", async () => {
|
||||
const ws = new WebSocket(`ws://localhost:${server.port}?role=invalid`);
|
||||
|
||||
const connectionPromise = new Promise((resolve, reject) => {
|
||||
ws.onopen = () => reject(new Error("Should not connect"));
|
||||
ws.onerror = (error) => {
|
||||
// WebSocket connection should fail due to 400 response
|
||||
resolve("rejected");
|
||||
};
|
||||
ws.onclose = (event) => {
|
||||
resolve("rejected");
|
||||
};
|
||||
setTimeout(() => reject(new Error("Connection timeout")), 5000);
|
||||
});
|
||||
|
||||
const result = await connectionPromise;
|
||||
expect(result).toBe("rejected");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user