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:
+226
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+29
@@ -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();
|
||||
});
|
||||
});
|
||||
+232
@@ -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();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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('');
|
||||
});
|
||||
});
|
||||
+64
@@ -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');
|
||||
});
|
||||
});
|
||||
+54
@@ -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);
|
||||
});
|
||||
});
|
||||
+17
@@ -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 });
|
||||
});
|
||||
});
|
||||
+75
@@ -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);
|
||||
});
|
||||
});
|
||||
+10
@@ -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),
|
||||
});
|
||||
+100
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user