diff --git a/frontend/src/components/PlexPlayer.hls.dom.test.tsx b/frontend/src/components/PlexPlayer.hls.dom.test.tsx new file mode 100644 index 00000000..795a8869 --- /dev/null +++ b/frontend/src/components/PlexPlayer.hls.dom.test.tsx @@ -0,0 +1,164 @@ +// @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("is a failure, not a restart, when the session never played", async () => { + // Otherwise a session that 404s from the start would restart itself in a loop. + const session = mount(); + await waitFor(() => expect(hlsInstances).toHaveLength(1)); + act(() => hlsInstances[0]!.emit(Hls.Events.ERROR, lost404)); + await screen.findByText("plex.player.errGeneric"); + expect(session).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/PlexPlayer.session.dom.test.tsx b/frontend/src/components/PlexPlayer.session.dom.test.tsx index d2fecf9c..b33b0e86 100644 --- a/frontend/src/components/PlexPlayer.session.dom.test.tsx +++ b/frontend/src/components/PlexPlayer.session.dom.test.tsx @@ -18,7 +18,7 @@ vi.mock("react-i18next", async (importOriginal) => { }); import PlexPlayer from "./PlexPlayer"; -import { api, type PlexItemDetail } from "../lib/api"; +import { api, HttpError, type PlexItemDetail } from "../lib/api"; import { qk } from "../lib/queryKeys"; import { notify } from "../lib/notifications"; import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount"; @@ -276,3 +276,225 @@ it("shows a toast raised while it is open inside itself, not under its scrim", a while (host && !host.contains(toast)) host = host.parentElement; expect(host).not.toBe(document.body); // the player's own host, beside its