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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user