Files
siftlode/frontend/src/components/PlayerModal.dom.test.tsx
T
peter 33fe895539 fix(ui): every full-screen overlay hosts its own toasts, above the players' controls, without re-announcing them
Deep-review round 3 of bug hunt 2026-09 (the round-2 overlay toast host, finished).

- Inside an overlay the toast stack sits above the bottom band (bottom-24, the UpNextCard rung) and
  lets clicks between cards through: at the app's own spot it covered the Plex transport row and
  PlayerModal's docked bar and took their clicks while a toast was up (measured: play/pause, ±10,
  stop, prev/next, mute all hit the toast).
- ui/Modal and the onboarding wizard mount an overlay host too, so a toast raised under any dialog
  is readable and a click on it no longer closes the dialog; the Modal's Tab trap spans the whole
  dialog, so a toast's action and dismiss are keyboard-reachable. A test now enforces the
  convention: a component that covers the viewport at z-overlay must mount <Toaster overlay />.
- PlayerModal's host moves into the stage in true fullscreen (outside the element holding the lock it
  was painted under it).
- A host that takes over shows the toasts it inherits silently (no second role=alert, its region
  aria-live=off until they are in), and each countdown bar starts as far in as its toast already is
  instead of restarting while the real dismiss timer does not.
- The overlay-host registry uses lib/store instead of a hand-rolled listener triad.
2026-09-11 18:04:54 +02:00

293 lines
12 KiB
TypeScript

// @vitest-environment jsdom
//
// The YouTube player modal, rendered for real. Its pure helpers (afterRemoval, the progress cache)
// had tests; what the modal DOES with them had none — a deep-review of the 2026-09 bug hunt found
// that deleting the lines that apply them left every lane green.
//
// `window.YT` is a fake installed before the module's API loader first runs (that loader memoises
// its promise in a module-level singleton, so this file must not share a module instance with a
// test that leaves `window.YT` unset). Each item the modal plays constructs a new YT.Player, and the
// fake records what it was asked for — that is how these tests see where playback starts.
import { describe, it, expect, vi, afterEach, beforeAll, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, o?: Record<string, unknown>) => (o?.title ? `${key}:${o.title}` : key),
i18n: { language: "en" },
}),
};
});
vi.mock("./NavigationProvider", () => ({ useNavigationActions: () => ({ openChannel: vi.fn() }) }));
// Their own queries and popovers are not what is under test here.
vi.mock("./AddToPlaylist", () => ({ default: () => null }));
vi.mock("./DownloadButton", () => ({ default: () => null }));
import PlayerModal from "./PlayerModal";
import { ConfirmProvider, useConfirm } from "./ConfirmProvider";
import { api, type Video } from "../lib/api";
import { clearAll, notify } from "../lib/notifications";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
// --- the fake IFrame API ------------------------------------------------------------------------
const clock = { current: 0, duration: 3000 };
const started: { videoId: string; start: number | undefined }[] = [];
const base = {
getCurrentTime: () => clock.current,
getDuration: () => clock.duration,
getVideoData: () => null,
};
// Anything else the modal calls on the player (volume, rate, destroy…) is a no-op. Not `then`:
// a thenable would be swallowed by the first `await` that touched it.
const fakePlayer = new Proxy(base, {
get: (t, k) => (k in t ? t[k as keyof typeof t] : k === "then" ? undefined : () => undefined),
});
window.YT = {
Player: function (_el: unknown, opts: { videoId: string; playerVars: { start?: number } }) {
started.push({ videoId: opts.videoId, start: opts.playerVars.start });
return fakePlayer;
} as never,
};
const vid = (id: string, position_seconds: number): Video =>
({
id,
title: `Video ${id}`,
channel_id: "UCx",
channel_title: "Channel",
status: "new",
position_seconds,
duration_seconds: 3000,
watch_url: `https://www.youtube.com/watch?v=${id}`,
}) as unknown as Video;
function renderModal(ui: ReactNode) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
started.length = 0;
});
const lastStart = () => started[started.length - 1];
describe("PlayerModal — where each item starts", () => {
it("resumes an item played earlier in the SAME session where this session left it", async () => {
// The launch queue is frozen at mount, so its rows keep the position they were snapshotted
// with (748). Stepping next→prev back to an item already watched to 830 used to start it at
// 748 — and the next checkpoint then wrote 748 over the 830 the server held.
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
video_id: id,
position_seconds: id === "A" ? 830 : 0,
}));
const A = vid("A", 748);
const B = vid("B", 0);
clock.current = 830;
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
await waitFor(() => expect(lastStart()).toEqual({ videoId: "A", start: 748 }));
fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
fireEvent.click(screen.getAllByRole("button", { name: "player.previous" })[0]!);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
expect(lastStart()?.start).toBe(830);
});
});
// --- "remove from this playlist", from inside the player ----------------------------------------
const trash = (title: string) =>
screen.getByRole("button", { name: `playlists.removeItemOf:Video ${title}` });
const next = () => fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
const prev = () => fireEvent.click(screen.getAllByRole("button", { name: "player.previous" })[0]!);
const settle = () => act(async () => {});
describe("PlayerModal — removing the playing video from its playlist", () => {
const [A, B, C, D] = ["A", "B", "C", "D"].map((id) => vid(id, 0));
beforeEach(() => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
video_id: id,
position_seconds: 0,
}));
clock.current = 0;
});
it("steps to the item that followed, and the removed one is gone from the queue", async () => {
renderModal(
<PlayerModal
video={B!}
queue={[A!, B!, C!]}
startIndex={1}
onClose={() => {}}
onState={() => {}}
onRemoveFromList={async () => true}
/>,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
fireEvent.click(trash("B"));
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
prev(); // …straight back to A: B is no longer there to step onto
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
});
it("closes the player when the last item goes", async () => {
const onClose = vi.fn();
renderModal(
<PlayerModal
video={A!}
queue={[A!]}
startIndex={0}
onClose={onClose}
onState={() => {}}
onRemoveFromList={async () => true}
/>,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
fireEvent.click(trash("A"));
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("leaves playback and the queue alone when the removal did not happen", async () => {
const onClose = vi.fn();
renderModal(
<PlayerModal
video={A!}
queue={[A!, B!]}
startIndex={0}
onClose={onClose}
onState={() => {}}
onRemoveFromList={async () => false}
/>,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
fireEvent.click(trash("A"));
await settle();
expect(onClose).not.toHaveBeenCalled();
expect(started.map((s) => s.videoId)).toEqual(["A"]);
next();
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
prev(); // A is still in the queue
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
});
it("keeps playback where the viewer went while the confirm was open", async () => {
// prev/next keep working under the confirm dialog; deciding from the cursor as it was when the
// button was pressed threw the viewer back onto a video they had already moved past.
let answer!: (ok: boolean) => void;
renderModal(
<PlayerModal
video={A!}
queue={[A!, B!, C!, D!]}
startIndex={0}
onClose={() => {}}
onState={() => {}}
onRemoveFromList={() => new Promise<boolean>((r) => (answer = r))}
/>,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
fireEvent.click(trash("A"));
next();
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
next();
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
await act(async () => answer(true));
expect(lastStart()?.videoId).toBe("C");
prev();
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
// …and A is gone from the queue, not just skipped over: B is its head now, so stepping back
// again goes nowhere. (One step back lands on B either way — it took the second to tell.)
prev();
await settle();
expect(lastStart()?.videoId).toBe("B");
expect(started.filter((s) => s.videoId === "A")).toHaveLength(1); // never played again
});
it("gives focus back to the Remove button when the confirm is cancelled", async () => {
// The button used to be disabled for the confirm's whole lifetime, so the dialog's focus
// restore landed on a disabled element — a no-op — and a keyboard user was left on <body>,
// outside the aria-modal player.
function WithConfirm() {
const confirm = useConfirm();
return (
<PlayerModal
video={A!}
queue={[A!, B!]}
startIndex={0}
onClose={() => {}}
onState={() => {}}
onRemoveFromList={(v) =>
confirm({ title: "t", message: v.title, confirmLabel: "remove", danger: true })
}
/>
);
}
renderModal(
<ConfirmProvider>
<WithConfirm />
</ConfirmProvider>,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
trash("A").focus();
fireEvent.click(trash("A"));
fireEvent.click(await screen.findByRole("button", { name: "common.cancel" }));
await waitFor(() => expect(document.activeElement).toBe(trash("A")));
});
});
describe("PlayerModal — toasts raised while it is open", () => {
afterEach(() => act(() => clearAll()));
it("shows them inside the player, not under its scrim", async () => {
// The app's Toaster is in a stacking context below this body-level portal: a failed Remove
// reported by a toast was drawn under the 80% black scrim, i.e. still silent.
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
act(() => void notify({ level: "error", message: "Couldn't remove the video." }));
expect(screen.getByTestId("player-modal").textContent).toContain("Couldn't remove the video.");
});
it("shows them inside the stage in true fullscreen — the element holding the lock", async () => {
// Anything outside the fullscreen element is painted under it. The player's host sat beside the
// card, so in fullscreen a lost connection was reported to nobody.
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const stage = document.querySelector(".player-stage")!;
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => stage });
try {
act(() => void document.dispatchEvent(new Event("fullscreenchange")));
act(() => void notify({ level: "error", message: "Connection lost" }));
await waitFor(() => expect(stage.textContent).toContain("Connection lost"));
} finally {
delete (document as { fullscreenElement?: unknown }).fullscreenElement;
}
});
});