Files
siftlode/frontend/src/features/downloads/DownloadTray.dom.test.tsx
T
peter b515c16117 fix(notifications): a notice's button is said when shown, like its text
An action label was translated when the notice was raised, so after a language switch a live
toast or center row read in one language with its button in the other. The access-requests nudge
no longer re-issues on a switch for it. New tests: the button and a server refusal follow a
switch.
2026-10-30 00:10:38 +01:00

396 lines
15 KiB
TypeScript

// @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<string, unknown>(),
intervalMs: ((_d: unknown) => 0) as (d: unknown) => number,
}));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
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: <T,>(key: string, initial: T) => {
const [value, setValue] = useState<T>(() =>
h.prefs.has(key) ? (h.prefs.get(key) as T) : initial,
);
return [
value,
(next: T) => {
h.prefs.set(key, next);
setValue(next);
},
];
},
}));
vi.mock("../../modules/files/filesContext", () => ({
useFilesActions: () => ({ openFile: h.openFile }),
}));
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("../../lib/downloads", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/downloads")>()),
quickShareLink: h.share,
}));
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
notify: h.notify,
}));
import DownloadTray from "./DownloadTray";
import { msg, type NotifText, raw } from "../../lib/notifications";
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<QueueJob> = {}) =>
({
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 = () => (
<QueryClientProvider client={qc}>
<DownloadTray />
</QueryClientProvider>
);
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<HTMLElement>("[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: NotifText;
message: NotifText;
action: { label: NotifText; onClick: () => void };
secondaryAction: { label: NotifText; onClick: () => void };
};
expect(notice.title).toEqual(msg("tray.done.title", { title: raw("Clip 1") }));
expect(notice.message).toEqual(msg("tray.done.phone"));
expect(notice.action.label).toEqual(msg("tray.done.copyLink"));
expect(notice.secondaryAction.label).toEqual(msg("tray.done.open"));
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: NotifText }).title)).toEqual([
msg("tray.done.title", { title: raw("Clip 1") }),
msg("tray.done.title", { title: raw("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: msg("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: msg("tray.done.uploadedTitle", { title: raw("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(
<QueryClientProvider client={new QueryClient()}>
<DownloadTray />
</QueryClientProvider>,
);
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(
<QueryClientProvider client={new QueryClient()}>
<main id="content" tabIndex={-1} />
<DownloadTray />
</QueryClientProvider>,
);
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();
});
});
});