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:
Dermot Duffy
2026-07-14 14:22:41 -07:00
committed by GitHub
parent 5662e48c22
commit c02c692f68
125 changed files with 9608 additions and 807 deletions
@@ -35,6 +35,22 @@ describe('ProviderErrorDetector', () => {
expect(onChange).toHaveBeenCalledTimes(1);
});
it('should adopt the specific reason carried on the event', () => {
const host = createHostInDocument();
const detector = new ProviderErrorDetector(host, vi.fn());
detector.subscribe();
host.dispatchEvent(
new CustomEvent(LIVE_ERROR_EVENT, { bubbles: true, detail: 'unsupported' }),
);
expect(detector.getVerdict()).toEqual({
state: 'not_live',
authority: 'hard',
reason: 'unsupported',
});
});
it('should notify only on the transition to not live', () => {
const host = createHostInDocument();
const onChange = vi.fn();
@@ -89,7 +89,7 @@ describe('StreamLivenessController', () => {
const { controller } = setup();
expect(controller.isLive()).toBe(true);
expect(controller.getPlaceholder()).toBeNull();
expect(controller.getFailure()).toBeNull();
});
it('should aggregate a detector losing liveness only after host connect', () => {
@@ -123,9 +123,13 @@ describe('StreamLivenessController', () => {
failViaProviderError();
// Provider-error is not-live but does not want a placeholder.
// Provider-error is not-live but does not want a placeholder; its cause is
// still available for a wrapper that fills the frame itself.
expect(controller.isLive()).toBe(false);
expect(controller.getPlaceholder()).toBeNull();
expect(controller.getFailure()).toEqual({
reason: 'playback_error',
renderPlaceholder: false,
});
});
it('should not fire the issue without a target', () => {
@@ -154,7 +158,10 @@ describe('StreamLivenessController', () => {
fireMediaPlayerLiveness(false);
expect(controller.isLive()).toBe(false);
expect(controller.getPlaceholder()).toEqual({ reason: 'stalled' });
expect(controller.getFailure()).toEqual({
reason: 'stalled',
renderPlaceholder: true,
});
expect(issueTriggers).toEqual([
{ key: 'media_unavailable', targetID: 'camera.office', reason: 'stalled' },
]);
@@ -232,7 +239,10 @@ describe('StreamLivenessController', () => {
controller.hostConnected();
expect(controller.isLive()).toBe(false);
expect(controller.getPlaceholder()).toEqual({ reason: 'entity_unavailable' });
expect(controller.getFailure()).toEqual({
reason: 'entity_unavailable',
renderPlaceholder: true,
});
expect(issueTriggers).toEqual([
{
key: 'media_unavailable',
@@ -298,7 +308,7 @@ describe('StreamLivenessController', () => {
// Direct frame evidence outranks the entity proxy: no teardown, no issue.
expect(controller.isLive()).toBe(true);
expect(controller.getPlaceholder()).toBeNull();
expect(controller.getFailure()).toBeNull();
expect(issueTriggers).toEqual([]);
vi.useRealTimers();
@@ -343,6 +353,9 @@ describe('StreamLivenessController', () => {
// The always_error opt-in is authoritative: an unavailable entity overrides
// even confirmed frames.
expect(controller.isLive()).toBe(false);
expect(controller.getPlaceholder()).toEqual({ reason: 'entity_unavailable' });
expect(controller.getFailure()).toEqual({
reason: 'entity_unavailable',
renderPlaceholder: true,
});
});
});
@@ -0,0 +1,226 @@
import { afterEach, assert, describe, expect, it, vi } from 'vitest';
import { createBrowserMediaSource } from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/adapters/media-source';
class FakeMediaSource extends EventTarget {
public static instances: FakeMediaSource[] = [];
public addSourceBuffer = vi.fn();
public setLiveSeekableRange = vi.fn();
public static isTypeSupported = vi.fn<[string], boolean>(() => true);
constructor() {
super();
FakeMediaSource.instances.push(this);
}
}
class FakeManagedMediaSource extends EventTarget {
public addSourceBuffer = vi.fn();
public setLiveSeekableRange = vi.fn();
public readyState: 'closed' | 'open' | 'ended' = 'closed';
public static isTypeSupported = vi.fn<[string], boolean>(() => true);
}
const createObjectURL = vi.fn(() => 'blob:fake-url');
const revokeObjectURL = vi.fn();
const stubManagedMediaSource = (): void => {
vi.stubGlobal('ManagedMediaSource', FakeManagedMediaSource);
};
const stubClassicMediaSource = (): void => {
vi.stubGlobal('MediaSource', FakeMediaSource);
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL });
};
// @vitest-environment jsdom
describe('media-source', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
FakeMediaSource.instances = [];
});
it('should return null without any MediaSource support', () => {
expect(createBrowserMediaSource()).toBeNull();
});
describe('with ManagedMediaSource support', () => {
it('should prefer ManagedMediaSource over MediaSource', () => {
stubManagedMediaSource();
stubClassicMediaSource();
const instance = createBrowserMediaSource();
const video = document.createElement('video');
instance?.attach(video);
expect(video.srcObject).toBeInstanceOf(FakeManagedMediaSource);
});
it('should attach via srcObject with remote playback disabled', () => {
stubManagedMediaSource();
const instance = createBrowserMediaSource();
const video = document.createElement('video');
instance?.attach(video);
expect(video.disableRemotePlayback).toBe(true);
expect(video.srcObject).toBeInstanceOf(FakeManagedMediaSource);
});
it('should detach by clearing srcObject', () => {
stubManagedMediaSource();
const instance = createBrowserMediaSource();
const video = document.createElement('video');
instance?.attach(video);
instance?.detach(video);
expect(video.srcObject).toBeNull();
});
it('should delegate isTypeSupported to ManagedMediaSource', () => {
stubManagedMediaSource();
FakeManagedMediaSource.isTypeSupported.mockReturnValue(false);
const instance = createBrowserMediaSource();
expect(instance?.isTypeSupported('video/mp4')).toBe(false);
expect(FakeManagedMediaSource.isTypeSupported).toBeCalledWith('video/mp4');
});
});
describe('with classic MediaSource support', () => {
it('should attach via an object URL', () => {
stubClassicMediaSource();
const instance = createBrowserMediaSource();
const video = document.createElement('video');
instance?.attach(video);
expect(createObjectURL).toBeCalledTimes(1);
expect(video.src).toContain('blob:fake-url');
expect(video.srcObject).toBeNull();
});
it('should revoke the object URL once the source opens', () => {
stubClassicMediaSource();
const instance = createBrowserMediaSource();
const video = document.createElement('video');
instance?.attach(video);
expect(revokeObjectURL).not.toBeCalled();
FakeMediaSource.instances[0].dispatchEvent(new Event('sourceopen'));
expect(revokeObjectURL).toBeCalledWith('blob:fake-url');
instance?.detach(video);
expect(revokeObjectURL).toBeCalledTimes(1);
});
it('should delegate isTypeSupported to MediaSource', () => {
stubClassicMediaSource();
FakeMediaSource.isTypeSupported.mockReturnValue(false);
const instance = createBrowserMediaSource();
expect(instance?.isTypeSupported('video/mp4')).toBe(false);
expect(FakeMediaSource.isTypeSupported).toBeCalledWith('video/mp4');
});
it('should detach by clearing src and revoking the object URL', () => {
stubClassicMediaSource();
const instance = createBrowserMediaSource();
const video = document.createElement('video');
instance?.attach(video);
instance?.detach(video);
expect(revokeObjectURL).toBeCalledWith('blob:fake-url');
expect(video.getAttribute('src')).toBe('');
});
it('should not revoke the object URL twice', () => {
stubClassicMediaSource();
const instance = createBrowserMediaSource();
const video = document.createElement('video');
instance?.attach(video);
instance?.detach(video);
instance?.detach(video);
expect(revokeObjectURL).toBeCalledTimes(1);
});
});
describe('shared behavior', () => {
it('should subscribe and unsubscribe from sourceopen', () => {
stubManagedMediaSource();
const instance = createBrowserMediaSource();
const video = document.createElement('video');
instance?.attach(video);
const callback = vi.fn();
const unsubscribe = instance?.subscribeToSourceOpen(callback);
const mediaSource = video.srcObject;
assert(mediaSource instanceof FakeManagedMediaSource);
mediaSource.dispatchEvent(new Event('sourceopen'));
expect(callback).toBeCalledTimes(1);
unsubscribe?.();
mediaSource.dispatchEvent(new Event('sourceopen'));
expect(callback).toBeCalledTimes(1);
});
it('should delegate addSourceBuffer', () => {
stubManagedMediaSource();
const instance = createBrowserMediaSource();
const video = document.createElement('video');
instance?.attach(video);
instance?.addSourceBuffer('video/mp4; codecs="avc1.640029"');
const mediaSource = video.srcObject;
assert(mediaSource instanceof FakeManagedMediaSource);
expect(mediaSource.addSourceBuffer).toBeCalledWith(
'video/mp4; codecs="avc1.640029"',
);
});
it('should delegate setLiveSeekableRange', () => {
stubManagedMediaSource();
const instance = createBrowserMediaSource();
const video = document.createElement('video');
instance?.attach(video);
instance?.setLiveSeekableRange(10, 20);
const mediaSource = video.srcObject;
assert(mediaSource instanceof FakeManagedMediaSource);
expect(mediaSource.setLiveSeekableRange).toBeCalledWith(10, 20);
});
it('should report open only while the media source readyState is open', () => {
stubManagedMediaSource();
const instance = createBrowserMediaSource();
const video = document.createElement('video');
instance?.attach(video);
const mediaSource = video.srcObject;
assert(mediaSource instanceof FakeManagedMediaSource);
mediaSource.readyState = 'open';
expect(instance?.isOpen()).toBe(true);
mediaSource.readyState = 'closed';
expect(instance?.isOpen()).toBe(false);
});
});
});
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from 'vitest';
import {
createBrowserPeerConnection,
GO2RTC_PEER_CONNECTION_CONFIG,
} from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/adapters/peer-connection';
describe('peer-connection', () => {
it('should configure two STUN servers with max-bundle', () => {
expect(GO2RTC_PEER_CONNECTION_CONFIG).toEqual({
bundlePolicy: 'max-bundle',
iceServers: [
{
urls: ['stun:stun.l.google.com:19302', 'stun:stun.cloudflare.com:3478'],
},
],
});
});
it('should construct a real peer connection', () => {
const RTCPeerConnectionMock = vi.fn();
vi.stubGlobal('RTCPeerConnection', RTCPeerConnectionMock);
createBrowserPeerConnection(GO2RTC_PEER_CONNECTION_CONFIG);
expect(RTCPeerConnectionMock).toHaveBeenCalledWith(GO2RTC_PEER_CONNECTION_CONFIG);
vi.unstubAllGlobals();
});
});
@@ -0,0 +1,232 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ImageSurfaceController } from '../../../../../src/components-lib/live/providers/go2rtc-experimental/image-surface-controller';
import { createLitElement, flushPromises } from '../../../../test-utils';
const createFrame = (): Blob => new Blob(['frame'], { type: 'image/jpeg' });
// @vitest-environment jsdom
describe('ImageSurfaceController', () => {
let nextId: number;
beforeEach(() => {
nextId = 0;
URL.createObjectURL = vi.fn(() => `blob:mock-${nextId++}`);
URL.revokeObjectURL = vi.fn();
document.body.replaceChildren();
});
const createdURLs = (): string[] =>
vi.mocked(URL.createObjectURL).mock.results.map((result) => result.value);
// A connected visible <img> plus a detached loader whose decode() is stubbed
// (jsdom has none). The loader is injected so a test can hold a decode in
// flight to exercise coalescing.
const createSurfaceController = (options?: {
connected?: boolean;
decode?: () => Promise<void>;
}) => {
const image = document.createElement('img');
if (options?.connected !== false) {
document.body.appendChild(image);
}
const decoder = document.createElement('img');
decoder.decode = vi.fn(options?.decode ?? (() => Promise.resolve()));
const controller = new ImageSurfaceController(createLitElement(), () => image, {
createImage: () => decoder,
});
return { image, decoder, controller };
};
describe('controller', () => {
it('should expose an image media player controller', () => {
const controller = new ImageSurfaceController(createLitElement(), () => null);
expect(controller.getMediaPlayer().getPIPElement()).toBeNull();
});
it('should have liveness when given liveness options', () => {
const controller = new ImageSurfaceController(createLitElement(), () => null, {
livenessOptions: { isFrameExpected: () => true, stallWindowSeconds: 10 },
});
expect(controller.getMediaPlayer().subscribeLiveness).toBeDefined();
});
it('should have no liveness without liveness options', () => {
const controller = new ImageSurfaceController(createLitElement(), () => null);
expect(controller.getMediaPlayer().subscribeLiveness).toBeUndefined();
});
it('should expose the image element', () => {
const { image, controller } = createSurfaceController();
expect(controller.getElement()).toBe(image);
});
});
describe('showFrame', () => {
it('should decode a frame off-DOM and show it via an object URL', async () => {
const { image, decoder, controller } = createSurfaceController();
await controller.showFrame(createFrame());
expect(decoder.decode).toBeCalledTimes(1);
expect(URL.createObjectURL).toBeCalledTimes(1);
expect(image.getAttribute('src')).toBe(createdURLs()[0]);
expect(URL.revokeObjectURL).not.toBeCalled();
});
it('should revoke the previous frame when showing the next', async () => {
const { image, controller } = createSurfaceController();
await controller.showFrame(createFrame());
await controller.showFrame(createFrame());
expect(image.getAttribute('src')).toBe(createdURLs()[1]);
expect(URL.revokeObjectURL).toBeCalledTimes(1);
expect(URL.revokeObjectURL).toBeCalledWith(createdURLs()[0]);
});
it('should keep showing the previous frame until the next has decoded', async () => {
let releaseSecond: () => void = () => {};
let calls = 0;
const { image, controller } = createSurfaceController({
decode: () => {
calls++;
return calls >= 2
? new Promise<void>((resolve) => (releaseSecond = resolve))
: Promise.resolve();
},
});
await controller.showFrame(createFrame());
expect(image.getAttribute('src')).toBe(createdURLs()[0]);
controller.showFrame(createFrame());
await flushPromises();
// The next frame is still decoding off-DOM, so the visible <img> is
// untouched and the previous URL stays valid (revoking it would blank it).
expect(image.getAttribute('src')).toBe(createdURLs()[0]);
expect(URL.revokeObjectURL).not.toBeCalled();
releaseSecond();
await flushPromises();
expect(image.getAttribute('src')).toBe(createdURLs()[1]);
expect(URL.revokeObjectURL).toBeCalledWith(createdURLs()[0]);
});
it('should present only the newest frame while a decode is in flight', async () => {
let releaseDecode: () => void = () => {};
const decoding = new Promise<void>((resolve) => (releaseDecode = resolve));
const { image, controller } = createSurfaceController({ decode: () => decoding });
controller.showFrame(createFrame());
controller.showFrame(createFrame());
controller.showFrame(createFrame());
await flushPromises();
// Only the first frame's decode has started; the rest wait behind it.
expect(URL.createObjectURL).toBeCalledTimes(1);
releaseDecode();
await flushPromises();
// The middle frame was superseded, so only the newest is presented next.
expect(URL.createObjectURL).toBeCalledTimes(2);
expect(image.getAttribute('src')).toBe(createdURLs()[1]);
});
it('should drop an undecodable frame and keep the current one', async () => {
const { image, controller } = createSurfaceController({
decode: () => Promise.reject(new Error('bad frame')),
});
await controller.showFrame(createFrame());
expect(image.hasAttribute('src')).toBe(false);
expect(URL.revokeObjectURL).toBeCalledWith(createdURLs()[0]);
});
it('should not paint a frame that decoded after the surface detached', async () => {
let releaseDecode: () => void = () => {};
const { image, controller } = createSurfaceController({
decode: () => new Promise<void>((resolve) => (releaseDecode = resolve)),
});
controller.showFrame(createFrame());
await flushPromises();
image.remove();
releaseDecode();
await flushPromises();
expect(image.hasAttribute('src')).toBe(false);
expect(URL.revokeObjectURL).toBeCalledWith(createdURLs()[0]);
});
it('should do nothing without an image element', async () => {
const controller = new ImageSurfaceController(createLitElement(), () => null);
await controller.showFrame(createFrame());
expect(URL.createObjectURL).not.toBeCalled();
});
it('should not paint onto a detached element', async () => {
const { controller } = createSurfaceController({ connected: false });
await controller.showFrame(createFrame());
expect(URL.createObjectURL).not.toBeCalled();
});
});
describe('reset', () => {
it('should revoke the current object URL and clear the image', async () => {
const { image, controller } = createSurfaceController();
await controller.showFrame(createFrame());
controller.reset();
expect(URL.revokeObjectURL).toBeCalledWith(createdURLs()[0]);
expect(image.hasAttribute('src')).toBe(false);
});
it('should clear the image with no frame shown', () => {
const { image, controller } = createSurfaceController();
controller.reset();
expect(URL.revokeObjectURL).not.toBeCalled();
expect(image.hasAttribute('src')).toBe(false);
});
it('should drop a frame still decoding so it never paints', async () => {
let releaseDecode: () => void = () => {};
const { image, controller } = createSurfaceController({
decode: () => new Promise<void>((resolve) => (releaseDecode = resolve)),
});
controller.showFrame(createFrame());
await flushPromises();
controller.reset();
releaseDecode();
await flushPromises();
// The in-flight frame was invalidated by reset, so it never painted.
expect(image.hasAttribute('src')).toBe(false);
});
it('should do nothing without an image element', () => {
const controller = new ImageSurfaceController(createLitElement(), () => null);
expect(() => controller.reset()).not.toThrow();
expect(URL.revokeObjectURL).not.toBeCalled();
});
});
});
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest';
import { OffscreenImage } from '../../../../../src/components-lib/live/providers/go2rtc-experimental/offscreen-image';
// @vitest-environment jsdom
describe('OffscreenImage', () => {
it('should create the image from the factory on get', () => {
const image = document.createElement('img');
const offscreen = new OffscreenImage(() => image);
expect(offscreen.get()).toBe(image);
});
it('should reuse the same image across repeated get', () => {
const create = vi.fn(() => document.createElement('img'));
const offscreen = new OffscreenImage(create);
expect(offscreen.get()).toBe(offscreen.get());
expect(create).toBeCalledTimes(1);
});
it('should create an image with the default factory when none is injected', () => {
const offscreen = new OffscreenImage();
expect(offscreen.get()).toBeInstanceOf(HTMLImageElement);
});
it('should detach src on clear', () => {
const image = document.createElement('img');
const offscreen = new OffscreenImage(() => image);
offscreen.get();
image.src = 'data:image/jpeg;base64,AAAA';
offscreen.clear();
expect(image.hasAttribute('src')).toBe(false);
});
it('should create a fresh image after clear', () => {
const create = vi.fn(() => document.createElement('img'));
const offscreen = new OffscreenImage(create);
offscreen.get();
offscreen.clear();
offscreen.get();
expect(create).toBeCalledTimes(2);
});
it('should tolerate clear when no image is held', () => {
const offscreen = new OffscreenImage(() => document.createElement('img'));
expect(() => offscreen.clear()).not.toThrow();
});
});
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest';
import { OffscreenVideo } from '../../../../../src/components-lib/live/providers/go2rtc-experimental/offscreen-video';
import { FakeMediaStream, FakeMediaStreamTrack } from './test-utils';
// @vitest-environment jsdom
describe('OffscreenVideo', () => {
it('should create the video from the factory on get', () => {
const video = document.createElement('video');
const offscreen = new OffscreenVideo(() => video);
expect(offscreen.get()).toBe(video);
});
it('should reuse the same video across repeated get', () => {
const create = vi.fn(() => document.createElement('video'));
const offscreen = new OffscreenVideo(create);
expect(offscreen.get()).toBe(offscreen.get());
expect(create).toBeCalledTimes(1);
});
it('should create a video with the default factory when none is injected', () => {
const offscreen = new OffscreenVideo();
expect(offscreen.get()).toBeInstanceOf(HTMLVideoElement);
});
it('should detach src and srcObject on clear', () => {
const video = document.createElement('video');
const offscreen = new OffscreenVideo(() => video);
offscreen.get();
video.src = 'data:video/mp4;base64,AAAA';
video.srcObject = new FakeMediaStream([
new FakeMediaStreamTrack('video'),
]).asMediaStream();
offscreen.clear();
expect(video.hasAttribute('src')).toBe(false);
expect(video.srcObject).toBeNull();
});
it('should create a fresh video after clear', () => {
const create = vi.fn(() => document.createElement('video'));
const offscreen = new OffscreenVideo(create);
offscreen.get();
offscreen.clear();
offscreen.get();
expect(create).toBeCalledTimes(2);
});
it('should tolerate clear when no video is held', () => {
const offscreen = new OffscreenVideo(() => document.createElement('video'));
expect(() => offscreen.clear()).not.toThrow();
});
});
@@ -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();
});
});
@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from 'vitest';
import {
createBinarySource,
createWebRTCSource,
type BinaryStreamTargets,
} from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/sources/factory';
import { MJPEGStreamSource } from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/sources/mjpeg';
import { MP4StreamSource } from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/sources/mp4';
import { MSEStreamSource } from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/sources/mse';
import { WebRTCStreamSource } from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/sources/webrtc';
import type {
StreamSourceCallbacks,
StreamSourceContext,
VideoStreamTarget,
} from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/types';
import { FakeStreamSourceChannel } from '../test-utils';
const createTargets = (): BinaryStreamTargets => ({
video: { kind: 'video', video: document.createElement('video') },
image: { kind: 'image', showFrame: vi.fn() },
});
const createCallbacks = (): StreamSourceCallbacks => ({
loadedCallback: vi.fn(),
failedCallback: vi.fn(),
});
// @vitest-environment jsdom
describe('createBinarySource', () => {
it('should create an MSE source on the video surface', () => {
const result = createBinarySource(
'mse',
createTargets(),
new FakeStreamSourceChannel(),
createCallbacks(),
);
expect(result?.source).toBeInstanceOf(MSEStreamSource);
expect(result?.surface).toBe('video');
});
it('should create an MP4 source on the image surface', () => {
const result = createBinarySource(
'mp4',
createTargets(),
new FakeStreamSourceChannel(),
createCallbacks(),
);
expect(result?.source).toBeInstanceOf(MP4StreamSource);
expect(result?.surface).toBe('image');
});
it('should create an MJPEG source on the image surface', () => {
const result = createBinarySource(
'mjpeg',
createTargets(),
new FakeStreamSourceChannel(),
createCallbacks(),
);
expect(result?.source).toBeInstanceOf(MJPEGStreamSource);
expect(result?.surface).toBe('image');
});
it('should return null for the webrtc mode', () => {
// WebRTC is not a binary source; it is created via createWebRTCSource.
expect(
createBinarySource(
'webrtc',
createTargets(),
new FakeStreamSourceChannel(),
createCallbacks(),
),
).toBeNull();
});
});
describe('createWebRTCSource', () => {
it('should create a WebRTC source', () => {
const context: StreamSourceContext<VideoStreamTarget> = {
target: { kind: 'video', video: document.createElement('video') },
channel: new FakeStreamSourceChannel(),
callbacks: createCallbacks(),
};
expect(createWebRTCSource(context)).toBeInstanceOf(WebRTCStreamSource);
});
});
@@ -0,0 +1,170 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MJPEGStreamSource } from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/sources/mjpeg';
import type {
ImageStreamTarget,
StreamSourceContext,
} from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/types';
import { flushPromises } from '../../../../../test-utils';
import { FakeStreamSourceChannel } from '../test-utils';
// @vitest-environment jsdom
describe('MJPEGStreamSource', () => {
const setup = () => {
const channel = new FakeStreamSourceChannel();
const loadedCallback = vi.fn();
const failedCallback = vi.fn();
const showFrame = vi.fn<[Blob], Promise<void>>(() => Promise.resolve());
const context: StreamSourceContext<ImageStreamTarget> = {
target: { kind: 'image', showFrame },
channel,
callbacks: { loadedCallback, failedCallback },
};
const source = new MJPEGStreamSource(context);
return { channel, failedCallback, loadedCallback, showFrame, source };
};
const frame = (): ArrayBuffer => new TextEncoder().encode('Hi').buffer;
it('should request the mjpeg stream on start', () => {
const { source, channel } = setup();
source.start();
expect(channel.sent).toEqual([{ type: 'mjpeg' }]);
expect(channel.binaryCallback).not.toBeNull();
});
it('should show each frame as a JPEG image', () => {
const { source, channel, showFrame } = setup();
source.start();
channel.binaryCallback?.(frame());
expect(showFrame).toBeCalledTimes(1);
const shown = showFrame.mock.calls[0][0] as Blob;
expect(shown).toBeInstanceOf(Blob);
expect(shown.type).toBe('image/jpeg');
expect(shown.size).toBe(2);
});
it('should report loaded only on the first frame', async () => {
const { source, channel, loadedCallback } = setup();
source.start();
channel.binaryCallback?.(frame());
channel.binaryCallback?.(frame());
await flushPromises();
expect(loadedCallback).toBeCalledTimes(1);
});
it('should not report loaded when stopped before the first frame decodes', async () => {
const { source, channel, loadedCallback, showFrame } = setup();
let resolveDecode: () => void = () => {};
showFrame.mockReturnValue(
new Promise<void>((resolve) => {
resolveDecode = resolve;
}),
);
source.start();
channel.binaryCallback?.(frame());
// Stop before the frame's decode resolves; the deferred loaded report must
// then be dropped.
source.stop();
resolveDecode();
await flushPromises();
expect(loadedCallback).not.toBeCalled();
});
it('should fail on a server error for mjpeg', () => {
const { source, channel, failedCallback } = setup();
source.start();
channel.receiveMessage({ type: 'error', value: 'mjpeg: stream not found' });
expect(failedCallback).toBeCalledWith('server_error');
});
it('should ignore a server error for another mode', () => {
const { source, channel, failedCallback } = setup();
source.start();
channel.receiveMessage({ type: 'error', value: 'mse: stream not found' });
expect(failedCallback).not.toBeCalled();
});
it('should stop cleanly', () => {
const { source, channel } = setup();
source.start();
source.stop();
expect(channel.binaryCallback).toBeNull();
expect(channel.getMessageCallbackCount()).toBe(0);
});
it('should tolerate stopping before starting', () => {
const { source } = setup();
expect(() => source.stop()).not.toThrow();
});
it('should report no-pause capabilities', () => {
const { source } = setup();
expect(source.getCapabilities()).toEqual({ supportsPause: false });
});
it('should report mjpeg technology', () => {
const { source } = setup();
expect(source.getTechnology()).toEqual(['mjpeg']);
});
it('should report a video-only stream profile', () => {
const { source } = setup();
expect(source.getStreamProfile()).toEqual({
hasVideo: true,
hasH265Video: false,
hasAudio: false,
hasAACAudio: false,
});
});
describe('first-frame timeout', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should fail when no frame arrives within the timeout', () => {
const { source, failedCallback } = setup();
source.start();
vi.advanceTimersByTime(5 * 1000);
expect(failedCallback).toBeCalledWith('connect_timeout');
});
it('should not fail once a frame has arrived', () => {
const { source, channel, failedCallback } = setup();
source.start();
channel.binaryCallback?.(frame());
vi.advanceTimersByTime(5 * 1000);
expect(failedCallback).not.toBeCalled();
});
it('should not fail after stop', () => {
const { source, failedCallback } = setup();
source.start();
source.stop();
vi.advanceTimersByTime(5 * 1000);
expect(failedCallback).not.toBeCalled();
});
});
});
@@ -0,0 +1,232 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MP4StreamSource } from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/sources/mp4';
import type {
ImageStreamTarget,
StreamSourceContext,
} from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/types';
import { flushPromises } from '../../../../../test-utils';
import { FakeStreamSourceChannel } from '../test-utils';
class FakeCanvasContext {
public drawImage = vi.fn();
}
class FakeCanvas {
public width = 0;
public height = 0;
public context: FakeCanvasContext | null = new FakeCanvasContext();
public getContext = vi.fn(() => this.context);
public toBlob = vi.fn((callback: (blob: Blob | null) => void) =>
callback(new Blob(['frame'], { type: 'image/jpeg' })),
);
public asCanvas(): HTMLCanvasElement {
return this as unknown as HTMLCanvasElement;
}
}
// @vitest-environment jsdom
describe('MP4StreamSource', () => {
const setup = () => {
const channel = new FakeStreamSourceChannel();
const loadedCallback = vi.fn();
const failedCallback = vi.fn();
const showFrame = vi.fn<[Blob], Promise<void>>(() => Promise.resolve());
const context: StreamSourceContext<ImageStreamTarget> = {
target: { kind: 'image', showFrame },
channel,
callbacks: { loadedCallback, failedCallback },
};
const decoderVideo = document.createElement('video');
const canvas = new FakeCanvas();
const createVideoElement = vi.fn(() => decoderVideo);
const source = new MP4StreamSource(context, {
createVideoElement,
createCanvasElement: () => canvas.asCanvas(),
});
return {
canvas,
channel,
createVideoElement,
decoderVideo,
failedCallback,
loadedCallback,
showFrame,
source,
};
};
const frame = (): ArrayBuffer => new TextEncoder().encode('Hi').buffer;
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should request the mp4 stream on start', () => {
const { source, channel } = setup();
source.start();
expect(channel.sent[0].type).toBe('mp4');
expect(channel.binaryCallback).not.toBeNull();
});
it('should feed each frame to a muted autoplay decoder video', () => {
const { source, channel, decoderVideo } = setup();
source.start();
channel.binaryCallback?.(frame());
expect(decoderVideo.autoplay).toBe(true);
expect(decoderVideo.muted).toBe(true);
expect(decoderVideo.getAttribute('src')).toBe('data:video/mp4;base64,SGk=');
});
it('should reuse the decoder across frames', () => {
const { source, channel, createVideoElement } = setup();
source.start();
channel.binaryCallback?.(frame());
channel.binaryCallback?.(frame());
// Second frame reuses the same decoder rather than creating another.
expect(createVideoElement).toBeCalledTimes(1);
});
it('should draw a decoded frame and show it as an image', async () => {
const { source, channel, decoderVideo, canvas, showFrame, loadedCallback } = setup();
source.start();
channel.binaryCallback?.(frame());
decoderVideo.dispatchEvent(new Event('loadeddata'));
await flushPromises();
expect(canvas.context?.drawImage).toBeCalled();
expect(showFrame).toBeCalledTimes(1);
const shown = showFrame.mock.calls[0][0] as Blob;
expect(shown).toBeInstanceOf(Blob);
expect(shown.type).toBe('image/jpeg');
expect(loadedCallback).toBeCalledTimes(1);
});
it('should report loaded only on the first drawn frame', async () => {
const { source, channel, decoderVideo, loadedCallback } = setup();
source.start();
channel.binaryCallback?.(frame());
decoderVideo.dispatchEvent(new Event('loadeddata'));
decoderVideo.dispatchEvent(new Event('loadeddata'));
await flushPromises();
expect(loadedCallback).toBeCalledTimes(1);
});
it('should not show a frame when the canvas produces no blob', () => {
const { source, channel, decoderVideo, canvas, showFrame } = setup();
canvas.toBlob = vi.fn((callback: (blob: Blob | null) => void) => callback(null));
source.start();
channel.binaryCallback?.(frame());
decoderVideo.dispatchEvent(new Event('loadeddata'));
expect(showFrame).not.toBeCalled();
});
it('should do nothing when the canvas has no 2d context', () => {
const { source, channel, decoderVideo, canvas, showFrame } = setup();
canvas.context = null;
source.start();
channel.binaryCallback?.(frame());
decoderVideo.dispatchEvent(new Event('loadeddata'));
expect(showFrame).not.toBeCalled();
});
it('should fail on a server error for mp4', () => {
const { source, channel, failedCallback } = setup();
source.start();
channel.receiveMessage({ type: 'error', value: 'mp4: stream not found' });
expect(failedCallback).toBeCalledWith('server_error');
});
it('should clear the decoder on stop', () => {
const { source, channel, decoderVideo } = setup();
source.start();
channel.binaryCallback?.(frame());
source.stop();
expect(decoderVideo.hasAttribute('src')).toBe(false);
expect(channel.binaryCallback).toBeNull();
});
it('should not show a frame decoded after stop', () => {
const { source, channel, decoderVideo, showFrame } = setup();
source.start();
channel.binaryCallback?.(frame());
source.stop();
// A frame whose decode completes after stop() must not reach the image
// surface.
decoderVideo.dispatchEvent(new Event('loadeddata'));
expect(showFrame).not.toBeCalled();
});
describe('first-frame timeout', () => {
it('should fail when no frame arrives within the timeout', () => {
const { source, failedCallback } = setup();
source.start();
vi.advanceTimersByTime(5 * 1000);
expect(failedCallback).toBeCalledWith('connect_timeout');
});
it('should not fail once a frame has been drawn', () => {
const { source, channel, decoderVideo, failedCallback } = setup();
source.start();
channel.binaryCallback?.(frame());
decoderVideo.dispatchEvent(new Event('loadeddata'));
vi.advanceTimersByTime(5 * 1000);
expect(failedCallback).not.toBeCalled();
});
it('should not fail after stop', () => {
const { source, failedCallback } = setup();
source.start();
source.stop();
vi.advanceTimersByTime(5 * 1000);
expect(failedCallback).not.toBeCalled();
});
});
it('should report mp4 technology', () => {
const { source } = setup();
expect(source.getTechnology()).toEqual(['mp4']);
});
it('should default to document element factories when none are injected', () => {
const channel = new FakeStreamSourceChannel();
const source = new MP4StreamSource({
target: { kind: 'image', showFrame: vi.fn() },
channel,
callbacks: { loadedCallback: vi.fn(), failedCallback: vi.fn() },
});
const createElement = vi.spyOn(document, 'createElement');
source.start();
channel.binaryCallback?.(frame());
const decoder = createElement.mock.results[
createElement.mock.calls.findIndex((call) => call[0] === 'video')
].value as HTMLVideoElement;
decoder.dispatchEvent(new Event('loadeddata'));
expect(createElement).toHaveBeenCalledWith('video');
expect(createElement).toHaveBeenCalledWith('canvas');
createElement.mockRestore();
});
});
@@ -0,0 +1,646 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MSEStreamSource } from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/sources/mse';
import type {
StreamSourceContext,
VideoStreamTarget,
} from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/types';
import {
CHROME_USER_AGENT,
createFakeMediaSourceFactory,
createTimeRanges,
FakeMediaSourceInstance,
FakeStreamSourceChannel,
SAFARI_17_USER_AGENT,
} from '../test-utils';
const ALL_CHROME_CODECS =
'avc1.640029,avc1.64002A,avc1.640033,hvc1.1.6.L153.B0,mp4a.40.2,mp4a.40.5,flac,opus';
// @vitest-environment jsdom
describe('MSEStreamSource', () => {
const setup = (options?: { userAgent?: string; unsupported?: boolean }) => {
const video = document.createElement('video');
// jsdom reports a fresh <video> as paused; live playback is the default
// state under test, and the live-edge logic only runs while playing.
Object.defineProperty(video, 'paused', {
configurable: true,
writable: true,
value: false,
});
const channel = new FakeStreamSourceChannel();
const loadedCallback = vi.fn();
const failedCallback = vi.fn();
const context: StreamSourceContext<VideoStreamTarget> = {
target: { kind: 'video', video },
channel,
callbacks: { loadedCallback, failedCallback },
};
const instance = new FakeMediaSourceInstance();
const source = new MSEStreamSource(context, {
createMediaSource: createFakeMediaSourceFactory(
options?.unsupported ? null : instance,
),
userAgent: options?.userAgent ?? CHROME_USER_AGENT,
});
return { channel, failedCallback, instance, loadedCallback, source, video };
};
type SetupResult = ReturnType<typeof setup>;
const negotiate = (setupResult: SetupResult): void => {
setupResult.source.start();
setupResult.instance.fireSourceOpen();
setupResult.channel.receiveMessage({
type: 'mse',
value: 'video/mp4; codecs="avc1.640029,mp4a.40.2"',
});
};
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('startup', () => {
it('should fail without MediaSource support', () => {
const { source, failedCallback } = setup({ unsupported: true });
source.start();
expect(failedCallback).toBeCalledWith('unsupported');
});
it('should attach the media source to the video on start', () => {
const { source, instance, video } = setup();
source.start();
expect(instance.attach).toBeCalledWith(video);
});
});
describe('negotiation', () => {
it('should offer supported codecs when the source opens', () => {
const { source, instance, channel } = setup();
source.start();
instance.fireSourceOpen();
expect(channel.sent).toEqual([{ type: 'mse', value: ALL_CHROME_CODECS }]);
});
it('should offer Safari-reliable codecs on Safari', () => {
const { source, instance, channel } = setup({ userAgent: SAFARI_17_USER_AGENT });
source.start();
instance.fireSourceOpen();
expect(channel.sent).toEqual([
{
type: 'mse',
value:
'avc1.640029,avc1.64002A,avc1.640033,hvc1.1.6.L153.B0,mp4a.40.2,mp4a.40.5,flac',
},
]);
});
it('should exclude codecs the media source does not support', () => {
const { source, instance, channel } = setup();
instance.isTypeSupported.mockImplementation(
(mimeType: string) => mimeType === 'video/mp4; codecs="avc1.640029"',
);
source.start();
instance.fireSourceOpen();
expect(channel.sent).toEqual([{ type: 'mse', value: 'avc1.640029' }]);
});
it('should fail when negotiation times out', () => {
const { source, instance, failedCallback } = setup();
source.start();
instance.fireSourceOpen();
vi.advanceTimersByTime(5 * 1000);
expect(failedCallback).toBeCalledWith('negotiation_timeout');
});
it('should not time out after a successful negotiation', () => {
const setupResult = setup();
negotiate(setupResult);
vi.advanceTimersByTime(5 * 1000);
expect(setupResult.failedCallback).not.toBeCalled();
});
it('should create a source buffer in segments mode on negotiation', () => {
const setupResult = setup();
negotiate(setupResult);
expect(setupResult.instance.addSourceBuffer).toBeCalledWith(
'video/mp4; codecs="avc1.640029,mp4a.40.2"',
);
expect(setupResult.instance.sourceBuffer.mode).toBe('segments');
expect(setupResult.channel.binaryCallback).not.toBeNull();
});
it('should ignore a repeated negotiation response', () => {
const setupResult = setup();
negotiate(setupResult);
setupResult.channel.receiveMessage({ type: 'mse', value: 'video/mp4' });
expect(setupResult.instance.addSourceBuffer).toBeCalledTimes(1);
});
it('should ignore negotiation responses without a string value', () => {
const setupResult = setup();
setupResult.source.start();
setupResult.channel.receiveMessage({ type: 'mse', value: 42 });
expect(setupResult.instance.addSourceBuffer).not.toBeCalled();
});
it('should ignore unrelated messages', () => {
const setupResult = setup();
setupResult.source.start();
setupResult.channel.receiveMessage({ type: 'webrtc/answer', value: 'sdp' });
expect(setupResult.instance.addSourceBuffer).not.toBeCalled();
expect(setupResult.failedCallback).not.toBeCalled();
});
});
describe('server errors', () => {
it('should fail on a server error for mse', () => {
const { source, instance, channel, failedCallback } = setup();
source.start();
instance.fireSourceOpen();
channel.receiveMessage({ type: 'error', value: 'mse: stream not found' });
expect(failedCallback).toBeCalledWith('server_error');
// The negotiation timer must have stopped.
failedCallback.mockClear();
vi.advanceTimersByTime(5 * 1000);
expect(failedCallback).not.toBeCalled();
});
it('should ignore server errors for other modes', () => {
const { source, channel, failedCallback } = setup();
source.start();
channel.receiveMessage({ type: 'error', value: 'webrtc/offer: failed' });
expect(failedCallback).not.toBeCalled();
});
it('should ignore server errors without a string value', () => {
const { source, channel, failedCallback } = setup();
source.start();
channel.receiveMessage({ type: 'error' });
expect(failedCallback).not.toBeCalled();
});
});
describe('source buffer', () => {
it('should fail when the source buffer cannot be created', () => {
const { source, instance, channel, failedCallback } = setup();
instance.addSourceBuffer.mockImplementation(() => {
throw new Error('InvalidStateError');
});
source.start();
instance.fireSourceOpen();
channel.receiveMessage({ type: 'mse', value: 'video/mp4' });
expect(failedCallback).toBeCalledWith('media_error');
});
it('should append binary data directly when idle', () => {
const setupResult = setup();
negotiate(setupResult);
const data = new ArrayBuffer(8);
setupResult.channel.binaryCallback?.(data);
expect(setupResult.instance.sourceBuffer.appendBuffer).toBeCalledWith(data);
});
it('should swallow direct append failures', () => {
const setupResult = setup();
negotiate(setupResult);
setupResult.instance.sourceBuffer.appendBuffer.mockImplementation(() => {
throw new Error('QuotaExceededError');
});
expect(() =>
setupResult.channel.binaryCallback?.(new ArrayBuffer(8)),
).not.toThrow();
expect(setupResult.failedCallback).not.toBeCalled();
});
it('should stage binary data while the source buffer updates', () => {
const setupResult = setup();
negotiate(setupResult);
setupResult.instance.sourceBuffer.updating = true;
const staged = new ArrayBuffer(8);
setupResult.channel.binaryCallback?.(staged);
expect(setupResult.instance.sourceBuffer.appendBuffer).not.toBeCalled();
setupResult.instance.sourceBuffer.updating = false;
setupResult.instance.sourceBuffer.fireUpdateEnd();
expect(setupResult.instance.sourceBuffer.appendBuffer).toBeCalledWith(staged);
});
it('should stage binary data behind earlier staged data', () => {
const setupResult = setup();
negotiate(setupResult);
const sourceBuffer = setupResult.instance.sourceBuffer;
sourceBuffer.updating = true;
const first = new ArrayBuffer(8);
const second = new ArrayBuffer(4);
setupResult.channel.binaryCallback?.(first);
sourceBuffer.updating = false;
setupResult.channel.binaryCallback?.(second);
expect(sourceBuffer.appendBuffer).not.toBeCalled();
sourceBuffer.fireUpdateEnd();
expect(sourceBuffer.appendBuffer).toHaveBeenNthCalledWith(1, first);
sourceBuffer.fireUpdateEnd();
expect(sourceBuffer.appendBuffer).toHaveBeenNthCalledWith(2, second);
});
it('should fail when staged data exceeds the pending limit', () => {
const setupResult = setup();
negotiate(setupResult);
const sourceBuffer = setupResult.instance.sourceBuffer;
sourceBuffer.updating = true;
setupResult.channel.binaryCallback?.(new ArrayBuffer(2 * 1024 * 1024));
expect(setupResult.failedCallback).not.toBeCalled();
setupResult.channel.binaryCallback?.(new ArrayBuffer(1));
expect(setupResult.failedCallback).toBeCalledWith('buffer_overflow');
});
});
describe('live edge', () => {
it('should do nothing on updateend while still updating', () => {
const setupResult = setup();
negotiate(setupResult);
const sourceBuffer = setupResult.instance.sourceBuffer;
sourceBuffer.buffered = createTimeRanges([[0, 20]]);
sourceBuffer.updating = true;
sourceBuffer.fireUpdateEnd();
expect(sourceBuffer.remove).not.toBeCalled();
});
it('should do nothing on updateend without buffered content', () => {
const setupResult = setup();
negotiate(setupResult);
setupResult.instance.sourceBuffer.fireUpdateEnd();
expect(setupResult.instance.sourceBuffer.remove).not.toBeCalled();
expect(setupResult.instance.setLiveSeekableRange).not.toBeCalled();
});
it('should not trim after the media source has closed', () => {
const setupResult = setup();
negotiate(setupResult);
const sourceBuffer = setupResult.instance.sourceBuffer;
sourceBuffer.buffered = createTimeRanges([[0, 20]]);
setupResult.video.currentTime = 19;
// A queued updateend fires after the source detaches on reconnect: its
// duration is NaN, so remove() would throw. The trim must be skipped.
setupResult.instance.isOpen.mockReturnValue(false);
sourceBuffer.fireUpdateEnd();
expect(sourceBuffer.remove).not.toBeCalled();
expect(setupResult.instance.setLiveSeekableRange).not.toBeCalled();
});
it('should trim media behind the retained window', () => {
const setupResult = setup();
negotiate(setupResult);
const sourceBuffer = setupResult.instance.sourceBuffer;
sourceBuffer.buffered = createTimeRanges([[0, 20]]);
setupResult.video.currentTime = 19;
sourceBuffer.fireUpdateEnd();
// Retains the last 15s (end 20 -> retainedStart 5).
expect(sourceBuffer.remove).toBeCalledWith(0, 5);
expect(setupResult.instance.setLiveSeekableRange).toBeCalledWith(5, 20);
});
it('should not trim when all media is within the retained window', () => {
const setupResult = setup();
negotiate(setupResult);
const sourceBuffer = setupResult.instance.sourceBuffer;
sourceBuffer.buffered = createTimeRanges([[16, 20]]);
setupResult.video.currentTime = 19;
sourceBuffer.fireUpdateEnd();
expect(sourceBuffer.remove).not.toBeCalled();
});
it('should not move the playhead when it falls behind the window', () => {
const setupResult = setup();
negotiate(setupResult);
const sourceBuffer = setupResult.instance.sourceBuffer;
sourceBuffer.buffered = createTimeRanges([[0, 20]]);
setupResult.video.currentTime = 2;
sourceBuffer.fireUpdateEnd();
// The trim no longer snaps the playhead forward; a playhead behind the
// window is caught up by rate (or, on resume, a jump), not dragged.
expect(setupResult.video.currentTime).toBe(2);
});
it('should raise the playback rate when lag rises above the stream norm', () => {
const setupResult = setup();
negotiate(setupResult);
const sourceBuffer = setupResult.instance.sourceBuffer;
sourceBuffer.buffered = createTimeRanges([[16, 20]]);
// Establish a healthy 1s lag norm.
setupResult.video.currentTime = 19;
sourceBuffer.fireUpdateEnd();
expect(setupResult.video.playbackRate).toBe(1);
// A 5s lag now exceeds the adaptive threshold and triggers a gentle
// catch-up above realtime.
setupResult.video.currentTime = 15;
sourceBuffer.fireUpdateEnd();
expect(setupResult.video.playbackRate).toBeGreaterThan(1);
expect(setupResult.video.playbackRate).toBeLessThan(1.1);
});
it('should reset the playback rate when caught up', () => {
const setupResult = setup();
negotiate(setupResult);
const sourceBuffer = setupResult.instance.sourceBuffer;
sourceBuffer.buffered = createTimeRanges([[16, 20]]);
setupResult.video.currentTime = 19;
setupResult.video.playbackRate = 2;
sourceBuffer.fireUpdateEnd();
expect(setupResult.video.playbackRate).toBe(1);
});
it('should leave an unchanged playback rate alone', () => {
const setupResult = setup();
negotiate(setupResult);
const sourceBuffer = setupResult.instance.sourceBuffer;
sourceBuffer.buffered = createTimeRanges([[16, 20]]);
setupResult.video.currentTime = 19;
sourceBuffer.fireUpdateEnd();
expect(setupResult.video.playbackRate).toBe(1);
});
it('should seek forward to the hold-back on WebKit instead of changing the rate', () => {
const setupResult = setup({ userAgent: SAFARI_17_USER_AGENT });
negotiate(setupResult);
const sourceBuffer = setupResult.instance.sourceBuffer;
sourceBuffer.buffered = createTimeRanges([[10, 20]]);
setupResult.video.currentTime = 13;
sourceBuffer.fireUpdateEnd();
// Default GOP 1s -> hold-back 3s; a 7s lag is far behind, so seek to
// bufferedEnd - 3 rather than changing the rate.
expect(setupResult.video.currentTime).toBe(17);
expect(setupResult.video.playbackRate).toBe(1);
});
it('should seek back to the hold-back when starving at the live edge on WebKit', () => {
const setupResult = setup({ userAgent: SAFARI_17_USER_AGENT });
negotiate(setupResult);
const sourceBuffer = setupResult.instance.sourceBuffer;
sourceBuffer.buffered = createTimeRanges([[10, 20]]);
setupResult.video.currentTime = 19.5;
sourceBuffer.fireUpdateEnd();
// Within a GOP of the edge -> seek back to bufferedEnd - hold-back.
expect(setupResult.video.currentTime).toBe(17);
});
it('should respect the forward-seek cooldown on WebKit', () => {
const setupResult = setup({ userAgent: SAFARI_17_USER_AGENT });
negotiate(setupResult);
const sourceBuffer = setupResult.instance.sourceBuffer;
sourceBuffer.buffered = createTimeRanges([[10, 20]]);
setupResult.video.currentTime = 13;
sourceBuffer.fireUpdateEnd();
expect(setupResult.video.currentTime).toBe(17);
// Fall behind again immediately: within the cooldown there is no second
// seek.
setupResult.video.currentTime = 13;
sourceBuffer.fireUpdateEnd();
expect(setupResult.video.currentTime).toBe(13);
// After the cooldown the forward seek resumes.
vi.advanceTimersByTime(6 * 1000);
sourceBuffer.fireUpdateEnd();
expect(setupResult.video.currentTime).toBe(17);
});
it('should trim but not chase the live edge while paused', () => {
const setupResult = setup();
negotiate(setupResult);
Object.defineProperty(setupResult.video, 'paused', {
configurable: true,
value: true,
});
const sourceBuffer = setupResult.instance.sourceBuffer;
sourceBuffer.buffered = createTimeRanges([[0, 20]]);
setupResult.video.currentTime = 2;
setupResult.video.playbackRate = 1;
sourceBuffer.fireUpdateEnd();
// Trim still bounds memory, but the playhead and rate are left untouched.
expect(sourceBuffer.remove).toBeCalled();
expect(setupResult.video.currentTime).toBe(2);
expect(setupResult.video.playbackRate).toBe(1);
});
it('should jump to the live edge on resume', () => {
const setupResult = setup();
negotiate(setupResult);
setupResult.instance.sourceBuffer.buffered = createTimeRanges([[10, 20]]);
setupResult.video.currentTime = 12;
setupResult.video.dispatchEvent(new Event('play'));
// Jumps to bufferedEnd - 0.75.
expect(setupResult.video.currentTime).toBe(19.25);
});
it('should not jump on resume when already near the live edge', () => {
const setupResult = setup();
negotiate(setupResult);
setupResult.instance.sourceBuffer.buffered = createTimeRanges([[10, 20]]);
setupResult.video.currentTime = 19.5;
setupResult.video.dispatchEvent(new Event('play'));
// Lag (0.5s) is under the resume threshold, so the playhead is left alone.
expect(setupResult.video.currentTime).toBe(19.5);
});
it('should ignore resume before any media is buffered', () => {
const setupResult = setup();
negotiate(setupResult);
setupResult.video.currentTime = 5;
setupResult.video.dispatchEvent(new Event('play'));
expect(setupResult.video.currentTime).toBe(5);
});
});
describe('media events', () => {
it('should report loaded media', () => {
const setupResult = setup();
negotiate(setupResult);
setupResult.video.dispatchEvent(new Event('loadeddata'));
expect(setupResult.loadedCallback).toBeCalledTimes(1);
});
it('should fail on video element errors', () => {
const setupResult = setup();
setupResult.source.start();
setupResult.video.dispatchEvent(new Event('error'));
expect(setupResult.failedCallback).toBeCalledWith('media_error');
});
});
describe('lifecycle', () => {
it('should stop cleanly', () => {
const setupResult = setup();
negotiate(setupResult);
setupResult.source.stop();
expect(setupResult.instance.detach).toBeCalledWith(setupResult.video);
expect(setupResult.channel.binaryCallback).toBeNull();
expect(setupResult.channel.getMessageCallbackCount()).toBe(0);
expect(setupResult.instance.getSourceOpenCallbackCount()).toBe(0);
setupResult.video.dispatchEvent(new Event('loadeddata'));
setupResult.video.dispatchEvent(new Event('error'));
expect(setupResult.loadedCallback).not.toBeCalled();
expect(setupResult.failedCallback).not.toBeCalled();
setupResult.instance.sourceBuffer.buffered = createTimeRanges([[0, 20]]);
setupResult.instance.sourceBuffer.fireUpdateEnd();
expect(setupResult.instance.sourceBuffer.remove).not.toBeCalled();
});
it('should stop the negotiation timer on stop', () => {
const setupResult = setup();
setupResult.source.start();
setupResult.instance.fireSourceOpen();
setupResult.source.stop();
vi.advanceTimersByTime(5 * 1000);
expect(setupResult.failedCallback).not.toBeCalled();
});
it('should tolerate stopping before starting', () => {
const { source } = setup();
expect(() => source.stop()).not.toThrow();
});
});
describe('reporting', () => {
it('should report capabilities before negotiation', () => {
const { source } = setup();
// Without negotiated codecs, audio detection falls back to the video
// element, whose jsdom audio track list is empty.
expect(source.getCapabilities()).toEqual({
supportsPause: true,
hasAudio: false,
has2WayAudio: false,
});
});
it('should report audio from negotiated codecs', () => {
const setupResult = setup();
negotiate(setupResult);
expect(setupResult.source.getCapabilities()).toEqual({
supportsPause: true,
hasAudio: true,
has2WayAudio: false,
});
});
it('should report no audio for video-only codecs', () => {
const setupResult = setup();
setupResult.source.start();
setupResult.instance.fireSourceOpen();
setupResult.channel.receiveMessage({
type: 'mse',
value: 'video/mp4; codecs="avc1.640029"',
});
expect(setupResult.source.getCapabilities()).toEqual({
supportsPause: true,
hasAudio: false,
has2WayAudio: false,
});
});
it('should report technology', () => {
const { source } = setup();
expect(source.getTechnology()).toEqual(['mse']);
});
it('should report an empty stream profile before negotiation', () => {
const { source } = setup();
expect(source.getStreamProfile()).toEqual({
hasVideo: false,
hasH265Video: false,
hasAudio: false,
hasAACAudio: false,
});
});
it('should report an H.264 and AAC stream profile from negotiated codecs', () => {
const setupResult = setup();
negotiate(setupResult);
expect(setupResult.source.getStreamProfile()).toEqual({
hasVideo: true,
hasH265Video: false,
hasAudio: true,
hasAACAudio: true,
});
});
it('should report an H.265 stream profile', () => {
const setupResult = setup();
setupResult.source.start();
setupResult.instance.fireSourceOpen();
setupResult.channel.receiveMessage({
type: 'mse',
value: 'video/mp4; codecs="hvc1.1.6.L153.B0,opus"',
});
expect(setupResult.source.getStreamProfile()).toEqual({
hasVideo: true,
hasH265Video: true,
hasAudio: true,
hasAACAudio: false,
});
});
});
});
@@ -0,0 +1,549 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { WebRTCStreamSource } from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/sources/webrtc';
import type {
StreamSourceContext,
VideoStreamTarget,
} from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/types';
import { flushPromises } from '../../../../../test-utils';
import {
FakeMediaStream,
FakeMediaStreamTrack,
FakeRTCPeerConnection,
FakeStreamSourceChannel,
} from '../test-utils';
// @vitest-environment jsdom
describe('WebRTCStreamSource', () => {
const setup = (options?: { microphoneStream?: FakeMediaStream | null }) => {
const video = document.createElement('video');
const channel = new FakeStreamSourceChannel();
const loadedCallback = vi.fn();
const failedCallback = vi.fn();
const context: StreamSourceContext<VideoStreamTarget> = {
target: { kind: 'video', video },
channel,
callbacks: { loadedCallback, failedCallback },
};
const pc = new FakeRTCPeerConnection();
const createPeerConnection = vi.fn(() => pc.asPeerConnection());
const source = new WebRTCStreamSource(context, {
createPeerConnection,
createMediaStream: (tracks) =>
new FakeMediaStream(tracks as unknown as FakeMediaStreamTrack[]).asMediaStream(),
microphoneStream: options?.microphoneStream?.asMediaStream() ?? null,
});
return {
channel,
context,
createPeerConnection,
failedCallback,
loadedCallback,
pc,
source,
video,
};
};
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('transceivers', () => {
it('should pre-arm a sendonly audio transceiver and recvonly video and audio', () => {
const { source, pc } = setup();
source.start();
expect(pc.transceivers).toHaveLength(3);
expect(pc.transceivers[0].direction).toBe('sendonly');
expect(pc.transceivers[1].direction).toBe('recvonly');
expect(pc.transceivers[2].direction).toBe('recvonly');
// Kind-only pre-arm: no track, so no getUserMedia and no permission prompt.
expect(pc.transceivers[0].sender.track).toBeNull();
});
it('should pre-arm with the current microphone track', () => {
const micTrack = new FakeMediaStreamTrack('audio');
const { source, pc } = setup({
microphoneStream: new FakeMediaStream([micTrack]),
});
source.start();
expect(pc.transceivers[0].sender.track).toBe(micTrack);
});
});
describe('negotiation', () => {
it('should send an offer after setting the local description', async () => {
const { source, channel, pc } = setup();
source.start();
await flushPromises();
expect(pc.setLocalDescription).toHaveBeenCalled();
expect(channel.sent).toContainEqual({
type: 'webrtc/offer',
value: 'v=0\r\noffer',
});
});
it('should send an empty offer sdp when the browser omits it', async () => {
const { source, channel, pc } = setup();
pc.createOffer.mockResolvedValue({ type: 'offer', sdp: undefined });
source.start();
await flushPromises();
expect(channel.sent).toContainEqual({ type: 'webrtc/offer', value: '' });
});
it('should not send an offer if the connection was replaced mid-negotiation', async () => {
const { source, channel, pc } = setup();
let resolveOffer: (value: { type: string; sdp: string }) => void = () => {};
pc.createOffer.mockReturnValue(
new Promise((resolve) => {
resolveOffer = resolve;
}),
);
source.start();
source.stop();
resolveOffer({ type: 'offer', sdp: 'v=0\r\noffer' });
await flushPromises();
expect(channel.sent).not.toContainEqual(
expect.objectContaining({ type: 'webrtc/offer' }),
);
});
it('should not send an offer if replaced between offer and local description', async () => {
const { source, channel, pc } = setup();
let resolveLocal: () => void = () => {};
pc.setLocalDescription.mockReturnValue(
new Promise((resolve) => {
resolveLocal = resolve;
}),
);
source.start();
await flushPromises();
source.stop();
resolveLocal();
await flushPromises();
expect(channel.sent).not.toContainEqual(
expect.objectContaining({ type: 'webrtc/offer' }),
);
});
it('should fail on a negotiation error for the current connection', async () => {
const { source, pc, failedCallback } = setup();
pc.createOffer.mockRejectedValue(new Error('offer failed'));
source.start();
await flushPromises();
expect(failedCallback).toHaveBeenCalledWith('media_error');
});
it('should ignore a negotiation error after the connection was replaced', async () => {
const { source, pc, failedCallback } = setup();
let rejectOffer: (reason: Error) => void = () => {};
pc.createOffer.mockReturnValue(
new Promise((_resolve, reject) => {
rejectOffer = reject;
}),
);
source.start();
source.stop();
rejectOffer(new Error('offer failed'));
await flushPromises();
expect(failedCallback).not.toHaveBeenCalled();
});
});
describe('signaling', () => {
it('should send ICE candidates to the server', () => {
const { source, channel, pc } = setup();
source.start();
pc.fireIceCandidate('candidate:1 1 udp 2 1.2.3.4 5 typ host');
expect(channel.sent).toContainEqual({
type: 'webrtc/candidate',
value: 'candidate:1 1 udp 2 1.2.3.4 5 typ host',
});
});
it('should send an empty candidate at end-of-candidates', () => {
const { source, channel, pc } = setup();
source.start();
pc.fireIceCandidate(null);
expect(channel.sent).toContainEqual({ type: 'webrtc/candidate', value: '' });
});
it('should not send a candidate after stop', () => {
const { source, channel, pc } = setup();
source.start();
source.stop();
const sentBefore = channel.sent.length;
pc.fireIceCandidate('candidate:1 1 udp 2 1.2.3.4 5 typ host');
expect(channel.sent).toHaveLength(sentBefore);
});
it('should apply the server answer', () => {
const { source, channel, pc } = setup();
source.start();
channel.receiveMessage({ type: 'webrtc/answer', value: 'v=0\r\nanswer' });
expect(pc.setRemoteDescription).toHaveBeenCalledWith({
type: 'answer',
sdp: 'v=0\r\nanswer',
});
});
it('should swallow a rejected setRemoteDescription', async () => {
const { source, channel, pc } = setup();
pc.setRemoteDescription.mockRejectedValue(new Error('bad answer'));
source.start();
expect(() =>
channel.receiveMessage({ type: 'webrtc/answer', value: 'v=0\r\nanswer' }),
).not.toThrow();
await flushPromises();
});
it('should swallow a rejected addIceCandidate', async () => {
const { source, channel, pc } = setup();
pc.addIceCandidate.mockRejectedValue(new Error('bad candidate'));
source.start();
expect(() =>
channel.receiveMessage({
type: 'webrtc/candidate',
value: 'candidate:1 1 udp 2 1.2.3.4 5 typ host',
}),
).not.toThrow();
await flushPromises();
});
it('should add server ICE candidates with a fixed sdpMid', () => {
const { source, channel, pc } = setup();
source.start();
channel.receiveMessage({
type: 'webrtc/candidate',
value: 'candidate:2 1 udp 1 5.6.7.8 9 typ host',
});
expect(pc.addIceCandidate).toHaveBeenCalledWith({
candidate: 'candidate:2 1 udp 1 5.6.7.8 9 typ host',
sdpMid: '0',
});
});
it('should ignore an empty server ICE candidate', () => {
const { source, channel, pc } = setup();
source.start();
channel.receiveMessage({ type: 'webrtc/candidate', value: '' });
expect(pc.addIceCandidate).not.toHaveBeenCalled();
});
it('should ignore messages with a non-string value', () => {
const { source, channel, pc } = setup();
source.start();
channel.receiveMessage({ type: 'webrtc/answer', value: 42 });
expect(pc.setRemoteDescription).not.toHaveBeenCalled();
});
it('should ignore messages after the connection was replaced', () => {
const { source, channel, pc } = setup();
source.start();
source.stop();
channel.receiveMessage({ type: 'webrtc/answer', value: 'v=0\r\nanswer' });
expect(pc.setRemoteDescription).not.toHaveBeenCalled();
});
});
describe('server errors', () => {
it('should fail on a webrtc server error', () => {
const { source, channel, failedCallback } = setup();
source.start();
channel.receiveMessage({ type: 'error', value: 'webrtc/offer: stream not found' });
expect(failedCallback).toHaveBeenCalledWith('server_error');
});
it('should ignore server errors for other modes', () => {
const { source, channel, failedCallback } = setup();
source.start();
channel.receiveMessage({ type: 'error', value: 'mse: stream not found' });
expect(failedCallback).not.toHaveBeenCalled();
});
});
describe('connection', () => {
it('should attach the received stream and report loaded', () => {
const { source, pc, video, loadedCallback } = setup();
source.start();
pc.fireConnectionStateChange('connected');
expect(video.srcObject).not.toBeNull();
video.dispatchEvent(new Event('loadeddata'));
expect(loadedCallback).toHaveBeenCalledTimes(1);
});
it('should only build the received stream once', () => {
const { source, pc, video } = setup();
source.start();
pc.fireConnectionStateChange('connected');
const firstStream = video.srcObject;
pc.fireConnectionStateChange('connected');
expect(video.srcObject).toBe(firstStream);
});
it('should fail when the connection fails', () => {
const { source, pc, failedCallback } = setup();
source.start();
pc.fireConnectionStateChange('failed');
expect(failedCallback).toHaveBeenCalledWith('media_error');
});
it('should not fail on a recoverable disconnect', () => {
const { source, pc, failedCallback } = setup();
source.start();
pc.fireConnectionStateChange('disconnected');
expect(failedCallback).not.toHaveBeenCalled();
});
it('should fail when a disconnect escalates to failed', () => {
const { source, pc, failedCallback } = setup();
source.start();
pc.fireConnectionStateChange('disconnected');
pc.fireConnectionStateChange('failed');
expect(failedCallback).toHaveBeenCalledWith('media_error');
});
it('should ignore intermediate connection states', () => {
const { source, pc, video, failedCallback } = setup();
source.start();
pc.fireConnectionStateChange('connecting');
expect(video.srcObject).toBeFalsy();
expect(failedCallback).not.toHaveBeenCalled();
});
it('should ignore connection state changes after stop', () => {
const { source, pc, failedCallback } = setup();
source.start();
source.stop();
pc.fireConnectionStateChange('failed');
expect(failedCallback).not.toHaveBeenCalled();
});
it('should build the media stream with the global MediaStream by default', () => {
vi.stubGlobal('MediaStream', FakeMediaStream);
const video = document.createElement('video');
const channel = new FakeStreamSourceChannel();
const pc = new FakeRTCPeerConnection();
const source = new WebRTCStreamSource(
{
target: { kind: 'video', video },
channel,
callbacks: { loadedCallback: vi.fn(), failedCallback: vi.fn() },
},
{ createPeerConnection: () => pc.asPeerConnection() },
);
source.start();
pc.fireConnectionStateChange('connected');
expect(source.getMediaStream()).toBeInstanceOf(FakeMediaStream);
vi.unstubAllGlobals();
});
});
describe('connect timeout', () => {
it('should fail if no frame decodes within the connect timeout', () => {
const { source, pc, failedCallback } = setup();
source.start();
pc.fireConnectionStateChange('connected');
// Connected, but the real video never fires loadeddata.
vi.advanceTimersByTime(5 * 1000);
expect(failedCallback).toHaveBeenCalledWith('connect_timeout');
});
it('should cancel the connect timeout once loaded', () => {
const { source, pc, video, failedCallback } = setup();
source.start();
pc.fireConnectionStateChange('connected');
video.dispatchEvent(new Event('loadeddata'));
vi.advanceTimersByTime(5 * 1000);
expect(failedCallback).not.toHaveBeenCalledWith('connect_timeout');
});
});
describe('lifecycle', () => {
it('should close the connection on stop and clear the video', () => {
const { source, pc, video } = setup();
source.start();
pc.fireConnectionStateChange('connected');
source.stop();
expect(pc.close).toHaveBeenCalled();
expect(video.srcObject).toBeNull();
expect(source.getMediaStream()).toBeNull();
});
it('should tolerate stopping before starting', () => {
const { source } = setup();
expect(() => source.stop()).not.toThrow();
});
});
describe('reporting', () => {
it('should expose the media stream and peer connection', () => {
const { source, pc } = setup();
source.start();
pc.fireConnectionStateChange('connected');
expect(source.getMediaStream()).not.toBeNull();
expect(source.getPeerConnection()).toBe(pc.asPeerConnection());
});
it('should report webrtc technology', () => {
const { source } = setup();
expect(source.getTechnology()).toEqual(['webrtc']);
});
it('should report a stream profile from the tracks and SDP', () => {
const { source, pc } = setup();
source.start();
pc.setRemoteDescription({ sdp: 'a=rtpmap:98 H265/90000\r\n' });
pc.fireConnectionStateChange('connected');
expect(source.getStreamProfile()).toEqual({
hasVideo: true,
hasH265Video: true,
hasAudio: true,
hasAACAudio: false,
});
});
it('should report an empty profile before connection', () => {
const { source } = setup();
source.start();
expect(source.getStreamProfile()).toEqual({
hasVideo: false,
hasH265Video: false,
hasAudio: false,
hasAACAudio: false,
});
});
it('should report 2-way audio capability once a mic track is armed', () => {
const micTrack = new FakeMediaStreamTrack('audio');
const { source, pc } = setup({
microphoneStream: new FakeMediaStream([micTrack]),
});
source.start();
pc.fireConnectionStateChange('connected');
expect(source.getCapabilities().has2WayAudio).toBe(true);
expect(source.getCapabilities().supportsPause).toBe(true);
});
});
describe('setMicrophoneStream', () => {
it('should do nothing for an unchanged stream', async () => {
const stream = new FakeMediaStream([new FakeMediaStreamTrack('audio')]);
const { source, pc } = setup({ microphoneStream: stream });
source.start();
await source.setMicrophoneStream(stream.asMediaStream());
expect(pc.getMicrophoneTransceiver().sender.replaceTrack).not.toHaveBeenCalled();
});
it('should replace the outbound track without renegotiating', async () => {
const { source, pc } = setup();
source.start();
const newTrack = new FakeMediaStreamTrack('audio');
await source.setMicrophoneStream(new FakeMediaStream([newTrack]).asMediaStream());
expect(pc.getMicrophoneTransceiver().sender.replaceTrack).toHaveBeenCalledWith(
newTrack,
);
});
it('should clear the outbound track for a null stream', async () => {
const stream = new FakeMediaStream([new FakeMediaStreamTrack('audio')]);
const { source, pc } = setup({ microphoneStream: stream });
source.start();
await source.setMicrophoneStream(null);
expect(pc.getMicrophoneTransceiver().sender.replaceTrack).toHaveBeenCalledWith(
null,
);
});
it('should do nothing before there is a peer connection', async () => {
const { source, failedCallback } = setup();
await source.setMicrophoneStream(
new FakeMediaStream([new FakeMediaStreamTrack('audio')]).asMediaStream(),
);
expect(failedCallback).not.toHaveBeenCalled();
});
it('should fail when a current replaceTrack rejects', async () => {
const { source, pc, failedCallback } = setup();
source.start();
pc.getMicrophoneTransceiver().sender.replaceTrack.mockRejectedValue(
new Error('replace failed'),
);
await source.setMicrophoneStream(
new FakeMediaStream([new FakeMediaStreamTrack('audio')]).asMediaStream(),
);
expect(failedCallback).toHaveBeenCalledWith('two_way_audio_error');
});
it('should ignore a stale replaceTrack rejection after stop', async () => {
const { source, pc, failedCallback } = setup();
source.start();
let rejectReplace: (reason: Error) => void = () => {};
pc.getMicrophoneTransceiver().sender.replaceTrack.mockReturnValue(
new Promise((_resolve, reject) => {
rejectReplace = reject;
}),
);
const promise = source.setMicrophoneStream(
new FakeMediaStream([new FakeMediaStreamTrack('audio')]).asMediaStream(),
);
source.stop();
rejectReplace(new Error('replace failed'));
await promise;
expect(failedCallback).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,270 @@
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);
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { arrayBufferToBase64 } from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/base64';
// @vitest-environment jsdom
describe('arrayBufferToBase64', () => {
it('should base64-encode the bytes', () => {
const bytes = new TextEncoder().encode('Hi');
expect(arrayBufferToBase64(bytes.buffer)).toBe('SGk=');
});
it('should encode an empty buffer as an empty string', () => {
expect(arrayBufferToBase64(new ArrayBuffer(0))).toBe('');
});
});
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { BoundedBufferQueue } from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/bounded-buffer-queue';
describe('BoundedBufferQueue', () => {
it('should start empty', () => {
expect(new BoundedBufferQueue(10).isEmpty).toBe(true);
});
it('should accept a chunk within the byte cap', () => {
const queue = new BoundedBufferQueue(10);
expect(queue.push(new ArrayBuffer(4))).toBe(true);
expect(queue.isEmpty).toBe(false);
});
it('should accept a chunk that fills the cap exactly', () => {
expect(new BoundedBufferQueue(10).push(new ArrayBuffer(10))).toBe(true);
});
it('should reject a chunk that would exceed the byte cap and stage nothing', () => {
const queue = new BoundedBufferQueue(10);
expect(queue.push(new ArrayBuffer(8))).toBe(true);
expect(queue.push(new ArrayBuffer(3))).toBe(false);
// The rejected chunk left the byte total unchanged, so a smaller one fits.
expect(queue.push(new ArrayBuffer(2))).toBe(true);
});
it('should return staged chunks oldest first', () => {
const queue = new BoundedBufferQueue(10);
const first = new ArrayBuffer(2);
const second = new ArrayBuffer(3);
queue.push(first);
queue.push(second);
expect(queue.shift()).toBe(first);
expect(queue.shift()).toBe(second);
expect(queue.isEmpty).toBe(true);
});
it('should return null when shifting an empty queue', () => {
expect(new BoundedBufferQueue(10).shift()).toBeNull();
});
it('should free the shifted chunk bytes back toward the cap', () => {
const queue = new BoundedBufferQueue(10);
queue.push(new ArrayBuffer(8));
expect(queue.push(new ArrayBuffer(4))).toBe(false);
queue.shift();
expect(queue.push(new ArrayBuffer(4))).toBe(true);
});
it('should reset the chunks and the byte total on clear', () => {
const queue = new BoundedBufferQueue(10);
queue.push(new ArrayBuffer(8));
queue.clear();
expect(queue.isEmpty).toBe(true);
// The byte total reset too, so the full cap is available again.
expect(queue.push(new ArrayBuffer(10))).toBe(true);
});
});
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import {
convertToCodecString,
getCodecsForUserAgent,
GO2RTC_CODECS,
selectSupportedCodecs,
} from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/codecs';
import { CHROME_USER_AGENT, SAFARI_17_USER_AGENT } from '../test-utils';
const safariUserAgent = (version: number): string =>
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 ' +
`(KHTML, like Gecko) Version/${version}.0 Safari/605.1.15`;
describe('getCodecsForUserAgent', () => {
it('should return all codecs for non-Safari browsers', () => {
expect(getCodecsForUserAgent(CHROME_USER_AGENT)).toEqual([...GO2RTC_CODECS]);
});
it('should exclude AAC and later for Safari before version 13', () => {
expect(getCodecsForUserAgent(safariUserAgent(12))).toEqual([
'avc1.640029',
'avc1.64002A',
'avc1.640033',
'hvc1.1.6.L153.B0',
]);
});
it('should exclude FLAC and later for Safari before version 14', () => {
expect(getCodecsForUserAgent(safariUserAgent(13))).toEqual([
'avc1.640029',
'avc1.64002A',
'avc1.640033',
'hvc1.1.6.L153.B0',
'mp4a.40.2',
'mp4a.40.5',
]);
});
it('should exclude OPUS for modern Safari', () => {
expect(getCodecsForUserAgent(SAFARI_17_USER_AGENT)).toEqual([
'avc1.640029',
'avc1.64002A',
'avc1.640033',
'hvc1.1.6.L153.B0',
'mp4a.40.2',
'mp4a.40.5',
'flac',
]);
});
});
describe('selectSupportedCodecs', () => {
it('should include all supported codecs for video and audio', () => {
expect(
selectSupportedCodecs(GO2RTC_CODECS, { audio: true, video: true }, () => true),
).toEqual([
'avc1.640029',
'avc1.64002A',
'avc1.640033',
'hvc1.1.6.L153.B0',
'mp4a.40.2',
'mp4a.40.5',
'flac',
'opus',
]);
});
it('should exclude audio codecs when audio is not requested', () => {
expect(
selectSupportedCodecs(GO2RTC_CODECS, { audio: false, video: true }, () => true),
).toEqual(['avc1.640029', 'avc1.64002A', 'avc1.640033', 'hvc1.1.6.L153.B0']);
});
it('should exclude video codecs when video is not requested', () => {
expect(
selectSupportedCodecs(GO2RTC_CODECS, { audio: true, video: false }, () => true),
).toEqual(['mp4a.40.2', 'mp4a.40.5', 'flac', 'opus']);
});
it('should exclude codecs the support callback rejects', () => {
expect(
selectSupportedCodecs(
GO2RTC_CODECS,
{ audio: true, video: true },
(mimeType) => mimeType === 'video/mp4; codecs="avc1.640029"',
),
).toEqual(['avc1.640029']);
});
});
describe('convertToCodecString', () => {
it('should join codecs with commas', () => {
expect(convertToCodecString(['avc1.640029', 'mp4a.40.2'])).toBe(
'avc1.640029,mp4a.40.2',
);
});
it('should return an empty string for no codecs', () => {
expect(convertToCodecString([])).toBe('');
});
});
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { mapFailureReasonToIssueReason } from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/failure-reason';
describe('mapFailureReasonToIssueReason', () => {
it.each([
['connect_timeout', 'not_loading'],
['negotiation_timeout', 'not_loading'],
['media_error', 'playback_error'],
['buffer_overflow', 'playback_error'],
['two_way_audio_error', 'two_way_audio_error'],
['server_error', 'server_error'],
['unsupported', 'unsupported'],
] as const)('should map %s to the %s cause', (reason, expected) => {
expect(mapFailureReasonToIssueReason(reason)).toBe(expected);
});
it('should map a null reason to a generic playback error', () => {
expect(mapFailureReasonToIssueReason(null)).toBe('playback_error');
});
});
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import {
GOP_SAMPLE_WINDOW_SIZE,
GOPCadenceEstimator,
} from '../../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/live-edge-tracker/gop-cadence-estimator';
describe('GOPCadenceEstimator', () => {
it('should return the default GOP before any samples', () => {
expect(new GOPCadenceEstimator().estimateSeconds()).toBe(1);
});
it('should average the interval between buffer advances', () => {
const estimator = new GOPCadenceEstimator();
estimator.sample(10, new Date(0));
estimator.sample(12, new Date(2000));
estimator.sample(14, new Date(4000));
expect(estimator.estimateSeconds()).toBe(2);
});
it('should ignore updates that do not advance the buffer', () => {
const estimator = new GOPCadenceEstimator();
estimator.sample(10, new Date(0));
estimator.sample(12, new Date(2000));
// A trim: same buffered end, later time -> not a delivery interval, and it
// must not reset the last-advance timestamp.
estimator.sample(12, new Date(5000));
estimator.sample(14, new Date(6000));
// Intervals are 2s (0 -> 2000) and 4s (2000 -> 6000) -> average 3s.
expect(estimator.estimateSeconds()).toBe(3);
});
it('should ignore advances with no elapsed time', () => {
const estimator = new GOPCadenceEstimator();
estimator.sample(10, new Date(1000));
estimator.sample(12, new Date(1000));
expect(estimator.estimateSeconds()).toBe(1);
});
it('should evict the oldest sample beyond the window', () => {
const estimator = new GOPCadenceEstimator();
// A slow 5s interval, then a full window of 1s intervals evicts it.
estimator.sample(0, new Date(0));
estimator.sample(5, new Date(5000));
let time = 5000;
let end = 5;
for (let i = 0; i < GOP_SAMPLE_WINDOW_SIZE; ++i) {
time += 1000;
end += 1;
estimator.sample(end, new Date(time));
}
expect(estimator.estimateSeconds()).toBe(1);
});
});
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { LiveEdgeTracker } from '../../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/live-edge-tracker';
import { createStatus } from './test-utils';
describe('LiveEdgeTracker', () => {
it('should use the seek strategy on WebKit', () => {
const tracker = new LiveEdgeTracker({ webkit: true });
// Far behind: the WebKit strategy seeks to the default 3s hold-back.
expect(tracker.next(createStatus(20, 13))).toEqual({ action: 'seek', seconds: 17 });
});
it('should use the playback-rate strategy on other browsers', () => {
const tracker = new LiveEdgeTracker({ webkit: false });
expect(tracker.next(createStatus(20, 18))).toEqual({ action: 'rate', rate: 1 });
});
});
@@ -0,0 +1,75 @@
import { assert, describe, expect, it } from 'vitest';
import {
LAG_SAMPLE_WINDOW_SIZE,
NonWebKitLiveEdgeStrategy,
} from '../../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/live-edge-tracker/non-webkit';
import { createStatus } from './test-utils';
describe('NonWebKitLiveEdgeStrategy', () => {
it('should play at realtime when close to the live edge', () => {
const strategy = new NonWebKitLiveEdgeStrategy();
expect(strategy.next(createStatus(20, 18))).toEqual({ action: 'rate', rate: 1 });
});
it('should nudge the rate up when lag exceeds the stream norm', () => {
const strategy = new NonWebKitLiveEdgeStrategy();
for (let i = 0; i < LAG_SAMPLE_WINDOW_SIZE; ++i) {
strategy.next(createStatus(20, 19));
}
const action = strategy.next(createStatus(20, 15));
assert(action.action === 'rate');
expect(action.rate).toBeGreaterThan(1);
expect(action.rate).toBeLessThanOrEqual(2);
});
it('should cap the catch-up rate', () => {
const strategy = new NonWebKitLiveEdgeStrategy();
for (let i = 0; i < LAG_SAMPLE_WINDOW_SIZE; ++i) {
strategy.next(createStatus(20, 20));
}
expect(strategy.next(createStatus(60, 10))).toEqual({ action: 'rate', rate: 2 });
});
it('should stay near realtime within the stream normal lag', () => {
const strategy = new NonWebKitLiveEdgeStrategy();
for (let i = 0; i < LAG_SAMPLE_WINDOW_SIZE; ++i) {
strategy.next(createStatus(24, 20));
}
const action = strategy.next(createStatus(24, 20));
assert(action.action === 'rate');
expect(action.rate).toBeCloseTo(1, 2);
});
it('should catch up hard before any baseline samples exist', () => {
const strategy = new NonWebKitLiveEdgeStrategy();
// The very first sample is taken while already catching up, so it is
// excluded and there is no average to temper the threshold.
expect(strategy.next(createStatus(20, 15, { playbackRate: 2 }))).toEqual({
action: 'rate',
rate: 2,
});
});
it('should drop stale lag samples as the stream recovers', () => {
const strategy = new NonWebKitLiveEdgeStrategy();
for (let i = 0; i < LAG_SAMPLE_WINDOW_SIZE; ++i) {
strategy.next(createStatus(26, 20));
}
// A full window of low lag evicts the earlier high-lag samples.
for (let i = 0; i < LAG_SAMPLE_WINDOW_SIZE; ++i) {
strategy.next(createStatus(21, 20));
}
const action = strategy.next(createStatus(25, 20));
assert(action.action === 'rate');
expect(action.rate).toBeGreaterThan(1.1);
});
});
@@ -0,0 +1,10 @@
export const createStatus = (
bufferedEndSeconds: number,
currentTimeSeconds: number,
options?: { playbackRate?: number; now?: Date },
) => ({
bufferedEndSeconds,
currentTimeSeconds,
playbackRate: options?.playbackRate ?? 1,
now: options?.now ?? new Date(0),
});
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import { GOP_SAMPLE_WINDOW_SIZE } from '../../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/live-edge-tracker/gop-cadence-estimator';
import { WebKitLiveEdgeStrategy } from '../../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/live-edge-tracker/webkit';
import { createStatus } from './test-utils';
describe('WebKitLiveEdgeStrategy', () => {
// With no measured cadence the GOP defaults to 1s, so the hold-back is 3s.
it('should do nothing within the hold-back band', () => {
const strategy = new WebKitLiveEdgeStrategy();
expect(strategy.next(createStatus(20, 18))).toEqual({ action: 'none' });
});
it('should seek back to the hold-back when starving within a GOP of the edge', () => {
const strategy = new WebKitLiveEdgeStrategy();
expect(strategy.next(createStatus(20, 19.5))).toEqual({
action: 'seek',
seconds: 17,
});
});
it('should seek forward to the hold-back when far behind', () => {
const strategy = new WebKitLiveEdgeStrategy();
expect(strategy.next(createStatus(20, 13))).toEqual({ action: 'seek', seconds: 17 });
});
it('should not seek forward again within the cooldown', () => {
const strategy = new WebKitLiveEdgeStrategy();
strategy.next(createStatus(20, 13, { now: new Date(0) }));
expect(strategy.next(createStatus(20, 13, { now: new Date(2000) }))).toEqual({
action: 'none',
});
});
it('should seek forward again after the cooldown', () => {
const strategy = new WebKitLiveEdgeStrategy();
strategy.next(createStatus(20, 13, { now: new Date(0) }));
expect(strategy.next(createStatus(20, 13, { now: new Date(6000) }))).toEqual({
action: 'seek',
seconds: 17,
});
});
it('should widen the hold-back to the measured GOP cadence', () => {
const strategy = new WebKitLiveEdgeStrategy();
// Buffer advances 2s apart -> GOP ~2s -> hold-back 6s. Runs past the sample
// window so the oldest samples are evicted. (Sampling happens before the
// action, so intermediate seeks do not affect the estimate.)
let end = 20;
for (let i = 1; i <= GOP_SAMPLE_WINDOW_SIZE + 2; ++i) {
end = 20 + i * 2;
strategy.next(createStatus(end, end - 4, { now: new Date(i * 2000) }));
}
// A far-behind sample now seeks to bufferedEnd - 6, not - 3.
expect(
strategy.next(createStatus(end, end - 12, { now: new Date(100000) })),
).toEqual({
action: 'seek',
seconds: end - 6,
});
});
it('should clamp the widened hold-back to the maximum', () => {
const strategy = new WebKitLiveEdgeStrategy();
// Buffer advances 5s apart -> GOP 5s -> 15s, clamped to 8s.
let end = 0;
for (let i = 1; i <= GOP_SAMPLE_WINDOW_SIZE + 1; ++i) {
end = i * 5;
strategy.next(createStatus(end, end - 2, { now: new Date(i * 5000) }));
}
expect(
strategy.next(createStatus(end, end - 19, { now: new Date(100000) })),
).toEqual({
action: 'seek',
seconds: end - 8,
});
});
it('should clamp the shrunken hold-back to the minimum', () => {
const strategy = new WebKitLiveEdgeStrategy();
// Buffer advances 0.3s apart -> GOP 0.3s -> 0.9s, clamped to 1.5s.
let end = 20;
for (let i = 1; i <= GOP_SAMPLE_WINDOW_SIZE + 1; ++i) {
end = 20 + i * 0.3;
strategy.next(createStatus(end, end - 0.1, { now: new Date(i * 300) }));
}
expect(strategy.next(createStatus(end, end - 3, { now: new Date(100000) }))).toEqual(
{
action: 'seek',
seconds: end - 1.5,
},
);
});
});
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { isServerErrorForMode } from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/messages';
describe('isServerErrorForMode', () => {
it('should match an error for the mode', () => {
expect(isServerErrorForMode({ type: 'error', value: 'mse: not found' }, 'mse')).toBe(
true,
);
});
it('should not match an error for another mode', () => {
expect(isServerErrorForMode({ type: 'error', value: 'webrtc: failed' }, 'mse')).toBe(
false,
);
});
it('should not match a non-error message', () => {
expect(isServerErrorForMode({ type: 'mse', value: 'codecs' }, 'mse')).toBe(false);
});
it('should not match an error with a non-string value', () => {
expect(isServerErrorForMode({ type: 'error', value: 42 }, 'mse')).toBe(false);
});
it('should not match an error with no value', () => {
expect(isServerErrorForMode({ type: 'error' }, 'mse')).toBe(false);
});
});
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import type { StreamProfile } from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/types';
import { getPreferredSource } from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/source-priority';
const createProfile = (overrides: Partial<StreamProfile>): StreamProfile => ({
hasVideo: false,
hasH265Video: false,
hasAudio: false,
hasAACAudio: false,
...overrides,
});
describe('getPreferredSource', () => {
it('should prefer WebRTC when both offer equal H.264 video and audio', () => {
expect(
getPreferredSource(
createProfile({ hasVideo: true, hasAudio: true }),
createProfile({ hasVideo: true, hasAACAudio: true }),
),
).toBe('webrtc');
});
it('should prefer WebRTC H.265 over binary-source H.265', () => {
expect(
getPreferredSource(
createProfile({ hasVideo: true, hasH265Video: true }),
createProfile({ hasVideo: true, hasH265Video: true }),
),
).toBe('webrtc');
});
it('should prefer binary-source H.265 with audio over WebRTC H.264 with audio', () => {
expect(
getPreferredSource(
createProfile({ hasVideo: true, hasAudio: true }),
createProfile({ hasVideo: true, hasH265Video: true, hasAACAudio: true }),
),
).toBe('binary');
});
it('should prefer WebRTC when the binary source has no media', () => {
expect(
getPreferredSource(createProfile({ hasVideo: true }), createProfile({})),
).toBe('webrtc');
});
it('should prefer the binary source when WebRTC has no video', () => {
expect(
getPreferredSource(
createProfile({ hasAudio: true }),
createProfile({ hasVideo: true }),
),
).toBe('binary');
});
it('should prefer the stream with audio when video is otherwise equal', () => {
expect(
getPreferredSource(
createProfile({ hasVideo: true }),
createProfile({ hasVideo: true, hasAACAudio: true }),
),
).toBe('binary');
});
it('should not count non-AAC MSE audio', () => {
// MSE opus audio (hasAudio true, hasAACAudio false) does not raise the
// binary-side score, so WebRTC video wins.
expect(
getPreferredSource(
createProfile({ hasVideo: true }),
createProfile({ hasVideo: true, hasAudio: true }),
),
).toBe('webrtc');
});
});
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import {
getSafariMajorVersion,
isWebKitUserAgent,
} from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/user-agent';
describe('isWebKitUserAgent', () => {
it.each([
[
'Safari on macOS',
'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',
true,
],
[
'Safari on iOS',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 ' +
'(KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1',
true,
],
[
'iOS WebView without a Safari token',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 ' +
'(KHTML, like Gecko) Mobile/21A329',
true,
],
[
'Chrome on iOS',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 ' +
'(KHTML, like Gecko) CriOS/123.0.6312.52 Mobile/15E148 Safari/604.1',
true,
],
[
'Firefox on iOS',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 ' +
'(KHTML, like Gecko) FxiOS/124.0 Mobile/15E148 Safari/605.1.15',
true,
],
[
'Chrome on Linux',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
false,
],
[
'Chrome on Android',
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/126.0.6478.71 Mobile Safari/537.36',
false,
],
[
'Edge on Windows',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0',
false,
],
[
'Firefox on Linux',
'Mozilla/5.0 (X11; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0',
false,
],
])('should detect %s', (_name: string, userAgent: string, expected: boolean) => {
expect(isWebKitUserAgent(userAgent)).toBe(expected);
});
});
describe('getSafariMajorVersion', () => {
it('should return the major version for Safari', () => {
expect(
getSafariMajorVersion(
'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',
),
).toBe(17);
});
it('should return null for a non-Safari user agent', () => {
expect(
getSafariMajorVersion(
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
),
).toBe(null);
});
});
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { sdpHasH265 } from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/webrtc-sdp';
describe('sdpHasH265', () => {
it('should detect an H.265 rtpmap', () => {
expect(sdpHasH265('a=rtpmap:98 H265/90000\r\n')).toBe(true);
});
it('should return false without an H.265 rtpmap', () => {
expect(sdpHasH265('a=rtpmap:96 H264/90000\r\n')).toBe(false);
});
});
@@ -11,3 +11,14 @@ it('should dispatch live error event', () => {
dispatchLiveErrorEvent(element);
expect(handler).toBeCalled();
});
it('should forward the reason as the event detail', () => {
const element = document.createElement('div');
const handler = vi.fn();
element.addEventListener('advanced-camera-card:live:error', handler);
dispatchLiveErrorEvent(element, 'unsupported');
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ detail: 'unsupported' }),
);
});
@@ -6,7 +6,11 @@ import {
MediaActionsController,
type MediaActionsControllerOptions,
} from '../../src/components-lib/media-actions-controller';
import type { MediaPlayerController, MediaPlayerElement } from '../../src/types';
import type {
MediaPlayerController,
MediaPlayerElement,
PlaybackControl,
} from '../../src/types';
import {
callIntersectionHandler,
callMutationHandler,
@@ -25,11 +29,25 @@ const getPlayer = (
return element.querySelector(selector);
};
// play/pause live on the optional `playback` capability; mute/unmute on the
// controller itself.
const getActionSpy = (
controller: MediaPlayerController | null | undefined,
func: string,
): unknown => {
if (func === 'play' || func === 'pause') {
return controller?.playback?.[func];
}
return func === 'mute' ? controller?.mute : controller?.unmute;
};
const createPlayerElement = (controller?: MediaPlayerController): MediaPlayerElement => {
const player = document.createElement('video');
player['getMediaPlayerController'] = vi
.fn()
.mockResolvedValue(controller ?? mock<MediaPlayerController>());
.mockResolvedValue(
controller ?? mock<MediaPlayerController>({ playback: mock<PlaybackControl>() }),
);
return player as unknown as MediaPlayerElement;
};
@@ -84,7 +102,8 @@ describe('MediaActionsController', () => {
await controller.setTarget(0, true);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
?.play,
).not.toBeCalled();
});
@@ -106,7 +125,9 @@ describe('MediaActionsController', () => {
const parent = createParent({ children: createPlayerSlideNodes(1) });
controller.setRoot(parent);
const mediaPlayerController = mock<MediaPlayerController>();
const mediaPlayerController = mock<MediaPlayerController>({
playback: mock<PlaybackControl>(),
});
const newPlayer = createPlayerElement(mediaPlayerController);
const newChild = document.createElement('div');
@@ -117,7 +138,7 @@ describe('MediaActionsController', () => {
await controller.setTarget(1, true);
expect(mediaPlayerController.play).toBeCalled();
expect(mediaPlayerController.playback?.play).toBeCalled();
});
});
@@ -138,7 +159,8 @@ describe('MediaActionsController', () => {
await controller.setTarget(0, true);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
?.play,
).not.toBeCalled();
});
});
@@ -169,7 +191,10 @@ describe('MediaActionsController', () => {
await controller.setTarget(0, true);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
getActionSpy(
await getPlayer(children[0], 'video')?.getMediaPlayerController(),
func,
),
).toBeCalledTimes(called ? 1 : 0);
},
);
@@ -187,13 +212,15 @@ describe('MediaActionsController', () => {
await controller.setTarget(0, true);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
?.play,
).toBeCalledTimes(1);
await controller.setTarget(0, true);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
?.play,
).toBeCalledTimes(1);
});
@@ -212,7 +239,8 @@ describe('MediaActionsController', () => {
await controller.setTarget(1, true);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.pause,
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
?.pause,
).toBeCalled();
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.mute,
@@ -233,7 +261,8 @@ describe('MediaActionsController', () => {
await controller.setTarget(0, false);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
?.play,
).not.toBeCalled();
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
@@ -242,7 +271,8 @@ describe('MediaActionsController', () => {
await controller.setTarget(0, true);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
?.play,
).toBeCalled();
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
@@ -264,7 +294,8 @@ describe('MediaActionsController', () => {
await controller.setTarget(0, true);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
?.play,
).toBeCalledTimes(1);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
@@ -279,7 +310,8 @@ describe('MediaActionsController', () => {
// Play/Mute will not have been called again.
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
?.play,
).toBeCalledTimes(1);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
@@ -300,7 +332,8 @@ describe('MediaActionsController', () => {
await controller.setTarget(0, true);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
?.play,
).toBeCalledTimes(1);
getPlayer(children[0], 'video')?.dispatchEvent(
@@ -310,7 +343,8 @@ describe('MediaActionsController', () => {
await flushPromises();
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
?.play,
).toBeCalledTimes(2);
});
@@ -360,7 +394,8 @@ describe('MediaActionsController', () => {
await flushPromises();
expect(
(await getPlayer(children[9], 'video')?.getMediaPlayerController())?.play,
(await getPlayer(children[9], 'video')?.getMediaPlayerController())?.playback
?.play,
).not.toBeCalled();
expect(
(await getPlayer(children[9], 'video')?.getMediaPlayerController())?.unmute,
@@ -381,7 +416,8 @@ describe('MediaActionsController', () => {
await controller.setTarget(0, false);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
?.play,
).toBeCalledTimes(1);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
@@ -394,7 +430,8 @@ describe('MediaActionsController', () => {
await flushPromises();
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
?.play,
).toBeCalledTimes(2);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
@@ -429,7 +466,10 @@ describe('MediaActionsController', () => {
await controller.setTarget(0, false);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
getActionSpy(
await getPlayer(children[0], 'video')?.getMediaPlayerController(),
func,
),
).toBeCalledTimes(called ? 1 : 0);
},
);
@@ -472,7 +512,10 @@ describe('MediaActionsController', () => {
await callVisibilityHandler(true);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
getActionSpy(
await getPlayer(children[0], 'video')?.getMediaPlayerController(),
func,
),
).toBeCalledTimes(called ? 1 : 0);
},
);
@@ -514,7 +557,10 @@ describe('MediaActionsController', () => {
await callVisibilityHandler(false);
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
getActionSpy(
await getPlayer(children[0], 'video')?.getMediaPlayerController(),
func,
),
).toBeCalledTimes(called ? 1 : 0);
},
);
@@ -546,7 +592,10 @@ describe('MediaActionsController', () => {
// Not configured to take action on selection.
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
getActionSpy(
await getPlayer(children[0], 'video')?.getMediaPlayerController(),
func,
),
).not.toBeCalled();
// There's always a first call to an intersection observer handler. In
@@ -557,7 +606,10 @@ describe('MediaActionsController', () => {
// Not configured to take action on selection.
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
getActionSpy(
await getPlayer(children[0], 'video')?.getMediaPlayerController(),
func,
),
).toBeCalledTimes(called ? 1 : 0);
},
);
@@ -589,7 +641,10 @@ describe('MediaActionsController', () => {
// Not configured to take action on selection.
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
getActionSpy(
await getPlayer(children[0], 'video')?.getMediaPlayerController(),
func,
),
).not.toBeCalled();
// There's always a first call to an intersection observer handler. In
@@ -600,7 +655,10 @@ describe('MediaActionsController', () => {
// Not configured to take action on selection.
expect(
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
getActionSpy(
await getPlayer(children[0], 'video')?.getMediaPlayerController(),
func,
),
).toBeCalledTimes(called ? 1 : 0);
},
);
+243 -44
View File
@@ -1,43 +1,40 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, assert, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ImageMediaPlayerController } from '../../../src/components-lib/media-player/image';
import {
ImageMediaPlayerController,
type ImageUpdateControl,
} from '../../../src/components-lib/media-player/image';
import { screenshotImage } from '../../../src/utils/screenshot';
import { createLitElement } from '../../test-utils';
vi.mock('../../../src/utils/screenshot.js');
const STALL_SECONDS = 10;
const STALL_MS = STALL_SECONDS * 1000;
const createImageMediaPlayerWithLiveness = (
isFrameExpected: () => boolean,
getImageCallback: () => HTMLImageElement | null,
stallWindowSeconds = STALL_SECONDS,
): ImageMediaPlayerController =>
new ImageMediaPlayerController(createLitElement(), getImageCallback, {
livenessOptions: { isFrameExpected, stallWindowSeconds },
});
// @vitest-environment jsdom
describe('ImageMediaPlayerController', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should ignore play', async () => {
const image = mock<HTMLImageElement>();
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
await controller.play();
// Currently no observable side effects.
});
it('should ignore pause', async () => {
const image = mock<HTMLImageElement>();
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
await controller.pause();
// Currently no observable side effects.
});
it('should ignore mute', async () => {
const image = mock<HTMLImageElement>();
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
await controller.mute();
// Currently no observable side effects.
// No audio, so nothing to observe.
});
it('should ignore unmute', async () => {
@@ -46,7 +43,7 @@ describe('ImageMediaPlayerController', () => {
await controller.unmute();
// Currently no observable side effects.
// No audio, so nothing to observe.
});
it('should always report muted', () => {
@@ -56,32 +53,16 @@ describe('ImageMediaPlayerController', () => {
expect(controller.isMuted()).toBeTruthy();
});
it('should ignore seek', async () => {
const image = mock<HTMLImageElement>();
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
await controller.seek(10);
// Currently no observable side effects.
});
it('should ignore set controls', async () => {
const image = mock<HTMLImageElement>();
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
await controller.setControls(true);
// Currently no observable side effects.
// No playback controls, so nothing to observe.
});
it('should always report unpaused', () => {
const image = mock<HTMLImageElement>();
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
expect(controller.isPaused()).toBeFalsy();
});
describe('should get screenshot URL', async () => {
describe('should get screenshot URL', () => {
it('should return screenshot URL with image', async () => {
const url = 'data:image/png;base64,';
vi.mocked(screenshotImage).mockReturnValue(url);
@@ -98,18 +79,31 @@ describe('ImageMediaPlayerController', () => {
expect(await controller.getScreenshotURL()).toBeNull();
});
it('should use the screenshot provider when given', async () => {
const url = 'data:image/png;base64,provider';
const image = mock<HTMLImageElement>();
const controller = new ImageMediaPlayerController(
createLitElement(),
() => image,
{ screenshotProvider: async () => url },
);
expect(await controller.getScreenshotURL()).toBe(url);
expect(screenshotImage).not.toBeCalled();
});
});
describe('should get fullscreen element', async () => {
it('should return fullscreen element with image', async () => {
describe('should get fullscreen element', () => {
it('should return fullscreen element with image', () => {
const image = mock<HTMLImageElement>();
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
expect(await controller.getFullscreenElement()).toBe(image);
expect(controller.getFullscreenElement()).toBe(image);
});
it('should return null without image', async () => {
it('should return null without image', () => {
const controller = new ImageMediaPlayerController(createLitElement(), () => null);
expect(controller.getFullscreenElement()).toBeNull();
@@ -121,4 +115,209 @@ describe('ImageMediaPlayerController', () => {
expect(controller.getPIPElement()).toBeNull();
});
describe('playback', () => {
it('should be absent without an update control', () => {
const controller = new ImageMediaPlayerController(createLitElement(), () =>
mock<HTMLImageElement>(),
);
expect(controller.playback).toBeUndefined();
});
it('should start the update loop on play', async () => {
const updateControl = mock<ImageUpdateControl>();
const controller = new ImageMediaPlayerController(
createLitElement(),
() => mock<HTMLImageElement>(),
{ updateControl },
);
await controller.playback?.play();
expect(updateControl.start).toBeCalled();
});
it('should stop the update loop on pause', async () => {
const updateControl = mock<ImageUpdateControl>();
const controller = new ImageMediaPlayerController(
createLitElement(),
() => mock<HTMLImageElement>(),
{ updateControl },
);
await controller.playback?.pause();
expect(updateControl.stop).toBeCalled();
});
it('should report paused when the update loop is not running', () => {
const updateControl = mock<ImageUpdateControl>();
updateControl.isRunning.mockReturnValue(false);
const controller = new ImageMediaPlayerController(
createLitElement(),
() => mock<HTMLImageElement>(),
{ updateControl },
);
expect(controller.playback?.isPaused()).toBeTruthy();
});
it('should report unpaused when the update loop is running', () => {
const updateControl = mock<ImageUpdateControl>();
updateControl.isRunning.mockReturnValue(true);
const controller = new ImageMediaPlayerController(
createLitElement(),
() => mock<HTMLImageElement>(),
{ updateControl },
);
expect(controller.playback?.isPaused()).toBeFalsy();
});
});
describe('subscribeLiveness', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should be absent without liveness options', () => {
const controller = new ImageMediaPlayerController(createLitElement(), () =>
document.createElement('img'),
);
expect(controller.subscribeLiveness).toBeUndefined();
});
it('should report a stall when frames stop arriving', () => {
const image = document.createElement('img');
const controller = createImageMediaPlayerWithLiveness(
() => true,
() => image,
);
const subscribe = controller.subscribeLiveness;
assert(subscribe);
const callback = vi.fn();
subscribe(callback);
image.dispatchEvent(new Event('load'));
vi.advanceTimersByTime(STALL_MS);
expect(callback).toHaveBeenNthCalledWith(1, true);
expect(callback).toHaveBeenNthCalledWith(2, false);
});
it('should stay live while frames keep arriving', () => {
const image = document.createElement('img');
const controller = createImageMediaPlayerWithLiveness(
() => true,
() => image,
);
const subscribe = controller.subscribeLiveness;
assert(subscribe);
const callback = vi.fn();
subscribe(callback);
image.dispatchEvent(new Event('load'));
vi.advanceTimersByTime(STALL_MS - 1000);
image.dispatchEvent(new Event('load'));
vi.advanceTimersByTime(STALL_MS - 1000);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(true);
});
it('should report no stall while frames are not expected', () => {
const image = document.createElement('img');
const controller = createImageMediaPlayerWithLiveness(
() => false,
() => image,
);
const subscribe = controller.subscribeLiveness;
assert(subscribe);
const callback = vi.fn();
subscribe(callback);
vi.advanceTimersByTime(STALL_MS);
expect(callback).not.toHaveBeenCalled();
});
it('should report no stall without an image element', () => {
const controller = createImageMediaPlayerWithLiveness(
() => true,
() => null,
);
const subscribe = controller.subscribeLiveness;
assert(subscribe);
const callback = vi.fn();
subscribe(callback);
vi.advanceTimersByTime(STALL_MS);
expect(callback).not.toHaveBeenCalled();
});
it('should stop watching on unsubscribe', () => {
const image = document.createElement('img');
const controller = createImageMediaPlayerWithLiveness(
() => true,
() => image,
);
const subscribe = controller.subscribeLiveness;
assert(subscribe);
const callback = vi.fn();
const unsubscribe = subscribe(callback);
image.dispatchEvent(new Event('load'));
unsubscribe();
vi.advanceTimersByTime(STALL_MS);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(true);
});
it('should tolerate the image going away before unsubscribe', () => {
let image: HTMLImageElement | null = document.createElement('img');
const controller = createImageMediaPlayerWithLiveness(
() => true,
() => image,
);
const subscribe = controller.subscribeLiveness;
assert(subscribe);
const callback = vi.fn();
const unsubscribe = subscribe(callback);
image.dispatchEvent(new Event('load'));
image = null;
unsubscribe();
vi.advanceTimersByTime(STALL_MS);
expect(callback).toHaveBeenCalledTimes(1);
});
it('should honor a custom stall window', () => {
const shortSeconds = 3;
const image = document.createElement('img');
const controller = createImageMediaPlayerWithLiveness(
() => true,
() => image,
shortSeconds,
);
const subscribe = controller.subscribeLiveness;
assert(subscribe);
const callback = vi.fn();
subscribe(callback);
image.dispatchEvent(new Event('load'));
vi.advanceTimersByTime(shortSeconds * 1000);
expect(callback).toHaveBeenNthCalledWith(1, true);
expect(callback).toHaveBeenNthCalledWith(2, false);
});
});
});
@@ -21,7 +21,7 @@ describe('JSMPEGMediaPlayerController', () => {
() => mock<HTMLCanvasElement>(),
);
await controller.play();
await controller.playback.play();
expect(videoElement.play).toBeCalled();
});
@@ -35,7 +35,7 @@ describe('JSMPEGMediaPlayerController', () => {
() => mock<HTMLCanvasElement>(),
);
await controller.pause();
await controller.playback.pause();
expect(videoElement.stop).toBeCalled();
});
@@ -140,18 +140,6 @@ describe('JSMPEGMediaPlayerController', () => {
});
});
it('should ignore seek', async () => {
const controller = new JSMPEGMediaPlayerController(
createLitElement(),
() => mock<JSMpeg.VideoElement>(),
() => mock<HTMLCanvasElement>(),
);
await controller.seek(10);
// Currently no observable side effects.
});
it('should ignore set controls', async () => {
const controller = new JSMPEGMediaPlayerController(
createLitElement(),
@@ -175,7 +163,7 @@ describe('JSMPEGMediaPlayerController', () => {
() => mock<HTMLCanvasElement>(),
);
expect(controller.isPaused()).toBeTruthy();
expect(controller.playback.isPaused()).toBeTruthy();
});
it('should return false when not paused', async () => {
@@ -189,7 +177,7 @@ describe('JSMPEGMediaPlayerController', () => {
() => mock<HTMLCanvasElement>(),
);
expect(controller.isPaused()).toBeFalsy();
expect(controller.playback.isPaused()).toBeFalsy();
});
it('should return true when no video', () => {
@@ -199,7 +187,7 @@ describe('JSMPEGMediaPlayerController', () => {
() => mock<HTMLCanvasElement>(),
);
expect(controller.isPaused()).toBeTruthy();
expect(controller.playback.isPaused()).toBeTruthy();
});
});
@@ -1,203 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { CachedValueController } from '../../../src/components-lib/cached-value-controller';
import { ImageMediaPlayerController } from '../../../src/components-lib/media-player/image';
import { UpdatingImageMediaPlayerController } from '../../../src/components-lib/media-player/updating-image';
import { createLitElement } from '../../test-utils';
// @vitest-environment jsdom
describe('UpdatingImageMediaPlayerController', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should play', async () => {
const cachedValueController = mock<CachedValueController<string>>();
const controller = new UpdatingImageMediaPlayerController(
createLitElement(),
() => mock<HTMLImageElement>(),
() => cachedValueController,
);
await controller.play();
expect(cachedValueController.startTimer).toHaveBeenCalled();
});
it('should pause', async () => {
const cachedValueController = mock<CachedValueController<string>>();
const controller = new UpdatingImageMediaPlayerController(
createLitElement(),
() => mock<HTMLImageElement>(),
() => cachedValueController,
);
await controller.pause();
expect(cachedValueController.stopTimer).toHaveBeenCalled();
});
it('should ignore mute', async () => {
const controller = new UpdatingImageMediaPlayerController(
createLitElement(),
() => mock<HTMLImageElement>(),
() => mock<CachedValueController<string>>(),
);
await controller.mute();
// Currently no observable side effects.
});
it('should ignore unmute', async () => {
const controller = new UpdatingImageMediaPlayerController(
createLitElement(),
() => mock<HTMLImageElement>(),
() => mock<CachedValueController<string>>(),
);
await controller.unmute();
// Currently no observable side effects.
});
it('should always report muted', () => {
const controller = new UpdatingImageMediaPlayerController(
createLitElement(),
() => mock<HTMLImageElement>(),
() => mock<CachedValueController<string>>(),
);
expect(controller.isMuted()).toBeTruthy();
});
it('should ignore seek', async () => {
const controller = new UpdatingImageMediaPlayerController(
createLitElement(),
() => mock<HTMLImageElement>(),
() => mock<CachedValueController<string>>(),
);
await controller.seek(10);
// Currently no observable side effects.
});
it('should ignore set controls', async () => {
const controller = new UpdatingImageMediaPlayerController(
createLitElement(),
() => mock<HTMLImageElement>(),
() => mock<CachedValueController<string>>(),
);
await controller.setControls(true);
// Currently no observable side effects.
});
it('should always report unpaused', () => {
const image = mock<HTMLImageElement>();
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
expect(controller.isPaused()).toBeFalsy();
});
describe('should get paused state', () => {
it('should return true when the cached value controller does not have a timer', () => {
const cachedValueController = mock<CachedValueController<string>>();
cachedValueController.hasTimer.mockReturnValue(false);
const controller = new UpdatingImageMediaPlayerController(
createLitElement(),
() => mock<HTMLImageElement>(),
() => cachedValueController,
);
expect(controller.isPaused()).toBeTruthy();
});
it('should return false when the cached value controller has a timer', () => {
const cachedValueController = mock<CachedValueController<string>>();
cachedValueController.hasTimer.mockReturnValue(true);
const controller = new UpdatingImageMediaPlayerController(
createLitElement(),
() => mock<HTMLImageElement>(),
() => cachedValueController,
);
expect(controller.isPaused()).toBeFalsy();
});
it('should return true without cached value controller', () => {
const controller = new UpdatingImageMediaPlayerController(
createLitElement(),
() => mock<HTMLImageElement>(),
() => null,
);
expect(controller.isPaused()).toBeTruthy();
});
});
describe('should get screenshot URL', () => {
it('should return screenshot URL with cached value controller', async () => {
const url = 'data:image/png;base64,';
const cachedValueController = mock<CachedValueController<string>>();
cachedValueController.getValue.mockReturnValue(url);
const controller = new UpdatingImageMediaPlayerController(
createLitElement(),
() => mock<HTMLImageElement>(),
() => cachedValueController,
);
expect(await controller.getScreenshotURL()).toBe(url);
});
it('should return null without cached value controller', async () => {
const controller = new UpdatingImageMediaPlayerController(
createLitElement(),
() => mock<HTMLImageElement>(),
() => null,
);
expect(await controller.getScreenshotURL()).toBeNull();
});
});
describe('should get fullscreen element', async () => {
it('should return fullscreen element with image', async () => {
const image = mock<HTMLImageElement>();
const controller = new UpdatingImageMediaPlayerController(
createLitElement(),
() => image,
() => mock<CachedValueController<string>>(),
);
expect(await controller.getFullscreenElement()).toBe(image);
});
it('should return null without image', async () => {
const controller = new UpdatingImageMediaPlayerController(
createLitElement(),
() => null,
() => mock<CachedValueController<string>>(),
);
expect(controller.getFullscreenElement()).toBeNull();
});
});
it('should return null for getPIPElement', () => {
const controller = new UpdatingImageMediaPlayerController(
createLitElement(),
() => null,
() => mock<CachedValueController<string>>(),
);
expect(controller.getPIPElement()).toBeNull();
});
});
+69 -11
View File
@@ -28,6 +28,9 @@ const createVideo = (options?: {
seeking?: boolean;
ended?: boolean;
rvfc?: boolean;
poster?: string;
currentSrc?: string;
srcObject?: MediaStream | null;
}): {
video: HTMLVideoElement;
deliverFrame: () => void;
@@ -41,6 +44,9 @@ const createVideo = (options?: {
define('paused', options?.paused ?? false);
define('seeking', options?.seeking ?? false);
define('ended', options?.ended ?? false);
define('poster', options?.poster ?? '');
define('currentSrc', options?.currentSrc ?? '');
define('srcObject', options?.srcObject ?? null);
let frameCallback: (() => void) | null = null;
const cancel = vi.fn();
@@ -72,7 +78,7 @@ describe('VideoMediaPlayerController', () => {
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
await controller.play();
await controller.playback.play();
expect(video.play).toBeCalled();
});
@@ -84,7 +90,7 @@ describe('VideoMediaPlayerController', () => {
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
await controller.play();
await controller.playback.play();
expect(video.play).toBeCalledTimes(2);
expect(video.muted).toBeTruthy();
@@ -97,7 +103,7 @@ describe('VideoMediaPlayerController', () => {
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
await controller.play();
await controller.playback.play();
expect(video.play).toBeCalledTimes(1);
expect(video.muted).toBeTruthy();
@@ -110,7 +116,7 @@ describe('VideoMediaPlayerController', () => {
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
await controller.play();
await controller.playback.play();
expect(video.play).toBeCalledTimes(2);
expect(video.muted).toBeTruthy();
@@ -119,7 +125,7 @@ describe('VideoMediaPlayerController', () => {
it('should ignore calls without a video', async () => {
const controller = new VideoMediaPlayerController(createLitElement(), () => null);
await controller.play();
await controller.playback.play();
// Currently no observable side effects.
});
@@ -129,7 +135,7 @@ describe('VideoMediaPlayerController', () => {
const video = mock<HTMLVideoElement>();
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
await controller.pause();
await controller.playback.pause();
expect(video.pause).toBeCalled();
});
@@ -257,9 +263,9 @@ describe('VideoMediaPlayerController', () => {
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
await controller.pause();
await controller.playback.pause();
expect(controller.isPaused()).toBeTruthy();
expect(controller.playback.isPaused()).toBeTruthy();
});
it('should return false when not paused', async () => {
@@ -268,15 +274,15 @@ describe('VideoMediaPlayerController', () => {
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
await controller.pause();
await controller.playback.pause();
expect(controller.isPaused()).toBeFalsy();
expect(controller.playback.isPaused()).toBeFalsy();
});
it('should return true when no video', () => {
const controller = new VideoMediaPlayerController(createLitElement(), () => null);
expect(controller.isPaused()).toBeTruthy();
expect(controller.playback.isPaused()).toBeTruthy();
});
});
@@ -386,6 +392,58 @@ describe('VideoMediaPlayerController', () => {
expect(callback).toHaveBeenCalledWith(false);
});
it('should not report a stall for a poster shown with no media loaded', () => {
// A still-image surface (an MJPEG/MP4 poster slideshow): a poster with no
// media never presents video frames, so a missing frame is not a stall.
const { video } = createVideo({
poster: 'data:image/jpeg;base64,xxx',
paused: true,
readyState: HTMLMediaElement.HAVE_NOTHING,
});
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
const callback = vi.fn();
controller.subscribeLiveness(callback);
vi.advanceTimersByTime(STALL_MS);
expect(callback).not.toHaveBeenCalled();
});
it('should still watch a poster shown over media loaded from a src', () => {
// A loading placeholder over real media (e.g. an HLS player): playback is
// expected, so a missing frame is still a stall.
const { video } = createVideo({
poster: 'data:image/jpeg;base64,xxx',
currentSrc: 'blob:http://localhost/stream',
paused: true,
readyState: HTMLMediaElement.HAVE_NOTHING,
});
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
const callback = vi.fn();
controller.subscribeLiveness(callback);
vi.advanceTimersByTime(STALL_MS);
expect(callback).toHaveBeenCalledWith(false);
});
it('should still watch a poster shown over a media stream', () => {
// A loading placeholder over a live stream (e.g. an HA WebRTC player).
const { video } = createVideo({
poster: 'data:image/jpeg;base64,xxx',
srcObject: mock<MediaStream>(),
paused: true,
readyState: HTMLMediaElement.HAVE_NOTHING,
});
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
const callback = vi.fn();
controller.subscribeLiveness(callback);
vi.advanceTimersByTime(STALL_MS);
expect(callback).toHaveBeenCalledWith(false);
});
it('should report no stall when requestVideoFrameCallback is unavailable', () => {
const { video } = createVideo({ rvfc: false });
expect('requestVideoFrameCallback' in video).toBe(false);
@@ -23,7 +23,11 @@ import type { MenuItem } from '../../src/config/schema/elements/custom/menu/type
import type { AdvancedCameraCardConfig } from '../../src/config/schema/types.js';
import type { HomeAssistant } from '../../src/ha/types.js';
import { QuerySource } from '../../src/query-source';
import { PTZMovementType, type MediaPlayerController } from '../../src/types.js';
import {
PTZMovementType,
type MediaPlayerController,
type PlaybackControl,
} from '../../src/types.js';
import { createGeneralAction, createViewAction } from '../../src/utils/action.js';
import { ViewMedia, ViewMediaType } from '../../src/view/item.js';
import { QueryResults } from '../../src/view/query-results.js';
@@ -1901,7 +1905,9 @@ describe('MenuButtonController', () => {
});
it('should have pause button', () => {
const mediaPlayerController = mock<MediaPlayerController>();
const playback = mock<PlaybackControl>();
playback.isPaused.mockReturnValue(false);
const mediaPlayerController = mock<MediaPlayerController>({ playback });
const buttons = calculateButtons(controller, {
currentMediaLoadedInfo: createMediaLoadedInfo({
capabilities: {
@@ -1925,8 +1931,9 @@ describe('MenuButtonController', () => {
});
it('should have play button', () => {
const mediaPlayerController = mock<MediaPlayerController>();
mediaPlayerController.isPaused.mockReturnValue(true);
const playback = mock<PlaybackControl>();
playback.isPaused.mockReturnValue(true);
const mediaPlayerController = mock<MediaPlayerController>({ playback });
const buttons = calculateButtons(controller, {
currentMediaLoadedInfo: createMediaLoadedInfo({
capabilities: {
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import { createMediaNotification } from '../../../src/components-lib/notification/media';
describe('createMediaNotification', () => {
it('should put the title and icon in the heading', () => {
const notification = createMediaNotification({
icon: 'mdi:alert-circle',
title: 'Streaming server error',
});
expect(notification.heading).toEqual({
icon: 'mdi:alert-circle',
text: 'Streaming server error',
});
});
it('should default the icon to a generic alert icon', () => {
const notification = createMediaNotification({
title: 'Streaming server error',
});
expect(notification.heading?.icon).toBe('mdi:alert-circle');
});
it('should append the camera title to the heading', () => {
const notification = createMediaNotification({
icon: 'mdi:alert-circle',
title: 'Streaming server error',
targetTitle: 'Front Door',
});
expect(notification.heading?.text).toBe('Streaming server error: Front Door');
});
it('should show the detail as the body', () => {
const notification = createMediaNotification({
icon: 'mdi:alert-circle',
title: 'Configuration error',
detail: 'No endpoint',
});
expect(notification.body?.text).toBe('No endpoint');
});
it('should omit the body without a detail', () => {
const notification = createMediaNotification({
icon: 'mdi:alert-circle',
title: 'X',
});
expect(notification.body).toBeUndefined();
});
it('should include the troubleshooting link', () => {
const notification = createMediaNotification({
icon: 'mdi:alert-circle',
title: 'X',
});
expect(notification.link?.title).toBe('Check troubleshooting');
expect(notification.link?.url).toBeTruthy();
});
it('should omit the troubleshooting link when troubleshooting is false', () => {
const notification = createMediaNotification({
icon: 'mdi:alert-circle',
title: 'X',
troubleshooting: false,
});
expect(notification.link).toBeUndefined();
});
it('should show a spinner by default', () => {
const notification = createMediaNotification({
icon: 'mdi:alert-circle',
title: 'X',
});
expect(notification.in_progress).toBe(true);
});
it('should omit the spinner when not retrying', () => {
const notification = createMediaNotification({
icon: 'mdi:alert-circle',
title: 'X',
inProgress: false,
});
expect(notification.in_progress).toBeUndefined();
});
});