rx-player
Version:
Canal+ HTML5 Video Player
332 lines (295 loc) • 10.7 kB
text/typescript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NetworkErrorTypes } from "../../../../errors";
import { RequestError } from "../../../../utils/request";
import TaskCanceller from "../../../../utils/task_canceller";
import { scheduleRequestWithCdns, scheduleRequestPromise } from "../schedule_request";
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
const { mockLog, mockCancellableSleep, mockGetFuzzedDelay, mockGetTimestamp } =
vi.hoisted(() => {
return {
mockLog: { warn: vi.fn() },
mockCancellableSleep: vi.fn(),
mockGetFuzzedDelay: vi.fn((delay: number) => delay),
mockGetTimestamp: vi.fn(() => 0),
};
});
vi.mock("../../../log", () => ({ default: mockLog }));
vi.mock("../../../utils/cancellable_sleep", () => ({ default: mockCancellableSleep }));
vi.mock("../../../utils/get_fuzzed_delay", () => ({ default: mockGetFuzzedDelay }));
vi.mock("../../../utils/monotonic_timestamp", () => ({ default: mockGetTimestamp }));
const defaultOptions = {
baseDelay: 100,
maxDelay: 2000,
maxRetry: 3,
onRetry: vi.fn(),
};
function makeCdn(id: string): any {
return { baseUrl: id };
}
beforeEach(() => {
mockGetTimestamp.mockReturnValue(0);
mockGetFuzzedDelay.mockImplementation((delay: number) => delay);
// By default cancellableSleep resolves immediately
mockCancellableSleep.mockResolvedValue(undefined);
});
afterEach(() => {
vi.resetAllMocks();
});
describe.skip("scheduleRequestWithCdns", () => {
it("resolves with the result of a successful request", async () => {
const cdn = makeCdn("https://cdn1.example.com");
const performRequest = vi.fn().mockResolvedValue("ok");
const result = await scheduleRequestWithCdns(
[cdn],
null,
performRequest,
defaultOptions,
new TaskCanceller("test").signal,
);
expect(result).toBe("ok");
expect(performRequest).toHaveBeenCalledTimes(1);
expect(performRequest).toHaveBeenCalledWith(cdn, expect.anything());
});
it("passes null cdn when cdns param is null", async () => {
const performRequest = vi.fn().mockResolvedValue("data");
await scheduleRequestWithCdns(
null,
null,
performRequest,
defaultOptions,
new TaskCanceller("test").signal,
);
expect(performRequest).toHaveBeenCalledWith(null, expect.anything());
});
it("throws immediately if cancellationSignal is already cancelled", async () => {
const cancellationError = new Error("cancelled");
const signal: any = {
cancellationError,
isCancelled: () => true,
};
await expect(
scheduleRequestWithCdns([makeCdn("a")], null, vi.fn(), defaultOptions, signal),
).rejects.toThrow("cancelled");
});
it("throws 'No CDN to request' when given an empty CDN array", async () => {
await expect(
scheduleRequestWithCdns(
[],
null,
vi.fn(),
defaultOptions,
new TaskCanceller("test").signal,
),
).rejects.toThrow("No CDN to request");
expect(mockLog.warn).toHaveBeenCalled();
});
it("retries on a 500 HTTP error and resolves on second attempt", async () => {
const cdn = makeCdn("https://cdn1.example.com");
const error = new RequestError("url", 500, NetworkErrorTypes.ERROR_HTTP_CODE);
const performRequest = vi
.fn()
.mockRejectedValueOnce(error)
.mockResolvedValueOnce("recovered");
const onRetry = vi.fn();
const result = await scheduleRequestWithCdns(
[cdn],
null,
performRequest,
{ ...defaultOptions, onRetry },
new TaskCanceller("test").signal,
);
expect(result).toBe("recovered");
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry).toHaveBeenCalledWith(error);
});
it("retries on TIMEOUT error", async () => {
const cdn = makeCdn("https://cdn1.example.com");
const error = new RequestError("url", 0, NetworkErrorTypes.TIMEOUT);
const performRequest = vi
.fn()
.mockRejectedValueOnce(error)
.mockResolvedValueOnce("ok");
const result = await scheduleRequestWithCdns(
[cdn],
null,
performRequest,
defaultOptions,
new TaskCanceller("test").signal,
);
expect(result).toBe("ok");
});
it("does NOT retry on a 403 HTTP error and rejects immediately", async () => {
const cdn = makeCdn("https://cdn1.example.com");
const error = new RequestError("url", 403, NetworkErrorTypes.ERROR_HTTP_CODE);
const performRequest = vi.fn().mockRejectedValue(error);
const onRetry = vi.fn();
await expect(
scheduleRequestWithCdns(
[cdn],
null,
performRequest,
{ ...defaultOptions, maxRetry: 5, onRetry },
new TaskCanceller("test").signal,
),
).rejects.toThrow();
// 403 should not be retried — performRequest called once only
expect(performRequest).toHaveBeenCalledTimes(1);
expect(onRetry).not.toHaveBeenCalled();
});
it("blacklists a CDN after maxRetry exhaustion and rejects", async () => {
const cdn = makeCdn("https://cdn1.example.com");
const error = new RequestError("url", 500, NetworkErrorTypes.ERROR_HTTP_CODE);
const performRequest = vi.fn().mockRejectedValue(error);
const onRetry = vi.fn();
const maxRetry = 2;
await expect(
scheduleRequestWithCdns(
[cdn],
null,
performRequest,
{ ...defaultOptions, maxRetry, onRetry },
new TaskCanceller("test").signal,
),
).rejects.toThrow();
// initial attempt + maxRetry retries = maxRetry+1 calls
expect(performRequest).toHaveBeenCalledTimes(maxRetry + 1);
});
it("falls over to next CDN when first CDN fails with a non-retryable error", async () => {
const cdn1 = makeCdn("https://cdn1.example.com");
const cdn2 = makeCdn("https://cdn2.example.com");
// 403 is non-retryable → should move to cdn2
const error403 = new RequestError("url", 403, NetworkErrorTypes.ERROR_HTTP_CODE);
const performRequest = vi
.fn()
.mockRejectedValueOnce(error403)
.mockResolvedValueOnce("cdn2-response");
const result = await scheduleRequestWithCdns(
[cdn1, cdn2],
null,
performRequest,
defaultOptions,
new TaskCanceller("test").signal,
);
expect(result).toBe("cdn2-response");
expect(performRequest).toHaveBeenNthCalledWith(1, cdn1, expect.anything());
expect(performRequest).toHaveBeenNthCalledWith(2, cdn2, expect.anything());
});
it("rejects with the last error when all CDNs are exhausted", async () => {
const cdn1 = makeCdn("cdn1");
const cdn2 = makeCdn("cdn2");
const error1 = new RequestError("url1", 403, NetworkErrorTypes.ERROR_HTTP_CODE);
const error2 = new RequestError("url2", 403, NetworkErrorTypes.ERROR_HTTP_CODE);
const performRequest = vi
.fn()
.mockRejectedValueOnce(error1)
.mockRejectedValueOnce(error2);
await expect(
scheduleRequestWithCdns(
[cdn1, cdn2],
null,
performRequest,
defaultOptions,
new TaskCanceller("test").signal,
),
).rejects.toThrow();
expect(performRequest).toHaveBeenCalledTimes(2);
});
it("downgrades CDN in prioritizer after failure", async () => {
const cdn = makeCdn("cdn1");
const error = new RequestError("url", 403, NetworkErrorTypes.ERROR_HTTP_CODE);
const cdnPrioritizer = {
getCdnPreferenceForResource: vi.fn().mockReturnValue([cdn]),
downgradeCdn: vi.fn(),
addEventListener: vi.fn(),
};
const performRequest = vi.fn().mockRejectedValue(error);
await expect(
scheduleRequestWithCdns(
[cdn],
cdnPrioritizer as never,
performRequest,
defaultOptions,
new TaskCanceller("test").signal,
),
).rejects.toThrow();
expect(cdnPrioritizer.downgradeCdn).toHaveBeenCalledWith(cdn);
});
it("retries on INTEGRITY_ERROR", async () => {
const cdn = makeCdn("cdn1");
const integrityError = { code: "INTEGRITY_ERROR" };
const performRequest = vi
.fn()
.mockRejectedValueOnce(integrityError)
.mockResolvedValueOnce("ok");
const result = await scheduleRequestWithCdns(
[cdn],
null,
performRequest,
{ ...defaultOptions, maxRetry: 2 },
new TaskCanceller("test").signal,
);
expect(result).toBe("ok");
});
it("computes exponential backoff delay capped at maxDelay", async () => {
const cdn = makeCdn("cdn1");
const error = new RequestError("url", 500, NetworkErrorTypes.ERROR_HTTP_CODE);
// Fail twice then succeed
const performRequest = vi
.fn()
.mockRejectedValueOnce(error)
.mockRejectedValueOnce(error)
.mockResolvedValueOnce("done");
const baseDelay = 100;
const maxDelay = 150;
await scheduleRequestWithCdns(
[cdn],
null,
performRequest,
{ baseDelay, maxDelay, maxRetry: 5, onRetry: vi.fn() },
new TaskCanceller("test").signal,
);
// After 1st failure: delay = min(100 * 2^0, 150) = 100
// After 2nd failure: delay = min(100 * 2^1, 150) = 150
expect(mockGetFuzzedDelay).toHaveBeenNthCalledWith(1, 100);
expect(mockGetFuzzedDelay).toHaveBeenNthCalledWith(2, 150);
});
});
describe("scheduleRequestPromise", () => {
it("resolves with the performRequest result", async () => {
const performRequest = vi.fn().mockResolvedValue("result");
const result = await scheduleRequestPromise(
performRequest,
defaultOptions,
new TaskCanceller("test").signal,
);
expect(result).toBe("result");
});
it("retries and resolves after transient failure", async () => {
const error = new RequestError("url", 500, NetworkErrorTypes.ERROR_HTTP_CODE);
const performRequest = vi
.fn()
.mockRejectedValueOnce(error)
.mockResolvedValueOnce("ok");
const result = await scheduleRequestPromise(
performRequest,
{ ...defaultOptions, maxRetry: 2 },
new TaskCanceller("test").signal,
);
expect(result).toBe("ok");
});
it("rejects after all retries are exhausted", async () => {
const error = new RequestError("url", 500, NetworkErrorTypes.ERROR_HTTP_CODE);
const performRequest = vi.fn().mockRejectedValue(error);
await expect(
scheduleRequestPromise(
performRequest,
{ ...defaultOptions, maxRetry: 1 },
new TaskCanceller("test").signal,
),
).rejects.toThrow();
// 1 initial + 1 retry = 2 total
expect(performRequest).toHaveBeenCalledTimes(2);
});
});