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>, _options: IBackoffSettings, globalCancellationSignal: CancellationSignal, ): Promise> => { 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) => 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) => 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) => 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) => void; const pipeline = makePipeline( ( _cdnMetadata, _segment, _options, cancellationSignal: CancellationSignal, ): Promise> => { loadSignal = cancellationSignal; return new Promise((res) => { resolveRequest = res; }); }, ); mockScheduleRequestWithCdns.mockImplementation( ( cdns: ICdnMetadata[] | null, _cdnPrioritizer: CdnPrioritizer | null, performRequest: ( cdn: ICdnMetadata | null, cancellationSignal: CancellationSignal, ) => Promise>, _options: IBackoffSettings, globalCancellationSignal: CancellationSignal, ): Promise> => { 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>, 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; parseThumbnail: Mock; } { 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" }, }; }