rx-player
Version:
Canal+ HTML5 Video Player
506 lines (446 loc) • 16.9 kB
text/typescript
import type { Mock } from "vitest";
import { describe, it, expect, vi, afterEach } from "vitest";
import type { ICdnMetadata } from "../../../../parsers/manifest";
import type {
IRequestedData,
IThumbnailLoader,
IThumbnailParser,
} from "../../../../transports";
import type { CancellationSignal } from "../../../../utils/task_canceller";
import TaskCanceller, { CancellationError } from "../../../../utils/task_canceller";
import type CdnPrioritizer from "../../cdn_prioritizer";
import type { IBackoffSettings } from "../../utils/schedule_request";
import createThumbnailFetcher from "../thumbnail_fetcher";
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
const {
mockConfig,
mockLog,
mockScheduleRequestWithCdns,
mockFormatError,
mockErrorSelector,
} = vi.hoisted(() => {
return {
mockConfig: {
getCurrent: vi.fn(() => ({
DEFAULT_MAX_THUMBNAIL_REQUESTS_RETRY_ON_ERROR: 3,
DEFAULT_THUMBNAIL_REQUEST_TIMEOUT: 10000,
DEFAULT_THUMBNAIL_CONNECTION_TIMEOUT: 5000,
INITIAL_BACKOFF_DELAY_BASE: { REGULAR: 200 },
MAX_BACKOFF_DELAY_BASE: { REGULAR: 3000 },
})),
},
mockLog: {
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
mockScheduleRequestWithCdns: vi.fn(
(
cdns: ICdnMetadata[] | null,
_cdnPrioritizer: CdnPrioritizer | null,
performRequest: (
cdn: ICdnMetadata | null,
cancellationSignal: CancellationSignal,
) => Promise<IRequestedData<ArrayBuffer>>,
_options: IBackoffSettings,
globalCancellationSignal: CancellationSignal,
): Promise<IRequestedData<ArrayBuffer>> => {
if (globalCancellationSignal.isCancelled()) {
return Promise.reject(globalCancellationSignal.cancellationError);
}
return performRequest(cdns?.[0] ?? null, new TaskCanceller("test task").signal);
},
),
mockFormatError: vi.fn((err: unknown): any => err),
mockErrorSelector: vi.fn((err: unknown): any => err),
};
});
vi.mock("../../../../config", () => ({ default: mockConfig }));
vi.mock("../../../../log", () => ({ default: mockLog }));
vi.mock("../../utils/schedule_request", () => ({
scheduleRequestWithCdns: mockScheduleRequestWithCdns,
}));
vi.mock("../../../../errors", () => ({ formatError: mockFormatError }));
vi.mock("../../utils/error_selector", () => ({ default: mockErrorSelector }));
describe("createThumbnailFetcher", () => {
afterEach(() => {
vi.resetAllMocks();
});
it("returns a function (fetchThumbnail)", () => {
const fetcher = createThumbnailFetcher(makePipeline(), null);
expect(typeof fetcher).toBe("function");
});
describe("successful fetch", () => {
it("calls loadThumbnail and parseThumbnail, returns parsed result", async () => {
const pipeline = makePipeline(
() =>
Promise.resolve({
responseData: new Uint8Array([0, 1, 2]).buffer,
size: 3,
requestDuration: 1000,
}),
(d) => ({ data: d, mimeType: "image/png", thumbnails: [] }),
);
pipeline.loadThumbnail.mockResolvedValue({
responseData: new Uint8Array([7, 7, 7]).buffer,
size: 3,
requestDuration: 500,
});
const fetcher = createThumbnailFetcher(pipeline, null);
const canceller = new TaskCanceller("test");
const ctx = makeThumbnailContext();
const result = await fetcher(ctx, canceller.signal);
expect(result).toEqual({
data: new Uint8Array([7, 7, 7]).buffer,
mimeType: "image/png",
thumbnails: [],
});
expect(pipeline.parseThumbnail).toHaveBeenCalledTimes(1);
});
});
describe("request deduplication", () => {
it("shares the same promise when the same thumbnail is requested twice concurrently", async () => {
const pipeline = makePipeline();
let resolveRequest!: (v: IRequestedData<ArrayBuffer>) => void;
mockScheduleRequestWithCdns.mockReturnValue(
new Promise((res) => {
resolveRequest = res;
}),
);
pipeline.parseThumbnail.mockReturnValue({
mimeType: "image/gif",
data: new Uint8Array([4, 2, 2]).buffer,
thumbnails: [],
});
const fetcher = createThumbnailFetcher(pipeline, null);
const canceller1 = new TaskCanceller("test1");
const canceller2 = new TaskCanceller("test2");
const ctx = makeThumbnailContext();
const p1 = fetcher(ctx, canceller1.signal);
const p2 = fetcher(ctx, canceller2.signal);
// Only one network call should have been made
expect(mockScheduleRequestWithCdns).toHaveBeenCalledTimes(1);
resolveRequest({
responseData: new Uint8Array([5, 6, 7]).buffer,
size: 3,
requestDuration: 900,
});
const [r1, r2] = await Promise.all([p1, p2]);
expect(r1).toEqual({
mimeType: "image/gif",
data: new Uint8Array([4, 2, 2]).buffer,
thumbnails: [],
});
expect(r2).toEqual({
mimeType: "image/gif",
data: new Uint8Array([4, 2, 2]).buffer,
thumbnails: [],
});
});
it("makes a new request when thumbnails differ", async () => {
const pipeline = makePipeline();
mockScheduleRequestWithCdns.mockResolvedValue({
responseData: new Uint8Array([7, 8, 7]).buffer,
size: 3,
requestDuration: 500,
});
const fetcher = createThumbnailFetcher(pipeline, null);
const canceller1 = new TaskCanceller("test1");
const canceller2 = new TaskCanceller("test2");
// Fetch sequentially so there is no overlap
await fetcher(makeThumbnailContext({ segmentId: "seg-1" }), canceller1.signal);
await fetcher(makeThumbnailContext({ segmentId: "seg-2" }), canceller2.signal);
expect(mockScheduleRequestWithCdns).toHaveBeenCalledTimes(2);
});
});
describe("cancellation", () => {
it("rejects with CancellationError when the signal is already cancelled on entry", async () => {
const pipeline = makePipeline();
let resolveRequest!: (v: IRequestedData<ArrayBuffer>) => void;
mockScheduleRequestWithCdns.mockReturnValue(
new Promise((res) => {
resolveRequest = res;
}),
);
const fetcher = createThumbnailFetcher(pipeline, null);
const canceller = new TaskCanceller("test");
const ctx = makeThumbnailContext();
const fetchPromise = fetcher(ctx, canceller.signal);
canceller.cancel("cancelled");
resolveRequest({
responseData: new Uint8Array([9, 7, 7]).buffer,
size: 3,
requestDuration: 500,
});
await expect(fetchPromise).rejects.toBeInstanceOf(CancellationError);
});
it("cancels the underlying request when all callers cancel", async () => {
const pipeline = makePipeline();
let rejectRequest!: (e: unknown) => void;
mockScheduleRequestWithCdns.mockReturnValue(
new Promise((_res, rej) => {
rejectRequest = rej;
}),
);
const fetcher = createThumbnailFetcher(pipeline, null);
const canceller1 = new TaskCanceller("test1");
const canceller2 = new TaskCanceller("test2");
const ctx = makeThumbnailContext();
const p1 = fetcher(ctx, canceller1.signal);
const p2 = fetcher(ctx, canceller2.signal);
// Only one network request made
expect(mockScheduleRequestWithCdns).toHaveBeenCalledTimes(1);
canceller1.cancel("cancel1");
// Request should still be live because p2 is still pending
canceller2.cancel("cancel2");
// Trigger the underlying rejection to settle the promises
const cancelErr = new CancellationError("test task", "aborted");
rejectRequest(cancelErr);
await expect(p1).rejects.toBeInstanceOf(CancellationError);
await expect(p2).rejects.toBeInstanceOf(CancellationError);
});
it("keeps the request alive when only one of two callers cancels", async () => {
const pipeline = makePipeline();
let resolveRequest!: (v: IRequestedData<ArrayBuffer>) => void;
mockScheduleRequestWithCdns.mockReturnValue(
new Promise((res) => {
resolveRequest = res;
}),
);
const fetcher = createThumbnailFetcher(pipeline, null);
const canceller1 = new TaskCanceller("test1");
const canceller2 = new TaskCanceller("test2");
const ctx = makeThumbnailContext();
const p1 = fetcher(ctx, canceller1.signal);
const p2 = fetcher(ctx, canceller2.signal);
canceller1.cancel("only first cancels");
resolveRequest({
responseData: new Uint8Array([4, 7, 7]).buffer,
size: 3,
requestDuration: 500,
});
// p2 should still resolve
const result = await p2;
expect(result).toEqual({
data: new Uint8Array([4, 7, 7]).buffer,
// Default mocked characteristics
mimeType: "image/png",
thumbnails: [
{
height: 720,
width: 1280,
offsetX: 0,
offsetY: 0,
start: 0,
end: Number.MAX_SAFE_INTEGER,
},
],
});
// p1 should reject
await expect(p1).rejects.toBeInstanceOf(CancellationError);
});
it("does not pass the first caller's cancellation signal to the shared loader", async () => {
let loadSignal: CancellationSignal | undefined;
let resolveRequest!: (v: IRequestedData<ArrayBuffer>) => void;
const pipeline = makePipeline(
(
_cdnMetadata,
_segment,
_options,
cancellationSignal: CancellationSignal,
): Promise<IRequestedData<ArrayBuffer>> => {
loadSignal = cancellationSignal;
return new Promise((res) => {
resolveRequest = res;
});
},
);
mockScheduleRequestWithCdns.mockImplementation(
(
cdns: ICdnMetadata[] | null,
_cdnPrioritizer: CdnPrioritizer | null,
performRequest: (
cdn: ICdnMetadata | null,
cancellationSignal: CancellationSignal,
) => Promise<IRequestedData<ArrayBuffer>>,
_options: IBackoffSettings,
globalCancellationSignal: CancellationSignal,
): Promise<IRequestedData<ArrayBuffer>> => {
return performRequest(cdns?.[0] ?? null, globalCancellationSignal);
},
);
const fetcher = createThumbnailFetcher(pipeline, null);
const canceller1 = new TaskCanceller("test1");
const canceller2 = new TaskCanceller("test2");
const ctx = makeThumbnailContext();
const p1 = fetcher(ctx, canceller1.signal);
const p2 = fetcher(ctx, canceller2.signal);
expect(mockScheduleRequestWithCdns).toHaveBeenCalledTimes(1);
expect(loadSignal).not.toBe(canceller1.signal);
expect(loadSignal).not.toBe(canceller2.signal);
canceller1.cancel("only first cancels");
expect(loadSignal?.isCancelled()).toBe(false);
resolveRequest({
responseData: new Uint8Array([4, 7, 7]).buffer,
size: 3,
requestDuration: 500,
});
await expect(p1).rejects.toBeInstanceOf(CancellationError);
await expect(p2).resolves.toEqual({
data: new Uint8Array([4, 7, 7]).buffer,
mimeType: "image/png",
thumbnails: [
{
height: 720,
width: 1280,
offsetX: 0,
offsetY: 0,
start: 0,
end: Number.MAX_SAFE_INTEGER,
},
],
});
});
});
describe("error handling", () => {
it("throws a formatted error when the network request fails with a non-cancellation error", async () => {
const networkError = new Error("network failure");
const formattedError = new Error("formatted request error");
mockScheduleRequestWithCdns.mockRejectedValue(networkError);
mockErrorSelector.mockReturnValue(formattedError);
const pipeline = makePipeline();
const fetcher = createThumbnailFetcher(pipeline, null);
const canceller = new TaskCanceller("test");
await expect(fetcher(makeThumbnailContext(), canceller.signal)).rejects.toBe(
formattedError,
);
expect(mockErrorSelector).toHaveBeenCalledWith(networkError);
});
it("throws a formatted error when parseThumbnail throws", async () => {
const parseError = new Error("parse boom");
const formattedParseError = new Error("formatted parse error");
const pipeline = makePipeline();
pipeline.loadThumbnail.mockResolvedValue({
responseData: new Uint8Array([0, 1, 2]).buffer,
size: 3,
requestDuration: 1100,
});
pipeline.parseThumbnail.mockImplementation(() => {
throw parseError;
});
mockFormatError.mockReturnValue(formattedParseError);
const fetcher = createThumbnailFetcher(pipeline, null);
const canceller = new TaskCanceller("test");
await expect(fetcher(makeThumbnailContext(), canceller.signal)).rejects.toBe(
formattedParseError,
);
expect(mockFormatError).toHaveBeenCalledWith(
parseError,
expect.objectContaining({
defaultCode: "PIPELINE_PARSE_ERROR",
}),
);
});
it("propagates CancellationError from scheduleRequestWithCdns without wrapping", async () => {
const cancelErr = new CancellationError("test task", "aborted");
mockScheduleRequestWithCdns.mockRejectedValue(cancelErr);
const pipeline = makePipeline();
const fetcher = createThumbnailFetcher(pipeline, null);
const canceller = new TaskCanceller("test");
await expect(fetcher(makeThumbnailContext(), canceller.signal)).rejects.toBe(
cancelErr,
);
// errorSelector should not be called for CancellationError
expect(mockErrorSelector).not.toHaveBeenCalled();
});
});
describe("retry logging", () => {
it("calls onRetry and logs a warning when the request retries", async () => {
const retryError = new Error("retry me");
const formattedRetryError = new Error("formatted retry");
mockErrorSelector.mockReturnValue(formattedRetryError);
mockScheduleRequestWithCdns.mockImplementation(
async (
_cdns: ICdnMetadata[] | null,
_cdnPrioritizer: CdnPrioritizer | null,
_performRequest: (
cdn: ICdnMetadata | null,
cancellationSignal: CancellationSignal,
) => Promise<IRequestedData<ArrayBuffer>>,
options: IBackoffSettings,
_globalCancellationSignal: CancellationSignal,
) => {
// Simulate the retry callback being invoked before eventual failure
options.onRetry?.(retryError);
throw retryError;
},
);
mockErrorSelector.mockReturnValue(formattedRetryError);
const pipeline = makePipeline();
const fetcher = createThumbnailFetcher(pipeline, null);
const canceller = new TaskCanceller("test");
await expect(
fetcher(makeThumbnailContext(), canceller.signal),
).rejects.toBeDefined();
expect(mockLog.warn).toHaveBeenCalledWith(
"Thumbnails",
"Thumbnail request retry ",
expect.any(Object),
formattedRetryError,
);
});
});
});
/**
* Construct `pipeline` argument for a ThumbnailFetcher
*/
function makePipeline(
loadThumbnailImpl?: IThumbnailLoader,
parseThumbnailImpl?: IThumbnailParser,
): {
loadThumbnail: Mock<IThumbnailLoader>;
parseThumbnail: Mock<IThumbnailParser>;
} {
return {
loadThumbnail: vi.fn(
loadThumbnailImpl ??
(() =>
Promise.resolve({
responseData: new Uint8Array([0, 1, 2]).buffer,
requestDuration: undefined,
url: undefined,
sendingTime: undefined,
receivedTime: undefined,
size: undefined,
})),
),
parseThumbnail: vi.fn(
parseThumbnailImpl ??
((data: ArrayBuffer) => ({
data,
mimeType: "image/png",
thumbnails: [
{
height: 720,
width: 1280,
offsetX: 0,
offsetY: 0,
start: 0,
end: Number.MAX_SAFE_INTEGER,
},
],
})),
),
};
}
function makeThumbnailContext(
overrides: { segmentId?: string; trackId?: string; periodId?: string } = {},
): any {
return {
segment: { id: overrides.segmentId ?? "seg-1", time: 0 },
track: { id: overrides.trackId ?? "track-1", cdnMetadata: [] },
period: { id: overrides.periodId ?? "period-1" },
};
}