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:
@@ -35,6 +35,22 @@ describe('ProviderErrorDetector', () => {
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should adopt the specific reason carried on the event', () => {
|
||||
const host = createHostInDocument();
|
||||
const detector = new ProviderErrorDetector(host, vi.fn());
|
||||
detector.subscribe();
|
||||
|
||||
host.dispatchEvent(
|
||||
new CustomEvent(LIVE_ERROR_EVENT, { bubbles: true, detail: 'unsupported' }),
|
||||
);
|
||||
|
||||
expect(detector.getVerdict()).toEqual({
|
||||
state: 'not_live',
|
||||
authority: 'hard',
|
||||
reason: 'unsupported',
|
||||
});
|
||||
});
|
||||
|
||||
it('should notify only on the transition to not live', () => {
|
||||
const host = createHostInDocument();
|
||||
const onChange = vi.fn();
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('StreamLivenessController', () => {
|
||||
const { controller } = setup();
|
||||
|
||||
expect(controller.isLive()).toBe(true);
|
||||
expect(controller.getPlaceholder()).toBeNull();
|
||||
expect(controller.getFailure()).toBeNull();
|
||||
});
|
||||
|
||||
it('should aggregate a detector losing liveness only after host connect', () => {
|
||||
@@ -123,9 +123,13 @@ describe('StreamLivenessController', () => {
|
||||
|
||||
failViaProviderError();
|
||||
|
||||
// Provider-error is not-live but does not want a placeholder.
|
||||
// Provider-error is not-live but does not want a placeholder; its cause is
|
||||
// still available for a wrapper that fills the frame itself.
|
||||
expect(controller.isLive()).toBe(false);
|
||||
expect(controller.getPlaceholder()).toBeNull();
|
||||
expect(controller.getFailure()).toEqual({
|
||||
reason: 'playback_error',
|
||||
renderPlaceholder: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not fire the issue without a target', () => {
|
||||
@@ -154,7 +158,10 @@ describe('StreamLivenessController', () => {
|
||||
fireMediaPlayerLiveness(false);
|
||||
|
||||
expect(controller.isLive()).toBe(false);
|
||||
expect(controller.getPlaceholder()).toEqual({ reason: 'stalled' });
|
||||
expect(controller.getFailure()).toEqual({
|
||||
reason: 'stalled',
|
||||
renderPlaceholder: true,
|
||||
});
|
||||
expect(issueTriggers).toEqual([
|
||||
{ key: 'media_unavailable', targetID: 'camera.office', reason: 'stalled' },
|
||||
]);
|
||||
@@ -232,7 +239,10 @@ describe('StreamLivenessController', () => {
|
||||
controller.hostConnected();
|
||||
|
||||
expect(controller.isLive()).toBe(false);
|
||||
expect(controller.getPlaceholder()).toEqual({ reason: 'entity_unavailable' });
|
||||
expect(controller.getFailure()).toEqual({
|
||||
reason: 'entity_unavailable',
|
||||
renderPlaceholder: true,
|
||||
});
|
||||
expect(issueTriggers).toEqual([
|
||||
{
|
||||
key: 'media_unavailable',
|
||||
@@ -298,7 +308,7 @@ describe('StreamLivenessController', () => {
|
||||
|
||||
// Direct frame evidence outranks the entity proxy: no teardown, no issue.
|
||||
expect(controller.isLive()).toBe(true);
|
||||
expect(controller.getPlaceholder()).toBeNull();
|
||||
expect(controller.getFailure()).toBeNull();
|
||||
expect(issueTriggers).toEqual([]);
|
||||
|
||||
vi.useRealTimers();
|
||||
@@ -343,6 +353,9 @@ describe('StreamLivenessController', () => {
|
||||
// The always_error opt-in is authoritative: an unavailable entity overrides
|
||||
// even confirmed frames.
|
||||
expect(controller.isLive()).toBe(false);
|
||||
expect(controller.getPlaceholder()).toEqual({ reason: 'entity_unavailable' });
|
||||
expect(controller.getFailure()).toEqual({
|
||||
reason: 'entity_unavailable',
|
||||
renderPlaceholder: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+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);
|
||||
});
|
||||
});
|
||||
@@ -11,3 +11,14 @@ it('should dispatch live error event', () => {
|
||||
dispatchLiveErrorEvent(element);
|
||||
expect(handler).toBeCalled();
|
||||
});
|
||||
|
||||
it('should forward the reason as the event detail', () => {
|
||||
const element = document.createElement('div');
|
||||
const handler = vi.fn();
|
||||
element.addEventListener('advanced-camera-card:live:error', handler);
|
||||
|
||||
dispatchLiveErrorEvent(element, 'unsupported');
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ detail: 'unsupported' }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
MediaActionsController,
|
||||
type MediaActionsControllerOptions,
|
||||
} from '../../src/components-lib/media-actions-controller';
|
||||
import type { MediaPlayerController, MediaPlayerElement } from '../../src/types';
|
||||
import type {
|
||||
MediaPlayerController,
|
||||
MediaPlayerElement,
|
||||
PlaybackControl,
|
||||
} from '../../src/types';
|
||||
import {
|
||||
callIntersectionHandler,
|
||||
callMutationHandler,
|
||||
@@ -25,11 +29,25 @@ const getPlayer = (
|
||||
return element.querySelector(selector);
|
||||
};
|
||||
|
||||
// play/pause live on the optional `playback` capability; mute/unmute on the
|
||||
// controller itself.
|
||||
const getActionSpy = (
|
||||
controller: MediaPlayerController | null | undefined,
|
||||
func: string,
|
||||
): unknown => {
|
||||
if (func === 'play' || func === 'pause') {
|
||||
return controller?.playback?.[func];
|
||||
}
|
||||
return func === 'mute' ? controller?.mute : controller?.unmute;
|
||||
};
|
||||
|
||||
const createPlayerElement = (controller?: MediaPlayerController): MediaPlayerElement => {
|
||||
const player = document.createElement('video');
|
||||
player['getMediaPlayerController'] = vi
|
||||
.fn()
|
||||
.mockResolvedValue(controller ?? mock<MediaPlayerController>());
|
||||
.mockResolvedValue(
|
||||
controller ?? mock<MediaPlayerController>({ playback: mock<PlaybackControl>() }),
|
||||
);
|
||||
return player as unknown as MediaPlayerElement;
|
||||
};
|
||||
|
||||
@@ -84,7 +102,8 @@ describe('MediaActionsController', () => {
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
|
||||
?.play,
|
||||
).not.toBeCalled();
|
||||
});
|
||||
|
||||
@@ -106,7 +125,9 @@ describe('MediaActionsController', () => {
|
||||
const parent = createParent({ children: createPlayerSlideNodes(1) });
|
||||
controller.setRoot(parent);
|
||||
|
||||
const mediaPlayerController = mock<MediaPlayerController>();
|
||||
const mediaPlayerController = mock<MediaPlayerController>({
|
||||
playback: mock<PlaybackControl>(),
|
||||
});
|
||||
|
||||
const newPlayer = createPlayerElement(mediaPlayerController);
|
||||
const newChild = document.createElement('div');
|
||||
@@ -117,7 +138,7 @@ describe('MediaActionsController', () => {
|
||||
|
||||
await controller.setTarget(1, true);
|
||||
|
||||
expect(mediaPlayerController.play).toBeCalled();
|
||||
expect(mediaPlayerController.playback?.play).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,7 +159,8 @@ describe('MediaActionsController', () => {
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
|
||||
?.play,
|
||||
).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
@@ -169,7 +191,10 @@ describe('MediaActionsController', () => {
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
||||
getActionSpy(
|
||||
await getPlayer(children[0], 'video')?.getMediaPlayerController(),
|
||||
func,
|
||||
),
|
||||
).toBeCalledTimes(called ? 1 : 0);
|
||||
},
|
||||
);
|
||||
@@ -187,13 +212,15 @@ describe('MediaActionsController', () => {
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
|
||||
?.play,
|
||||
).toBeCalledTimes(1);
|
||||
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
|
||||
?.play,
|
||||
).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -212,7 +239,8 @@ describe('MediaActionsController', () => {
|
||||
await controller.setTarget(1, true);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.pause,
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
|
||||
?.pause,
|
||||
).toBeCalled();
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.mute,
|
||||
@@ -233,7 +261,8 @@ describe('MediaActionsController', () => {
|
||||
await controller.setTarget(0, false);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
|
||||
?.play,
|
||||
).not.toBeCalled();
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
|
||||
@@ -242,7 +271,8 @@ describe('MediaActionsController', () => {
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
|
||||
?.play,
|
||||
).toBeCalled();
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
|
||||
@@ -264,7 +294,8 @@ describe('MediaActionsController', () => {
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
|
||||
?.play,
|
||||
).toBeCalledTimes(1);
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
|
||||
@@ -279,7 +310,8 @@ describe('MediaActionsController', () => {
|
||||
|
||||
// Play/Mute will not have been called again.
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
|
||||
?.play,
|
||||
).toBeCalledTimes(1);
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
|
||||
@@ -300,7 +332,8 @@ describe('MediaActionsController', () => {
|
||||
await controller.setTarget(0, true);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
|
||||
?.play,
|
||||
).toBeCalledTimes(1);
|
||||
|
||||
getPlayer(children[0], 'video')?.dispatchEvent(
|
||||
@@ -310,7 +343,8 @@ describe('MediaActionsController', () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
|
||||
?.play,
|
||||
).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -360,7 +394,8 @@ describe('MediaActionsController', () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[9], 'video')?.getMediaPlayerController())?.play,
|
||||
(await getPlayer(children[9], 'video')?.getMediaPlayerController())?.playback
|
||||
?.play,
|
||||
).not.toBeCalled();
|
||||
expect(
|
||||
(await getPlayer(children[9], 'video')?.getMediaPlayerController())?.unmute,
|
||||
@@ -381,7 +416,8 @@ describe('MediaActionsController', () => {
|
||||
await controller.setTarget(0, false);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
|
||||
?.play,
|
||||
).toBeCalledTimes(1);
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
|
||||
@@ -394,7 +430,8 @@ describe('MediaActionsController', () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.play,
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.playback
|
||||
?.play,
|
||||
).toBeCalledTimes(2);
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute,
|
||||
@@ -429,7 +466,10 @@ describe('MediaActionsController', () => {
|
||||
await controller.setTarget(0, false);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
||||
getActionSpy(
|
||||
await getPlayer(children[0], 'video')?.getMediaPlayerController(),
|
||||
func,
|
||||
),
|
||||
).toBeCalledTimes(called ? 1 : 0);
|
||||
},
|
||||
);
|
||||
@@ -472,7 +512,10 @@ describe('MediaActionsController', () => {
|
||||
await callVisibilityHandler(true);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
||||
getActionSpy(
|
||||
await getPlayer(children[0], 'video')?.getMediaPlayerController(),
|
||||
func,
|
||||
),
|
||||
).toBeCalledTimes(called ? 1 : 0);
|
||||
},
|
||||
);
|
||||
@@ -514,7 +557,10 @@ describe('MediaActionsController', () => {
|
||||
await callVisibilityHandler(false);
|
||||
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
||||
getActionSpy(
|
||||
await getPlayer(children[0], 'video')?.getMediaPlayerController(),
|
||||
func,
|
||||
),
|
||||
).toBeCalledTimes(called ? 1 : 0);
|
||||
},
|
||||
);
|
||||
@@ -546,7 +592,10 @@ describe('MediaActionsController', () => {
|
||||
|
||||
// Not configured to take action on selection.
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
||||
getActionSpy(
|
||||
await getPlayer(children[0], 'video')?.getMediaPlayerController(),
|
||||
func,
|
||||
),
|
||||
).not.toBeCalled();
|
||||
|
||||
// There's always a first call to an intersection observer handler. In
|
||||
@@ -557,7 +606,10 @@ describe('MediaActionsController', () => {
|
||||
|
||||
// Not configured to take action on selection.
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
||||
getActionSpy(
|
||||
await getPlayer(children[0], 'video')?.getMediaPlayerController(),
|
||||
func,
|
||||
),
|
||||
).toBeCalledTimes(called ? 1 : 0);
|
||||
},
|
||||
);
|
||||
@@ -589,7 +641,10 @@ describe('MediaActionsController', () => {
|
||||
|
||||
// Not configured to take action on selection.
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
||||
getActionSpy(
|
||||
await getPlayer(children[0], 'video')?.getMediaPlayerController(),
|
||||
func,
|
||||
),
|
||||
).not.toBeCalled();
|
||||
|
||||
// There's always a first call to an intersection observer handler. In
|
||||
@@ -600,7 +655,10 @@ describe('MediaActionsController', () => {
|
||||
|
||||
// Not configured to take action on selection.
|
||||
expect(
|
||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
||||
getActionSpy(
|
||||
await getPlayer(children[0], 'video')?.getMediaPlayerController(),
|
||||
func,
|
||||
),
|
||||
).toBeCalledTimes(called ? 1 : 0);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,43 +1,40 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, assert, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { ImageMediaPlayerController } from '../../../src/components-lib/media-player/image';
|
||||
import {
|
||||
ImageMediaPlayerController,
|
||||
type ImageUpdateControl,
|
||||
} from '../../../src/components-lib/media-player/image';
|
||||
import { screenshotImage } from '../../../src/utils/screenshot';
|
||||
import { createLitElement } from '../../test-utils';
|
||||
|
||||
vi.mock('../../../src/utils/screenshot.js');
|
||||
|
||||
const STALL_SECONDS = 10;
|
||||
const STALL_MS = STALL_SECONDS * 1000;
|
||||
|
||||
const createImageMediaPlayerWithLiveness = (
|
||||
isFrameExpected: () => boolean,
|
||||
getImageCallback: () => HTMLImageElement | null,
|
||||
stallWindowSeconds = STALL_SECONDS,
|
||||
): ImageMediaPlayerController =>
|
||||
new ImageMediaPlayerController(createLitElement(), getImageCallback, {
|
||||
livenessOptions: { isFrameExpected, stallWindowSeconds },
|
||||
});
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('ImageMediaPlayerController', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should ignore play', async () => {
|
||||
const image = mock<HTMLImageElement>();
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
|
||||
|
||||
await controller.play();
|
||||
|
||||
// Currently no observable side effects.
|
||||
});
|
||||
|
||||
it('should ignore pause', async () => {
|
||||
const image = mock<HTMLImageElement>();
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
|
||||
|
||||
await controller.pause();
|
||||
|
||||
// Currently no observable side effects.
|
||||
});
|
||||
|
||||
it('should ignore mute', async () => {
|
||||
const image = mock<HTMLImageElement>();
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
|
||||
|
||||
await controller.mute();
|
||||
|
||||
// Currently no observable side effects.
|
||||
// No audio, so nothing to observe.
|
||||
});
|
||||
|
||||
it('should ignore unmute', async () => {
|
||||
@@ -46,7 +43,7 @@ describe('ImageMediaPlayerController', () => {
|
||||
|
||||
await controller.unmute();
|
||||
|
||||
// Currently no observable side effects.
|
||||
// No audio, so nothing to observe.
|
||||
});
|
||||
|
||||
it('should always report muted', () => {
|
||||
@@ -56,32 +53,16 @@ describe('ImageMediaPlayerController', () => {
|
||||
expect(controller.isMuted()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should ignore seek', async () => {
|
||||
const image = mock<HTMLImageElement>();
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
|
||||
|
||||
await controller.seek(10);
|
||||
|
||||
// Currently no observable side effects.
|
||||
});
|
||||
|
||||
it('should ignore set controls', async () => {
|
||||
const image = mock<HTMLImageElement>();
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
|
||||
|
||||
await controller.setControls(true);
|
||||
|
||||
// Currently no observable side effects.
|
||||
// No playback controls, so nothing to observe.
|
||||
});
|
||||
|
||||
it('should always report unpaused', () => {
|
||||
const image = mock<HTMLImageElement>();
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
|
||||
|
||||
expect(controller.isPaused()).toBeFalsy();
|
||||
});
|
||||
|
||||
describe('should get screenshot URL', async () => {
|
||||
describe('should get screenshot URL', () => {
|
||||
it('should return screenshot URL with image', async () => {
|
||||
const url = 'data:image/png;base64,';
|
||||
vi.mocked(screenshotImage).mockReturnValue(url);
|
||||
@@ -98,18 +79,31 @@ describe('ImageMediaPlayerController', () => {
|
||||
|
||||
expect(await controller.getScreenshotURL()).toBeNull();
|
||||
});
|
||||
|
||||
it('should use the screenshot provider when given', async () => {
|
||||
const url = 'data:image/png;base64,provider';
|
||||
const image = mock<HTMLImageElement>();
|
||||
const controller = new ImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => image,
|
||||
{ screenshotProvider: async () => url },
|
||||
);
|
||||
|
||||
expect(await controller.getScreenshotURL()).toBe(url);
|
||||
expect(screenshotImage).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should get fullscreen element', async () => {
|
||||
it('should return fullscreen element with image', async () => {
|
||||
describe('should get fullscreen element', () => {
|
||||
it('should return fullscreen element with image', () => {
|
||||
const image = mock<HTMLImageElement>();
|
||||
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
|
||||
|
||||
expect(await controller.getFullscreenElement()).toBe(image);
|
||||
expect(controller.getFullscreenElement()).toBe(image);
|
||||
});
|
||||
|
||||
it('should return null without image', async () => {
|
||||
it('should return null without image', () => {
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () => null);
|
||||
|
||||
expect(controller.getFullscreenElement()).toBeNull();
|
||||
@@ -121,4 +115,209 @@ describe('ImageMediaPlayerController', () => {
|
||||
|
||||
expect(controller.getPIPElement()).toBeNull();
|
||||
});
|
||||
|
||||
describe('playback', () => {
|
||||
it('should be absent without an update control', () => {
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () =>
|
||||
mock<HTMLImageElement>(),
|
||||
);
|
||||
|
||||
expect(controller.playback).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should start the update loop on play', async () => {
|
||||
const updateControl = mock<ImageUpdateControl>();
|
||||
const controller = new ImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
{ updateControl },
|
||||
);
|
||||
|
||||
await controller.playback?.play();
|
||||
|
||||
expect(updateControl.start).toBeCalled();
|
||||
});
|
||||
|
||||
it('should stop the update loop on pause', async () => {
|
||||
const updateControl = mock<ImageUpdateControl>();
|
||||
const controller = new ImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
{ updateControl },
|
||||
);
|
||||
|
||||
await controller.playback?.pause();
|
||||
|
||||
expect(updateControl.stop).toBeCalled();
|
||||
});
|
||||
|
||||
it('should report paused when the update loop is not running', () => {
|
||||
const updateControl = mock<ImageUpdateControl>();
|
||||
updateControl.isRunning.mockReturnValue(false);
|
||||
const controller = new ImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
{ updateControl },
|
||||
);
|
||||
|
||||
expect(controller.playback?.isPaused()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should report unpaused when the update loop is running', () => {
|
||||
const updateControl = mock<ImageUpdateControl>();
|
||||
updateControl.isRunning.mockReturnValue(true);
|
||||
const controller = new ImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
{ updateControl },
|
||||
);
|
||||
|
||||
expect(controller.playback?.isPaused()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribeLiveness', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should be absent without liveness options', () => {
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () =>
|
||||
document.createElement('img'),
|
||||
);
|
||||
|
||||
expect(controller.subscribeLiveness).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should report a stall when frames stop arriving', () => {
|
||||
const image = document.createElement('img');
|
||||
const controller = createImageMediaPlayerWithLiveness(
|
||||
() => true,
|
||||
() => image,
|
||||
);
|
||||
const subscribe = controller.subscribeLiveness;
|
||||
assert(subscribe);
|
||||
const callback = vi.fn();
|
||||
|
||||
subscribe(callback);
|
||||
image.dispatchEvent(new Event('load'));
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).toHaveBeenNthCalledWith(1, true);
|
||||
expect(callback).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
|
||||
it('should stay live while frames keep arriving', () => {
|
||||
const image = document.createElement('img');
|
||||
const controller = createImageMediaPlayerWithLiveness(
|
||||
() => true,
|
||||
() => image,
|
||||
);
|
||||
const subscribe = controller.subscribeLiveness;
|
||||
assert(subscribe);
|
||||
const callback = vi.fn();
|
||||
|
||||
subscribe(callback);
|
||||
image.dispatchEvent(new Event('load'));
|
||||
vi.advanceTimersByTime(STALL_MS - 1000);
|
||||
image.dispatchEvent(new Event('load'));
|
||||
vi.advanceTimersByTime(STALL_MS - 1000);
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should report no stall while frames are not expected', () => {
|
||||
const image = document.createElement('img');
|
||||
const controller = createImageMediaPlayerWithLiveness(
|
||||
() => false,
|
||||
() => image,
|
||||
);
|
||||
const subscribe = controller.subscribeLiveness;
|
||||
assert(subscribe);
|
||||
const callback = vi.fn();
|
||||
|
||||
subscribe(callback);
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should report no stall without an image element', () => {
|
||||
const controller = createImageMediaPlayerWithLiveness(
|
||||
() => true,
|
||||
() => null,
|
||||
);
|
||||
const subscribe = controller.subscribeLiveness;
|
||||
assert(subscribe);
|
||||
const callback = vi.fn();
|
||||
|
||||
subscribe(callback);
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should stop watching on unsubscribe', () => {
|
||||
const image = document.createElement('img');
|
||||
const controller = createImageMediaPlayerWithLiveness(
|
||||
() => true,
|
||||
() => image,
|
||||
);
|
||||
const subscribe = controller.subscribeLiveness;
|
||||
assert(subscribe);
|
||||
const callback = vi.fn();
|
||||
|
||||
const unsubscribe = subscribe(callback);
|
||||
image.dispatchEvent(new Event('load'));
|
||||
unsubscribe();
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should tolerate the image going away before unsubscribe', () => {
|
||||
let image: HTMLImageElement | null = document.createElement('img');
|
||||
const controller = createImageMediaPlayerWithLiveness(
|
||||
() => true,
|
||||
() => image,
|
||||
);
|
||||
const subscribe = controller.subscribeLiveness;
|
||||
assert(subscribe);
|
||||
const callback = vi.fn();
|
||||
|
||||
const unsubscribe = subscribe(callback);
|
||||
image.dispatchEvent(new Event('load'));
|
||||
image = null;
|
||||
unsubscribe();
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should honor a custom stall window', () => {
|
||||
const shortSeconds = 3;
|
||||
const image = document.createElement('img');
|
||||
const controller = createImageMediaPlayerWithLiveness(
|
||||
() => true,
|
||||
() => image,
|
||||
shortSeconds,
|
||||
);
|
||||
const subscribe = controller.subscribeLiveness;
|
||||
assert(subscribe);
|
||||
const callback = vi.fn();
|
||||
|
||||
subscribe(callback);
|
||||
image.dispatchEvent(new Event('load'));
|
||||
vi.advanceTimersByTime(shortSeconds * 1000);
|
||||
|
||||
expect(callback).toHaveBeenNthCalledWith(1, true);
|
||||
expect(callback).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('JSMPEGMediaPlayerController', () => {
|
||||
() => mock<HTMLCanvasElement>(),
|
||||
);
|
||||
|
||||
await controller.play();
|
||||
await controller.playback.play();
|
||||
|
||||
expect(videoElement.play).toBeCalled();
|
||||
});
|
||||
@@ -35,7 +35,7 @@ describe('JSMPEGMediaPlayerController', () => {
|
||||
() => mock<HTMLCanvasElement>(),
|
||||
);
|
||||
|
||||
await controller.pause();
|
||||
await controller.playback.pause();
|
||||
|
||||
expect(videoElement.stop).toBeCalled();
|
||||
});
|
||||
@@ -140,18 +140,6 @@ describe('JSMPEGMediaPlayerController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore seek', async () => {
|
||||
const controller = new JSMPEGMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<JSMpeg.VideoElement>(),
|
||||
() => mock<HTMLCanvasElement>(),
|
||||
);
|
||||
|
||||
await controller.seek(10);
|
||||
|
||||
// Currently no observable side effects.
|
||||
});
|
||||
|
||||
it('should ignore set controls', async () => {
|
||||
const controller = new JSMPEGMediaPlayerController(
|
||||
createLitElement(),
|
||||
@@ -175,7 +163,7 @@ describe('JSMPEGMediaPlayerController', () => {
|
||||
() => mock<HTMLCanvasElement>(),
|
||||
);
|
||||
|
||||
expect(controller.isPaused()).toBeTruthy();
|
||||
expect(controller.playback.isPaused()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return false when not paused', async () => {
|
||||
@@ -189,7 +177,7 @@ describe('JSMPEGMediaPlayerController', () => {
|
||||
() => mock<HTMLCanvasElement>(),
|
||||
);
|
||||
|
||||
expect(controller.isPaused()).toBeFalsy();
|
||||
expect(controller.playback.isPaused()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return true when no video', () => {
|
||||
@@ -199,7 +187,7 @@ describe('JSMPEGMediaPlayerController', () => {
|
||||
() => mock<HTMLCanvasElement>(),
|
||||
);
|
||||
|
||||
expect(controller.isPaused()).toBeTruthy();
|
||||
expect(controller.playback.isPaused()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { CachedValueController } from '../../../src/components-lib/cached-value-controller';
|
||||
import { ImageMediaPlayerController } from '../../../src/components-lib/media-player/image';
|
||||
import { UpdatingImageMediaPlayerController } from '../../../src/components-lib/media-player/updating-image';
|
||||
import { createLitElement } from '../../test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('UpdatingImageMediaPlayerController', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should play', async () => {
|
||||
const cachedValueController = mock<CachedValueController<string>>();
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => cachedValueController,
|
||||
);
|
||||
|
||||
await controller.play();
|
||||
|
||||
expect(cachedValueController.startTimer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pause', async () => {
|
||||
const cachedValueController = mock<CachedValueController<string>>();
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => cachedValueController,
|
||||
);
|
||||
|
||||
await controller.pause();
|
||||
|
||||
expect(cachedValueController.stopTimer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore mute', async () => {
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => mock<CachedValueController<string>>(),
|
||||
);
|
||||
|
||||
await controller.mute();
|
||||
|
||||
// Currently no observable side effects.
|
||||
});
|
||||
|
||||
it('should ignore unmute', async () => {
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => mock<CachedValueController<string>>(),
|
||||
);
|
||||
|
||||
await controller.unmute();
|
||||
|
||||
// Currently no observable side effects.
|
||||
});
|
||||
|
||||
it('should always report muted', () => {
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => mock<CachedValueController<string>>(),
|
||||
);
|
||||
|
||||
expect(controller.isMuted()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should ignore seek', async () => {
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => mock<CachedValueController<string>>(),
|
||||
);
|
||||
|
||||
await controller.seek(10);
|
||||
|
||||
// Currently no observable side effects.
|
||||
});
|
||||
|
||||
it('should ignore set controls', async () => {
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => mock<CachedValueController<string>>(),
|
||||
);
|
||||
|
||||
await controller.setControls(true);
|
||||
|
||||
// Currently no observable side effects.
|
||||
});
|
||||
|
||||
it('should always report unpaused', () => {
|
||||
const image = mock<HTMLImageElement>();
|
||||
const controller = new ImageMediaPlayerController(createLitElement(), () => image);
|
||||
|
||||
expect(controller.isPaused()).toBeFalsy();
|
||||
});
|
||||
|
||||
describe('should get paused state', () => {
|
||||
it('should return true when the cached value controller does not have a timer', () => {
|
||||
const cachedValueController = mock<CachedValueController<string>>();
|
||||
cachedValueController.hasTimer.mockReturnValue(false);
|
||||
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => cachedValueController,
|
||||
);
|
||||
|
||||
expect(controller.isPaused()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return false when the cached value controller has a timer', () => {
|
||||
const cachedValueController = mock<CachedValueController<string>>();
|
||||
cachedValueController.hasTimer.mockReturnValue(true);
|
||||
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => cachedValueController,
|
||||
);
|
||||
|
||||
expect(controller.isPaused()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return true without cached value controller', () => {
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => null,
|
||||
);
|
||||
|
||||
expect(controller.isPaused()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should get screenshot URL', () => {
|
||||
it('should return screenshot URL with cached value controller', async () => {
|
||||
const url = 'data:image/png;base64,';
|
||||
const cachedValueController = mock<CachedValueController<string>>();
|
||||
cachedValueController.getValue.mockReturnValue(url);
|
||||
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => cachedValueController,
|
||||
);
|
||||
|
||||
expect(await controller.getScreenshotURL()).toBe(url);
|
||||
});
|
||||
|
||||
it('should return null without cached value controller', async () => {
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => mock<HTMLImageElement>(),
|
||||
() => null,
|
||||
);
|
||||
|
||||
expect(await controller.getScreenshotURL()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should get fullscreen element', async () => {
|
||||
it('should return fullscreen element with image', async () => {
|
||||
const image = mock<HTMLImageElement>();
|
||||
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => image,
|
||||
() => mock<CachedValueController<string>>(),
|
||||
);
|
||||
|
||||
expect(await controller.getFullscreenElement()).toBe(image);
|
||||
});
|
||||
|
||||
it('should return null without image', async () => {
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => null,
|
||||
() => mock<CachedValueController<string>>(),
|
||||
);
|
||||
|
||||
expect(controller.getFullscreenElement()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for getPIPElement', () => {
|
||||
const controller = new UpdatingImageMediaPlayerController(
|
||||
createLitElement(),
|
||||
() => null,
|
||||
() => mock<CachedValueController<string>>(),
|
||||
);
|
||||
|
||||
expect(controller.getPIPElement()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,9 @@ const createVideo = (options?: {
|
||||
seeking?: boolean;
|
||||
ended?: boolean;
|
||||
rvfc?: boolean;
|
||||
poster?: string;
|
||||
currentSrc?: string;
|
||||
srcObject?: MediaStream | null;
|
||||
}): {
|
||||
video: HTMLVideoElement;
|
||||
deliverFrame: () => void;
|
||||
@@ -41,6 +44,9 @@ const createVideo = (options?: {
|
||||
define('paused', options?.paused ?? false);
|
||||
define('seeking', options?.seeking ?? false);
|
||||
define('ended', options?.ended ?? false);
|
||||
define('poster', options?.poster ?? '');
|
||||
define('currentSrc', options?.currentSrc ?? '');
|
||||
define('srcObject', options?.srcObject ?? null);
|
||||
|
||||
let frameCallback: (() => void) | null = null;
|
||||
const cancel = vi.fn();
|
||||
@@ -72,7 +78,7 @@ describe('VideoMediaPlayerController', () => {
|
||||
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
|
||||
await controller.play();
|
||||
await controller.playback.play();
|
||||
|
||||
expect(video.play).toBeCalled();
|
||||
});
|
||||
@@ -84,7 +90,7 @@ describe('VideoMediaPlayerController', () => {
|
||||
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
|
||||
await controller.play();
|
||||
await controller.playback.play();
|
||||
|
||||
expect(video.play).toBeCalledTimes(2);
|
||||
expect(video.muted).toBeTruthy();
|
||||
@@ -97,7 +103,7 @@ describe('VideoMediaPlayerController', () => {
|
||||
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
|
||||
await controller.play();
|
||||
await controller.playback.play();
|
||||
|
||||
expect(video.play).toBeCalledTimes(1);
|
||||
expect(video.muted).toBeTruthy();
|
||||
@@ -110,7 +116,7 @@ describe('VideoMediaPlayerController', () => {
|
||||
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
|
||||
await controller.play();
|
||||
await controller.playback.play();
|
||||
|
||||
expect(video.play).toBeCalledTimes(2);
|
||||
expect(video.muted).toBeTruthy();
|
||||
@@ -119,7 +125,7 @@ describe('VideoMediaPlayerController', () => {
|
||||
it('should ignore calls without a video', async () => {
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => null);
|
||||
|
||||
await controller.play();
|
||||
await controller.playback.play();
|
||||
|
||||
// Currently no observable side effects.
|
||||
});
|
||||
@@ -129,7 +135,7 @@ describe('VideoMediaPlayerController', () => {
|
||||
const video = mock<HTMLVideoElement>();
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
|
||||
await controller.pause();
|
||||
await controller.playback.pause();
|
||||
|
||||
expect(video.pause).toBeCalled();
|
||||
});
|
||||
@@ -257,9 +263,9 @@ describe('VideoMediaPlayerController', () => {
|
||||
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
|
||||
await controller.pause();
|
||||
await controller.playback.pause();
|
||||
|
||||
expect(controller.isPaused()).toBeTruthy();
|
||||
expect(controller.playback.isPaused()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return false when not paused', async () => {
|
||||
@@ -268,15 +274,15 @@ describe('VideoMediaPlayerController', () => {
|
||||
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
|
||||
await controller.pause();
|
||||
await controller.playback.pause();
|
||||
|
||||
expect(controller.isPaused()).toBeFalsy();
|
||||
expect(controller.playback.isPaused()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return true when no video', () => {
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => null);
|
||||
|
||||
expect(controller.isPaused()).toBeTruthy();
|
||||
expect(controller.playback.isPaused()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -386,6 +392,58 @@ describe('VideoMediaPlayerController', () => {
|
||||
expect(callback).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should not report a stall for a poster shown with no media loaded', () => {
|
||||
// A still-image surface (an MJPEG/MP4 poster slideshow): a poster with no
|
||||
// media never presents video frames, so a missing frame is not a stall.
|
||||
const { video } = createVideo({
|
||||
poster: 'data:image/jpeg;base64,xxx',
|
||||
paused: true,
|
||||
readyState: HTMLMediaElement.HAVE_NOTHING,
|
||||
});
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
const callback = vi.fn();
|
||||
|
||||
controller.subscribeLiveness(callback);
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still watch a poster shown over media loaded from a src', () => {
|
||||
// A loading placeholder over real media (e.g. an HLS player): playback is
|
||||
// expected, so a missing frame is still a stall.
|
||||
const { video } = createVideo({
|
||||
poster: 'data:image/jpeg;base64,xxx',
|
||||
currentSrc: 'blob:http://localhost/stream',
|
||||
paused: true,
|
||||
readyState: HTMLMediaElement.HAVE_NOTHING,
|
||||
});
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
const callback = vi.fn();
|
||||
|
||||
controller.subscribeLiveness(callback);
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should still watch a poster shown over a media stream', () => {
|
||||
// A loading placeholder over a live stream (e.g. an HA WebRTC player).
|
||||
const { video } = createVideo({
|
||||
poster: 'data:image/jpeg;base64,xxx',
|
||||
srcObject: mock<MediaStream>(),
|
||||
paused: true,
|
||||
readyState: HTMLMediaElement.HAVE_NOTHING,
|
||||
});
|
||||
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||
const callback = vi.fn();
|
||||
|
||||
controller.subscribeLiveness(callback);
|
||||
vi.advanceTimersByTime(STALL_MS);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should report no stall when requestVideoFrameCallback is unavailable', () => {
|
||||
const { video } = createVideo({ rvfc: false });
|
||||
expect('requestVideoFrameCallback' in video).toBe(false);
|
||||
|
||||
@@ -23,7 +23,11 @@ import type { MenuItem } from '../../src/config/schema/elements/custom/menu/type
|
||||
import type { AdvancedCameraCardConfig } from '../../src/config/schema/types.js';
|
||||
import type { HomeAssistant } from '../../src/ha/types.js';
|
||||
import { QuerySource } from '../../src/query-source';
|
||||
import { PTZMovementType, type MediaPlayerController } from '../../src/types.js';
|
||||
import {
|
||||
PTZMovementType,
|
||||
type MediaPlayerController,
|
||||
type PlaybackControl,
|
||||
} from '../../src/types.js';
|
||||
import { createGeneralAction, createViewAction } from '../../src/utils/action.js';
|
||||
import { ViewMedia, ViewMediaType } from '../../src/view/item.js';
|
||||
import { QueryResults } from '../../src/view/query-results.js';
|
||||
@@ -1901,7 +1905,9 @@ describe('MenuButtonController', () => {
|
||||
});
|
||||
|
||||
it('should have pause button', () => {
|
||||
const mediaPlayerController = mock<MediaPlayerController>();
|
||||
const playback = mock<PlaybackControl>();
|
||||
playback.isPaused.mockReturnValue(false);
|
||||
const mediaPlayerController = mock<MediaPlayerController>({ playback });
|
||||
const buttons = calculateButtons(controller, {
|
||||
currentMediaLoadedInfo: createMediaLoadedInfo({
|
||||
capabilities: {
|
||||
@@ -1925,8 +1931,9 @@ describe('MenuButtonController', () => {
|
||||
});
|
||||
|
||||
it('should have play button', () => {
|
||||
const mediaPlayerController = mock<MediaPlayerController>();
|
||||
mediaPlayerController.isPaused.mockReturnValue(true);
|
||||
const playback = mock<PlaybackControl>();
|
||||
playback.isPaused.mockReturnValue(true);
|
||||
const mediaPlayerController = mock<MediaPlayerController>({ playback });
|
||||
const buttons = calculateButtons(controller, {
|
||||
currentMediaLoadedInfo: createMediaLoadedInfo({
|
||||
capabilities: {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createMediaNotification } from '../../../src/components-lib/notification/media';
|
||||
|
||||
describe('createMediaNotification', () => {
|
||||
it('should put the title and icon in the heading', () => {
|
||||
const notification = createMediaNotification({
|
||||
icon: 'mdi:alert-circle',
|
||||
title: 'Streaming server error',
|
||||
});
|
||||
expect(notification.heading).toEqual({
|
||||
icon: 'mdi:alert-circle',
|
||||
text: 'Streaming server error',
|
||||
});
|
||||
});
|
||||
|
||||
it('should default the icon to a generic alert icon', () => {
|
||||
const notification = createMediaNotification({
|
||||
title: 'Streaming server error',
|
||||
});
|
||||
expect(notification.heading?.icon).toBe('mdi:alert-circle');
|
||||
});
|
||||
|
||||
it('should append the camera title to the heading', () => {
|
||||
const notification = createMediaNotification({
|
||||
icon: 'mdi:alert-circle',
|
||||
title: 'Streaming server error',
|
||||
targetTitle: 'Front Door',
|
||||
});
|
||||
expect(notification.heading?.text).toBe('Streaming server error: Front Door');
|
||||
});
|
||||
|
||||
it('should show the detail as the body', () => {
|
||||
const notification = createMediaNotification({
|
||||
icon: 'mdi:alert-circle',
|
||||
title: 'Configuration error',
|
||||
detail: 'No endpoint',
|
||||
});
|
||||
expect(notification.body?.text).toBe('No endpoint');
|
||||
});
|
||||
|
||||
it('should omit the body without a detail', () => {
|
||||
const notification = createMediaNotification({
|
||||
icon: 'mdi:alert-circle',
|
||||
title: 'X',
|
||||
});
|
||||
expect(notification.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include the troubleshooting link', () => {
|
||||
const notification = createMediaNotification({
|
||||
icon: 'mdi:alert-circle',
|
||||
title: 'X',
|
||||
});
|
||||
expect(notification.link?.title).toBe('Check troubleshooting');
|
||||
expect(notification.link?.url).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should omit the troubleshooting link when troubleshooting is false', () => {
|
||||
const notification = createMediaNotification({
|
||||
icon: 'mdi:alert-circle',
|
||||
title: 'X',
|
||||
troubleshooting: false,
|
||||
});
|
||||
expect(notification.link).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should show a spinner by default', () => {
|
||||
const notification = createMediaNotification({
|
||||
icon: 'mdi:alert-circle',
|
||||
title: 'X',
|
||||
});
|
||||
expect(notification.in_progress).toBe(true);
|
||||
});
|
||||
|
||||
it('should omit the spinner when not retrying', () => {
|
||||
const notification = createMediaNotification({
|
||||
icon: 'mdi:alert-circle',
|
||||
title: 'X',
|
||||
inProgress: false,
|
||||
});
|
||||
expect(notification.in_progress).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user