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((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((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, ): 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); }