Deep-review round 3 of bug hunt 2026-09. - loadSession checks, after its await, that it is still the newest call and the player is still mounted. A late answer used to replace what was playing (Next twice quickly: the slower transcode landed last and played the middle item under the last one's title, recording its progress there) or attach a stream to the detached <video> after Back, never destroyed. - The <video>'s clock is trusted only once the new session has answered. go()'s pause() fires a timeupdate AFTER the reset to 0 (measured in Chrome), so the previous item's position was sent as the next item's subtitle position and saved as its progress (final, pushed to Plex). - Next settles the item being left (final checkpoint + reopen-cache patch) before switching: the progress cleanup ran with the position already reset and skipped, so every Next dropped the last stretch watched. - A seek-restart puts its target into the position state too; the ref alone was re-synced back to the pre-seek spot by the restart's own render. - The unmount teardown captures the <video> at mount: React detaches refs before an unmount's cleanups, so the stop/detach never ran. - The tracks panel exposes the selected audio/subtitle track via aria-pressed. Verified in real Chrome on localdev (Westworld S1E6 → Next): the item left was settled at 783 s, the next asked for subtitles at its own start (0), and no progress was written for it.
279 lines
11 KiB
TypeScript
279 lines
11 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The Plex player's session lifecycle, rendered for real (deep-review round 3 of bug hunt 2026-09).
|
|
//
|
|
// jsdom has no media pipeline, so the <video> is a small stand-in that behaves like Chrome where it
|
|
// matters here: `pause()` fires its `timeupdate` in a LATER task (measured in Chrome — which is how the
|
|
// previous item's position used to arrive after the player had already reset it for the next one).
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
|
import { render, cleanup, waitFor, screen, fireEvent, 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" } }),
|
|
};
|
|
});
|
|
|
|
import PlexPlayer from "./PlexPlayer";
|
|
import { api, type PlexItemDetail } from "../lib/api";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { notify } from "../lib/notifications";
|
|
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;
|
|
const later = (el: HTMLMediaElement, ...types: string[]) =>
|
|
setTimeout(() => types.forEach((ty) => el.dispatchEvent(new Event(ty))), 0);
|
|
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(this: HTMLMediaElement) {
|
|
later(this, "timeupdate", "pause"); // as Chrome does: after the call has returned
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLMediaElement.prototype, "load", {
|
|
configurable: true,
|
|
value(this: Media) {
|
|
this._t = 0;
|
|
},
|
|
});
|
|
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(() => {
|
|
// A signed-in account whose saved subtitle language is English (prefs are per account).
|
|
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
|
|
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en" }));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
sessionStorage.clear();
|
|
localStorage.clear();
|
|
});
|
|
|
|
const item = (id: string, position = 0): PlexItemDetail =>
|
|
({
|
|
id,
|
|
title: `Title ${id}`,
|
|
type: "episode",
|
|
duration_seconds: 3000,
|
|
status: "new",
|
|
position_seconds: position,
|
|
audio_streams: [{ ord: 0, label: "English", language: "en", default: true }],
|
|
subtitle_streams: [{ ord: 0, label: "English", language: "en", text: true }],
|
|
markers: [],
|
|
}) as unknown as PlexItemDetail;
|
|
|
|
const direct = (id: string) =>
|
|
({ mode: "direct", url: `/media/${id}`, start_s: 0 }) as Awaited<
|
|
ReturnType<typeof api.plexSession>
|
|
>;
|
|
|
|
/** A session answer the test releases when it wants to. */
|
|
function deferred() {
|
|
let resolve!: (v: Awaited<ReturnType<typeof api.plexSession>>) => void;
|
|
const promise = new Promise<Awaited<ReturnType<typeof api.plexSession>>>((r) => (resolve = r));
|
|
return { promise, resolve };
|
|
}
|
|
|
|
function mount(opts: {
|
|
details: Record<string, PlexItemDetail>;
|
|
sessions: Record<string, () => Promise<Awaited<ReturnType<typeof api.plexSession>>>>;
|
|
queue?: string[];
|
|
qc?: QueryClient;
|
|
}) {
|
|
vi.spyOn(api, "plexItem").mockImplementation((id: string) => Promise.resolve(opts.details[id]!));
|
|
vi.spyOn(api, "plexSession").mockImplementation((id: string) => opts.sessions[id]!());
|
|
const progress = vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
|
|
const status = vi
|
|
.spyOn(api, "plexSubtitleStatus")
|
|
.mockResolvedValue({ state: "preparing", code: null, gen: 0 });
|
|
const qc = opts.qc ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
const first = opts.queue?.[0] ?? Object.keys(opts.details)[0]!;
|
|
const view = render(
|
|
<QueryClientProvider client={qc}>
|
|
<PlexPlayer itemId={first} onClose={() => {}} queue={opts.queue} />
|
|
</QueryClientProvider>,
|
|
);
|
|
return { ...view, progress, status, qc };
|
|
}
|
|
|
|
const video = () => document.querySelector("video") as Media;
|
|
|
|
async function playingAt(src: string, t: number) {
|
|
await waitFor(() => expect(video()?.getAttribute("src")).toBe(src));
|
|
const v = video();
|
|
v._t = t;
|
|
act(() => void v.dispatchEvent(new Event("timeupdate")));
|
|
}
|
|
|
|
const tick = () => act(() => new Promise((r) => setTimeout(r, 20)));
|
|
|
|
describe("moving to the next item", () => {
|
|
it("settles the item being left, and gives the next one none of its position", async () => {
|
|
const { progress, status, unmount } = mount({
|
|
details: { A: item("A"), B: item("B", 300) },
|
|
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
|
queue: ["A", "B"],
|
|
});
|
|
await playingAt("/media/A", 1500);
|
|
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
|
await waitFor(() => expect(status.mock.calls.some(([id]) => id === "B")).toBe(true));
|
|
await tick(); // the timeupdate that pause() queued has landed by now
|
|
// B's session has not answered, so where B is is where it was asked to start — not A's 25:00.
|
|
expect(status.mock.calls.filter(([id]) => id === "B").map(([, , at]) => at)).not.toContain(
|
|
1500,
|
|
);
|
|
expect(status.mock.calls.find(([id]) => id === "B")?.[2]).toBe(300);
|
|
// A got its final checkpoint at the position it was left at…
|
|
expect(progress.mock.calls).toContainEqual(["A", 1500, 3000, true]);
|
|
unmount();
|
|
// …and leaving B while it was still loading saved nothing of A's position as B's.
|
|
expect(progress.mock.calls.filter(([id]) => id === "B")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it("a seek that restarts the session is saved at its target while the new session loads", async () => {
|
|
// seekTo wrote the target into the position ref only — and the restart re-renders at once, which
|
|
// re-syncs that ref from the state: a page-hide during the restart saved the pre-seek spot.
|
|
let calls = 0;
|
|
mount({
|
|
details: { A: item("A") },
|
|
sessions: {
|
|
A: () => (calls++ === 0 ? Promise.resolve(direct("A")) : new Promise(() => {})),
|
|
},
|
|
});
|
|
const beacon = vi.spyOn(api, "plexProgressBeacon").mockImplementation(() => {});
|
|
await playingAt("/media/A", 1500);
|
|
fireEvent.click(screen.getByLabelText("plex.player.forward10"));
|
|
await waitFor(() => expect(api.plexSession).toHaveBeenCalledTimes(2)); // a restart, not a native seek
|
|
await tick();
|
|
act(() => void window.dispatchEvent(new Event("pagehide")));
|
|
expect(beacon).toHaveBeenLastCalledWith("A", 1510, 3000, true);
|
|
});
|
|
|
|
describe("a session answer that lands late", () => {
|
|
it("is dropped when the viewer has moved on to another item meanwhile", async () => {
|
|
const slowB = deferred();
|
|
mount({
|
|
details: { A: item("A"), B: item("B"), C: item("C") },
|
|
sessions: {
|
|
A: () => Promise.resolve(direct("A")),
|
|
B: () => slowB.promise,
|
|
C: () => Promise.resolve(direct("C")),
|
|
},
|
|
queue: ["A", "B", "C"],
|
|
});
|
|
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
|
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
|
await screen.findByText("Title B");
|
|
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
|
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/C"));
|
|
await act(async () => slowB.resolve(direct("B")));
|
|
await tick();
|
|
expect(video().getAttribute("src")).toBe("/media/C");
|
|
});
|
|
|
|
it("is dropped when the player has been closed meanwhile", async () => {
|
|
const slowA = deferred();
|
|
const { unmount } = mount({ details: { A: item("A") }, sessions: { A: () => slowA.promise } });
|
|
await waitFor(() => expect(api.plexSession).toHaveBeenCalled());
|
|
const v = video();
|
|
unmount();
|
|
await act(async () => slowA.resolve(direct("A")));
|
|
await tick();
|
|
expect(v.getAttribute("src")).toBeNull();
|
|
});
|
|
});
|
|
|
|
it("closing the player detaches the <video> it was playing", async () => {
|
|
const { unmount } = mount({
|
|
details: { A: item("A") },
|
|
sessions: { A: () => Promise.resolve(direct("A")) },
|
|
});
|
|
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
|
const v = video();
|
|
unmount();
|
|
expect(v.getAttribute("src")).toBeNull();
|
|
});
|
|
|
|
describe("the subtitle poll and notice", () => {
|
|
it("asks about where a resumed session was asked to start, before playback has ticked", async () => {
|
|
const { status } = mount({
|
|
details: { A: item("A", 1198) },
|
|
sessions: { A: () => new Promise(() => {}) },
|
|
});
|
|
await waitFor(() => expect(status).toHaveBeenCalled());
|
|
expect(status.mock.calls[0]).toEqual(["A", 0, 1198]);
|
|
});
|
|
|
|
it("says a failing poll even while an earlier window is still on screen", async () => {
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
qc.setQueryData(qk.plexSubtitle("A", 0), { state: "partial", code: null, gen: 1 });
|
|
const view = mount({
|
|
details: { A: item("A") },
|
|
sessions: { A: () => Promise.resolve(direct("A")) },
|
|
qc,
|
|
});
|
|
view.status.mockRejectedValue(new Error("offline"));
|
|
await waitFor(() => expect(view.status).toHaveBeenCalled());
|
|
await screen.findByText("plex.player.subUnavailable.failed");
|
|
expect(document.querySelector("track")).not.toBeNull(); // the window stays mounted meanwhile
|
|
});
|
|
|
|
it("marks which track is selected for assistive tech, not only by colour", async () => {
|
|
mount({ details: { A: item("A") }, sessions: { A: () => new Promise(() => {}) } });
|
|
await screen.findByText("Title A");
|
|
fireEvent.click(screen.getByLabelText("plex.player.tracks"));
|
|
const off = await screen.findByText("plex.player.subOff");
|
|
expect(off.closest("button")?.getAttribute("aria-pressed")).toBe("false");
|
|
const pressed = document.querySelectorAll('[aria-pressed="true"]');
|
|
expect([...pressed].map((b) => b.textContent)).toEqual(["English"]); // the saved subtitle
|
|
});
|
|
});
|
|
|
|
it("shows a toast raised while it is open inside itself, not under its scrim", async () => {
|
|
mount({ details: { A: item("A") }, sessions: { A: () => new Promise(() => {}) } });
|
|
await screen.findByText("Title A");
|
|
act(() => notify({ level: "error", message: "Couldn't mark it watched." }));
|
|
const toast = await screen.findByText("Couldn't mark it watched.");
|
|
let host: Element | null = video();
|
|
while (host && !host.contains(toast)) host = host.parentElement;
|
|
expect(host).not.toBe(document.body); // the player's own host, beside its <video>
|
|
});
|