// @vitest-environment jsdom // // The download tray: up only while something waits, runs or failed — and what finished in this tab, // until it is closed; a row's right click and "…" are the queue's menu; a failure says why and offers // a retry; a finish is announced once with "Copy link" and "Open"; it folds to a pill, remembered. // The queue is fed straight in (the poll is stubbed); a poll's look is `noteQueue`, as fetchQueue does. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useState } from "react"; import { api, type QueueJob } from "../lib/api"; const h = vi.hoisted(() => ({ jobs: [] as unknown[], updatedAt: 0, openFile: vi.fn(), share: vi.fn(async () => "https://x/watch/t"), notify: vi.fn(), prefs: new Map(), intervalMs: ((_d: unknown) => 0) as (d: unknown) => number, })); vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), useTranslation: () => ({ i18n: { language: "en" }, t: (key: string | string[], o?: { count?: number; title?: string }) => `${Array.isArray(key) ? key[0] : key}${o?.count !== undefined ? `:${o.count}` : ""}${o?.title ? `:${o.title}` : ""}`, }), })); vi.mock("../lib/useLiveQuery", () => ({ useLiveQuery: (_key: unknown, _fn: unknown, opts: { intervalMs: (d: unknown) => number }) => ( (h.intervalMs = opts.intervalMs), { data: h.jobs, dataUpdatedAt: h.updatedAt, isError: false, isLoading: false, refetch: vi.fn(), } ), })); // The prefs as state over a map that outlives a mount — a reload, as far as the tray can tell. How // usePref stores them is its own business (usePref.dom.test). vi.mock("../lib/usePref", () => ({ usePref: (key: string, initial: T) => { const [value, setValue] = useState(() => h.prefs.has(key) ? (h.prefs.get(key) as T) : initial, ); return [ value, (next: T) => { h.prefs.set(key, next); setValue(next); }, ]; }, })); vi.mock("./filesContext", () => ({ useFilesActions: () => ({ openFile: h.openFile }) })); vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true })); vi.mock("../lib/downloads", async (importOriginal) => ({ ...(await importOriginal()), quickShareLink: h.share, })); vi.mock("../lib/notifications", async (importOriginal) => ({ ...(await importOriginal()), notify: h.notify, })); import DownloadTray from "./DownloadTray"; import { noteQueue, resetQueueWatch, showInTray } from "../lib/downloadQueue"; import { pushLayer, removeLayer } from "../lib/layerStack"; import { qk } from "../lib/queryKeys"; beforeEach(() => { resetQueueWatch(); h.prefs.clear(); h.updatedAt = 0; globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver; }); afterEach(() => { cleanup(); vi.restoreAllMocks(); for (const f of [h.openFile, h.share, h.notify]) f.mockClear(); }); const job = (id: number, status: string, extra: Partial = {}) => ({ id, status, progress: 40, phase: "video", error_code: null, error_detail: null, job_kind: "download", display_name: `Clip ${id}`, source_key: "site:tiktok.com", folder_id: 9, asset: { media_kind: "video" }, ...extra, }) as unknown as QueueJob; /** A poll: the queue the tray now sees. */ function poll(jobs: QueueJob[], view?: { rerender: () => void }) { h.jobs = jobs; act(() => noteQueue(jobs)); view?.rerender(); } function mount() { const qc = new QueryClient(); const tree = () => ( ); const { rerender } = render(tree()); return { rerender: () => act(() => rerender(tree())) }; } const tray = () => screen.queryByRole("region", { name: "tray.label" }); const rowOf = (title: string) => screen.getByText(title).closest("[data-job-id]")!; describe("DownloadTray", () => { it("is not there while nothing waits, runs or failed", () => { poll([job(1, "done")]); mount(); expect(tray()).toBeNull(); }); it("lists what waits, runs and failed, its sites, and sums them up", () => { poll([job(1, "running"), job(2, "queued"), job(3, "error", { error_code: "login_required" })]); mount(); expect(tray()).not.toBeNull(); expect(screen.getByText(/tray\.count\.running:2, tray\.count\.error:1/)).toBeTruthy(); expect(within(rowOf("Clip 1")).getByText("tiktok.com")).toBeTruthy(); expect(within(rowOf("Clip 1")).getByText("40%")).toBeTruthy(); expect(within(rowOf("Clip 2")).getByText("downloads.status.queued")).toBeTruthy(); }); it("a running job names its step: the second stream's bar does not read as a restart", () => { poll([job(1, "running", { phase: "audio" }), job(2, "running", { phase: "merging" })]); mount(); const [audio, merging] = screen.getAllByRole("progressbar"); expect(within(rowOf("Clip 1")).getByText("downloads.phase.audio")).toBeTruthy(); expect(audio!.getAttribute("aria-valuenow")).toBe("40"); // A step that reports no bytes has no % to claim. expect(within(rowOf("Clip 2")).getByText("downloads.phase.merging")).toBeTruthy(); expect(merging!.getAttribute("aria-valuenow")).toBeNull(); expect(within(rowOf("Clip 2")).queryByText("40%")).toBeNull(); }); it("a paused download is waited on slowly, and the tray neither spins nor says done", () => { poll([job(1, "paused")]); mount(); // Nothing moves until its user resumes it: no fast polling for that. expect(h.intervalMs([job(1, "paused")])).toBe(60000); expect(h.intervalMs([job(1, "paused"), job(2, "queued")])).toBe(2000); const marks = [...tray()!.querySelectorAll("svg")].map((i) => i.getAttribute("class") ?? ""); expect(marks.some((c) => /lucide-(circle-check|check-circle)/.test(c))).toBe(false); expect( marks.some((c) => /lucide-(circle-pause|pause-circle)/.test(c) && /text-warn/.test(c)), ).toBe(true); }); it("a failure says why, and its Retry resumes it", () => { const resume = vi.spyOn(api, "resumeDownload").mockResolvedValue({} as never); poll([job(3, "error", { error_code: "login_required" })]); mount(); expect( within(rowOf("Clip 3")).getByText("downloads.errors.fail.cat.login_required.short"), ).toBeTruthy(); fireEvent.click(within(rowOf("Clip 3")).getByText("downloads.actions.retry")); return waitFor(() => expect(resume).toHaveBeenCalledWith(3)); }); it("a row's right click opens the queue's menu", async () => { poll([job(1, "running")]); mount(); act(() => { fireEvent.contextMenu(rowOf("Clip 1")); }); await waitFor(() => expect(screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent)).toContain( "downloads.actions.pause", ), ); }); it("a finish is announced once — Copy link, Open — and stays as Done · Open", async () => { poll([job(1, "running")]); const view = mount(); poll([job(1, "done")], view); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); const notice = h.notify.mock.calls[0]![0] as { title: string; message: string; action: { label: string; onClick: () => void }; secondaryAction: { label: string; onClick: () => void }; }; expect(notice.title).toBe("tray.done.title:Clip 1"); expect(notice.message).toBe("tray.done.phone"); expect(notice.action.label).toBe("tray.done.copyLink"); notice.action.onClick(); expect(h.share).toHaveBeenCalled(); notice.secondaryAction.onClick(); expect(h.openFile).toHaveBeenCalledWith(1, 9); // Still on the tray, done; its Open opens it. fireEvent.click(within(rowOf("Clip 1")).getByText("tray.done.open")); expect(h.openFile).toHaveBeenCalledTimes(2); view.rerender(); expect(h.notify).toHaveBeenCalledTimes(1); }); it("each finish is announced once: a later one does not repeat an earlier", async () => { poll([job(1, "running"), job(2, "running")]); const view = mount(); poll([job(1, "done"), job(2, "running")], view); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); poll([job(1, "done"), job(2, "done")], view); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2)); expect(h.notify.mock.calls.map((c) => (c[0] as { title: string }).title)).toEqual([ "tray.done.title:Clip 1", "tray.done.title:Clip 2", ]); }); it("a plain file's notice does not promise a phone", async () => { poll([job(1, "running", { asset: { media_kind: "file" } as never })]); const view = mount(); poll([job(1, "done", { asset: { media_kind: "file" } as never })], view); await waitFor(() => expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ message: "tray.done.file" })), ); }); it("a finish refreshes Files at once, so the notice's Open finds the file listed", async () => { const invalidate = vi.spyOn(QueryClient.prototype, "invalidateQueries"); poll([job(1, "running")]); const view = mount(); expect(invalidate).not.toHaveBeenCalledWith({ queryKey: qk.files() }); poll([job(1, "done")], view); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.files() }); expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.fileFolders() }); }); it("an upload the worker converted first is announced as uploaded, not downloaded", async () => { poll([job(1, "running", { source_key: "upload" })]); const view = mount(); poll([job(1, "done", { source_key: "upload" })], view); await waitFor(() => expect(h.notify).toHaveBeenCalledWith( expect.objectContaining({ title: "tray.done.uploadedTitle:Clip 1" }), ), ); }); it("closed, it forgets what finished and stays away until the work changes", () => { poll([job(1, "running"), job(2, "running")]); const view = mount(); poll([job(1, "running"), job(2, "done")], view); fireEvent.click(screen.getByRole("button", { name: "tray.close" })); expect(tray()).toBeNull(); // The same work moving on is not news… poll([job(1, "running", { progress: 80 })], view); expect(tray()).toBeNull(); // …a new job is. poll([job(1, "running"), job(4, "queued")], view); expect(tray()).not.toBeNull(); expect(screen.queryByText("Clip 2")).toBeNull(); }); it("closed on an old failure, it stays closed after a reload", () => { poll([job(3, "error")]); const first = render( , ); fireEvent.click(screen.getByRole("button", { name: "tray.close" })); first.unmount(); resetQueueWatch(); poll([job(3, "error")]); mount(); expect(tray()).toBeNull(); }); it("closed on an old failure, a new download's finish still shows", () => { poll([job(3, "error")]); const view = mount(); fireEvent.click(screen.getByRole("button", { name: "tray.close" })); expect(tray()).toBeNull(); poll([job(4, "running"), job(3, "error")], view); expect(tray()).not.toBeNull(); // Done, the queue is the old failure alone again — the very queue it was closed on. poll([job(4, "done"), job(3, "error")], view); expect(tray()).not.toBeNull(); expect(within(rowOf("Clip 4")).getByText("tray.done.label")).toBeTruthy(); }); it("folds to a pill that sums it up, and opens again — the keyboard following", () => { poll([job(1, "running")]); mount(); fireEvent.click(screen.getByRole("button", { name: "tray.fold" })); expect(screen.queryByText("Clip 1")).toBeNull(); const pill = screen.getByRole("button", { name: /tray\.count\.running:1/ }); expect(document.activeElement).toBe(pill); fireEvent.click(pill); expect(screen.getByText("Clip 1")).toBeTruthy(); expect(document.activeElement).toBe(screen.getByRole("button", { name: "tray.fold" })); }); it("closed, it leaves the keyboard on the page", () => { poll([job(1, "running")]); render(
, ); fireEvent.click(screen.getByRole("button", { name: "tray.close" })); expect(document.activeElement?.id).toBe("content"); }); describe("View on a job just started", () => { it("brings a closed tray back, on the job's row", async () => { poll([job(3, "error")]); const view = mount(); fireEvent.click(screen.getByRole("button", { name: "tray.close" })); expect(tray()).toBeNull(); act(() => showInTray(3)); view.rerender(); expect(tray()).not.toBeNull(); await waitFor(() => expect(document.activeElement).toBe(rowOf("Clip 3"))); }); it("unfolds a folded tray onto the row", async () => { poll([job(1, "running"), job(2, "queued")]); mount(); fireEvent.click(screen.getByRole("button", { name: "tray.fold" })); act(() => showInTray(2)); await waitFor(() => expect(document.activeElement).toBe(rowOf("Clip 2"))); }); it("asked from inside a player, it opens under it but leaves the keyboard there", async () => { poll([job(1, "running"), job(2, "queued")]); mount(); fireEvent.click(screen.getByRole("button", { name: "tray.fold" })); const player = document.createElement("button"); document.body.append(player); player.focus(); const layer = pushLayer(); act(() => showInTray(2)); await waitFor(() => expect(screen.getByText("Clip 2")).toBeTruthy()); expect(document.activeElement).toBe(player); removeLayer(layer); player.remove(); }); it("a job done before the tray saw it run opens as a file", () => { // The queue's first look only learns what is there: this one never ran in this tab. poll([job(5, "done", { folder_id: 7 })]); mount(); act(() => showInTray(5)); expect(h.openFile).toHaveBeenCalledWith(5, 7); expect(tray()).toBeNull(); }); it("a job a later poll does not have is let go — nothing opens, nothing waits for it", () => { poll([job(1, "running")]); const view = mount(); fireEvent.click(screen.getByRole("button", { name: "tray.fold" })); act(() => showInTray(9)); h.updatedAt = Date.now() + 1000; poll([job(1, "running")], view); // Had the ask stayed, the job turning up later would pull the tray open on it. poll([job(1, "running"), job(9, "running")], view); expect(screen.queryByText("Clip 9")).toBeNull(); expect(h.openFile).not.toHaveBeenCalled(); }); }); });