// @vitest-environment jsdom // // The Files page: a row has Copy link, Share and Save (on a wide list, shown while it is hovered or // focused) and its "…" — a slot an action does not apply to stays empty, so the icons line up down // the list — and its every action is its ONE menu, // opened by the "…" or a right-click. Renaming happens in the row; moving goes to an own folder or // back home; a shared file is the owner's, so its menu only plays, saves, cuts or drops it from my // list. i18n is key-passthrough, so labels read as keys. import { useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, createEvent, fireEvent, render, screen, waitFor, } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { api, type FileFolder, type LibraryFile } from "../lib/api"; import { formatDateTime } from "../lib/format"; import { ALL_FILES, FilesActionsContext, FilesStateContext, type FilesFocus, type FilesView, } from "./filesContext"; const mocks = vi.hoisted(() => ({ files: [] as unknown[], folders: [] as unknown[], confirm: vi.fn(async (_o?: unknown) => true), keys: [] as unknown[], fetching: false, titleArgs: [] as unknown[], searched: [] as unknown[], // The server's search/sort answer is still on its way (no data yet). wideLoading: false, // The server's search/sort answer failed. wideFailed: false, foldersFailed: false, notify: vi.fn(), })); vi.mock("../lib/notifications", async (importOriginal) => ({ ...(await importOriginal()), notify: mocks.notify, })); vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (key: string, o?: Record) => { if (o) mocks.titleArgs.push([key, o]); return o && "folder" in o ? `${key}:${String(o.folder)}` : key; }, }), }; }); vi.mock("./LibraryPlayer", () => ({ default: ({ job, fromStart }: { job: { id: number }; fromStart?: boolean }) => (
{`playing ${job.id}${fromStart ? " from the start" : ""}`}
), })); vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm })); vi.mock("../lib/useLiveQuery", () => ({ useLiveQuery: (key: readonly unknown[], _fn: unknown, opts?: { enabled?: boolean }) => { if (opts?.enabled === false) return { data: undefined, isError: false, isLoading: false }; mocks.keys.push(key); const asked = key[1] as { q?: string; sort?: string } | undefined; if (key[0] === "files" && (asked?.q || asked?.sort) && mocks.wideFailed) return { data: undefined, isError: true, isLoading: false, isFetching: false, refetch: vi.fn(), }; if (key[0] === "files" && (asked?.q || asked?.sort) && mocks.wideLoading) return { data: undefined, isError: false, isLoading: true, isFetching: true, refetch: vi.fn(), }; return { data: key[0] === "files" ? asked?.q || asked?.sort ? mocks.searched : mocks.files : key[0] === "file-folders" ? mocks.foldersFailed ? undefined : mocks.folders : undefined, isError: key[0] === "file-folders" && mocks.foldersFailed, isLoading: false, isFetching: mocks.fetching, refetch: vi.fn(), }; }, })); vi.mock("./ui/PageShell", async (importOriginal) => ({ ...(await importOriginal()), PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}, })); import Files from "./Files"; import { FILES_LIST_CAP, type QueueJob } from "../lib/api"; import { noteQueue, resetQueueWatch, useFinished } from "../lib/downloadQueue"; const file = (id: number, extra: Partial = {}): LibraryFile => ({ id, status: "done", progress: 100, created_at: "2026-10-10T10:00:00+00:00", source_kind: "youtube", source_ref: `v${id}`, source_url: null, poster_url: null, display_name: `Clip ${id}`, display_uploader: null, extra_links: [], can_download: true, job_kind: "download", folder_id: 1, kept: false, expires_at: null, shared: false, source_key: "youtube", asset: { media_kind: "video", duration_s: 60, size_bytes: 2048 }, ...extra, }) as unknown as LibraryFile; const folder = (id: number, extra: Partial): FileFolder => ({ id, kind: "source", source_key: null, name: null, retention_days: 30, count: 1, size_bytes: 0, created_at: null, ...extra, }); const YT = folder(1, { source_key: "youtube" }); const OWN = folder(2, { kind: "own", name: "Cimboráknak", retention_days: null }); afterEach(() => { cleanup(); vi.restoreAllMocks(); mocks.confirm.mockClear(); mocks.keys = []; mocks.fetching = false; mocks.titleArgs = []; mocks.wideLoading = false; mocks.wideFailed = false; mocks.notify.mockClear(); resetQueueWatch(); }); function mount( view: FilesView = ALL_FILES, setView = vi.fn(), search = "", { focus = null, consumeFocus = vi.fn(), }: { focus?: FilesFocus | null; consumeFocus?: () => void } = {}, ) { render( , ); return setView; } const rowOf = (text: string) => screen.getByText(text).closest("[data-nav-index]")!; const moreOf = (text: string) => rowOf(text).querySelector('button[aria-label="files.rowMenu"]')!; // A folder in the Move submenu is a choice of one (menuitemradio), the rest plain rows. const menuEls = () => [ ...screen.getAllByRole("menuitem", { hidden: true }), ...screen.queryAllByRole("menuitemradio", { hidden: true }), ]; const menuRows = () => menuEls().map((b) => b.textContent ?? ""); const menuItem = (label: string) => menuEls().find((b) => b.textContent === label)!; async function openMenu(open: () => void) { act(open); await waitFor(() => expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0), ); } const rowButtons = (text: string) => [...rowOf(text).querySelectorAll("button[aria-label], a[aria-label]")].map((b) => b.getAttribute("aria-label"), ); describe("Files · my file's row", () => { it("shows Copy link, Share, Save and the '…'; the menu holds every action", async () => { mocks.files = [file(2, { source_url: "https://youtu.be/x" })]; mocks.folders = [YT, OWN]; mount(); expect(rowButtons("Clip 2")).toEqual([ "files.actions.copyLink", "files.actions.share", "downloads.actions.download", "files.rowMenu", ]); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); expect(menuRows()).toEqual([ "downloads.actions.play", "files.actions.copyLink", "files.actions.share", "downloads.actions.download", "downloads.actions.edit", "files.actions.rename", "files.actions.move", "files.actions.keep", "files.actions.details", "files.actions.redownload", "common.copy.title", "files.actions.copySourceLink", "files.actions.delete", ]); }); it("holds back the quick icons on a wide row until it is hovered or focused; the '…' stays", () => { mocks.files = [file(2)]; mocks.folders = [YT]; mount(); const row = rowOf("Clip 2"); const quick = row.querySelector("[data-quick-actions]")!; expect(row.className).toMatch(/(^|\s)group(\s|$)/); expect(quick.className).toContain("md:opacity-0"); expect(quick.className).toContain("md:group-hover:opacity-100"); expect(quick.className).toContain("md:group-focus-within:opacity-100"); expect(quick.contains(row.querySelector('[aria-label="files.actions.share"]'))).toBe(true); expect(quick.contains(moreOf("Clip 2"))).toBe(false); }); it("keeps the slot of an action a plain file lacks, so the icons line up", () => { mocks.files = [file(3, { job_kind: "upload", asset: { media_kind: "file" } } as never)]; mocks.folders = [YT]; mount(); expect(rowButtons("Clip 3")).toEqual([ "files.actions.share", "downloads.actions.download", "files.rowMenu", ]); // The empty slot holds the Copy link's place, the same width as an icon. const actions = rowOf("Clip 3").querySelector( 'button[aria-label="files.actions.share"]', )!.parentElement!; expect(actions.firstElementChild!.getAttribute("aria-hidden")).toBe("true"); }); it("copies the link from the row's own icon", async () => { mocks.files = [file(2)]; mocks.folders = [YT]; vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never); const create = vi .spyOn(api, "createDownloadLink") .mockResolvedValue({ id: 1, url: "/s/tok" } as never); mount(); fireEvent.click( rowOf("Clip 2").querySelector('button[aria-label="files.actions.copyLink"]')!, ); await waitFor(() => expect(create).toHaveBeenCalledWith(2, {})); }); it("saves through the file's own address", async () => { mocks.files = [file(2)]; mocks.folders = [YT]; const followed: string[] = []; vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function ( this: HTMLAnchorElement, ) { followed.push(new URL(this.href).pathname); }); mount(); await openMenu(() => fireEvent.click(moreOf("Clip 2"))); fireEvent.click(menuItem("downloads.actions.download")); expect(followed).toEqual(["/api/files/2/file"]); const icon = rowOf("Clip 2").querySelector( 'a[aria-label="downloads.actions.download"]', )!; expect(new URL(icon.href).pathname).toBe("/api/files/2/file"); }); it("renames in the row: Enter saves, an emptied name gives the title back", async () => { mocks.files = [file(2)]; mocks.folders = [YT]; const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never); mount(); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); fireEvent.click(menuItem("files.actions.rename")); const field = await screen.findByRole("textbox", { name: "files.actions.rename" }); fireEvent.change(field, { target: { value: " Új név " } }); fireEvent.keyDown(field, { key: "Enter" }); await waitFor(() => expect(update).toHaveBeenCalledWith(2, { name: "Új név" })); expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull(); // The keyboard is handed back to the row, not dropped on . await waitFor(() => expect(rowOf("Clip 2").contains(document.activeElement)).toBe(true)); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); fireEvent.click(menuItem("files.actions.rename")); const again = await screen.findByRole("textbox", { name: "files.actions.rename" }); fireEvent.change(again, { target: { value: "" } }); fireEvent.keyDown(again, { key: "Enter" }); await waitFor(() => expect(update).toHaveBeenLastCalledWith(2, { name: null })); }); it("a rename left as it was sends nothing — a name never set stays unset", async () => { // The field starts on the title the file shows; saving that would pin it as a name of its own. mocks.files = [file(2, { display_name: null, asset: { title: "Its own title" } } as never)]; mocks.folders = [YT]; const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never); mount(); await openMenu(() => fireEvent.contextMenu(rowOf("Its own title"))); fireEvent.click(menuItem("files.actions.rename")); const field = await screen.findByRole("textbox", { name: "files.actions.rename" }); expect((field as HTMLInputElement).value).toBe("Its own title"); fireEvent.keyDown(field, { key: "Enter" }); await waitFor(() => expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull(), ); expect(update).not.toHaveBeenCalled(); }); it("Escape leaves the name as it was", async () => { mocks.files = [file(2)]; mocks.folders = [YT]; const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never); mount(); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); fireEvent.click(menuItem("files.actions.rename")); const field = await screen.findByRole("textbox", { name: "files.actions.rename" }); fireEvent.change(field, { target: { value: "Más" } }); fireEvent.keyDown(field, { key: "Escape" }); expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull(); expect(update).not.toHaveBeenCalled(); await waitFor(() => expect(rowOf("Clip 2").contains(document.activeElement)).toBe(true)); }); it("moves to an own folder, and from there back to its source folder only", async () => { mocks.files = [file(2, { folder_id: 2 })]; // Another source folder first, so "its own" is chosen by its key, not by its place in the list. mocks.folders = [folder(3, { source_key: "site:tiktok.com" }), YT, OWN]; const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never); mount(); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); fireEvent.keyDown(menuItem("files.actions.move"), { key: "ArrowRight" }); await waitFor(() => expect(menuRows()).toContain("Cimboráknak")); // The folder it is in, ticked; home is its own source folder, never another site's. expect(menuRows()).toContain("files.actions.moveHome:files.source.youtube"); expect(menuRows().some((r) => r.includes("tiktok"))).toBe(false); expect(menuItem("Cimboráknak").getAttribute("aria-checked")).toBe("true"); fireEvent.click(menuItem("files.actions.moveHome:files.source.youtube")); await waitFor(() => expect(update).toHaveBeenCalledWith(2, { folder_id: 1 })); }); it("offers no move home while it is home, and says so when there is nowhere to go", async () => { mocks.files = [file(2, { folder_id: 1 })]; mocks.folders = [YT]; mount(); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); fireEvent.keyDown(menuItem("files.actions.move"), { key: "ArrowRight" }); await waitFor(() => expect(menuRows()).toContain("files.actions.noOwnFolders")); expect(menuItem("files.actions.noOwnFolders").hasAttribute("disabled")).toBe(true); }); it("keeps a file, and a kept one is handed back to its folder", async () => { mocks.files = [file(2), file(3, { kept: true })]; mocks.folders = [YT]; const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never); mount(); expect(rowOf("Clip 3").textContent).toContain("files.retention.kept"); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); fireEvent.click(menuItem("files.actions.keep")); await waitFor(() => expect(update).toHaveBeenCalledWith(2, { kept: true })); fireEvent.keyDown(document.activeElement!, { key: "Escape" }); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 3"))); fireEvent.click(menuItem("files.actions.unkeep")); await waitFor(() => expect(update).toHaveBeenLastCalledWith(3, { kept: false })); }); it("deletes only through the confirm", async () => { mocks.files = [file(2)]; mocks.folders = [YT]; const del = vi.spyOn(api, "deleteFile").mockResolvedValue({} as never); mocks.confirm.mockResolvedValueOnce(false); mount(); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); fireEvent.click(menuItem("files.actions.delete")); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); expect(del).not.toHaveBeenCalled(); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); fireEvent.click(menuItem("files.actions.delete")); await waitFor(() => expect(del).toHaveBeenCalledWith(2)); }); it("a deleted file leaves the tray's finished list: its Open would reach nothing", async () => { mocks.files = [file(2)]; mocks.folders = [YT]; vi.spyOn(api, "deleteFile").mockResolvedValue({} as never); const job = (status: string) => ({ id: 2, status }) as unknown as QueueJob; noteQueue([job("running")]); noteQueue([job("done")]); let finished: number[] = []; function Tray() { finished = useFinished().map((j) => j.id); return null; } render(); expect(finished).toEqual([2]); mount(); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); fireEvent.click(menuItem("files.actions.delete")); await waitFor(() => expect(finished).toEqual([])); }); }); describe("Files · a library past the list's cap", () => { it("a search under the cap runs on the rows; one past it asks the server, so an old file is found", () => { mocks.folders = [YT]; mocks.files = [file(1, { display_name: "Recept" }), file(2)]; mocks.keys = []; mount(ALL_FILES, vi.fn(), "recept"); expect(rowOf("Recept")).toBeTruthy(); expect( mocks.keys.some( (k) => (k as unknown[])[0] === "files" && "q" in ((k as unknown[])[1] as object), ), ).toBe(false); cleanup(); // The newest FILES_LIST_CAP files came back: the one searched for may be older than all of them. mocks.files = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1)); mocks.searched = [file(9999, { display_name: "Régi recept" })]; mocks.keys = []; mount(ALL_FILES, vi.fn(), "recept"); expect(rowOf("Régi recept")).toBeTruthy(); expect(mocks.keys).toContainEqual(["files", { q: "recept" }]); }); const capped = () => Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1)); const nameHeader = () => screen.getByRole("button", { name: /files\.columns\.name/ }); it("a sort asked of the server keeps the table on screen until its answer is in", () => { mocks.folders = [YT]; mocks.files = capped(); mocks.wideLoading = true; mount(); const header = nameHeader(); fireEvent.click(header); // Not a skeleton in its place: that would take the header just pressed, and the keyboard with it. expect(header.isConnected).toBe(true); expect(rowOf("Clip 1")).toBeTruthy(); expect(screen.getByText("files.countNewest")).toBeTruthy(); }); it("says a list the server's sort cut holds only its first files; a short search is whole", () => { mocks.folders = [YT]; mocks.files = capped(); mocks.searched = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1000)); mount(); expect(screen.getByText("files.countNewest")).toBeTruthy(); fireEvent.click(nameHeader()); expect(screen.getByText("files.countFirst")).toBeTruthy(); cleanup(); // A search with no sort of the server's: it answers the newest matches first. mount(ALL_FILES, vi.fn(), "clip"); expect(screen.getByText("files.countNewest")).toBeTruthy(); cleanup(); mocks.searched = [file(9999, { display_name: "Régi recept" })]; mount(ALL_FILES, vi.fn(), "recept"); expect(screen.getByText("files.count")).toBeTruthy(); }); it("a search past the cap waits for the server's answer — never a 'no match' it has not given", () => { mocks.folders = [YT]; mocks.files = capped(); mocks.wideLoading = true; mount(ALL_FILES, vi.fn(), "recept"); expect(screen.getByRole("status")).toBeTruthy(); expect(screen.queryByText("files.empty.search")).toBeNull(); }); it("a server search that failed says so, not the newest files searched here", () => { mocks.folders = [YT]; mocks.files = capped(); mocks.wideFailed = true; mount(ALL_FILES, vi.fn(), "clip"); expect(screen.getByText("common.loadError")).toBeTruthy(); expect(screen.queryByText("Clip 1")).toBeNull(); }); it("a file opened while the server's sort was on its way stays open when the answer leaves it out", async () => { mocks.folders = [YT]; const doc = file(7, { job_kind: "upload", display_name: "Doc 7", asset: { media_kind: "file", preview: "pdf", container: "pdf", size_bytes: 10 }, } as never); mocks.files = [doc, ...capped().slice(1)]; mocks.wideLoading = true; mount(); fireEvent.click(nameHeader()); fireEvent.click(screen.getByText("Doc 7")); expect(await screen.findByRole("dialog")).toBeTruthy(); // The answer: the first files by name, without it. mocks.wideLoading = false; mocks.searched = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1000)); fireEvent.click(nameHeader()); expect(screen.getByText("Clip 1499")).toBeTruthy(); // by name, from the end expect(screen.getByRole("dialog")).toBeTruthy(); }); it("an arrival waits for the server's answer: the table can only turn to a file it holds", async () => { mocks.folders = [YT]; mocks.files = capped(); mocks.wideLoading = true; const consumeFocus = vi.fn(); mount(ALL_FILES, vi.fn(), "clip", { focus: { id: 3, token: 1 }, consumeFocus }); await new Promise((r) => setTimeout(r, 20)); expect(consumeFocus).not.toHaveBeenCalled(); }); it("a file past the end of a cut list is not said to be gone — it may only be older", async () => { mocks.folders = [YT]; mocks.files = capped(); mount(ALL_FILES, vi.fn(), "", { focus: { id: 9999, token: 1 } }); await waitFor(() => expect(mocks.notify).toHaveBeenCalledWith({ level: "info", message: "files.arrival.notListed", }), ); expect(mocks.notify).not.toHaveBeenCalledWith({ level: "info", message: "files.arrival.gone" }); }); it("an arrival's cleared search is asked of the server at once, not after the typing pause", () => { mocks.folders = [YT]; mocks.files = capped(); mocks.searched = []; const view = (search: string, focus: FilesFocus | null) => ( ); const r = render(view("recept", null)); expect(mocks.keys).toContainEqual(["files", { q: "recept" }]); mocks.keys = []; // openFile: the search cleared and an arrival pending, in one step. r.rerender(view("", { id: 3, token: 1 })); expect(mocks.keys).not.toContainEqual(["files", { q: "recept" }]); }); }); describe("Files · the confirms", () => { it("label the confirm button with the action, not the dialog's question", async () => { mocks.files = [file(2)]; mocks.folders = [YT]; mocks.confirm.mockResolvedValue(false); mount(); await openMenu(() => fireEvent.click(moreOf("Clip 2"))); fireEvent.click(menuItem("files.actions.redownload")); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); await openMenu(() => fireEvent.click(moreOf("Clip 2"))); fireEvent.click(menuItem("files.actions.delete")); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2)); expect( mocks.confirm.mock.calls.map(([o]) => { const opts = o as { title: string; confirmLabel?: string }; return [opts.title, opts.confirmLabel]; }), ).toEqual([ ["downloads.confirm.redownloadTitle", "downloads.confirm.redownloadAction"], ["downloads.confirm.deleteTitle", "downloads.actions.delete"], ]); expect(mocks.titleArgs).toContainEqual([ "downloads.confirm.redownloadTitle", { name: "Clip 2" }, ]); mocks.confirm.mockResolvedValue(true); }); it.each([ ["upload", "downloads.confirm.deleteUploadBody"], ["edit", "downloads.confirm.deleteEditBody"], ["download", "downloads.confirm.deleteBody"], ] as const)("deleting a %s names it and says what happens to its file", async (kind, body) => { // Only a download's file may stay for other users; an upload's or a clip's is theirs alone. mocks.files = [file(5, { job_kind: kind })]; mocks.folders = [YT]; mocks.confirm.mockResolvedValueOnce(false); mount(); await openMenu(() => fireEvent.click(moreOf("Clip 5"))); fireEvent.click(menuItem("files.actions.delete")); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); const opts = mocks.confirm.mock.calls[0]![0] as { title: string; message?: string }; expect([opts.title, opts.message]).toEqual(["downloads.confirm.deleteTitle", body]); expect(mocks.titleArgs).toContainEqual(["downloads.confirm.deleteTitle", { name: "Clip 5" }]); }); }); describe("Files · a shared file's row", () => { it("plays, saves, cuts, copies and drops it from my list — nothing of the owner's", async () => { mocks.files = [file(4, { shared: true, shared_at: "2026-10-11T08:00:00+00:00" })]; mocks.folders = [YT]; const remove = vi.spyOn(api, "removeSharedFile").mockResolvedValue({} as never); mount({ kind: "shared" }); expect(rowButtons("Clip 4")).toEqual(["downloads.actions.download", "files.rowMenu"]); await openMenu(() => fireEvent.click(moreOf("Clip 4"))); expect(menuRows()).toEqual([ "downloads.actions.play", "downloads.actions.download", "downloads.actions.edit", "common.copy.title", "files.actions.removeShared", ]); fireEvent.click(menuItem("files.actions.removeShared")); await waitFor(() => expect(remove).toHaveBeenCalledWith(4)); expect(mocks.titleArgs).toContainEqual([ "downloads.confirm.removeSharedTitle", { name: "Clip 4" }, ]); // The shared list is its own read, and nothing can be uploaded into it. expect(mocks.keys).toContainEqual(["files", { shared: true }]); expect(screen.queryByText("files.actions.upload")).toBeNull(); }); }); describe("Files · the viewer", () => { const doc = (id: number, preview: string | null = "pdf") => file(id, { job_kind: "upload", display_name: `Doc ${id}`, asset: { media_kind: "file", preview, container: "pdf", size_bytes: 10 }, } as never); const viewer = () => screen.findByRole("dialog"); it("opens a file that is not media from its name, in the sandboxed preview", async () => { mocks.files = [file(1), doc(2)]; mocks.folders = [YT]; mount(); fireEvent.click(screen.getByText("Doc 2")); const dialog = await viewer(); expect(dialog.querySelector("iframe")!.getAttribute("src")).toBe(api.previewUrl(2)); }); it("puts Preview first in such a file's menu, and it opens the viewer", async () => { mocks.files = [doc(2, null)]; mocks.folders = [YT]; mount(); await openMenu(() => fireEvent.contextMenu(rowOf("Doc 2"))); expect(menuRows()[0]).toBe("files.actions.view"); fireEvent.click(menuItem("files.actions.view")); expect(await viewer()).toBeTruthy(); expect(screen.getByText("files.preview.none")).toBeTruthy(); }); it("steps through the list's files that are not media, in the list's order", async () => { mocks.files = [doc(2), file(1), doc(3)]; mocks.folders = [YT]; mount(); fireEvent.click(screen.getByText("Doc 2")); const dialog = await viewer(); fireEvent.keyDown(window, { key: "ArrowRight" }); await waitFor(() => expect(dialog.querySelector("iframe")!.getAttribute("src")).toBe(api.previewUrl(3)), ); }); it("leaves Preview and Rename out of the viewer's own menu", async () => { mocks.files = [doc(2)]; mocks.folders = [YT, OWN]; mount(); fireEvent.click(screen.getByText("Doc 2")); const dialog = await viewer(); await openMenu(() => fireEvent.click(dialog.querySelector('button[aria-label="files.rowMenu"]')!), ); expect(menuRows()).not.toContain("files.actions.view"); expect(menuRows()).not.toContain("files.actions.rename"); expect(menuRows()).toContain("files.actions.move"); }); }); describe("Files · the view", () => { it("starts the list at the scroller's top, so its sticky header does not jump on the first scroll", () => { mocks.files = [file(2)]; mocks.folders = [YT]; mount(); const list = screen.getByRole("table").closest(".max-w-7xl")!; expect(list.className).not.toMatch(/(^|\s)(pt|py|p)-/); }); it("reads one folder's files and names the folder", () => { mocks.files = [file(2, { folder_id: 2 })]; mocks.folders = [YT, OWN]; mount({ kind: "folder", id: 2 }); expect(mocks.keys).toContainEqual(["files", { folderId: 2 }]); expect(screen.getByRole("heading", { name: "Cimboráknak" })).toBeTruthy(); }); it("opens the whole library when the folder it was given is gone", async () => { mocks.files = []; mocks.folders = [YT]; const setView = mount({ kind: "folder", id: 99 }); expect(mocks.keys).toContainEqual(["files", {}]); expect(mocks.keys).not.toContainEqual(["files", { folderId: 99 }]); await waitFor(() => expect(setView).toHaveBeenCalledWith(ALL_FILES)); }); it("searches the rows it holds by name and channel", () => { mocks.files = [ file(2, { display_name: "Szméagol szompolyog" }), file(3, { display_name: "Más", display_uploader: "Szméagol rajongó" }), file(4, { display_name: "Harmadik" }), ]; mocks.folders = [YT]; mount(ALL_FILES, vi.fn(), "szméagol"); expect(screen.queryByText("Szméagol szompolyog")).not.toBeNull(); expect(screen.queryByText("Más")).not.toBeNull(); expect(screen.queryByText("Harmadik")).toBeNull(); }); it("dates and sorts a file by when it was done, not when it was asked for", () => { // Done in another order than asked: a download that failed for days and then went through is new. mocks.files = [ file(2, { created_at: "2026-10-01T10:00:00Z", ready_at: "2026-10-05T10:00:00Z" }), file(3, { created_at: "2026-10-02T10:00:00Z", ready_at: "2026-10-12T10:00:00Z" }), file(4, { created_at: "2026-10-03T10:00:00Z", ready_at: "2026-10-03T10:00:00Z" }), ]; mocks.folders = [YT]; mount(); const order = () => [...document.querySelectorAll("[data-nav-index]")].map( (r) => r.textContent?.match(/Clip \d/)?.[0], ); const byDone = [ ["Clip 4", "Clip 2", "Clip 3"], ["Clip 3", "Clip 2", "Clip 4"], ]; fireEvent.click(screen.getByRole("button", { name: /files\.columns\.added/ })); expect(byDone).toContainEqual(order()); fireEvent.click(screen.getByRole("button", { name: /files\.columns\.added/ })); expect(byDone).toContainEqual(order()); expect( rowOf("Clip 3").querySelector(`[title="${formatDateTime("2026-10-12T10:00:00Z", "en")}"]`), ).not.toBeNull(); }); }); describe("Files · the grid", () => { // The layout is a pref, saved to the server; nothing here is about that. const quiet = () => vi.spyOn(api, "savePrefs").mockResolvedValue({} as never); async function pick(layout: "grid" | "list") { fireEvent.click(screen.getByRole("button", { name: /files\.layout\.label/ })); const options = await screen.findAllByRole("menuitemradio", { hidden: true }); fireEvent.click(options.find((o) => o.textContent === `files.layout.${layout}`)!); } it("paints the same files as cards, each with the same menu as its row", async () => { quiet(); mocks.files = [file(2, { kept: true }), file(3)]; mocks.folders = [YT, OWN]; mount(); expect(rowOf("Clip 2").tagName).toBe("TR"); await pick("grid"); await waitFor(() => expect(rowOf("Clip 2").tagName).toBe("DIV")); // Kept says so on its card. expect(rowOf("Clip 2").textContent).toContain("files.retention.kept"); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); expect(menuRows()).toContain("files.actions.move"); expect(menuRows()).toContain("files.actions.delete"); }); it("is remembered per folder", async () => { quiet(); mocks.files = [file(2, { folder_id: 2 })]; mocks.folders = [YT, OWN]; // One mount whose view the test turns, as the rail would. let turn: (v: FilesView) => void = () => {}; function Harness() { const [view, setView] = useState(ALL_FILES); turn = setView; return ( ); } render( , ); await pick("grid"); await waitFor(() => expect(rowOf("Clip 2").tagName).toBe("DIV")); act(() => turn({ kind: "folder", id: 2 })); expect(rowOf("Clip 2").tagName).toBe("TR"); act(() => turn(ALL_FILES)); expect(rowOf("Clip 2").tagName).toBe("DIV"); }); }); describe("Files · the open folder's menu", () => { const folderMenu = () => screen.getByRole("button", { name: "files.folder.menu" }); it("an own folder is renamed, kept for a while, or deleted", async () => { mocks.files = []; mocks.folders = [YT, OWN]; mount({ kind: "folder", id: 2 }); await openMenu(() => fireEvent.click(folderMenu())); expect(menuRows()).toEqual([ "files.folder.rename", "files.folder.retention", "files.folder.delete", ]); }); it("a source folder only keeps its files for a while", async () => { mocks.files = []; mocks.folders = [YT, OWN]; mount({ kind: "folder", id: 1 }); await openMenu(() => fireEvent.click(folderMenu())); expect(menuRows()).toEqual(["files.folder.retention"]); }); it("sets the retention, its current value ticked — an odd one in its place", async () => { mocks.files = []; mocks.folders = [folder(1, { source_key: "youtube", retention_days: 14 })]; const update = vi.spyOn(api, "updateFolder").mockResolvedValue({} as never); mount({ kind: "folder", id: 1 }); await openMenu(() => fireEvent.click(folderMenu())); fireEvent.keyDown(menuItem("files.folder.retention"), { key: "ArrowRight" }); await waitFor(() => expect(menuRows()).toContain("files.folder.forever")); const choices = screen.queryAllByRole("menuitemradio", { hidden: true }); // 7, 14 (its own, ticked), 30, 90, forever. expect(choices.map((c) => c.getAttribute("aria-checked"))).toEqual([ "false", "true", "false", "false", "false", ]); fireEvent.click(menuItem("files.folder.forever")); await waitFor(() => expect(update).toHaveBeenCalledWith(1, { retention_days: null })); }); it("renames in the title: Enter saves, an unchanged name sends nothing", async () => { mocks.files = []; mocks.folders = [YT, OWN]; const update = vi.spyOn(api, "updateFolder").mockResolvedValue({} as never); mount({ kind: "folder", id: 2 }); await openMenu(() => fireEvent.click(folderMenu())); fireEvent.click(menuItem("files.folder.rename")); const field = await screen.findByRole("textbox", { name: "files.folder.rename" }); fireEvent.keyDown(field, { key: "Enter" }); expect(update).not.toHaveBeenCalled(); // The keyboard goes back to the "…" the rename was opened from. await waitFor(() => expect(document.activeElement).toBe(folderMenu())); await openMenu(() => fireEvent.click(folderMenu())); fireEvent.click(menuItem("files.folder.rename")); const again = await screen.findByRole("textbox", { name: "files.folder.rename" }); fireEvent.change(again, { target: { value: " Receptek " } }); fireEvent.keyDown(again, { key: "Enter" }); await waitFor(() => expect(update).toHaveBeenCalledWith(2, { name: "Receptek" })); // Only the changed name was sent: the first, unchanged Enter sent nothing. expect(update).toHaveBeenCalledTimes(1); }); it.each([ [3, "files.folder.deleteBody"], [0, "files.folder.deleteBodyEmpty"], ])("deletes only through a confirm that says where its %i files go", async (count, body) => { mocks.files = []; mocks.folders = [YT, { ...OWN, count }]; const del = vi.spyOn(api, "deleteFolder").mockResolvedValue({} as never); mocks.confirm.mockResolvedValueOnce(false); mount({ kind: "folder", id: 2 }); await openMenu(() => fireEvent.click(folderMenu())); fireEvent.click(menuItem("files.folder.delete")); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); expect(del).not.toHaveBeenCalled(); const opts = mocks.confirm.mock.calls[0]![0] as Record; expect([opts.title, opts.message, opts.confirmLabel, opts.danger]).toEqual([ "files.folder.deleteTitle", body, "files.folder.deleteAction", true, ]); await openMenu(() => fireEvent.click(folderMenu())); fireEvent.click(menuItem("files.folder.delete")); await waitFor(() => expect(del).toHaveBeenCalledWith(2)); }); }); describe("Files · arriving at one file", () => { const many = () => Array.from({ length: 60 }, (_, i) => file(i + 1)); // FilesProvider's arrival half: a pending focus, spent by the page into what it found. function arriveAt(id: number, startView: FilesView = ALL_FILES) { const seen = { setView: vi.fn(), consumed: [] as (string | null)[] }; function Harness() { const [view, setViewState] = useState(startView); const [focus, setFocus] = useState({ id, token: 1 }); const [arrived, setArrived] = useState<(FilesFocus & { view: string }) | null>(null); const actions = { setSearch: vi.fn(), setView: (v: FilesView) => { seen.setView(v); setViewState(v); }, openFile: vi.fn(), consumeFocus: (at: string | null) => { seen.consumed.push(at); setArrived(focus && at !== null ? { ...focus, view: at } : null); setFocus(null); }, }; return ( ); } render( , ); return seen; } it("turns to the page that holds it, rings it and puts the keyboard on it", async () => { mocks.files = many(); mocks.folders = [YT]; const seen = arriveAt(55); // Page two of fifty: without the arrival it would not be on screen at all. await waitFor(() => expect(screen.queryByText("Clip 55")).not.toBeNull()); expect(seen.consumed).toEqual(["all"]); expect(rowOf("Clip 55").className).toContain("ring-accent"); expect(rowOf("Clip 54").className).not.toContain("ring-accent"); await waitFor(() => expect(rowOf("Clip 55").contains(document.activeElement)).toBe(true)); }); it("finds it among every file when the folder on screen does not hold it", async () => { mocks.files = [file(2)]; mocks.folders = [YT, OWN]; const seen = arriveAt(2, { kind: "folder", id: 2 }); // The mock answers every view with the same rows, so "not in this folder" is staged by the // folder's read: it is the all-files read that holds the file. expect(seen.consumed).toEqual(["2"]); expect(seen.setView).not.toHaveBeenCalled(); }); it("spends an arrival at a file that is nowhere, ringing nothing", async () => { mocks.files = [file(2)]; mocks.folders = [YT]; const seen = arriveAt(9); await waitFor(() => expect(seen.consumed).toEqual([null])); expect(seen.setView).not.toHaveBeenCalled(); expect(rowOf("Clip 2").className).not.toContain("ring-accent"); }); it("waits while the list re-reads: a file just finished is not in the cached rows yet", async () => { mocks.files = [file(2)]; mocks.folders = [YT]; mocks.fetching = true; const seen = arriveAt(9); await new Promise((r) => setTimeout(r, 20)); expect(seen.consumed).toEqual([]); expect(seen.setView).not.toHaveBeenCalled(); }); it("looks in every file when this folder has no such file", async () => { mocks.files = [file(2)]; mocks.folders = [YT, OWN]; const seen = arriveAt(9, { kind: "folder", id: 2 }); await waitFor(() => expect(seen.setView).toHaveBeenCalledWith(ALL_FILES)); // Then, among every file, it is nowhere either: spent. await waitFor(() => expect(seen.consumed).toEqual([null])); }); }); const drag = (type: string, files: File[] = []) => { const e = new Event(type, { bubbles: true, cancelable: true }); const list = Object.assign([...files], { item: (i: number) => files[i] ?? null }); Object.defineProperty(e, "dataTransfer", { value: { types: ["Files"], files: list } }); act(() => { window.dispatchEvent(e); }); return e; }; describe("Files · dropping a file on the page", () => { it("names the folder it lands in, and opens Upload with the file", async () => { mocks.files = []; mocks.folders = [YT, OWN]; vi.spyOn(api, "filesUsage").mockResolvedValue({ upload_max_bytes: 1_000_000 } as never); mount({ kind: "folder", id: 2 }); // preventDefault is what makes the page a drop target at all. expect(drag("dragenter").defaultPrevented).toBe(true); expect(screen.getByText("files.drop.here:Cimboráknak")).toBeTruthy(); drag("drop", [new File(["x"], "recept.pdf")]); expect(screen.queryByText("files.drop.here:Cimboráknak")).toBeNull(); expect(await screen.findByText("recept.pdf")).toBeTruthy(); }); it("is no target where nothing can be uploaded — but the browser never gets the file", () => { mocks.files = []; mocks.folders = [YT]; mount({ kind: "shared" }); drag("dragenter"); expect(screen.queryByText(/files\.drop\.here/)).toBeNull(); // Left to the browser, a dropped file opens in the tab and the app is gone. const drop = drag("drop", [new File(["x"], "recept.pdf")]); expect(drop.defaultPrevented).toBe(true); expect(screen.queryByText("recept.pdf")).toBeNull(); }); it("a second file dropped beside the open Upload dialog does not unload the app", async () => { mocks.files = []; mocks.folders = [YT, OWN]; vi.spyOn(api, "filesUsage").mockResolvedValue({ upload_max_bytes: 1_000_000 } as never); mount({ kind: "folder", id: 2 }); drag("drop", [new File(["x"], "recept.pdf")]); expect(await screen.findByText("recept.pdf")).toBeTruthy(); const beside = drag("dragover"); expect(beside.defaultPrevented).toBe(true); expect((beside as DragEvent).dataTransfer?.dropEffect).toBe("none"); expect(drag("drop", [new File(["y"], "masik.pdf")]).defaultPrevented).toBe(true); }); it("a drop another zone took still ends the page's drag", () => { mocks.files = []; mocks.folders = [YT, OWN]; mount({ kind: "folder", id: 2 }); drag("dragenter"); expect(screen.getByText("files.drop.here:Cimboráknak")).toBeTruthy(); // Taken by a zone over the page (the issue composer's screenshot box): no dragleave follows. const e = new Event("drop", { bubbles: true, cancelable: true }); Object.defineProperty(e, "dataTransfer", { value: { types: ["Files"], files: Object.assign([], { item: () => null }) }, }); e.preventDefault(); act(() => { window.dispatchEvent(e); }); expect(screen.queryByText("files.drop.here:Cimboráknak")).toBeNull(); }); it("the open Upload dialog's own zone keeps the drop it accepted", async () => { mocks.files = []; mocks.folders = [YT, OWN]; vi.spyOn(api, "filesUsage").mockResolvedValue({ upload_max_bytes: 1_000_000 } as never); mount({ kind: "folder", id: 2 }); drag("drop", [new File(["x"], "recept.pdf")]); const zone = (await screen.findByText("recept.pdf")).closest("button")!; const over = createEvent.dragOver(zone, { dataTransfer: { types: ["Files"], files: [], dropEffect: "copy" }, }) as DragEvent; fireEvent(zone, over); // A "none" written by the page's own listener (it runs last) would refuse the zone's drop. expect(over.defaultPrevented).toBe(true); expect(over.dataTransfer?.dropEffect).toBe("copy"); }); }); describe("Files · when what it needs is missing", () => { afterEach(() => { mocks.foldersFailed = false; mocks.fetching = false; mocks.notify.mockClear(); }); it("folders that failed to load are said to be missing, never shown as none — and take no drop", async () => { mocks.files = [file(2)]; mocks.foldersFailed = true; mount({ kind: "folder", id: 2 }); expect(screen.getByText("files.folderUnknown")).toBeTruthy(); // Dropped now, the file would land in Uploads instead of the folder on screen. drag("dragenter"); expect(screen.queryByText(/files\.drop\.here/)).toBeNull(); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); fireEvent.click(menuItem("files.actions.move")); expect(await screen.findByText("files.actions.foldersUnknown")).toBeTruthy(); expect(screen.queryByText("files.actions.noOwnFolders")).toBeNull(); }); it("a folder the cached list does not have yet is waited for, not taken for gone", () => { mocks.files = [file(2)]; mocks.folders = [YT]; mocks.fetching = true; const setView = mount({ kind: "folder", id: 7 }); expect(setView).not.toHaveBeenCalled(); }); it("opening a file that is no longer there says so", async () => { mocks.files = [file(2)]; mocks.folders = [YT]; mount(ALL_FILES, vi.fn(), "", { focus: { id: 9, token: 1 } }); await waitFor(() => expect(mocks.notify).toHaveBeenCalledWith({ level: "info", message: "files.arrival.gone" }), ); }); }); describe("Files · the open folder deleted", () => { it("hands the keyboard to the page's heading, not to ", async () => { // The deleted folder's "…" held the focus and is gone with it. mocks.files = []; mocks.folders = [YT]; (document.activeElement as HTMLElement | null)?.blur(); mount({ kind: "folder", id: 2 }); await waitFor(() => expect(document.activeElement).toBe(screen.getByRole("heading", { name: "files.rail.all" })), ); }); }); describe("Files · a file watched part way", () => { it("leads with Continue and offers Start over, which plays it from the start", async () => { mocks.files = [file(2, { position_seconds: 120 })]; mocks.folders = [YT]; mount(); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); expect(menuRows().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]); fireEvent.click(menuItem("card.restartTitle")); expect(await screen.findByText("playing 2 from the start")).toBeTruthy(); }); it("one never played has no Start over, and plays where it is", async () => { mocks.files = [file(3)]; mocks.folders = [YT]; mount(); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 3"))); expect(menuRows()).not.toContain("card.restartTitle"); fireEvent.click(menuItem("downloads.actions.play")); expect(await screen.findByText("playing 3")).toBeTruthy(); }); });