Files
siftlode/frontend/src/components/PlexPlayer.session.dom.test.tsx
T
peter 8036251589 fix(plex): the reclaim window rolls, and a refused finish is not performed through a third route
Two ways round 6's own fixes did not bound what their comments said they bounded.

The reclaim budget's window anchor was written only where the window RESET, so it marked the window's
start rather than the last loss — a tumbling window, not the rolling one the constant is named for.
Any loss cadence slower than half the window satisfied the reset on every second loss, so the budget
never reached its bound: measured over 200 loss cycles, 61/70/90/119s apart all gave 200 reclaims and
never an error, while 5s and 30s apart correctly gave two and then the honest answer. The failing
band is exactly the case the ERROR handler names — a transcode that falls behind realtime — and both
committed tests spaced their losses 0s and 10min apart, i.e. outside it. The anchor now moves with
every loss.

And `finish`'s refusal handler lowered the play-through latch, which is the only gate on the 10s
interval, the page-hide beacon and `settle`. All three post `absRef`, which on the play-to-the-end
path IS the end of the media, and the backend reads a position within `_FINISH_MARGIN_S` of the
duration as a finish — so within ten seconds of the toast saying the mark had failed, the item was
marked watched, its position cleared and a scrobble sent to Plex. Round 6 closed the explicit
rollback write and left the other three open; the test it added set `upNext: false`, which tears the
effect down before any of them can fire. The refusal now takes back the CLAIM rather than the latch:
a second flag stops `settle` re-asserting watched, and playing again clears both.

`FINISH_MARGIN_S` duplicated the backend's constant in a second language with nothing relating them,
and its comment had the asymmetry backwards — if the BACKEND's margin grows, this guard starts
admitting a rollback the backend reads as a finish, i.e. re-opens the defect above. Pinned by a
contract test that reads the Python, and the comment corrected.
2026-09-12 16:21:51 +02:00

629 lines
28 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, HttpError, 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>
});
// --- deep-review round 4 ------------------------------------------------------------------------
/** A session answer that FAILS when the test says so. */
function deferredFailure() {
let reject!: (e: unknown) => void;
const promise = new Promise<Awaited<ReturnType<typeof api.plexSession>>>((_r, j) => (reject = j));
return { promise, reject };
}
const startsAsked = (id: string) =>
vi
.mocked(api.plexSession)
.mock.calls.filter(([i]) => i === id)
.map(([, at]) => at);
describe("leaving an item that was played through", () => {
const prefs = (p: object) =>
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en", ...p }));
// The end of the media, in the order the HTML spec mandates: the browser sets `paused`, fires
// 'pause', and only THEN fires 'ended'. Dispatching a bare 'ended' models a browser that does not
// exist, and hid the fact that the pause checkpoint goes out before the finish latch is up
// (deep-review round 5).
const endMedia = () => {
const el = video();
Object.defineProperty(el, "ended", { configurable: true, get: () => true });
act(() => void el.dispatchEvent(new Event("pause")));
act(() => void el.dispatchEvent(new Event("ended")));
};
it("settles it as watched when it ended, so Prev does not reopen it at its last seconds", async () => {
prefs({ upNext: false });
const { qc, progress } = mount({
details: { A: item("A"), B: item("B") },
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
queue: ["A", "B"],
});
vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
await playingAt("/media/A", 2995);
endMedia();
await screen.findByText("Title B");
await tick();
const cached = qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"));
expect([cached?.status, cached?.position_seconds]).toEqual(["watched", 0]);
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
fireEvent.click(screen.getByLabelText("plex.player.prev"));
await waitFor(() => expect(startsAsked("A")).toHaveLength(2));
expect(startsAsked("A")[1]).toBe(0);
});
it("sends no checkpoint at the credits when the auto-skip leaves it", async () => {
// A final checkpoint at the credits raced the watched mark: landing second, it left the row
// watched WITH a resume point, and pushed that point to Plex right after the scrobble.
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
const withCredits = {
...item("A"),
markers: [{ type: "credits", start_s: 2900, end_s: 2995 }],
} as unknown as PlexItemDetail;
const { qc, progress } = mount({
details: { A: withCredits, B: item("B") },
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
queue: ["A", "B"],
});
const setState = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
// The 'pause' event (and its checkpoint) can land before the player has re-rendered for the
// next item — here it lands at once.
video().pause = function (this: HTMLMediaElement) {
this.dispatchEvent(new Event("pause"));
};
await playingAt("/media/A", 2910);
await screen.findByText("Title B");
await tick(); // the pause() that go() queued has fired its events by now
expect(setState).toHaveBeenCalledWith("A", "watched");
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
const cached = qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"));
expect([cached?.status, cached?.position_seconds]).toEqual(["watched", 0]);
});
it("takes the finish back when the server refuses the watched mark", async () => {
// The mark was fired and forgotten (`.catch(() => {})`) while the cache was patched to
// watched / position 0 regardless, and the once-per-id start guard meant the corrected detail
// arriving later was never used: the episode reopened at 0:00 with a Watched badge the server
// disagreed with (round 5).
prefs({ upNext: false });
const { qc, progress } = mount({
details: { A: item("A"), B: item("B") },
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
queue: ["A", "B"],
});
vi.spyOn(api, "plexSetState").mockRejectedValue(new Error("offline"));
await playingAt("/media/A", 2995);
endMedia();
await screen.findByText("Title B");
await tick();
// The cached detail is not left claiming watched: only the server is honest now.
const cached = qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"));
expect(cached?.status).not.toBe("watched");
// And the rollback does NOT write the position back. 2995 of 3000 is inside the backend's
// finish margin, so that checkpoint would have set watched / position 0 and scrobbled to Plex
// — performing, through a second route, the very write that was just refused, while the error
// toast said it had failed (round 6). There is no resume point at the end of the media to
// restore; the cache drop is the whole of the rollback here.
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
});
it("performs the refused mark through no OTHER route while it stays on the finished item", async () => {
// Round 6 guarded the explicit rollback write, but the same catch lowered the play-through
// latch — and that latch is the only gate on the 10s interval, the page-hide beacon and
// `settle`. All three post `absRef`, which on this path IS the end of the media, and the
// backend reads a position within `_FINISH_MARGIN_S` of the duration as a finish: within ten
// seconds of the toast saying the mark had failed, the item was watched, its position cleared
// and a scrobble sent to Plex (deep-review round 7). `upNext` is ON here — the default — so the
// player STAYS on the finished item and those routes are live; the round-6 test above turns it
// off, which tears the effect down before any of them can fire.
prefs({ upNext: true });
const beacon = vi.spyOn(api, "plexProgressBeacon").mockImplementation(() => {});
const { qc, progress, unmount } = mount({
details: { A: item("A"), B: item("B") },
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
queue: ["A", "B"],
});
vi.spyOn(api, "plexSetState").mockRejectedValue(new Error("offline"));
await playingAt("/media/A", 2995);
endMedia(); // the up-next card is up, the item is finished, and the mark comes back refused
await tick();
progress.mockClear();
// The viewer closes the tab while the failed-mark toast is still up.
act(() => void window.dispatchEvent(new Event("pagehide")));
expect(beacon).not.toHaveBeenCalled();
// The refusal dropped the cached detail — and the open player's own observer refetches it, so
// by the time the viewer leaves there is an entry again. `settle` must not re-assert watched
// onto it: only the server is honest about this item now.
act(() => void qc.setQueryData(qk.plexItem("A", "en"), item("A")));
// And leaving the player: `settle` must neither post the end position nor re-assert watched.
unmount();
await tick();
expect(progress.mock.calls.filter(([id]) => id === "A")).toEqual([]);
expect(qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"))?.status).not.toBe("watched");
});
it("does restore the position when the refused finish came from the credits skip", async () => {
// The other caller of the same helper captures a MID-episode position, and there the rollback
// is the point: without it the viewer loses the stretch they watched. One helper, two callers,
// and only the near-end one must hold back.
prefs({ upNext: false, autoSkipCredits: true, autoSkipDelay: 0 });
const withCredits = {
...item("A"),
markers: [{ type: "credits", start_s: 2000, end_s: 2990 }],
} as unknown as PlexItemDetail;
const { qc, progress } = mount({
details: { A: withCredits, B: item("B") },
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
queue: ["A", "B"],
});
vi.spyOn(api, "plexSetState").mockRejectedValue(new Error("offline"));
await playingAt("/media/A", 2010); // inside the credits → auto-skip advances and marks watched
await screen.findByText("Title B");
await tick();
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([
["A", 2010, 3000, true],
]);
expect(qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"))?.status).not.toBe("watched");
});
it("records a second viewing when the viewer plays on from the finished frame", async () => {
// `finished` is a latch that makes every checkpoint a no-op; the only thing that lowers it is
// the video's own 'play' event. Nothing tested that, so dropping the listener would have
// discarded a whole rewatch with the gate green (round 5).
prefs({ upNext: true });
const { progress } = mount({
details: { A: item("A"), B: item("B") },
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
queue: ["A", "B"],
});
vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
await playingAt("/media/A", 2995);
endMedia(); // the up-next card is up; the item is finished
await tick();
progress.mockClear();
// The viewer cancels the card and rewinds: playing again takes the finish back.
const el = video() as Media;
Object.defineProperty(el, "ended", { configurable: true, get: () => false });
act(() => void el.dispatchEvent(new Event("play")));
el._t = 2400;
act(() => void el.dispatchEvent(new Event("timeupdate")));
act(() => void el.dispatchEvent(new Event("pause")));
await tick();
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([
["A", 2400, 3000, true],
]);
});
});
describe("restarting the session before playback has ticked", () => {
it("an audio switch or a keyboard seek keeps the resume point", async () => {
const twoAudio = {
...item("A", 1200),
audio_streams: [
{ ord: 0, label: "English", language: "en", default: true },
{ ord: 1, label: "Magyar", language: "hu", default: false },
],
} as unknown as PlexItemDetail;
mount({ details: { A: twoAudio }, sessions: { A: () => new Promise(() => {}) } });
await waitFor(() => expect(startsAsked("A")).toEqual([1200]));
fireEvent.keyDown(window, { key: "a" });
fireEvent.keyDown(window, { key: "ArrowRight" });
await waitFor(() => expect(startsAsked("A")).toHaveLength(3));
expect(startsAsked("A")).toEqual([1200, 1200, 1210]);
});
});
it("a seek made while a restart is pending wins over it", async () => {
let calls = 0;
const pending = deferred();
mount({
details: { A: item("A") },
sessions: {
A: () => {
calls++;
if (calls === 1) return Promise.resolve(direct("A"));
return calls === 2 ? pending.promise : new Promise(() => {});
},
},
});
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
Object.defineProperty(video(), "seekable", { get: () => ({ length: 1, end: () => 100 }) });
await playingAt("/media/A", 95);
fireEvent.click(screen.getByLabelText("plex.player.forward10")); // 105: past the buffer → restart
fireEvent.click(screen.getByLabelText("plex.player.rewind10")); // 95: inside the OLD buffer
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95]));
await act(async () => pending.resolve({ mode: "direct", url: "/media/A2", start_s: 0 } as never));
await tick();
expect(video().getAttribute("src")).toBe("/media/A"); // the superseded 105 answer never attached
});
it("a direct-play item left before its metadata does not seek the next one", async () => {
mount({
details: { A: item("A", 1200), B: item("B") },
sessions: { A: () => Promise.resolve(direct("A")), B: () => Promise.resolve(direct("B")) },
queue: ["A", "B"],
});
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
fireEvent.click(screen.getByLabelText("plex.player.next"));
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/B"));
act(() => void video().dispatchEvent(new Event("loadedmetadata")));
expect(video().currentTime).toBe(0); // not A's resume point, 1200
});
it("a refetch of the playing item's detail does not restart it", async () => {
// react-query refetches it on reconnect, and the in-player info panel invalidates it after a
// watched toggle — each one restarted the film at the last saved checkpoint.
const details = { A: item("A", 100) };
const { qc } = mount({ details, sessions: { A: () => Promise.resolve(direct("A")) } });
await playingAt("/media/A", 1500);
details.A = item("A", 1490);
await act(async () => {
await qc.invalidateQueries({ queryKey: qk.plexItem("A") });
});
await tick();
expect(vi.mocked(api.plexItem).mock.calls.length).toBeGreaterThan(1); // it did refetch
expect(startsAsked("A")).toEqual([100]);
});
describe("a session request that fails", () => {
it("is not reported when it has been superseded", async () => {
const slowB = deferredFailure();
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.reject(new HttpError(404)));
await tick();
expect(screen.queryByText("plex.player.errNotFound")).toBeNull();
});
it("keeps following the old stream when a seek-restart fails", async () => {
let calls = 0;
const { progress, unmount } = mount({
details: { A: item("A") },
sessions: {
A: () =>
calls++ === 0 ? Promise.resolve(direct("A")) : Promise.reject(new HttpError(500)),
},
});
await playingAt("/media/A", 1500);
fireEvent.click(screen.getByLabelText("plex.player.forward10"));
await screen.findByText("plex.player.errGeneric");
await playingAt("/media/A", 1600); // the old stream plays on
unmount();
expect(progress.mock.calls).toContainEqual(["A", 1600, 3000, true]);
});
it("saves nothing of the previous item's position when moving to the next one fails", async () => {
const { progress, unmount } = mount({
details: { A: item("A"), B: item("B") },
sessions: {
A: () => Promise.resolve(direct("A")),
B: () => Promise.reject(new HttpError(404)),
},
queue: ["A", "B"],
});
await playingAt("/media/A", 1500);
fireEvent.click(screen.getByLabelText("plex.player.next"));
await screen.findByText("plex.player.errNotFound");
await tick();
await playingAt("/media/A", 1600); // A's media, still attached, ticks once more
unmount();
expect(progress.mock.calls.filter(([id]) => id === "B")).toEqual([]);
});
});
it("a session answer that lands while the next item's detail is still loading is dropped", 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"));
const real = vi.mocked(api.plexItem).getMockImplementation()!;
vi.mocked(api.plexItem).mockImplementation((id: string, lang?: string) =>
id === "C" ? new Promise(() => {}) : real(id, lang),
);
fireEvent.click(screen.getByLabelText("plex.player.next"));
await waitFor(() => expect(startsAsked("B")).toHaveLength(1));
fireEvent.click(screen.getByLabelText("plex.player.next"));
await tick();
await act(async () => slowB.resolve(direct("B")));
await tick();
expect(video().getAttribute("src")).not.toBe("/media/B");
});