// @vitest-environment jsdom // // The Playlists page as the player sees it: which row it is launched with, and what it is told to // do when the viewer removes that row from inside it. PlayerModal itself is replaced by a stand-in // that records its props — its own behaviour is pinned in PlayerModal.dom.test.tsx. 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"; const h = vi.hoisted(() => ({ notify: vi.fn(), selectedId: 1 as number | null, mounted: false, mounts: 0, player: null as null | { video: { id: string; position_seconds: number }; onClose: () => void; onState: (id: string, status: string) => void; onRemoveFromList?: (v: unknown) => Promise; }, })); 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("../lib/notifications", async (importOriginal) => ({ ...(await importOriginal()), notify: (...a: unknown[]) => h.notify(...a), })); vi.mock("./playlistsContext", () => ({ usePlaylists: () => ({ selectedId: h.selectedId }) })); vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true })); vi.mock("./usePlaylistActions", () => ({ usePlaylistActions: () => ({ remove: vi.fn() }) })); vi.mock("./PlayerModal", async () => { const { useEffect } = await import("react"); return { default: function PlayerStandIn(props: NonNullable) { h.player = props; useEffect(() => { h.mounted = true; h.mounts++; return () => { h.mounted = false; }; }, []); return null; }, }; }); import Playlists from "./Playlists"; import { PageToolbarSlot } from "./ui/pageToolbarSlot"; import { api, HttpError, type Video } from "../lib/api"; import { qk } from "../lib/queryKeys"; import { patchCachedProgress } from "../lib/progressCache"; 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 vid = (id: string, position_seconds: number): Video => ({ id, title: `Video ${id}`, channel_id: "UCx", channel_title: "Channel", status: "new", position_seconds, duration_seconds: 3000, published_at: "2026-01-01T00:00:00Z", watch_url: `https://www.youtube.com/watch?v=${id}`, }) as unknown as Video; const WATCH_LATER = 9; const list = (id: number, items: Video[]) => ({ id, name: `List ${id}`, kind: id === WATCH_LATER ? "watch_later" : "user", yt_playlist_id: null, item_count: items.length, items, }); let qc: QueryClient; function mount(lists: Record, prefs: Record = {}) { vi.spyOn(api, "playlists").mockResolvedValue( Object.entries(lists).map(([id, items]) => list(Number(id), items)) as never, ); vi.spyOn(api, "playlist").mockImplementation( async (id: number) => list(id, lists[id] ?? []) as never, ); qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {}, preferences: prefs }); // The header portals into the shell's fixed toolbar band; without a slot it renders nothing, and // it went untested for exactly that reason. const slot = document.createElement("div"); document.body.appendChild(slot); const tree = () => ( ); const view = render(tree()); return { rerender: () => view.rerender(tree()) }; } const play = async (title: string) => fireEvent.click(await screen.findByRole("button", { name: `card.play — ${title}` })); beforeEach(() => { h.selectedId = 1; h.player = null; h.mounted = false; h.mounts = 0; h.notify.mockClear(); }); afterEach(() => { cleanup(); vi.restoreAllMocks(); document.body.replaceChildren(); }); describe("Playlists — the row the player is launched with", () => { it("carries the position the player just stored, not the one the list first loaded with", async () => { // The page renders from its own undoable copy of the list, re-seeded only when the SET of ids // changes — so the position the player wrote into the cache never reached it: reopen a video // closed at 830 s and it resumed at the old spot, and the next checkpoint wrote that over 830. mount({ 1: [vid("V", 748)] }); await play("Video V"); await waitFor(() => expect(h.player?.video.position_seconds).toBe(748)); act(() => h.player!.onClose()); h.player = null; // What the player's cleanup does with the position the server confirmed. react-query hands a // cache write to its observers on a setTimeout(0); outside act, the re-render it triggers is // scheduled by React on its own and raced the click below under the gate's parallel load (the // reopen read 748). The write is SYNCHRONOUS, so its notify timer is registered before ours and // fires first — and inside act, the render it causes is flushed before act returns. await act(async () => { patchCachedProgress(qc, new Map([["V", 830]])); await new Promise((r) => setTimeout(r, 0)); }); await play("Video V"); await waitFor(() => expect(h.player?.video.position_seconds).toBe(830)); }); }); describe("Playlists — removing a video from inside the player", () => { it("removes it from the list the player was launched from, even after the selection moved", async () => { // The selection can change under an open player (the command palette opens over it); the // removal read the LIVE selection, so it took the video out of a list the viewer never touched. const remove = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never); const { rerender } = mount({ 1: [vid("V", 0)], 2: [vid("V", 0), vid("W", 0)] }); await play("Video V"); await waitFor(() => expect(h.player).not.toBeNull()); h.selectedId = 2; rerender(); await screen.findByRole("button", { name: "card.play — Video W" }); // list 2 is on screen now await act(async () => { await h.player!.onRemoveFromList!(vid("V", 0)); }); expect(remove).toHaveBeenCalledWith(1, "V"); // …and the list on screen keeps its own copy of V: nothing was taken out of it. expect(screen.getByRole("button", { name: "card.play — Video V" })).toBeTruthy(); }); it("says so when the removal fails because the list is gone", async () => { // `req` leaves 403/404 to the caller; the catch assumed a global dialog had already reported // it, so a confirmed removal failed in total silence behind the full-screen player. vi.spyOn(api, "removeFromPlaylist").mockRejectedValue(new HttpError(404, "Unknown playlist")); mount({ 1: [vid("V", 0)] }); await play("Video V"); await waitFor(() => expect(h.player).not.toBeNull()); let done: boolean | undefined; await act(async () => { done = await h.player!.onRemoveFromList!(vid("V", 0)); }); expect(done).toBe(false); expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" })); }); it("offers Remove by the list it was launched from, not by whichever list is selected now", async () => { // Watch later deliberately has no Remove. Gated on the SELECTED list, it appeared on a player // launched from Watch later as soon as another list was selected under it — and deleted from // Watch later, since the removal itself targets the launch list. h.selectedId = WATCH_LATER; const { rerender } = mount({ [WATCH_LATER]: [vid("V", 0)], 2: [vid("W", 0)] }); await play("Video V"); await waitFor(() => expect(h.player).not.toBeNull()); expect(h.player!.onRemoveFromList).toBeUndefined(); h.selectedId = 2; rerender(); await screen.findByRole("button", { name: "card.play — Video W" }); expect(h.player!.onRemoveFromList).toBeUndefined(); }); it("takes the removed video out of the cached list, so a progress checkpoint cannot bring it back", async () => { // The player steps to the next item after a removal and stores the last one's position into // every cached list. Landing before the refetch, that write handed the page a list still holding // the video, and the page — seeing a different set of ids — reset: the row came back. vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never); mount({ 1: [vid("V", 0), vid("W", 0)] }); await play("Video V"); await waitFor(() => expect(h.player).not.toBeNull()); vi.mocked(api.playlist).mockReturnValue(new Promise(() => {})); // the refetch is still out await act(async () => { await h.player!.onRemoveFromList!(vid("V", 0)); }); await act(async () => { patchCachedProgress(qc, new Map([["V", 99]])); await new Promise((r) => setTimeout(r, 0)); }); expect(screen.queryByRole("button", { name: "card.play — Video V" })).toBeNull(); expect(screen.getByRole("button", { name: "card.play — Video W" })).toBeTruthy(); }); }); describe("Playlists — a failed refetch of a list that is on screen", () => { it("keeps the list, instead of replacing it with the error", async () => { // A background refetch — on focus, after a move or a removal — failing during an API restart // replaced a loaded list with "couldn't load" (the `isError && ` convention, ignored). mount({ 1: [vid("V", 0)] }); await screen.findByRole("button", { name: "card.play — Video V" }); vi.mocked(api.playlist).mockRejectedValue(new HttpError(502, "gateway")); await act(async () => { await qc.invalidateQueries({ queryKey: qk.playlist(1) }); await new Promise((r) => setTimeout(r, 20)); // react-query notifies its observers on a timer }); expect(qc.getQueryState(qk.playlist(1))?.status).toBe("error"); expect(screen.queryByText("playlists.loadFailed")).toBeNull(); expect(screen.getByRole("button", { name: "card.play — Video V" })).toBeTruthy(); }); }); describe("Playlists — the player outlives a change of selection", () => { it("stays mounted while a newly selected list is still loading", async () => { // The page swaps itself for "Loading…" until the new list arrives; with the player rendered only // in the loaded branch, that took it down mid-playback and put it back at its launch video. const { rerender } = mount({ 1: [vid("V", 0)] }); await play("Video V"); await waitFor(() => expect(h.mounted).toBe(true)); vi.mocked(api.playlist).mockReturnValue(new Promise(() => {})); h.selectedId = 2; rerender(); // The shared loading state, which a screen reader hears (the old bare line had no live region). expect( (await screen.findByText("playlists.loading")).closest('[role="status"]'), ).not.toBeNull(); expect(h.mounted).toBe(true); expect(h.mounts).toBe(1); // never went down — a remount would also end `mounted` }); it("stays mounted when the newly selected list fails to load — a list deleted elsewhere", async () => { const { rerender } = mount({ 1: [vid("V", 0)] }); await play("Video V"); await waitFor(() => expect(h.mounted).toBe(true)); vi.mocked(api.playlist).mockRejectedValue(new HttpError(404, "gone")); h.selectedId = 2; rerender(); await screen.findByText("playlists.loadFailed"); expect(h.mounted).toBe(true); expect(h.mounts).toBe(1); }); it("stays mounted when nothing is selected any more", async () => { const { rerender } = mount({ 1: [vid("V", 0)] }); await play("Video V"); await waitFor(() => expect(h.mounted).toBe(true)); h.selectedId = null; rerender(); await screen.findByText("playlists.pickOne"); expect(h.mounted).toBe(true); expect(h.mounts).toBe(1); }); it("refreshes the list it was launched from when the viewer marks the video in it", async () => { // Invalidating the SELECTED list left the launch list's row showing the old status. vi.spyOn(api, "setState").mockResolvedValue(undefined as never); const { rerender } = mount({ 1: [vid("V", 0)], 2: [vid("W", 0)] }); await play("Video V"); await waitFor(() => expect(h.player).not.toBeNull()); h.selectedId = 2; rerender(); await screen.findByRole("button", { name: "card.play — Video W" }); await act(async () => { h.player!.onState("V", "watched"); await new Promise((r) => setTimeout(r, 0)); }); expect(qc.getQueryState(qk.playlist(1))?.isInvalidated).toBe(true); }); }); const rowsOnScreen = () => screen .getAllByRole("button", { name: /^card\.play — / }) .map((b) => b.getAttribute("aria-label")?.replace("card.play — ", "")); async function rowMenu(title: string, item: string) { const row = (await screen.findByRole("button", { name: `card.play — ${title}` })).closest("tr")!; fireEvent.contextMenu(row); await waitFor(() => expect( screen.getAllByRole("menuitem", { hidden: true }).some((b) => b.textContent === item), ).toBe(true), ); fireEvent.click( screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === item)!, ); } describe("Playlists — a change the server refused is taken back on screen", () => { // The resync refetch returns the SAME list the server had, react-query keeps the same `data` // object for a deep-equal answer, and the page's re-seed effect is keyed on that object — so the // optimistic change used to stay on screen for good. it("puts back a row whose move failed", async () => { vi.spyOn(api, "moveToPlaylist").mockRejectedValue( new HttpError(404, "gone", { code: "not_found", reason: "move_target" }), ); mount({ 1: [vid("V", 0), vid("W", 0)], 2: [] }, { lastMovePlaylistId: 2 }); await rowMenu("Video V", "playlists.moveToRecent"); await waitFor(() => expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" })), ); await act(async () => void (await new Promise((r) => setTimeout(r, 20)))); expect(rowsOnScreen()).toEqual(["Video V", "Video W"]); }); it("puts back the order when saving a reorder failed", async () => { vi.spyOn(api, "reorderPlaylist").mockRejectedValue(new HttpError(500, "boom")); mount({ 1: [vid("V", 0), vid("W", 0)] }); await rowMenu("Video V", "playlists.orderDown"); await waitFor(() => expect(api.reorderPlaylist).toHaveBeenCalled()); await act(async () => void (await new Promise((r) => setTimeout(r, 20)))); expect(rowsOnScreen()).toEqual(["Video V", "Video W"]); }); it("puts back the order the server ACCEPTED, not the one it had two reorders ago", async () => { // The rollback reads the cached detail, and nothing wrote the accepted order into it — only the // refetch that follows a successful reorder did. So a second reorder rejected while that refetch // was still in flight rolled the rows back past the FIRST one, which the server had accepted. // The membership guard cannot correct an order (its key is the sorted id set), so the wrong // order stayed on screen for good and the next drag persisted it (deep-review round 5). const reorder = vi .spyOn(api, "reorderPlaylist") .mockResolvedValueOnce(undefined as never) .mockRejectedValue(new HttpError(500, "boom")); mount({ 1: [vid("V", 0), vid("W", 0), vid("X", 0)] }); await screen.findByRole("button", { name: "card.play — Video V" }); // From here no refetch ever lands, so the cache is whatever the page itself put there. vi.mocked(api.playlist).mockReturnValue(new Promise(() => {}) as never); await rowMenu("Video V", "playlists.orderDown"); await waitFor(() => expect(reorder).toHaveBeenCalledTimes(1)); expect(rowsOnScreen()).toEqual(["Video W", "Video V", "Video X"]); await rowMenu("Video X", "playlists.orderUp"); await waitFor(() => expect(reorder).toHaveBeenCalledTimes(2)); await act(async () => void (await new Promise((r) => setTimeout(r, 20)))); expect(rowsOnScreen()).toEqual(["Video W", "Video V", "Video X"]); }); it("puts back a row whose removal failed, even when the resync fails too", async () => { // A removal during an API restart: the DELETE fails, and so does the refetch meant to put the row // back — the list kept showing it as removed while it was still in the playlist. vi.spyOn(api, "removeFromPlaylist").mockRejectedValue(new TypeError("Failed to fetch")); mount({ 1: [vid("V", 0), vid("W", 0)] }); await screen.findByRole("button", { name: "card.play — Video V" }); vi.mocked(api.playlist).mockRejectedValue(new TypeError("Failed to fetch")); await rowMenu("Video V", "playlists.removeItem"); await waitFor(() => expect(api.removeFromPlaylist).toHaveBeenCalled()); await act(async () => void (await new Promise((r) => setTimeout(r, 20)))); expect(rowsOnScreen()).toEqual(["Video V", "Video W"]); expect(h.notify).toHaveBeenCalledWith( expect.objectContaining({ level: "error", title: "playlists.removeFailed" }), ); }); }); describe("Playlists — the rows are a keyboard list", () => { it("walks the rows with j and plays the focused one on Enter", async () => { mount({ 1: [vid("A", 0), vid("B", 0)] }); await screen.findByRole("button", { name: "card.play — Video B" }); const press = (key: string) => act(() => { (document.activeElement ?? document.body).dispatchEvent( new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), ); }); press("j"); press("j"); expect(document.activeElement?.tagName).toBe("TR"); expect(document.activeElement?.textContent).toContain("Video B"); press("Enter"); await waitFor(() => expect(h.player?.video.id).toBe("B")); }); }); // The header's meta line and its view-controls gate were typechecked and linted but never rendered, // so a regression in either shipped green (E9 review). The count and the runtime sum the WHOLE // list; an unknown duration counts as nothing rather than poisoning the sum; and a list of one has // no view to change. describe("Playlists · the header", () => { // MISSING, not null: `3000 + null` is 3000 in JS, so only an absent field shows whether the sum // guards an unknown duration or would turn the whole runtime into NaN. const untimed = (id: string) => { const { duration_seconds: _drop, ...rest } = vid(id, 0); return rest as unknown as Video; }; const meta = async () => ( await screen.findByText( (_, el) => el?.tagName === "SPAN" && /^playlists\.itemCount/.test(el.textContent ?? ""), ) ).textContent; it("sums what is known, and says nothing about a runtime nothing sums to", async () => { mount({ 1: [vid("A", 0), untimed("B")] }); expect(await meta()).toBe("playlists.itemCount · 50m"); cleanup(); mount({ 1: [untimed("C")] }); expect(await meta()).toBe("playlists.itemCount"); }); it("offers the view controls only once there is more than one row to arrange", async () => { mount({ 1: [vid("A", 0)] }); await screen.findByText("Video A"); expect(screen.queryByText("playlists.groupByChannel")).toBeNull(); cleanup(); mount({ 1: [vid("A", 0), vid("B", 0)] }); expect(await screen.findByText("playlists.groupByChannel")).toBeTruthy(); }); });