feat: Add experimental rewrite of go2rtc live provider (MSE/WebRTC/MP4/MJPEG) (#2580)
- Closes #2556 - Closes #2450 **Key intended features:** - go2rtc compatible - 100% test coverage to significantly improve ability to test, maintain and work around browser weirdnesses (e.g. Safari). - Written from the ground up in the style of the rest of the project. **To use:** - Change `live_provider` from `go2rtc` to `go2rtc-experimental`.
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { SignalingChannel } from '../../../../../src/components-lib/live/providers/go2rtc-experimental/signaling';
|
||||
import { FakeWebSocket } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('SignalingChannel', () => {
|
||||
const setup = (options?: {
|
||||
openCallback?: () => void;
|
||||
disconnectCallback?: () => void;
|
||||
}) => {
|
||||
const websockets: FakeWebSocket[] = [];
|
||||
const createWebSocket = vi.fn<[string], WebSocket>(() => {
|
||||
const websocket = new FakeWebSocket();
|
||||
websockets.push(websocket);
|
||||
return websocket.asWebSocket();
|
||||
});
|
||||
const channel = new SignalingChannel(
|
||||
'ws://host/api/ws?src=camera',
|
||||
{
|
||||
openCallback: options?.openCallback,
|
||||
disconnectCallback: options?.disconnectCallback,
|
||||
},
|
||||
{ createWebSocket },
|
||||
);
|
||||
return { channel, createWebSocket, websockets };
|
||||
};
|
||||
|
||||
it('should connect with an arraybuffer binary type', () => {
|
||||
const { channel, createWebSocket, websockets } = setup();
|
||||
channel.connect();
|
||||
|
||||
expect(createWebSocket).toBeCalledWith('ws://host/api/ws?src=camera');
|
||||
expect(websockets[0].binaryType).toBe('arraybuffer');
|
||||
});
|
||||
|
||||
it('should not connect twice', () => {
|
||||
const { channel, createWebSocket } = setup();
|
||||
channel.connect();
|
||||
channel.connect();
|
||||
|
||||
expect(createWebSocket).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should report open state and call the open callback', () => {
|
||||
const openCallback = vi.fn();
|
||||
const { channel, websockets } = setup({ openCallback });
|
||||
channel.connect();
|
||||
|
||||
expect(channel.isOpen()).toBe(false);
|
||||
|
||||
websockets[0].fireOpen();
|
||||
|
||||
expect(channel.isOpen()).toBe(true);
|
||||
expect(openCallback).toBeCalled();
|
||||
});
|
||||
|
||||
it('should tolerate an absent open callback', () => {
|
||||
const { channel, websockets } = setup();
|
||||
channel.connect();
|
||||
|
||||
expect(() => websockets[0].fireOpen()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not send before the connection is open', () => {
|
||||
const { channel, websockets } = setup();
|
||||
channel.connect();
|
||||
channel.send({ type: 'mse', value: 'codecs' });
|
||||
|
||||
expect(websockets[0].send).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should send JSON once open', () => {
|
||||
const { channel, websockets } = setup();
|
||||
channel.connect();
|
||||
websockets[0].fireOpen();
|
||||
const message = { type: 'mse', value: 'codecs' };
|
||||
channel.send(message);
|
||||
|
||||
expect(websockets[0].sent).toEqual([JSON.stringify(message)]);
|
||||
});
|
||||
|
||||
it('should dispatch parsed messages to subscribers', () => {
|
||||
const { channel, websockets } = setup();
|
||||
const callback = vi.fn();
|
||||
channel.subscribeToMessages(callback);
|
||||
channel.connect();
|
||||
websockets[0].fireMessage('{"type":"mse","value":"video/mp4"}');
|
||||
|
||||
expect(callback).toBeCalledWith({ type: 'mse', value: 'video/mp4' });
|
||||
});
|
||||
|
||||
it('should stop dispatching after unsubscribe', () => {
|
||||
const { channel, websockets } = setup();
|
||||
const callback = vi.fn();
|
||||
const unsubscribe = channel.subscribeToMessages(callback);
|
||||
channel.connect();
|
||||
unsubscribe();
|
||||
websockets[0].fireMessage('{"type":"mse"}');
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should dispatch to remaining subscribers when one unsubscribes during dispatch', () => {
|
||||
const { channel, websockets } = setup();
|
||||
const secondCallback = vi.fn();
|
||||
const unsubscribeDuringDispatch = vi.fn((): void => {
|
||||
unsubscribe();
|
||||
});
|
||||
const unsubscribe = channel.subscribeToMessages(unsubscribeDuringDispatch);
|
||||
channel.subscribeToMessages(secondCallback);
|
||||
channel.connect();
|
||||
websockets[0].fireMessage('{"type":"mse"}');
|
||||
|
||||
expect(unsubscribeDuringDispatch).toBeCalledTimes(1);
|
||||
expect(secondCallback).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should ignore invalid JSON', () => {
|
||||
const { channel, websockets } = setup();
|
||||
const callback = vi.fn();
|
||||
channel.subscribeToMessages(callback);
|
||||
channel.connect();
|
||||
websockets[0].fireMessage('NOT JSON');
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should ignore malformed messages', () => {
|
||||
const { channel, websockets } = setup();
|
||||
const callback = vi.fn();
|
||||
channel.subscribeToMessages(callback);
|
||||
channel.connect();
|
||||
websockets[0].fireMessage('{"type":6}');
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should ignore unexpected data types', () => {
|
||||
const { channel, websockets } = setup();
|
||||
const callback = vi.fn();
|
||||
channel.subscribeToMessages(callback);
|
||||
channel.connect();
|
||||
websockets[0].fireMessage(42);
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should route binary data to the binary callback', () => {
|
||||
const { channel, websockets } = setup();
|
||||
const binaryCallback = vi.fn();
|
||||
channel.setBinaryCallback(binaryCallback);
|
||||
channel.connect();
|
||||
const data = new ArrayBuffer(8);
|
||||
websockets[0].fireMessage(data);
|
||||
|
||||
expect(binaryCallback).toBeCalledWith(data);
|
||||
});
|
||||
|
||||
it('should drop binary data without a binary callback', () => {
|
||||
const { channel, websockets } = setup();
|
||||
channel.connect();
|
||||
|
||||
expect(() => websockets[0].fireMessage(new ArrayBuffer(8))).not.toThrow();
|
||||
});
|
||||
|
||||
it('should drop binary data after the binary callback is cleared', () => {
|
||||
const { channel, websockets } = setup();
|
||||
const binaryCallback = vi.fn();
|
||||
channel.setBinaryCallback(binaryCallback);
|
||||
channel.setBinaryCallback(null);
|
||||
channel.connect();
|
||||
websockets[0].fireMessage(new ArrayBuffer(8));
|
||||
|
||||
expect(binaryCallback).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should close the underlying websocket without firing the disconnect callback', () => {
|
||||
const disconnectCallback = vi.fn();
|
||||
const { channel, websockets } = setup({ disconnectCallback });
|
||||
channel.connect();
|
||||
websockets[0].fireOpen();
|
||||
channel.close();
|
||||
|
||||
expect(websockets[0].close).toBeCalled();
|
||||
expect(channel.isOpen()).toBe(false);
|
||||
expect(disconnectCallback).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should tolerate closing when never connected', () => {
|
||||
const { channel } = setup();
|
||||
|
||||
expect(() => channel.close()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should ignore websocket events delivered after close', () => {
|
||||
const openCallback = vi.fn();
|
||||
const disconnectCallback = vi.fn();
|
||||
const messageCallback = vi.fn();
|
||||
const { channel, websockets } = setup({ openCallback, disconnectCallback });
|
||||
channel.subscribeToMessages(messageCallback);
|
||||
channel.connect();
|
||||
channel.close();
|
||||
|
||||
websockets[0].fireOpen();
|
||||
websockets[0].fireMessage('{"type":"mse"}');
|
||||
websockets[0].fireClose();
|
||||
|
||||
expect(openCallback).not.toBeCalled();
|
||||
expect(messageCallback).not.toBeCalled();
|
||||
expect(disconnectCallback).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should fire the disconnect callback on unexpected closure', () => {
|
||||
const disconnectCallback = vi.fn();
|
||||
const { channel, websockets } = setup({ disconnectCallback });
|
||||
channel.connect();
|
||||
websockets[0].fireOpen();
|
||||
websockets[0].fireClose();
|
||||
|
||||
expect(disconnectCallback).toBeCalledTimes(1);
|
||||
expect(channel.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('should tolerate an absent disconnect callback on unexpected closure', () => {
|
||||
const { channel, websockets } = setup();
|
||||
channel.connect();
|
||||
|
||||
expect(() => websockets[0].fireClose()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should allow reconnecting after unexpected closure', () => {
|
||||
const { channel, createWebSocket, websockets } = setup();
|
||||
channel.connect();
|
||||
websockets[0].fireClose();
|
||||
channel.connect();
|
||||
|
||||
expect(createWebSocket).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should construct a real websocket by default', () => {
|
||||
const webSocketConstructor = vi.fn(() => new FakeWebSocket().asWebSocket());
|
||||
vi.stubGlobal('WebSocket', webSocketConstructor);
|
||||
const channel = new SignalingChannel('ws://host/api/ws', {});
|
||||
channel.connect();
|
||||
channel.close();
|
||||
|
||||
expect(webSocketConstructor).toHaveBeenCalledWith('ws://host/api/ws');
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user