Files
advanced-camera-card/tests/components-lib/live/providers/go2rtc-experimental/signaling.test.ts
T
Dermot Duffy c02c692f68 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`.
2026-07-14 14:22:41 -07:00

252 lines
7.8 KiB
TypeScript

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();
});
});