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.
396 lines
15 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|