Files
advanced-camera-card/tests/components-lib/live/providers/go2rtc-experimental/test-utils.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

271 lines
7.8 KiB
TypeScript

import { vi, type Mock } from 'vitest';
import type {
MediaSourceFactory,
MediaSourceInterface,
} from '../../../../../src/components-lib/live/providers/go2rtc-experimental/adapters/media-source';
import type {
BinaryCallback,
Go2RTCMessage,
MessageCallback,
StreamSourceChannel,
} from '../../../../../src/components-lib/live/providers/go2rtc-experimental/types';
import type { UnsubscribeCallback } from '../../../../../src/types';
// ===========================================================================
// User agents.
// ===========================================================================
export const CHROME_USER_AGENT =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
export const SAFARI_17_USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 ' +
'(KHTML, like Gecko) Version/17.4 Safari/605.1.15';
// ===========================================================================
// Fakes for browser APIs jsdom does not provide.
// ===========================================================================
export class FakeWebSocket extends EventTarget {
public binaryType = '';
public sent: string[] = [];
public close = vi.fn();
public send = vi.fn((data: string): void => {
this.sent.push(data);
});
public asWebSocket(): WebSocket {
return this as unknown as WebSocket;
}
public fireOpen(): void {
this.dispatchEvent(new Event('open'));
}
public fireClose(): void {
this.dispatchEvent(new Event('close'));
}
public fireMessage(data: unknown): void {
this.dispatchEvent(new MessageEvent('message', { data }));
}
}
export const createTimeRanges = (ranges: [number, number][]): TimeRanges => ({
length: ranges.length,
start: (index: number) => ranges[index][0],
end: (index: number) => ranges[index][1],
});
class FakeSourceBuffer extends EventTarget {
public mode = '';
public updating = false;
public buffered: TimeRanges = createTimeRanges([]);
public appendBuffer = vi.fn();
public remove = vi.fn();
public asSourceBuffer(): SourceBuffer {
return this as unknown as SourceBuffer;
}
public fireUpdateEnd(): void {
this.dispatchEvent(new Event('updateend'));
}
}
export class FakeMediaStreamTrack extends EventTarget {
public muted = false;
public kind: string;
constructor(kind: string) {
super();
this.kind = kind;
}
public asTrack(): MediaStreamTrack {
return this as unknown as MediaStreamTrack;
}
public setMuted(muted: boolean): void {
this.muted = muted;
this.dispatchEvent(new Event(muted ? 'mute' : 'unmute'));
}
}
export class FakeMediaStream {
private _tracks: FakeMediaStreamTrack[];
constructor(tracks: FakeMediaStreamTrack[] = []) {
this._tracks = tracks;
}
public getTracks(): FakeMediaStreamTrack[] {
return this._tracks;
}
public getVideoTracks(): FakeMediaStreamTrack[] {
return this._tracks.filter((track) => track.kind === 'video');
}
public getAudioTracks(): FakeMediaStreamTrack[] {
return this._tracks.filter((track) => track.kind === 'audio');
}
public asMediaStream(): MediaStream {
return this as unknown as MediaStream;
}
}
class FakeRTCTransceiver {
public direction: string;
public currentDirection: string;
public sender: {
track: FakeMediaStreamTrack | null;
replaceTrack: Mock<[MediaStreamTrack | null], Promise<void>>;
};
public receiver: { track: FakeMediaStreamTrack };
constructor(direction: string, kind: string, track: FakeMediaStreamTrack | null) {
this.direction = direction;
this.currentDirection = direction;
this.sender = {
track,
replaceTrack: vi.fn<[MediaStreamTrack | null], Promise<void>>(() =>
Promise.resolve(),
),
};
this.receiver = { track: new FakeMediaStreamTrack(kind) };
}
}
export class FakeRTCPeerConnection extends EventTarget {
public connectionState: RTCPeerConnectionState = 'new';
public remoteDescription: { sdp: string } | null = null;
public transceivers: FakeRTCTransceiver[] = [];
public createOffer = vi.fn(
(): Promise<{ type: string; sdp?: string }> =>
Promise.resolve({ type: 'offer', sdp: 'v=0\r\noffer' }),
);
public setLocalDescription = vi.fn(() => Promise.resolve());
public setRemoteDescription = vi.fn((description: { sdp: string }) => {
this.remoteDescription = description;
return Promise.resolve();
});
public addIceCandidate = vi.fn(() => Promise.resolve());
public close = vi.fn();
public addTransceiver(
trackOrKind: FakeMediaStreamTrack | string,
init: { direction: string },
): FakeRTCTransceiver {
const kind = typeof trackOrKind === 'string' ? trackOrKind : trackOrKind.kind;
const track = typeof trackOrKind === 'string' ? null : trackOrKind;
const transceiver = new FakeRTCTransceiver(init.direction, kind, track);
this.transceivers.push(transceiver);
return transceiver;
}
public getTransceivers(): FakeRTCTransceiver[] {
return this.transceivers;
}
public getReceivers(): { track: FakeMediaStreamTrack }[] {
return this.transceivers.map((transceiver) => transceiver.receiver);
}
public asPeerConnection(): RTCPeerConnection {
return this as unknown as RTCPeerConnection;
}
public getMicrophoneTransceiver(): FakeRTCTransceiver {
return this.transceivers[0];
}
public fireConnectionStateChange(state: RTCPeerConnectionState): void {
this.connectionState = state;
this.dispatchEvent(new Event('connectionstatechange'));
}
public fireIceCandidate(candidate: string | null): void {
const event = new Event('icecandidate');
Object.assign(event, {
candidate: candidate === null ? null : { candidate },
});
this.dispatchEvent(event);
}
}
// ===========================================================================
// Fakes for custom interfaces.
// ===========================================================================
export class FakeStreamSourceChannel implements StreamSourceChannel {
public sent: Go2RTCMessage[] = [];
public binaryCallback: BinaryCallback | null = null;
private _messageCallbacks = new Set<MessageCallback>();
public send(message: Go2RTCMessage): void {
this.sent.push(message);
}
public subscribeToMessages(callback: MessageCallback): UnsubscribeCallback {
this._messageCallbacks.add(callback);
return () => {
this._messageCallbacks.delete(callback);
};
}
public setBinaryCallback(callback: BinaryCallback | null): void {
this.binaryCallback = callback;
}
public receiveMessage(message: Go2RTCMessage): void {
[...this._messageCallbacks].forEach((callback) => callback(message));
}
public getMessageCallbackCount(): number {
return this._messageCallbacks.size;
}
}
export class FakeMediaSourceInstance implements MediaSourceInterface {
public sourceBuffer = new FakeSourceBuffer();
public attach = vi.fn();
public detach = vi.fn();
public setLiveSeekableRange = vi.fn();
public isOpen = vi.fn<[], boolean>(() => true);
public isTypeSupported = vi.fn<[string], boolean>(() => true);
public addSourceBuffer = vi.fn<[string], SourceBuffer>(() =>
this.sourceBuffer.asSourceBuffer(),
);
private _sourceOpenCallbacks = new Set<() => void>();
public subscribeToSourceOpen(callback: () => void): UnsubscribeCallback {
this._sourceOpenCallbacks.add(callback);
return () => {
this._sourceOpenCallbacks.delete(callback);
};
}
public fireSourceOpen(): void {
[...this._sourceOpenCallbacks].forEach((callback) => callback());
}
public getSourceOpenCallbackCount(): number {
return this._sourceOpenCallbacks.size;
}
}
export const createFakeMediaSourceFactory = (
instance: FakeMediaSourceInstance | null,
): MediaSourceFactory => vi.fn(() => instance);