// @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("../navigation/navigationContext", () => ({ useNavigationActions: () => ({ openChannel: vi.fn() }), })); // Their own queries and popovers are not what is under test here. vi.mock("../video/AddToPlaylist", () => ({ default: ({ videoId }: { videoId: string }) => , })); vi.mock("../downloads/DownloadButton", () => ({ default: ({ videoId }: { videoId: string }) => , })); import PlayerModal from "./PlayerModal"; import KeyboardHelp from "../../components/ui/KeyboardHelp"; import { closeKeyboardHelp, toggleKeyboardHelp } from "../../lib/keymap"; import { ConfirmProvider } from "../../components/ui/ConfirmProvider"; import { useConfirm } from "../../components/ui/confirmContext"; import { api, type Me, type Video } from "../../lib/api"; import { clearAll, notify } from "../../lib/notifications"; import { qk } from "../../lib/queryKeys"; 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 }[] = []; // What the player was asked to switch to in place (a description link, a related video). const loaded: { videoId: string; startSeconds: number }[] = []; // The last player's event handlers, so a test can say "the player is ready" when it wants to. let playerEvents: { onReady?: () => void; onStateChange?: (e: { data: number }) => void } = {}; const base = { getCurrentTime: () => clock.current, getDuration: () => clock.duration, getVideoData: () => null, loadVideoById: (o: { videoId: string; startSeconds: number }) => void loaded.push(o), seekTo: (s: number) => void seeks.push(s), setVolume: (v: number) => void volumes.push(v), }; const seeks: number[] = []; const volumes: number[] = []; // 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 }; events?: typeof playerEvents }, ) { started.push({ videoId: opts.videoId, start: opts.playerVars.start }); playerEvents = opts.events ?? {}; 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 } } }); qc.setQueryData(qk.me(), me); return render({ui}); } afterEach(() => { cleanup(); vi.restoreAllMocks(); started.length = 0; loaded.length = 0; seeks.length = 0; volumes.length = 0; }); // The signed-in account the player reads; related videos are on unless a test turns them off. let me = { related_enabled: true } as Me; beforeEach(() => { me = { related_enabled: true } as Me; }); 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(); } }); // The card takes the focus back on its own when it may have slipped into the iframe: the player // turning ready, the pointer leaving the video, the window regaining focus. None of those may take // it off a control the viewer tabbed to, or their next Tab starts over from the first button. it.each([ ["the player turns ready", () => playerEvents.onReady?.()], [ "the pointer leaves the video", () => { fireEvent.mouseLeave(document.querySelector(".player-stage")!); }, ], [ "the window regains focus", () => { fireEvent.focus(window); }, ], ])("leaves the focus on a control the viewer tabbed to when %s", async (_why, happen) => { open(); await waitFor(() => expect(playerEvents.onReady).toBeTypeOf("function")); const control = within(screen.getByTestId("player-modal")).getByTestId("player-close"); control.focus(); act(happen); expect(document.activeElement).toBe(control); }); it("takes the focus back from the iframe when the player turns ready", async () => { open(); await waitFor(() => expect(playerEvents.onReady).toBeTypeOf("function")); (document.activeElement as HTMLElement).blur(); act(() => playerEvents.onReady?.()); expect(screen.getByTestId("player-modal").contains(document.activeElement)).toBe(true); }); 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.map((c) => c.slice(0, 2))).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.map((c) => c.slice(0, 2))).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"); }); }); // The full-height strips beside the card were a second Previous / Next that cost the video 7rem of // width (owner): the panel's transport row and Shift+← / → step on their own. describe("PlayerModal — no side strips", () => { it("steps from the panel only, and the card keeps no gutter for strips", () => { vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); const A = vid("A", 0); const B = vid("B", 0); renderModal( {}} onState={() => {}} />, ); expect(screen.getAllByRole("button", { name: "player.next" })).toHaveLength(1); expect(screen.getAllByRole("button", { name: "player.previous" })).toHaveLength(1); // stage → its wrapper (with the clip panel) → the card → the row const row = document.querySelector(".player-stage")!.parentElement!.parentElement! .parentElement!; expect(row.className).not.toMatch(/px-14/); }); // The panel is narrow: worded, the queue row wrapped onto three lines. Each glyph keeps its name. it("keeps the panel's queue row and the watch toggle to glyphs, each still named", () => { vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); const A = vid("A", 0); const B = vid("B", 0); renderModal( {}} onState={() => {}} />, ); const named = [ "player.previous", "player.next", /^player\.autoAdvance\.label: /, /^player\.loop\.label: /, "player.markWatched", ]; for (const name of named) { const [button] = screen.getAllByRole("button", { name }); expect(button!.textContent!.trim(), String(name)).toBe(""); expect(button!.querySelector("svg"), String(name)).not.toBeNull(); } }); it("closes from one icon-only button", () => { vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); const onClose = vi.fn(); const A = vid("A", 0); renderModal( {}} />, ); const close = screen.getByTestId("player-close"); expect(close.getAttribute("aria-label")).toBe("player.close"); expect(close.textContent).toBe(""); fireEvent.click(close); expect(onClose).toHaveBeenCalledTimes(1); }); }); // 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={() => {}} />, ); // stage → its wrapper (with the clip panel) → the card → the row const row = document.querySelector(".player-stage")!.parentElement!.parentElement! .parentElement!; expect(row.style.maxWidth).toBe("min(56rem, max(36rem, (100dvh - 3rem - 160px) * 16 / 9))"); }); }); // 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!.parentElement!; // the stage sits in a wrapper (+ clip panel) 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. expect(card.parentElement!.style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)"); // A long channel name is cut on the panel's line, not run past its 20rem into a sideways scroll. const name = within(info).getByText("Channel").closest("button, a")!.parentElement!; expect(name.className).toMatch(/\bmin-w-0\b/); expect(name.className).toMatch(/\bflex-1\b/); expect(name.className).not.toMatch(/\bshrink-0\b/); // The description is cut to three lines, with a More once it is cut. expect((await screen.findByText("A long description")).closest(".line-clamp-3")).toBeTruthy(); }); it("shuts the panel to a strip for a bigger video, and keeps it shut as the queue steps on", () => { wideWindow(true); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); const A = vid("A", 0); const B = vid("B", 0); renderModal( {}} onState={() => {}} />, ); const row = () => document.querySelector(".player-stage")!.parentElement!.parentElement! .parentElement!; fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" })); expect(screen.queryByTestId("player-info")).toBeNull(); const strip = screen.getByTestId("player-strip"); // The pressed toggle is gone: focus is on its counterpart, not on . expect(document.activeElement).toBe( within(strip).getByRole("button", { name: "player.expandPanel" }), ); expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 2.75rem)"); // The strip keeps the way out and the stepping. expect(within(strip).getByTestId("player-close")).toBeTruthy(); // The dialog keeps its name with the title out of sight: it is named by that heading's id. expect(screen.getByRole("dialog", { name: "Video A" })).toBeTruthy(); fireEvent.click(within(strip).getByRole("button", { name: "player.next" })); expect(screen.getByTestId("player-strip")).toBeTruthy(); // still shut on the next video expect(screen.getByRole("dialog", { name: "Video B" })).toBeTruthy(); fireEvent.click( within(screen.getByTestId("player-strip")).getByRole("button", { name: "player.expandPanel", }), ); expect(screen.getByTestId("player-info")).toBeTruthy(); expect(document.activeElement).toBe( screen.getByRole("button", { name: "player.collapsePanel" }), ); expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)"); }); it("gives the focus to the player when the scissors that opened the clip is not there to take it", async () => { wideWindow(true); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); const A = vid("A", 0); renderModal( {}} onState={() => {}} />, ); await waitFor(() => expect(lastStart()?.videoId).toBe("A")); // Shut by the user: closing the clip leaves it shut, and its strip has no scissors. fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" })); act( () => void window.dispatchEvent( new KeyboardEvent("keydown", { key: "c", bubbles: true, cancelable: true }), ), ); fireEvent.click(screen.getByRole("button", { name: "clip.close" })); await waitFor(() => expect(document.activeElement).not.toBe(document.body)); expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true); }); it("shuts while a clip is marked and opens again after it, unless the user opened it meanwhile", async () => { wideWindow(true); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); const A = vid("A", 0); renderModal( {}} onState={() => {}} />, ); await waitFor(() => expect(lastStart()?.videoId).toBe("A")); fireEvent.click(screen.getByRole("button", { name: "clip.open" })); expect(screen.getByTestId("player-strip")).toBeTruthy(); // The clip panel sits under the video here too: the stacked card's floor, and its scroll past it // (without them the fit ran the card down to a sliver at 133% with the largest text). const stage = document.querySelector(".player-stage")!; const cardEl = stage.parentElement!.parentElement!; expect(cardEl.parentElement!.style.maxWidth).toBe( "calc(max(36rem, (100dvh - 3rem - 0px) * 16 / 9) + 2.75rem)", ); expect(cardEl.className).toContain("overflow-y-auto"); fireEvent.click(screen.getByRole("button", { name: "clip.close" })); expect(screen.getByTestId("player-info")).toBeTruthy(); expect(cardEl.className).toContain("overflow-hidden"); // Opened again by hand while clipping: it stays open, and closing the clip leaves it so. fireEvent.click(screen.getByRole("button", { name: "clip.open" })); fireEvent.click( within(screen.getByTestId("player-strip")).getByRole("button", { name: "player.expandPanel", }), ); fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" })); fireEvent.click(screen.getByRole("button", { name: "clip.close" })); expect(screen.getByTestId("player-strip")).toBeTruthy(); }); it("gives a panel with something to scroll the wheel for that alone, the volume elsewhere", 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(false); // at its top too: one gesture, one meaning (owner) top = 400; expect(spin(text, 100)).toBe(false); // and at its end, where it once fell through to the volume expect(spin(document.querySelector(".player-stage")!, 100)).toBe(true); // over the video // A panel that fits has nothing to scroll: the wheel is the volume there. Object.defineProperty(panel, "scrollHeight", { value: 500, configurable: true }); expect(spin(text, 100)).toBe(true); }); // A row is reached by its title (its picture is a link of the same name). const titleOf = (links: HTMLElement[]) => links.find((a) => a.hasAttribute("data-nav-open"))!; const related = (id: string, position_seconds = 0) => ({ ...vid(id, position_seconds), followed: false, }); it("wide: the related videos are a tab beside the description, and one plays in place", async () => { wideWindow(true); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); const asked = vi.spyOn(api, "related").mockResolvedValue({ items: [related("rel1"), related("rel2", 120)], has_more: false, next_cursor: null, }); const A = vid("A", 0); renderModal( {}} onState={() => {}} />, ); act(() => playerEvents.onReady?.()); // Closed until asked: nothing is fetched for a player nobody opened the list in. expect(screen.getByTestId("player-description")).toBeTruthy(); expect(asked).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("tab", { name: "player.related.title" })); fireEvent.click(titleOf(await screen.findAllByRole("link", { name: "Video rel2" }))); // In the same player, from where the user left it. await waitFor(() => expect(lastStart()).toEqual({ videoId: "rel2", start: 120 })); // The list stays the opened video's, to try the next one; it is not asked for again. expect(screen.getAllByRole("link", { name: "Video rel1" }).length).toBeGreaterThan(0); expect(asked.mock.calls).toEqual([["A", "topical", null]]); expect(screen.getByRole("button", { name: "player.back" })).toBeTruthy(); }); // A related video played in place is the item it now is: tracked like a queue item, while the // queue stays where it was and Back returns to the opened one. const playRel2 = async ( onState: (id: string, status: string, v?: Video) => void, queue?: Video[], opts: { onRemoveFromList?: (v: Video) => Promise; before?: () => void } = {}, ) => { wideWindow(true); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); vi.spyOn(api, "related").mockResolvedValue({ items: [related("rel1"), related("rel2", 120)], has_more: false, next_cursor: null, }); const A = vid("A", 40); renderModal( {}} onState={onState} onRemoveFromList={opts.onRemoveFromList} />, ); act(() => playerEvents.onReady?.()); opts.before?.(); fireEvent.click(screen.getByRole("tab", { name: "player.related.title" })); fireEvent.click(titleOf(await screen.findAllByRole("link", { name: "Video rel2" }))); await waitFor(() => expect(lastStart()?.videoId).toBe("rel2")); }; it("wide: a related video played in place shows its own title and controls", async () => { await playRel2(() => {}); expect(screen.getByRole("heading", { name: "Video rel2" })).toBeTruthy(); expect(screen.getAllByTestId("add-rel2").length).toBeGreaterThan(0); expect(screen.getAllByTestId("download-rel2").length).toBeGreaterThan(0); expect(screen.getAllByTitle("player.markWatched").length).toBeGreaterThan(0); }); it("wide: a related video's progress is checkpointed, and Back resumes the opened one", async () => { const saved = vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({ video_id: id, position_seconds: id === "rel2" ? 500 : 77, })); clock.current = 77; await playRel2(() => {}); clock.current = 500; fireEvent.click(screen.getByRole("button", { name: "player.back" })); // Where leaving it for the related one checkpointed it, not the queue's snapshot (40). await waitFor(() => expect(lastStart()).toEqual({ videoId: "A", start: 77 })); expect(saved.mock.calls.map((c) => c[0])).toContain("rel2"); expect(screen.queryByRole("button", { name: "player.back" })).toBeNull(); }); it("wide: a related video watched to its end is marked watched, and the queue stays put", async () => { // Auto-advance on: an end of the queue's own item would step to B. me = { related_enabled: true, preferences: { playerAutoAdvance: "next" } } as unknown as Me; const onState = vi.fn(); const B = vid("B", 0); await playRel2(onState, [vid("A", 40), B]); act(() => playerEvents.onStateChange?.({ data: 0 })); expect(onState.mock.calls.map((c) => c.slice(0, 2))).toContainEqual(["rel2", "watched"]); // Named for the host's notice: it is none of the host's own rows. expect(onState.mock.calls[0]![2]).toMatchObject({ id: "rel2", title: "Video rel2" }); await settle(); expect(lastStart()?.videoId).toBe("rel2"); }); // Restart is where the user asked to start: a position this session stored for the video (which a // plain pick resumes from) must not win over it. it("wide: Restart on a related video played this session starts it at 0", async () => { vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({ video_id: id, position_seconds: id === "rel2" ? 500 : 77, })); clock.current = 77; await playRel2(() => {}); clock.current = 500; fireEvent.click(screen.getByRole("button", { name: "player.back" })); await waitFor(() => expect(lastStart()?.videoId).toBe("A")); await settle(); const list = screen.getByTestId("player-related"); const row = within(list) .getAllByRole("link", { name: "Video rel2" })[0]! .closest("[data-nav-index]")!; fireEvent.click(within(row as HTMLElement).getAllByTitle("card.restartTitle")[0]!); await waitFor(() => expect(lastStart()?.videoId).toBe("rel2")); // A start of 0 is no `start` at all (the player starts at the top). expect(lastStart()?.start ?? 0).toBe(0); }); // Its row stays in the list while it plays; a new player for the same video is never built (one // per video), so Restart there is a seek. it("wide: Restart on the related video already playing seeks it to 0", async () => { await playRel2(() => {}); const before = started.length; const list = screen.getByTestId("player-related"); const row = within(list) .getAllByRole("link", { name: "Video rel2" })[0]! .closest("[data-nav-index]")!; fireEvent.click(within(row as HTMLElement).getAllByTitle("card.restartTitle")[0]!); expect(seeks).toContain(0); expect(started.length).toBe(before); }); // The toggle remembers each video's mark: a related one's did not overwrite the opened one's. it("wide: Back shows the opened video's own mark, not the one it was opened with", async () => { const onState = vi.fn(); await playRel2(onState, undefined, { before: () => fireEvent.click(screen.getAllByTitle("player.markWatched")[0]!), }); fireEvent.click(screen.getAllByTitle("player.markWatched")[0]!); // rel2's expect(onState.mock.calls.map((c) => c.slice(0, 2))).toEqual([ ["A", "watched"], ["rel2", "watched"], ]); fireEvent.click(screen.getByRole("button", { name: "player.back" })); await waitFor(() => expect(lastStart()?.videoId).toBe("A")); expect(screen.getAllByTitle("player.watchedUnmark").length).toBeGreaterThan(0); expect(screen.queryAllByTitle("player.markWatched")).toHaveLength(0); }); it("wide: a queue step leaves the related video for the queue's next item", async () => { await playRel2(() => {}, [vid("A", 40), vid("B", 0)]); next(); await waitFor(() => expect(lastStart()?.videoId).toBe("B")); expect(screen.getByRole("heading", { name: "Video B" })).toBeTruthy(); expect(screen.queryByRole("button", { name: "player.back" })).toBeNull(); }); // Removing from the list removes the queue's item — not the related video on screen. it("wide: offers no removal from the list while a related video plays", async () => { await playRel2(() => {}, undefined, { onRemoveFromList: async () => true }); expect(screen.queryByRole("button", { name: /playlists\.removeItemOf/ })).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "player.back" })); await waitFor(() => expect(lastStart()?.videoId).toBe("A")); expect(trash("A")).toBeTruthy(); }); it("leaves the related videos out when the instance turned them off", () => { me = { related_enabled: false } as Me; vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); const A = vid("A", 0); wideWindow(true); const wideView = renderModal( {}} onState={() => {}} />, ); expect(screen.queryByRole("tab", { name: "player.related.title" })).toBeNull(); expect(screen.getByTestId("player-description")).toBeTruthy(); wideView.unmount(); wideWindow(false); renderModal( {}} onState={() => {}} />, ); expect(screen.queryByRole("button", { name: "player.related.title" })).toBeNull(); }); // The list keys are the page's, silenced while a layer is up — but this list is IN the layer. const press = (key: string) => act(() => void fireEvent.keyDown(document.activeElement ?? document.body, { key })); const rowOf = (el: Element | null) => el?.closest("[data-nav-index]"); it("wide: the keyboard walks the related list, and its arrows are not the video's", async () => { wideWindow(true); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); vi.spyOn(api, "related").mockResolvedValue({ items: [related("rel1"), related("rel2")], has_more: false, next_cursor: null, }); const A = vid("A", 0); renderModal( {}} onState={() => {}} />, ); act(() => playerEvents.onReady?.()); fireEvent.click(screen.getByRole("tab", { name: "player.related.title" })); const first = titleOf(await screen.findAllByRole("link", { name: "Video rel1" })); const second = titleOf(screen.getAllByRole("link", { name: "Video rel2" })); act(() => first.focus()); // (jsdom lays nothing out, so the rows read as one row: ←→ step them, ↑↓ have no row to go to.) press("ArrowRight"); expect(document.activeElement).toBe(rowOf(second)); press("ArrowLeft"); expect(document.activeElement).toBe(rowOf(first)); press("j"); expect(document.activeElement).toBe(rowOf(second)); press("k"); press("j"); // None of the arrows reached the video: no seek, no volume step. press("ArrowUp"); press("ArrowDown"); expect(seeks).toEqual([]); expect(volumes).toEqual([]); // Enter on the row plays it, like its title's click. press("Enter"); await waitFor(() => expect(lastStart()?.videoId).toBe("rel2")); }); it("stacked: the keyboard walks the list in the drawer too", async () => { wideWindow(false); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); vi.spyOn(api, "related").mockResolvedValue({ items: [related("rel1"), related("rel2")], has_more: false, next_cursor: null, }); const A = vid("A", 0); renderModal( {}} onState={() => {}} />, ); act(() => playerEvents.onReady?.()); fireEvent.click(screen.getByRole("button", { name: "player.related.title" })); const drawer = await screen.findByRole("dialog", { name: "player.related.title" }); const first = titleOf(await within(drawer).findAllByRole("link", { name: "Video rel1" })); act(() => first.focus()); press("j"); expect(document.activeElement).toBe( rowOf(titleOf(within(drawer).getAllByRole("link", { name: "Video rel2" }))), ); }); it("stacked: a button opens the related videos in a drawer, and playing one closes it", async () => { wideWindow(false); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); vi.spyOn(api, "related").mockResolvedValue({ items: [related("rel1")], has_more: false, next_cursor: null, }); const A = vid("A", 0); const onClose = vi.fn(); renderModal( {}} />, ); act(() => playerEvents.onReady?.()); expect(screen.queryByRole("tab", { name: "player.related.title" })).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "player.related.title" })); const drawer = await screen.findByRole("dialog", { name: "player.related.title" }); fireEvent.click(titleOf(await within(drawer).findAllByRole("link", { name: "Video rel1" }))); await waitFor(() => expect(lastStart()?.videoId).toBe("rel1")); await waitFor(() => expect(screen.queryByRole("dialog", { name: "player.related.title" })).toBeNull(), ); // Only the drawer: the click on its row must not reach the player's backdrop (measured live — // rendered outside the card, the whole player closed). expect(onClose).not.toHaveBeenCalled(); }); const openDrawer = async () => { vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); vi.spyOn(api, "related").mockResolvedValue({ items: [related("rel1")], has_more: false, next_cursor: null, }); const A = vid("A", 0); renderModal( {}} onState={() => {}} />, ); act(() => playerEvents.onReady?.()); fireEvent.click(screen.getByRole("button", { name: "player.related.title" })); const drawer = await screen.findByRole("dialog", { name: "player.related.title" }); await within(drawer).findAllByRole("link", { name: "Video rel1" }); return { drawer }; }; it("stacked: the video's keys are the drawer's while it is open", async () => { wideWindow(false); const { drawer } = await openDrawer(); act(() => within(drawer).getByRole("button", { name: "common.close" }).focus()); press("ArrowUp"); press("ArrowDown"); press("ArrowLeft"); press("ArrowRight"); expect(volumes).toEqual([]); expect(seeks).toEqual([]); }); // So the help sheet opened over the drawer does not promise them; over the player itself, it does. it("stacked: the help sheet over the drawer leaves the video's keys out", async () => { wideWindow(false); const { drawer } = await openDrawer(); render(); act(() => toggleKeyboardHelp()); expect(screen.queryByText("keys.playPause")).toBeNull(); act(() => closeKeyboardHelp()); fireEvent.click(within(drawer).getByRole("button", { name: "common.close" })); await waitFor(() => expect(screen.queryByRole("dialog", { name: "player.related.title" })).toBeNull(), ); act(() => toggleKeyboardHelp()); expect(screen.getByText("keys.playPause")).toBeTruthy(); act(() => closeKeyboardHelp()); }); it("stacked: the window regaining focus leaves it in the drawer", async () => { wideWindow(false); const { drawer } = await openDrawer(); const row = titleOf(within(drawer).getAllByRole("link", { name: "Video rel1" })); act(() => row.focus()); act(() => void window.dispatchEvent(new Event("focus"))); expect(document.activeElement).toBe(row); }); it("going wide moves the drawer's list into the panel's tab, and narrow again reopens nothing", async () => { vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); vi.spyOn(api, "related").mockResolvedValue({ items: [related("rel1")], has_more: false, next_cursor: null, }); const A = vid("A", 0); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); qc.setQueryData(qk.me(), me); const ui = () => ( {}} onState={() => {}} /> ); wideWindow(false); const view = render(ui()); fireEvent.click(screen.getByRole("button", { name: "player.related.title" })); await screen.findByRole("dialog", { name: "player.related.title" }); wideWindow(true); view.rerender(ui()); await waitFor(() => expect(screen.queryByRole("dialog", { name: "player.related.title" })).toBeNull(), ); // The list the user was in is the panel's open tab now. expect( screen.getByRole("tab", { name: "player.related.title" }).getAttribute("aria-selected"), ).toBe("true"); wideWindow(false); view.rerender(ui()); expect(screen.queryByRole("dialog", { name: "player.related.title" })).toBeNull(); }); it("wide: the panel's tabs keep the row's gap", () => { wideWindow(true); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); const A = vid("A", 0); renderModal( {}} onState={() => {}} />, ); const row = screen.getByRole("tablist").className.split(" "); expect(row).toEqual(expect.arrayContaining(["flex", "flex-wrap", "gap-1"])); }); it("stacked: the card scrolls under the wheel, the video in it is still the volume", () => { wideWindow(false); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); const A = vid("A", 0); renderModal( {}} onState={() => {}} />, ); const stage = document.querySelector(".player-stage")!; const card = stage.parentElement!.parentElement!; // the stage sits in a wrapper (+ clip panel) Object.defineProperty(card, "scrollHeight", { value: 900, configurable: true }); Object.defineProperty(card, "clientHeight", { value: 500, configurable: true }); const spin = (el: Element) => { const e = new WheelEvent("wheel", { deltaY: 100, bubbles: true, cancelable: true }); el.dispatchEvent(e); return e.defaultPrevented; }; expect(spin(screen.getByRole("heading", { name: "Video A" }))).toBe(false); expect(spin(stage)).toBe(true); }); // 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(), }); // Brave's stand-in otherwise: a