// @vitest-environment jsdom // // The Plex player's HLS path, with hls.js replaced by a stand-in the test drives (jsdom has no // MediaSource, so the real one never runs here). Its own file because the module mock is file-wide. import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; import { render, cleanup, waitFor, screen, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), }; }); // Hoisted with the mock below (vi.mock runs before the module body). const { FakeHls, hlsInstances } = vi.hoisted(() => { type Handler = (event: string, data: unknown) => void; const hlsInstances: InstanceType[] = []; class FakeHls { handlers = new Map(); audioTrack = 0; constructor() { hlsInstances.push(this); } on(event: string, fn: Handler) { this.handlers.set(event, [...(this.handlers.get(event) ?? []), fn]); } emit(event: string, data: unknown = {}) { for (const fn of this.handlers.get(event) ?? []) fn(event, data); } loadSource() {} attachMedia() {} destroy() {} } return { FakeHls, hlsInstances }; }); vi.mock("hls.js", async (importOriginal) => { const actual = (await importOriginal<{ default: typeof import("hls.js").default }>()).default; return { default: Object.assign(FakeHls, { isSupported: () => true, Events: actual.Events, ErrorTypes: actual.ErrorTypes, }), }; }); import Hls from "hls.js"; import PlexPlayer from "./PlexPlayer"; import { api, type PlexItemDetail } from "../lib/api"; import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount"; type Media = HTMLMediaElement & { _t?: number }; beforeAll(() => { class Stub { observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } } globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; Object.defineProperty(HTMLMediaElement.prototype, "currentTime", { configurable: true, get(this: Media) { return this._t ?? 0; }, set(this: Media, v: number) { this._t = v; }, }); Object.defineProperty(HTMLMediaElement.prototype, "play", { configurable: true, value: () => Promise.resolve(), }); Object.defineProperty(HTMLMediaElement.prototype, "pause", { configurable: true, value() {} }); Object.defineProperty(HTMLMediaElement.prototype, "load", { configurable: true, value() {} }); Object.defineProperty(HTMLMediaElement.prototype, "seekable", { configurable: true, get: () => ({ length: 0, end: () => 0 }), }); Object.defineProperty(HTMLMediaElement.prototype, "textTracks", { configurable: true, get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }), }); }); beforeEach(() => { sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7"); hlsInstances.length = 0; }); afterEach(() => { cleanup(); vi.restoreAllMocks(); sessionStorage.clear(); localStorage.clear(); }); const detail = { id: "A", title: "Title A", type: "movie", duration_seconds: 3000, status: "new", position_seconds: 600, audio_streams: [{ ord: 0, label: "English", language: "en", default: true }], subtitle_streams: [], markers: [], } as unknown as PlexItemDetail; function mount() { vi.spyOn(api, "plexItem").mockResolvedValue(detail); vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never); const session = vi .spyOn(api, "plexSession") .mockResolvedValue({ mode: "hls", url: "/hls/A/index.m3u8", start_s: 600 } as never); render( {}} /> , ); return session; } const lost404 = { fatal: true, type: Hls.ErrorTypes.NETWORK_ERROR, details: "fragLoadError", response: { code: 404, text: "No active playback session" }, }; describe("an HLS session the server no longer has", () => { it("is restarted where the viewer is, instead of ending playback", async () => { // The server reaps a session nobody fetches from (a long pause; a deploy). hls.js never retries // a 4xx, so the next segment's 404 used to end playback with "couldn't play". const session = mount(); await waitFor(() => expect(hlsInstances).toHaveLength(1)); act(() => hlsInstances[0]!.emit(Hls.Events.MANIFEST_PARSED)); const v = document.querySelector("video") as Media; v._t = 700; // 600 + 700 − the HLS lag act(() => void v.dispatchEvent(new Event("timeupdate"))); act(() => hlsInstances[0]!.emit(Hls.Events.ERROR, lost404)); await waitFor(() => expect(session).toHaveBeenCalledTimes(2)); expect(session.mock.calls[1]![1]).toBeCloseTo(1299, 0); expect(screen.queryByText("plex.player.errGeneric")).toBeNull(); }); it("gives up and says so when the reclaimed session keeps 404ing", async () => { // `parsed` cannot bound this: it lives on ONE hls instance and every restart builds a fresh one, // so a session that parses its manifest and then 404s re-arms the guard every cycle. The server // also answers 404 for "that segment is not there YET" (a transcode falling behind realtime), // which is indistinguishable on the wire — so the player respawned ffmpeg forever behind a // spinner that never became an error. const session = mount(); const v = () => document.querySelector("video") as Media; // The first session plus the two it is allowed to reclaim; the third 404 has nothing left. for (let i = 0; i < 3; i++) { await waitFor(() => expect(hlsInstances).toHaveLength(i + 1)); act(() => hlsInstances[i]!.emit(Hls.Events.MANIFEST_PARSED)); v()._t = 700; act(() => void v().dispatchEvent(new Event("timeupdate"))); act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404)); } await screen.findByText("plex.player.errGeneric"); // Two reclaims, then the honest answer — not a fourth, fifth, hundredth session. expect(session).toHaveBeenCalledTimes(3); }); it("keeps giving up even though every reclaimed session plays a fragment first", async () => { // The budget used to be cleared by the first FRAG_LOADED of any session — and a reclaimed // session essentially always delivers one, because `start_session` does not return until its // first segment exists. So it bounded NEITHER case its own comment named: measured at 40 loss // cycles, the player opened 41 sessions and never showed an error. A // fragment is not proof the session can be served; surviving the window is. const session = mount(); const v = () => document.querySelector("video") as Media; for (let i = 0; i < 3; i++) { await waitFor(() => expect(hlsInstances).toHaveLength(i + 1)); act(() => hlsInstances[i]!.emit(Hls.Events.MANIFEST_PARSED)); v()._t = 700; act(() => void v().dispatchEvent(new Event("timeupdate"))); act(() => hlsInstances[i]!.emit(Hls.Events.FRAG_LOADED, { frag: { type: "main", duration: 6, stats: { total: 1000 } }, }), ); act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404)); } await screen.findByText("plex.player.errGeneric"); expect(session).toHaveBeenCalledTimes(3); }); it("starts the budget over for a loss long after the last one", async () => { // The other half: a session reaped once an hour is not a session that cannot be served. The // window is what separates the two, so a spent budget must not follow the viewer around. vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ["Date"] }); try { const session = mount(); const v = () => document.querySelector("video") as Media; for (let i = 0; i < 4; i++) { await waitFor(() => expect(hlsInstances).toHaveLength(i + 1)); act(() => hlsInstances[i]!.emit(Hls.Events.MANIFEST_PARSED)); v()._t = 700; act(() => void v().dispatchEvent(new Event("timeupdate"))); act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404)); act(() => void vi.setSystemTime(Date.now() + 10 * 60_000)); } expect(screen.queryByText("plex.player.errGeneric")).toBeNull(); expect(session).toHaveBeenCalledTimes(5); } finally { vi.useRealTimers(); } }); it("gives up on a session lost over and over on a SLOW cadence too", async () => { // The window's anchor used to be written only where the window RESET, so it marked the start of // the window instead of the last loss — a tumbling window, not the rolling one the constant is // named for. Any cadence slower than half the window then cleared the budget on every second // loss and the bound never bit: a transcode falling ~70s behind realtime — the case the ERROR // handler itself names — reclaimed forever (measured: 200 losses 70s apart → 200 reclaims, // never an error). The two cases above space their losses 0s and 10min apart, i.e. both sit // outside the 60–120s band where the defect lived. vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ["Date"] }); try { const session = mount(); const v = () => document.querySelector("video") as Media; for (let i = 0; i < 6 && !screen.queryByText("plex.player.errGeneric"); i++) { await waitFor(() => expect(hlsInstances).toHaveLength(i + 1)); act(() => hlsInstances[i]!.emit(Hls.Events.MANIFEST_PARSED)); v()._t = 700; act(() => void v().dispatchEvent(new Event("timeupdate"))); act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404)); act(() => void vi.setSystemTime(Date.now() + 70_000)); } await screen.findByText("plex.player.errGeneric"); // Two reclaims, each within two minutes of the loss before it — then the honest answer. expect(session).toHaveBeenCalledTimes(3); } finally { vi.useRealTimers(); } }); it("is reclaimed at the asked position, and still starts, when it was lost before it played", async () => { // Two session requests for one item in flight at once (a seek twice in quick succession; the // dev StrictMode replay of the start effect): the server's LAST spawn wins, and it may be the // older request's. The newer request's answer then names a session that is already gone, and // its playlist 404s before a single manifest parsed — which ended playback with "couldn't // start" (Arrow S03E14 on localdev, 2026-09-27). The video is still paused then, so the // reclaim must keep the start's own "play once ready", not read it off the paused element. const play = vi.spyOn(HTMLMediaElement.prototype, "play"); const session = mount(); await waitFor(() => expect(hlsInstances).toHaveLength(1)); act(() => hlsInstances[0]!.emit(Hls.Events.ERROR, { ...lost404, details: "manifestLoadError" }), ); await waitFor(() => expect(hlsInstances).toHaveLength(2)); expect(session).toHaveBeenCalledTimes(2); expect(session.mock.calls[1]![1]).toBe(600); act(() => hlsInstances[1]!.emit(Hls.Events.MANIFEST_PARSED)); expect(play).toHaveBeenCalled(); expect(screen.queryByText("plex.player.errGeneric")).toBeNull(); }); it("gives up and says so when a session that never played keeps 404ing", async () => { // The reclaim budget is what bounds a server that 404s every session from the start. const session = mount(); for (let i = 0; i < 3; i++) { await waitFor(() => expect(hlsInstances).toHaveLength(i + 1)); act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404)); } await screen.findByText("plex.player.errGeneric"); expect(session).toHaveBeenCalledTimes(3); }); });