Files
siftlode/frontend/src/components/PlexPlayer.hls.dom.test.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

283 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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<typeof import("react-i18next")>();
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<typeof FakeHls>[] = [];
class FakeHls {
handlers = new Map<string, Handler[]>();
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(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<PlexPlayer itemId="A" onClose={() => {}} />
</QueryClientProvider>,
);
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);
});
});