An upload that came back done was announced twice: by the upload dialog ("File uploaded") and
again by the tray, which saw a job turn done ("Downloaded: …"). The dialog now marks its job as
announced, so the queue keeps it for the tray but does not say it again. An upload the worker
converted first is still the tray's to announce, now as "Uploaded: …".
A finished job's notice offered "Open" while Files still listed the folder without it (its own
poll runs every 15 s): the tray now refreshes the library the moment it sees a finish.
388 lines
15 KiB
TypeScript
388 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("./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, 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: 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(
|
|
<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();
|
|
});
|
|
});
|
|
});
|