335 lines
10 KiB
TypeScript
335 lines
10 KiB
TypeScript
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');
|
|
});
|
|
}); |