// @vitest-environment jsdom // // E28 C1 on the Download Center: a download row keeps its primary action (play; pause / resume / // retry in the queue) and the save link in sight, and everything else — the editors, sharing, // re-download, the copies, cancel / delete / remove — is its ONE menu, opened by the row's "…" (it // shows on hover or focus) or a right-click on the row. A link inside the row keeps the browser's own // menu. i18n is key-passthrough, so labels read as keys. import { afterEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { api, type DownloadJob } from "../lib/api"; import { menuGroups } from "./ui/menuGroups"; const mocks = vi.hoisted(() => ({ jobs: [] as unknown[], confirm: vi.fn(async (_o?: unknown) => true), })); vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; }); vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm })); vi.mock("../lib/useLiveQuery", () => ({ useLiveQuery: (key: readonly unknown[]) => ({ data: key[0] === "downloads" ? mocks.jobs : undefined, isError: false, isLoading: false, refetch: vi.fn(), }), })); vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: "user" }) })); vi.mock("./ui/PageShell", async (importOriginal) => ({ ...(await importOriginal()), PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}, })); import DownloadCenter from "./DownloadCenter"; const job = ( id: number, status: DownloadJob["status"], extra: Partial = {}, ): DownloadJob => ({ id, status, progress: 0, speed_bps: null, eta_s: null, phase: null, error: null, error_code: null, error_detail: null, queue_pos: 0, created_at: null, source_kind: "youtube", source_ref: `v${id}`, source_url: null, poster_url: null, profile_id: null, spec: { mode: "av" }, display_name: `Clip ${id}`, display_uploader: null, display_uploader_url: null, extra_links: [], can_download: status === "done", expired: false, job_kind: "download", asset: { media_kind: "video", duration_s: 60 }, ...extra, }) as unknown as DownloadJob; afterEach(() => { cleanup(); vi.restoreAllMocks(); mocks.confirm.mockClear(); }); function mount(tab?: string) { render( , ); if (tab) fireEvent.click(screen.getByRole("button", { name: new RegExp(`downloads\\.tabs\\.${tab}`) })); } const rowOf = (text: string) => screen.getByText(text).closest("[data-nav-index]")!; const moreOf = (text: string) => rowOf(text).querySelector('button[aria-label="downloads.rowMenu"]')!; const menuRows = () => screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? ""); async function openMenu(open: () => void) { act(open); await waitFor(() => expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0), ); } const rowButtons = (text: string) => [...rowOf(text).querySelectorAll("button, a[aria-label]")].map((b) => b.getAttribute("aria-label"), ); describe("DownloadCenter · the queue row", () => { it("shows pause and its '…' only; the '…' hides until hover or focus", async () => { mocks.jobs = [job(1, "running")]; mount(); expect(rowButtons("Clip 1")).toEqual(["downloads.actions.pause", "downloads.rowMenu"]); expect(moreOf("Clip 1").classList.contains("reveal")).toBe(true); expect(rowOf("Clip 1").classList.contains("group")).toBe(true); }); it("holds pause, the copies and cancel; a failed job's first row is Retry", async () => { mocks.jobs = [job(1, "running", { source_url: "https://youtu.be/x" }), job(2, "error")]; mount(); await openMenu(() => fireEvent.click(moreOf("Clip 1"))); expect(menuRows()).toEqual([ "downloads.actions.pause", "common.copy.title", "common.copy.link", "downloads.actions.cancel", ]); expect(moreOf("Clip 1").getAttribute("aria-expanded")).toBe("true"); fireEvent.keyDown(document.activeElement!, { key: "Escape" }); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); expect(menuRows()).toEqual([ "downloads.actions.retry", "common.copy.title", "downloads.actions.cancel", ]); }); it("closes on a second press of the same '…'", async () => { mocks.jobs = [job(1, "running")]; mount(); await openMenu(() => fireEvent.click(moreOf("Clip 1"))); // The press lands outside the panel, which floating-ui's dismiss closes on; the click after // it must not open it again. act(() => { fireEvent.pointerDown(moreOf("Clip 1"), { pointerType: "mouse" }); fireEvent.mouseDown(moreOf("Clip 1")); fireEvent.click(moreOf("Clip 1"), { detail: 1 }); }); await waitFor(() => expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0), ); expect(moreOf("Clip 1").getAttribute("aria-expanded")).toBe("false"); }); it("still closes when a poll replaced the job under the open menu", async () => { mocks.jobs = [job(1, "running")]; mount(); await openMenu(() => fireEvent.click(moreOf("Clip 1"))); // The queue polls while a job runs: the next render lists a NEW object for the same job. mocks.jobs = [job(1, "running", { progress: 40 })]; fireEvent.change(screen.getByPlaceholderText("downloads.page.addUrl"), { target: { value: "x" }, }); act(() => { fireEvent.pointerDown(moreOf("Clip 1"), { pointerType: "mouse" }); fireEvent.mouseDown(moreOf("Clip 1")); fireEvent.click(moreOf("Clip 1"), { detail: 1 }); }); await waitFor(() => expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0), ); }); it("cancels only through the confirm", async () => { mocks.jobs = [job(1, "queued")]; const cancel = vi.spyOn(api, "cancelDownload").mockResolvedValue({} as never); mount(); const pickCancel = async () => { await openMenu(() => fireEvent.click(moreOf("Clip 1"))); fireEvent.click( screen .getAllByRole("menuitem", { hidden: true }) .find((b) => b.textContent === "downloads.actions.cancel")!, ); }; mocks.confirm.mockResolvedValueOnce(false); await pickCancel(); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true })); expect(cancel).not.toHaveBeenCalled(); await pickCancel(); await waitFor(() => expect(cancel).toHaveBeenCalledWith(1)); }); }); describe("DownloadCenter · the library row", () => { it("shows play, share, save and its '…'; the editors, managing shares and delete are in the menu", async () => { mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })]; mount("done"); await screen.findByText("Clip 2"); expect(rowButtons("Clip 2")).toEqual([ "downloads.actions.play", "downloads.actions.share", "downloads.actions.download", "downloads.rowMenu", ]); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); expect(menuRows()).toEqual([ "downloads.actions.play", "downloads.actions.share", "downloads.actions.download", "downloads.actions.edit", "downloads.actions.editDetails", "downloads.actions.redownload", "downloads.actions.shareManage", "common.copy.title", "common.copy.link", "downloads.actions.delete", ]); }); it("copies the share link from the row's own Share button", async () => { mocks.jobs = [job(2, "done")]; vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never); const create = vi .spyOn(api, "createDownloadLink") .mockResolvedValue({ id: 1, url: "/s/tok" } as never); mount("done"); await screen.findByText("Clip 2"); fireEvent.click( rowOf("Clip 2").querySelector('button[aria-label="downloads.actions.share"]')!, ); await waitFor(() => expect(create).toHaveBeenCalledWith(2, {})); expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0); }); it("saves the file through the same link as the row's save button", async () => { mocks.jobs = [job(2, "done")]; const followed: string[] = []; vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function ( this: HTMLAnchorElement, ) { followed.push(this.href); }); mount("done"); await screen.findByText("Clip 2"); await openMenu(() => fireEvent.click(moreOf("Clip 2"))); fireEvent.click( screen .getAllByRole("menuitem", { hidden: true }) .find((b) => b.textContent === "downloads.actions.download")!, ); const button = rowOf("Clip 2").querySelector( 'a[aria-label="downloads.actions.download"]', )!; expect(followed).toEqual([button.href]); }); it("leaves out what a file or a clip cannot do, and no rule is left behind", async () => { mocks.jobs = [job(3, "done", { job_kind: "upload", asset: { media_kind: "file" } } as never)]; mount("done"); await screen.findByText("Clip 3"); await openMenu(() => fireEvent.click(moreOf("Clip 3"))); expect(menuRows()).toEqual([ "downloads.actions.share", "downloads.actions.download", "downloads.actions.shareManage", "common.copy.title", "downloads.actions.delete", ]); }); it("gives a link inside the row the browser's own menu", async () => { mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })]; mount("done"); const link = await screen.findByText("youtu.be/x"); const ev = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); act(() => void link.dispatchEvent(ev)); expect(ev.defaultPrevented).toBe(false); expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0); }); }); describe("DownloadCenter · the shared row", () => { it("holds play, save, edit, the copies and Remove from my list", async () => { mocks.jobs = []; vi.spyOn(api, "downloadsShared").mockResolvedValue([ job(4, "done", { can_download: true }), ] as never); const remove = vi.spyOn(api, "removeSharedDownload").mockResolvedValue({} as never); mount("shared"); await screen.findByText("Clip 4"); await openMenu(() => fireEvent.click(moreOf("Clip 4"))); expect(menuRows()).toEqual([ "downloads.actions.play", "downloads.actions.download", "downloads.actions.edit", "common.copy.title", "downloads.actions.removeShared", ]); fireEvent.click( screen .getAllByRole("menuitem", { hidden: true }) .find((b) => b.textContent === "downloads.actions.removeShared")!, ); await waitFor(() => expect(remove).toHaveBeenCalledWith(4)); }); }); describe("menuGroups", () => { const row = (id: string) => ({ id, label: id, onSelect: () => {} }); it("rules between non-empty groups only", () => { const out = menuGroups([], [row("a"), row("b")], [], [row("c")]); expect(out.map((r) => [r.id, !!r.separatorBefore])).toEqual([ ["a", false], ["b", false], ["c", true], ]); }); });