rx-player
Version:
Canal+ HTML5 Video Player
609 lines (539 loc) • 21.3 kB
text/typescript
import { describe, it, expect, vi, afterEach } from "vitest";
import { CoreMessageType } from "../../core/types";
import { SourceBufferError } from "../../errors";
import { CancellationError } from "../../utils/task_canceller";
import { SourceBufferType } from "../types";
import WorkerMediaSourceInterface, {
WorkerSourceBufferInterface,
} from "../worker_media_source_interface";
const { mockLog } = vi.hoisted(() => {
return {
mockLog: {
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
},
};
});
vi.mock("../../log", () => ({ default: mockLog }));
function makeMessageSender() {
return vi.fn();
}
function makeWmsi(messageSender = makeMessageSender()) {
return {
wmsi: new WorkerMediaSourceInterface("ms-id-1", "content-1", messageSender),
messageSender,
};
}
function makeWsbi(messageSender = makeMessageSender()) {
const wsbi = new WorkerSourceBufferInterface(
SourceBufferType.Video,
"video/mp4; codecs=avc1",
"ms-id-1",
messageSender,
);
return { wsbi, messageSender };
}
/**
* Returns the first argument of the mock's most recent call, cast to T.
* Throws if the mock was never called, so tests fail clearly rather than
* silently receiving `undefined`.
*/
function getLastCallFirstArg<T>(fn: ReturnType<typeof vi.fn>): T {
if (fn.mock.lastCall === undefined) {
throw new Error("Mock was never called");
}
return fn.mock.lastCall[0] as T;
}
describe("WorkerMediaSourceInterface", () => {
afterEach(() => {
vi.resetAllMocks();
});
it("sends a CreateMediaSource message on construction", () => {
const messageSender = makeMessageSender();
new WorkerMediaSourceInterface("ms-id-1", "content-1", messageSender);
expect(messageSender).toHaveBeenCalledOnce();
expect(messageSender).toHaveBeenCalledWith({
contentId: "content-1",
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
mediaSourceId: expect.stringMatching(/\d+/),
type: CoreMessageType.CreateMediaSource,
});
});
it("initialises readyState as 'closed' and sourceBuffers as []", () => {
const { wmsi } = makeWmsi();
expect(wmsi.readyState).toBe("closed");
expect(wmsi.sourceBuffers).toEqual([]);
});
describe("onMediaSourceReadyStateChanged", () => {
it("sets readyState to 'open' and triggers mediaSourceOpen", async () => {
const { wmsi } = makeWmsi();
const prom = new Promise<void>((res, rej) => {
wmsi.addEventListener("mediaSourceOpen", () => {
wmsi.dispose("testend");
res();
});
wmsi.addEventListener("mediaSourceEnded", () => {
rej();
});
wmsi.addEventListener("mediaSourceClose", () => {
rej();
});
});
wmsi.onMediaSourceReadyStateChanged("open");
expect(wmsi.readyState).toBe("open");
await prom;
});
it("sets readyState to 'ended' and triggers mediaSourceEnded", async () => {
const { wmsi } = makeWmsi();
const prom = new Promise<void>((res, rej) => {
wmsi.addEventListener("mediaSourceOpen", () => {
rej();
});
wmsi.addEventListener("mediaSourceEnded", () => {
wmsi.dispose("testend");
res();
});
wmsi.addEventListener("mediaSourceClose", () => {
rej();
});
});
wmsi.onMediaSourceReadyStateChanged("ended");
expect(wmsi.readyState).toBe("ended");
await prom;
});
it("sets readyState to 'closed' and triggers mediaSourceClose", async () => {
const { wmsi } = makeWmsi();
const prom = new Promise<void>((res, rej) => {
let hasReceivedOpen = false;
wmsi.addEventListener("mediaSourceOpen", () => {
hasReceivedOpen = true;
});
wmsi.addEventListener("mediaSourceEnded", () => {
rej();
});
wmsi.addEventListener("mediaSourceClose", () => {
expect(hasReceivedOpen).toEqual(true);
wmsi.dispose("testend");
res();
});
});
wmsi.onMediaSourceReadyStateChanged("open");
wmsi.onMediaSourceReadyStateChanged("closed");
expect(wmsi.readyState).toBe("closed");
await prom;
});
});
describe("addSourceBuffer", () => {
it("sends an AddSourceBuffer message with correct fields", () => {
const { wmsi, messageSender } = makeWmsi();
messageSender.mockClear();
wmsi.addSourceBuffer(SourceBufferType.Video, "video/mp4; codecs=avc1");
expect(messageSender).toHaveBeenCalledOnce();
expect(messageSender).toHaveBeenCalledWith({
type: CoreMessageType.AddSourceBuffer,
mediaSourceId: "ms-id-1",
value: {
sourceBufferType: SourceBufferType.Video,
codec: "video/mp4; codecs=avc1",
},
});
});
it("pushes a WorkerSourceBufferInterface into sourceBuffers and returns it", () => {
const { wmsi } = makeWmsi();
const sb = wmsi.addSourceBuffer(
SourceBufferType.Audio,
"audio/mp4; codecs=mp4a.40.2",
);
expect(wmsi.sourceBuffers).toHaveLength(1);
expect(wmsi.sourceBuffers[0]).toBe(sb);
expect(sb).toBeInstanceOf(WorkerSourceBufferInterface);
});
});
describe("setDuration", () => {
it("sends an UpdateMediaSourceDuration message", () => {
const { wmsi, messageSender } = makeWmsi();
messageSender.mockClear();
wmsi.setDuration(120.5, true);
expect(messageSender).toHaveBeenCalledOnce();
expect(messageSender).toHaveBeenCalledWith({
type: CoreMessageType.UpdateMediaSourceDuration,
mediaSourceId: "ms-id-1",
value: {
duration: 120.5,
isRealEndKnown: true,
},
});
});
});
describe("interruptDurationSetting", () => {
it("sends an InterruptMediaSourceDurationUpdate message", () => {
const { wmsi, messageSender } = makeWmsi();
messageSender.mockClear();
wmsi.interruptDurationSetting();
expect(messageSender).toHaveBeenCalledOnce();
expect(messageSender).toHaveBeenCalledWith({
type: CoreMessageType.InterruptMediaSourceDurationUpdate,
mediaSourceId: "ms-id-1",
value: null,
});
});
});
describe("maintainEndOfStream", () => {
it("sends an EndOfStream message", () => {
const { wmsi, messageSender } = makeWmsi();
messageSender.mockClear();
wmsi.maintainEndOfStream();
expect(messageSender).toHaveBeenCalledOnce();
expect(messageSender).toHaveBeenCalledWith({
type: CoreMessageType.EndOfStream,
mediaSourceId: "ms-id-1",
value: null,
});
});
});
describe("stopEndOfStream", () => {
it("sends an InterruptEndOfStream message", () => {
const { wmsi, messageSender } = makeWmsi();
messageSender.mockClear();
wmsi.stopEndOfStream();
expect(messageSender).toHaveBeenCalledOnce();
expect(messageSender).toHaveBeenCalledWith({
type: CoreMessageType.InterruptEndOfStream,
mediaSourceId: "ms-id-1",
value: null,
});
});
});
describe("dispose", () => {
it("sends a DisposeMediaSource message", () => {
const { wmsi, messageSender } = makeWmsi();
messageSender.mockClear();
wmsi.dispose("test reason");
expect(messageSender).toHaveBeenCalledWith({
mediaSourceId: "ms-id-1",
type: CoreMessageType.DisposeMediaSource,
value: null,
});
});
it("calls dispose on every child source buffer", () => {
const { wmsi, messageSender } = makeWmsi();
wmsi.addSourceBuffer(SourceBufferType.Video, "video/mp4");
wmsi.addSourceBuffer(SourceBufferType.Audio, "audio/mp4");
messageSender.mockClear();
wmsi.dispose("cleanup");
expect(messageSender).toHaveBeenCalledWith({
mediaSourceId: "ms-id-1",
type: CoreMessageType.AbortSourceBuffer,
sourceBufferType: SourceBufferType.Video,
value: null,
});
expect(messageSender).toHaveBeenCalledWith({
mediaSourceId: "ms-id-1",
type: CoreMessageType.AbortSourceBuffer,
sourceBufferType: SourceBufferType.Audio,
value: null,
});
});
});
});
describe("WorkerSourceBufferInterface", () => {
afterEach(() => {
vi.resetAllMocks();
});
it("initialises with correct type, codec, empty queues", () => {
const { wsbi } = makeWsbi();
expect(wsbi.type).toBe(SourceBufferType.Video);
expect(wsbi.codec).toBe("video/mp4; codecs=avc1");
expect(wsbi._queuedOperations).toHaveLength(0);
expect(wsbi._pendingOperations.size).toBe(0);
});
describe("appendBuffer", () => {
it("sends a SourceBufferAppend message with transferable ArrayBuffer", async () => {
const { wsbi, messageSender } = makeWsbi();
const buffer = new ArrayBuffer(8);
const promise = wsbi.appendBuffer(buffer, { codec: "video/mp4" });
expect(messageSender).toHaveBeenCalledOnce();
expect(messageSender).toHaveBeenCalledWith(
{
type: CoreMessageType.SourceBufferAppend,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
operationId: expect.stringMatching(/\d+/),
mediaSourceId: "ms-id-1",
sourceBufferType: SourceBufferType.Video,
value: { data: buffer, params: { codec: "video/mp4" } },
},
[buffer],
);
const { operationId } = getLastCallFirstArg<{ operationId: string }>(messageSender);
const ranges = [{ start: 0, end: 10 }];
wsbi.onOperationSuccess(operationId, ranges);
await expect(promise).resolves.toEqual(ranges);
});
it("handles a TypedArray whose buffer is larger (slices correctly)", async () => {
const { wsbi, messageSender } = makeWsbi();
const bigBuffer = new ArrayBuffer(16);
// byteOffset=4, byteLength=4 → buffer.byteLength(16) !== byteLength(4)
const view = new Uint8Array(bigBuffer, 4, 4);
const promise = wsbi.appendBuffer(view, { codec: "video/mp4" });
expect(messageSender).toHaveBeenCalledOnce();
expect(messageSender).toHaveBeenCalledWith(
{
mediaSourceId: "ms-id-1",
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
operationId: expect.stringMatching(/\d+/),
sourceBufferType: SourceBufferType.Video,
type: CoreMessageType.SourceBufferAppend,
value: { data: bigBuffer, params: { codec: "video/mp4" } },
},
[bigBuffer],
);
const { operationId } = getLastCallFirstArg<{ operationId: string }>(messageSender);
wsbi.onOperationSuccess(operationId, []);
await expect(promise).resolves.toEqual([]);
});
it("handles a TypedArray whose byteLength equals buffer.byteLength", async () => {
const { wsbi, messageSender } = makeWsbi();
const buffer = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]).buffer;
const view = new Uint8Array(buffer); // byteLength === buffer.byteLength
const promise = wsbi.appendBuffer(view, { codec: "video/mp4" });
expect(messageSender).toHaveBeenCalledWith(
{
mediaSourceId: "ms-id-1",
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
operationId: expect.stringMatching(/\d+/),
sourceBufferType: SourceBufferType.Video,
type: CoreMessageType.SourceBufferAppend,
value: { data: buffer, params: { codec: "video/mp4" } },
},
[buffer],
);
const { operationId } = getLastCallFirstArg<{ operationId: string }>(messageSender);
wsbi.onOperationSuccess(operationId, []);
await promise;
});
it("queues a second appendBuffer call while one is pending (MAX_QUEUE not reached)", async () => {
const { wsbi, messageSender } = makeWsbi();
const buf1 = new ArrayBuffer(4);
const buf2 = new ArrayBuffer(4);
const promise1 = wsbi.appendBuffer(buf1, { codec: "video/mp4" });
expect(messageSender).toHaveBeenCalledTimes(1);
const promise2 = wsbi.appendBuffer(buf2, { codec: "video/mp4" });
// With MAX = Infinity and no prior queued operations, both calls go through directly.
expect(messageSender).toHaveBeenCalledTimes(2);
// Two distinct calls happened: extract both operationIds in one typed cast.
const calls = messageSender.mock.calls as Array<[{ operationId: string }]>;
const op1 = calls[0][0].operationId;
const op2 = calls[1][0].operationId;
wsbi.onOperationSuccess(op1, []);
wsbi.onOperationSuccess(op2, []);
await promise1;
await promise2;
});
});
describe("remove", () => {
it("sends a SourceBufferRemove message and resolves with ranges", async () => {
const { wsbi, messageSender } = makeWsbi();
const promise = wsbi.remove(5, 10);
expect(messageSender).toHaveBeenCalledOnce();
expect(messageSender).toHaveBeenCalledWith({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
operationId: expect.stringMatching(/\d+/),
type: CoreMessageType.SourceBufferRemove,
mediaSourceId: "ms-id-1",
sourceBufferType: SourceBufferType.Video,
value: { start: 5, end: 10 },
});
const { operationId } = getLastCallFirstArg<{ operationId: string }>(messageSender);
const ranges = [{ start: 0, end: 5 }];
wsbi.onOperationSuccess(operationId, ranges);
await expect(promise).resolves.toEqual(ranges);
});
});
describe("abort", () => {
it("sends an AbortSourceBuffer message", () => {
const { wsbi, messageSender } = makeWsbi();
wsbi.abort();
expect(messageSender).toHaveBeenCalledOnce();
expect(messageSender).toHaveBeenCalledWith({
type: CoreMessageType.AbortSourceBuffer,
mediaSourceId: "ms-id-1",
sourceBufferType: SourceBufferType.Video,
value: null,
});
});
});
describe("dispose", () => {
it("calls abort (sends AbortSourceBuffer)", () => {
const { wsbi, messageSender } = makeWsbi();
wsbi.dispose("test reason");
expect(messageSender).toHaveBeenCalledOnce();
expect(messageSender).toHaveBeenCalledWith({
mediaSourceId: "ms-id-1",
sourceBufferType: SourceBufferType.Video,
type: CoreMessageType.AbortSourceBuffer,
value: null,
});
});
it("rejects all pending operations with a CancellationError on dispose", async () => {
const { wsbi } = makeWsbi();
const buf1 = new ArrayBuffer(4);
const buf2 = new ArrayBuffer(4);
const promise1 = wsbi.appendBuffer(buf1, { codec: "video/mp4" });
const promise2 = wsbi.remove(0, 10);
// buf2 sits in the queue behind the two already-pending operations
wsbi._queuedOperations.push({
operationName: 0 /* SbiOperationName.Push */,
params: [buf2, { codec: "video/mp4" }],
resolve: vi.fn(),
reject: vi.fn(),
});
wsbi.dispose("cleanup");
await expect(promise1).rejects.toBeInstanceOf(CancellationError);
await expect(promise2).rejects.toBeInstanceOf(CancellationError);
});
});
describe("getBuffered", () => {
it("returns undefined", () => {
const { wsbi } = makeWsbi();
expect(wsbi.getBuffered()).toBeUndefined();
});
});
describe("onOperationSuccess", () => {
it("resolves the matching pending operation and removes it from the map", async () => {
const { wsbi, messageSender } = makeWsbi();
const buf = new ArrayBuffer(4);
const promise = wsbi.appendBuffer(buf, { codec: "video/mp4" });
const { operationId } = getLastCallFirstArg<{ operationId: string }>(messageSender);
expect(wsbi._pendingOperations.size).toBe(1);
wsbi.onOperationSuccess(operationId, [{ start: 0, end: 4 }]);
await expect(promise).resolves.toEqual([{ start: 0, end: 4 }]);
expect(wsbi._pendingOperations.size).toBe(0);
});
it("logs a warning when the operationId is unknown", () => {
const { wsbi } = makeWsbi();
wsbi.onOperationSuccess("unknown-op-id", []);
expect(mockLog.warn).toHaveBeenCalledWith(
"mse",
"unknown SourceBuffer operation succeeded",
{ type: "video" },
);
});
it("dequeues and sends the next queued push operation after success", async () => {
const { wsbi, messageSender } = makeWsbi();
const buf1 = new Uint8Array([0, 1, 2, 3]).buffer;
const buf2 = new Uint8Array([9, 8, 7, 6]).buffer;
const promise1 = wsbi.appendBuffer(buf1, { codec: "video/mp4" });
const { operationId: firstOpId } = getLastCallFirstArg<{ operationId: string }>(
messageSender,
);
wsbi._queuedOperations.push({
operationName: 0 /* SbiOperationName.Push */,
params: [buf2, { codec: "video/mp4" }],
resolve: vi.fn(),
reject: vi.fn(),
});
messageSender.mockClear();
wsbi.onOperationSuccess(firstOpId, []);
await promise1;
expect(messageSender).toHaveBeenCalledOnce();
expect(messageSender).toHaveBeenCalledWith(
{
mediaSourceId: "ms-id-1",
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
operationId: expect.stringMatching(/\d+/),
sourceBufferType: SourceBufferType.Video,
type: CoreMessageType.SourceBufferAppend,
value: { data: buf2, params: { codec: "video/mp4" } },
},
[buf2],
);
});
it("dequeues and sends the next queued remove operation after success", async () => {
const { wsbi, messageSender } = makeWsbi();
const buf = new ArrayBuffer(4);
const promise1 = wsbi.appendBuffer(buf, { codec: "video/mp4" });
const { operationId: firstOpId } = getLastCallFirstArg<{ operationId: string }>(
messageSender,
);
wsbi._queuedOperations.push({
operationName: 1 /* SbiOperationName.Remove */,
params: [0, 10],
resolve: vi.fn(),
reject: vi.fn(),
});
messageSender.mockClear();
wsbi.onOperationSuccess(firstOpId, []);
await promise1;
expect(messageSender).toHaveBeenCalledOnce();
expect(messageSender).toHaveBeenCalledWith({
type: CoreMessageType.SourceBufferRemove,
mediaSourceId: "ms-id-1",
sourceBufferType: SourceBufferType.Video,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
operationId: expect.stringMatching(/\d+/),
value: { start: 0, end: 10 },
});
});
});
describe("onOperationFailure", () => {
it("rejects the matching pending operation with a SourceBufferError", async () => {
const { wsbi, messageSender } = makeWsbi();
const buf = new ArrayBuffer(4);
const promise = wsbi.appendBuffer(buf, { codec: "video/mp4" });
const { operationId } = getLastCallFirstArg<{ operationId: string }>(messageSender);
wsbi.onOperationFailure(operationId, {
errorName: "SourceBufferError",
message: "Buffer full",
isBufferFull: true,
});
await expect(promise).rejects.toBeInstanceOf(SourceBufferError);
});
it("rejects the matching pending operation with a CancellationError", async () => {
const { wsbi, messageSender } = makeWsbi();
const buf = new ArrayBuffer(4);
const promise = wsbi.appendBuffer(buf, { codec: "video/mp4" });
const { operationId } = getLastCallFirstArg<{ operationId: string }>(messageSender);
wsbi.onOperationFailure(operationId, { errorName: "CancellationError" });
await expect(promise).rejects.toBeInstanceOf(CancellationError);
});
it("logs info when the operationId is unknown", () => {
const { wsbi } = makeWsbi();
wsbi.onOperationFailure("unknown-op-id", {
errorName: "SourceBufferError",
message: "Buffer full",
isBufferFull: true,
});
expect(mockLog.info).toHaveBeenCalledWith(
"mse",
"unknown SourceBuffer operation failed",
{ type: "video" },
new SourceBufferError("SourceBufferError", "Buffer full", true),
);
});
it("clears the queue and rejects all queued operations on failure", async () => {
const { wsbi, messageSender } = makeWsbi();
const buf = new ArrayBuffer(4);
const promise1 = wsbi.appendBuffer(buf, { codec: "video/mp4" });
const { operationId: firstOpId } = getLastCallFirstArg<{ operationId: string }>(
messageSender,
);
const reject2 = vi.fn();
wsbi._queuedOperations.push({
operationName: 0 /* Push */,
params: [new ArrayBuffer(4), { codec: "video/mp4" }],
resolve: vi.fn(),
reject: reject2,
});
wsbi.onOperationFailure(firstOpId, {
errorName: "SourceBufferError",
message: "full",
isBufferFull: true,
});
await expect(promise1).rejects.toBeInstanceOf(SourceBufferError);
expect(reject2).toHaveBeenCalledOnce();
expect(wsbi._queuedOperations).toHaveLength(0);
});
});
});