rx-player
Version:
Canal+ HTML5 Video Player
194 lines (173 loc) • 5.68 kB
text/typescript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import TaskCanceller from "../../utils/task_canceller";
import type { IPublicApiContentInfos } from "../api/public_api";
import renderThumbnail from "../render_thumbnail";
const { mockFormatError, mockGetPeriodForTime } = vi.hoisted(() => {
return {
mockFormatError: vi.fn((err: unknown) => err),
mockGetPeriodForTime: vi.fn(() => ({
id: "period-1",
thumbnailTracks: [{ id: "thumb-track-1" }],
})),
};
});
vi.mock("../../errors", () => ({
formatError: mockFormatError,
}));
vi.mock("../../manifest", () => ({
getPeriodForTime: mockGetPeriodForTime,
}));
describe("renderThumbnail", () => {
let imageInstances: FakeImage[];
beforeEach(() => {
imageInstances = [];
vi.stubGlobal(
"Image",
class FakeImage {
public onload: null | (() => void) = null;
public onerror: null | (() => void) = null;
set src(_value: string) {
imageInstances.push(this);
}
},
);
vi.stubGlobal("URL", {
createObjectURL: vi.fn(() => "blob:thumbnail"),
revokeObjectURL: vi.fn(),
});
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
drawImage: vi.fn(),
} as unknown as CanvasRenderingContext2D);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("keeps the newer thumbnail when an aborted older request finishes later", async () => {
let resolveFirstFetch!: (value: IThumbnailResponseLike) => void;
const contentInfos = createContentInfos(
vi
.fn()
.mockImplementationOnce(
() =>
new Promise<IThumbnailResponseLike>((resolve) => {
resolveFirstFetch = resolve;
}),
)
.mockResolvedValueOnce(makeThumbnailResponse(10, 20)),
);
const container = document.createElement("div");
const firstPromise = renderThumbnail(contentInfos, { container, time: 0.5 });
const secondPromise = renderThumbnail(contentInfos, { container, time: 12 });
await Promise.resolve();
expect(imageInstances).toHaveLength(1);
imageInstances[0].onload?.();
await expect(secondPromise).resolves.toBeUndefined();
expect(container.childElementCount).toBe(1);
resolveFirstFetch(makeThumbnailResponse(0, 10));
await expect(firstPromise).rejects.toMatchObject({ code: "ABORTED" });
expect(container.childElementCount).toBe(1);
expect(container.firstElementChild?.className).toBe("__rx-thumbnail__");
});
it("does not remove the newer pending request when an older request aborts", async () => {
let resolveFirstFetch!: (value: IThumbnailResponseLike) => void;
const contentInfos = createContentInfos(
vi
.fn()
.mockImplementationOnce(
() =>
new Promise<IThumbnailResponseLike>((resolve) => {
resolveFirstFetch = resolve;
}),
)
.mockResolvedValueOnce(makeThumbnailResponse(10, 20)),
);
const container = document.createElement("div");
const firstPromise = renderThumbnail(contentInfos, { container, time: 0.5 });
const secondPromise = renderThumbnail(contentInfos, { container, time: 12 });
const pendingRequest =
contentInfos.thumbnailRequestsInfo.pendingRequests.get(container);
expect(pendingRequest).toBeDefined();
await Promise.resolve();
expect(imageInstances).toHaveLength(1);
resolveFirstFetch(makeThumbnailResponse(0, 10));
await expect(firstPromise).rejects.toMatchObject({ code: "ABORTED" });
expect(contentInfos.thumbnailRequestsInfo.pendingRequests.get(container)).toBe(
pendingRequest,
);
imageInstances[0].onload?.();
await expect(secondPromise).resolves.toBeUndefined();
expect(
contentInfos.thumbnailRequestsInfo.pendingRequests.get(container),
).toBeUndefined();
});
});
function createContentInfos(
fetchThumbnailDataCallback: (
periodId: string,
thumbnailTrackId: string,
time: number,
) => Promise<IThumbnailResponseLike>,
): IPublicApiContentInfos {
return {
contentId: "content-1",
originalUrl: undefined,
manifest: {} as IPublicApiContentInfos["manifest"],
currentPeriod: null,
activeAdaptations: null,
activeRepresentations: null,
defaultAudioTrackSwitchingMode: "reload",
fetchThumbnailDataCallback,
handledTrackTypes: {
audio: true,
video: true,
text: true,
},
initializer: {} as IPublicApiContentInfos["initializer"],
isDirectFile: false,
mediaElementTracksStore: null,
onAudioTracksNotPlayable: "continue",
onVideoTracksNotPlayable: "continue",
playbackObserver: {} as IPublicApiContentInfos["playbackObserver"],
segmentSinkMetricsCallback: null,
thumbnailRequestsInfo: {
pendingRequests: new WeakMap(),
lastResponse: null,
},
tracksStore: null,
useWorker: false,
currentContentCanceller: new TaskCanceller("content"),
};
}
function makeThumbnailResponse(start: number, end: number): IThumbnailResponseLike {
return {
data: new Uint8Array([1, 2, 3]).buffer,
mimeType: "image/jpeg",
thumbnails: [
{
start,
end,
width: 320,
height: 180,
offsetX: 0,
offsetY: 0,
},
],
};
}
interface IThumbnailResponseLike {
data: ArrayBuffer;
mimeType: string;
thumbnails: Array<{
start: number;
end: number;
width: number;
height: number;
offsetX: number;
offsetY: number;
}>;
}
interface FakeImage {
onload: null | (() => void);
onerror: null | (() => void);
}