// @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 review during 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, within } 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(); return { ...actual, useTranslation: () => ({ t: (key: string, o?: Record) => (o?.title ? `${key}:${o.title}` : key), i18n: { language: "en" }, }), }; }); vi.mock("./navigationContext", () => ({ 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 } from "./ConfirmProvider"; import { useConfirm } from "./confirmContext"; 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({ui}); } 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} onState={() => {}} onRemoveFromList={() => new Promise((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 , // outside the aria-modal player. function WithConfirm() { const confirm = useConfirm(); return ( {}} onState={() => {}} onRemoveFromList={(v) => confirm({ title: "t", message: v.title, confirmLabel: "remove", danger: true }) } /> ); } renderModal( , ); 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( {}} 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( {}} 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; } }); }); // --- the dialog contract it now shares (ui/dialogFocus) ----------------------------------------- // This was once the one `aria-modal` shell with no accessible name, no Tab trap and no focus // restore: Tab walked out of an open player into the page behind the scrim, and closing it with // Escape left a keyboard user on , their place in a long feed lost. describe("PlayerModal — the dialog contract", () => { const open = (onClose = () => {}) => { vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); const A = vid("A", 0); return renderModal( {}} />, ); }; it("is named by the video's title", () => { open(); expect(screen.getByRole("dialog", { name: "Video A" })).toBeTruthy(); }); it("opens focused on its card, not on a control, so its shortcuts keep working", () => { open(); const active = document.activeElement as HTMLElement; expect(active.tagName).not.toBe("BUTTON"); expect(screen.getByTestId("player-modal").contains(active)).toBe(true); }); it("gives the focus back to what opened it when it closes", () => { const opener = document.createElement("button"); document.body.appendChild(opener); opener.focus(); try { const view = open(); expect(document.activeElement).not.toBe(opener); view.unmount(); expect(document.activeElement).toBe(opener); } finally { opener.remove(); } }); it("keeps Tab inside the player", () => { open(); const dialog = screen.getByTestId("player-modal"); const tabbables = [...dialog.querySelectorAll("button:not([disabled]), a[href]")]; const last = tabbables[tabbables.length - 1]!; last.focus(); // jsdom has no layout, so the trap's visibility filter would see nothing tabbable; give every // element an offsetParent for the duration of this check. const spy = vi .spyOn(HTMLElement.prototype, "offsetParent", "get") .mockReturnValue(document.body); try { fireEvent.keyDown(window, { key: "Tab" }); expect(dialog.contains(document.activeElement)).toBe(true); expect(document.activeElement).not.toBe(last); } finally { spy.mockRestore(); } }); }); // --- the watch toggle belongs to the item it describes ------------------------------------------- // A late writer exists by construction: useYtProgress's cleanup for the OUTGOING item runs after the // commit that already advanced `active`, and within the finish margin it auto-marks that item // watched. While the mirror was a bare boolean that write landed last and painted the INCOMING item // as watched. Keying the mirror by id makes the stray write not this item's, so it is ignored. describe("PlayerModal — the watch toggle after a queue advance", () => { const A = vid("A", 0); const B = vid("B", 0); beforeEach(() => { vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({ video_id: id, position_seconds: 0, })); }); it("does not inherit the outgoing item's auto-watch when stepping away near the end", async () => { const onState = vi.fn(); clock.current = 2995; // inside min(FINISH_MARGIN, duration/2) = 10s of 3000 renderModal( {}} onState={onState} />, ); await waitFor(() => expect(lastStart()?.videoId).toBe("A")); next(); await waitFor(() => expect(lastStart()?.videoId).toBe("B")); await settle(); // A really is marked watched — that part is correct and must keep working. expect(onState.mock.calls).toContainEqual(["A", "watched"]); // B is not, and its toggle says so. Before the fix both toggles read "watchedUnmark" here. expect(screen.queryAllByTitle("player.watchedUnmark")).toHaveLength(0); expect(screen.queryAllByTitle("player.markWatched").length).toBeGreaterThan(0); }); it("still reflects a watch toggled on the item you are actually on", async () => { const onState = vi.fn(); clock.current = 0; renderModal( {}} onState={onState} />, ); await waitFor(() => expect(lastStart()?.videoId).toBe("A")); fireEvent.click(screen.getAllByTitle("player.markWatched")[0]!); expect(onState.mock.calls).toContainEqual(["A", "watched"]); expect(screen.queryAllByTitle("player.watchedUnmark").length).toBeGreaterThan(0); }); }); // C3: the description used to open on hovering the title, which a finger never does. It opens from // an ⓘ beside the title now, by click or tap, and a hover over the title opens nothing. describe("PlayerModal — the description", () => { it("opens from the ⓘ, not from hovering the title", async () => { vi.spyOn(api, "videoDetail").mockResolvedValue({ description: "A long description", } as Awaited>); const A = vid("A", 0); renderModal( {}} onState={() => {}} />, ); fireEvent.mouseEnter(screen.getByRole("heading", { name: "Video A" })); fireEvent.pointerEnter(screen.getByRole("heading", { name: "Video A" })); await new Promise((r) => setTimeout(r, 500)); expect(screen.queryByText("A long description")).toBeNull(); const info = screen.getByRole("button", { name: "player.description" }); fireEvent.click(info); expect(await screen.findByText("A long description")).toBeTruthy(); expect(info.getAttribute("aria-expanded")).toBe("true"); }); }); // C3: the queue's side arrows sit in a gutter of the card's row, not outside it, so no window cuts // them off (outside the card, a 1024 window cut them and a portrait tablet lost them). describe("PlayerModal — the side arrows", () => { it("stand inside the row's gutter", () => { vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); const A = vid("A", 0); const B = vid("B", 0); renderModal( {}} onState={() => {}} />, ); const arrow = [...document.querySelectorAll("[aria-label='player.next']")].find( (b) => b.className.includes("w-12"), )!; expect(arrow).toBeTruthy(); expect(arrow.className).toMatch(/\bright-0\b/); expect(arrow.className).not.toMatch(/left-full/); expect(arrow.parentElement!.className).toMatch(/sm:px-14/); }); }); // YouTube's embed keeps its controls at fixed px from the iframe's edges: the seek bar across the bottom // 52–96 px up, the fullscreen button above its right end 80–128 px up, the gear and volume along the // top. A cutout given only as a share of the height lay over that button on a short player (a small // window, a browser zoomed to 150 %): the click paused instead. describe("PlayerModal — the click layer over the video", () => { const layers = () => { vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); const A = vid("A", 0); renderModal( {}} onState={() => {}} />, ); return screen.getAllByTestId("yt-click-layer"); }; const box = (el: HTMLElement) => { const { left, right, width, top, bottom } = el.style; return { left, right, width, top, bottom }; }; it("clears the seek bar across the width and the fullscreen button in its right-hand column", () => { const [picture, column] = layers(); expect(box(picture!)).toEqual({ left: "0px", right: "88px", width: "", top: "max(12%, 56px)", bottom: "max(22%, 104px)", }); expect(box(column!)).toEqual({ left: "", right: "0px", width: "88px", top: "max(12%, 56px)", bottom: "max(22%, 140px)", }); }); // In the big views our docked bar sits under the iframe; YouTube's px are counted from the iframe's // bottom, so a box measured from the stage's would start that bar's height too low again. it.each([ ["the windowed card", false, /(^|\s)relative(\s|$)/], ["true fullscreen", true, /(^|\s)bottom-14(\s|$)/], ])("measures those edges against the iframe's own box in %s", (_view, fullscreen, inset) => { const els = layers(); const stage = document.querySelector(".player-stage")!; if (fullscreen) { Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => stage, }); act(() => void document.dispatchEvent(new Event("fullscreenchange"))); } try { for (const el of screen.getAllByTestId("yt-click-layer")) { const wrapper = el.parentElement!; expect(wrapper).not.toBe(stage); expect(wrapper.parentElement).toBe(stage); expect(wrapper.className).toMatch(inset); expect(wrapper.className).not.toMatch(/(^|\s)p[byt]?-/); } expect(els).toHaveLength(2); } finally { delete (document as { fullscreenElement?: unknown }).fullscreenElement; } }); }); // At 133% zoom (a 685px window) a full-width card ran 20px past the bottom and cut its channel // row. The card's width is capped from the window height less what sits under the video, measured. describe("PlayerModal — fits the window's height", () => { it("caps the card's width by the chrome measured under the video", () => { vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(function ( this: HTMLElement, ) { return this.classList.contains("player-stage") ? 470 : 0; }); vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(function ( this: HTMLElement, ) { return this.querySelector(":scope > .player-stage") ? 630 : 0; }); const A = vid("A", 0); const B = vid("B", 0); renderModal( {}} onState={() => {}} />, ); const row = document.querySelector(".player-stage")!.parentElement!.parentElement!; expect(row.style.maxWidth).toBe( "min(63rem, calc(max(36rem, (100dvh - 3rem - 160px) * 16 / 9) + 7rem))", ); }); }); // The owner's pick: on a wide window the video was capped at ~1008 px of a 1920 screen, with // empty space all round. From 1280 px the title, channel, actions, description and queue sit in a side // panel beside the video, and the video takes the window's height. describe("PlayerModal — the side panel on a wide window", () => { const wideWindow = (matches: boolean) => vi.spyOn(window, "matchMedia").mockImplementation( (q: string) => ({ matches: matches && q === "(min-width: 1280px)", media: q, addEventListener: () => {}, removeEventListener: () => {}, }) as unknown as MediaQueryList, ); // jsdom has no matchMedia to spy on: a bare one is put in for these tests and taken out after, or // every later test's media query would read that bare stub. let added = false; beforeEach(() => { if (typeof window.matchMedia === "function") return; window.matchMedia = (() => ({})) as unknown as typeof window.matchMedia; added = true; }); afterEach(() => { // The spy goes first: restoring it later would put the bare stub back. vi.restoreAllMocks(); if (added) delete (window as { matchMedia?: unknown }).matchMedia; added = false; }); it("puts the details beside the video, the description open, and sizes the card for both", async () => { wideWindow(true); vi.spyOn(api, "videoDetail").mockResolvedValue({ description: "A long description", } as Awaited>); const A = vid("A", 0); const B = vid("B", 0); renderModal( {}} onState={() => {}} />, ); const stage = document.querySelector(".player-stage")!; const card = stage.parentElement!; const info = screen.getByTestId("player-info"); // The card is a row: the video, then the panel holding the title and the channel. expect(card.className).toMatch(/(^|\s)flex(\s|$)/); expect(card.contains(info) && !stage.contains(info)).toBe(true); expect(info.contains(screen.getByRole("heading", { name: "Video A" }))).toBe(true); expect(stage.compareDocumentPosition(info) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); // The description is shown outright; there is no ⓘ to open it. expect(await screen.findByText("A long description")).toBeTruthy(); expect(screen.queryByRole("button", { name: "player.description" })).toBeNull(); // The row's width: the video at the window's height, plus the panel and the side strips. expect(card.parentElement!.style.maxWidth).toBe( "calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem + 7rem)", ); }); it("lets the wheel scroll the panel while it can, and turn the volume everywhere else", async () => { wideWindow(true); vi.spyOn(api, "videoDetail").mockResolvedValue({ description: "A long description", } as Awaited>); const A = vid("A", 0); renderModal( {}} onState={() => {}} />, ); const text = await screen.findByText("A long description"); const panel = screen.getByTestId("player-info"); let top = 0; Object.defineProperty(panel, "scrollTop", { get: () => top, configurable: true }); Object.defineProperty(panel, "scrollHeight", { value: 900, configurable: true }); Object.defineProperty(panel, "clientHeight", { value: 500, configurable: true }); const spin = (el: Element, deltaY: number) => { const e = new WheelEvent("wheel", { deltaY, bubbles: true, cancelable: true }); el.dispatchEvent(e); return e.defaultPrevented; // prevented = the wheel went to the volume }; expect(spin(text, 100)).toBe(false); // room below: the panel scrolls expect(spin(text, -100)).toBe(true); // at its top: nothing to scroll up, so the volume top = 400; expect(spin(text, 100)).toBe(true); // at its end: the volume expect(spin(text, -100)).toBe(false); expect(spin(document.querySelector(".player-stage")!, 100)).toBe(true); // over the video }); // A failed load read "No description." in the always-open panel — an error drawn as empty. it("says the description failed to load, with a retry, instead of 'No description'", async () => { wideWindow(true); const detail = vi .spyOn(api, "videoDetail") .mockRejectedValueOnce(new Error("boom")) .mockResolvedValue({ description: "A long description" } as Awaited< ReturnType >); const A = vid("A", 0); renderModal( {}} onState={() => {}} />, ); const panel = screen.getByTestId("player-description"); expect(await within(panel).findByText("player.descriptionFailed")).toBeTruthy(); expect(within(panel).queryByText("player.noDescription")).toBeNull(); fireEvent.click(within(panel).getByRole("button", { name: "common.retry" })); expect(await within(panel).findByText("A long description")).toBeTruthy(); expect(detail).toHaveBeenCalledTimes(2); }); // The popover below 1280 px said it the same way only by a copy nobody tested. it("says it in the description popover below 1280 px too", async () => { wideWindow(false); vi.spyOn(api, "videoDetail").mockRejectedValue(new Error("boom")); const A = vid("A", 0); renderModal( {}} onState={() => {}} />, ); fireEvent.click(screen.getByRole("button", { name: "player.description" })); expect(await screen.findByText("player.descriptionFailed")).toBeTruthy(); expect(screen.queryByText("player.noDescription")).toBeNull(); }); it("keeps the details under the video below 1280 px", () => { wideWindow(false); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); const A = vid("A", 0); renderModal( {}} onState={() => {}} />, ); const card = document.querySelector(".player-stage")!.parentElement!; expect(card.className).not.toMatch(/(^|\s)flex(\s|$)/); expect(screen.getByRole("button", { name: "player.description" })).toBeTruthy(); expect(screen.queryByTestId("player-description")).toBeNull(); }); }); // Brave on an iPad cannot fullscreen an element (it fakes `fullscreenEnabled`): the docked bar's // true-fullscreen button did nothing there. The window-filling view is then the biggest one. describe("PlayerModal — the docked bar's fullscreen", () => { const maximised = (api_: boolean) => { if (api_) Object.defineProperty(Element.prototype, "requestFullscreen", { configurable: true, value: () => Promise.resolve(), }); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); const A = vid("A", 0); renderModal( {}} onState={() => {}} />, ); fireEvent.click(screen.getByLabelText("player.fullscreen")); return screen.getAllByLabelText("player.fullscreen").length; }; afterEach(() => { delete (Element.prototype as { requestFullscreen?: unknown }).requestFullscreen; }); it("is offered where the browser can fullscreen an element", () => { expect(maximised(true)).toBe(2); }); it("is left out where it cannot", () => { expect(maximised(false)).toBe(1); }); });