feat(files): download from anywhere — a link box, a pasted link, the palette; the running downloads in a tray
- Files toolbar link box (paste button, Enter); a lone http(s) link pasted onto any page opens the one app-wide download dialog, which now names the link; the palette's "Download from a link…" focuses the box on its history entry. - The download tray, bottom right on every page: what waits, runs, is paused or failed (reason + Retry, the queue's right-click / "…" menu), and what finished in this tab as "Done · Open" until closed; folds to a pill (per-account pref); closed, it stays shut — across a reload — until the work changes or something finishes. Back to top steps up over it, the page end keeps clear. - A finish is announced once: "Downloaded: …", whether a phone plays it, Copy link + Open (toasts and notices may carry a second action). - The inbox's ready notice opens the file in its folder: download_done carries job_id + folder_id. - GET /api/downloads is the queue only (in flight, failed, finished in the last 10 minutes), with each row's source key.
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
// @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[],
|
||||
openFile: vi.fn(),
|
||||
share: vi.fn(async () => "https://x/watch/t"),
|
||||
notify: vi.fn(),
|
||||
prefs: new Map<string, unknown>(),
|
||||
}));
|
||||
|
||||
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: () => ({ data: h.jobs, 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("./filesContext", () => ({ useFilesActions: () => ({ openFile: h.openFile }) }));
|
||||
vi.mock("./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 { noteQueue, resetQueueWatch } from "../lib/downloadQueue";
|
||||
|
||||
beforeEach(() => {
|
||||
resetQueueWatch();
|
||||
h.prefs.clear();
|
||||
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 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("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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user