// @vitest-environment jsdom // // The Files viewer: what each kind of file shows (an image, a PDF in a frame, text as text — HTML too, // never a page — or the "no preview" card), ← / → through the files it was given in their order, // and closing when the file it shows leaves the list. i18n is key-passthrough. import { afterEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { api, HttpError, type LibraryFile } from "../../lib/api"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (key: string, o?: Record) => o && "n" in o ? `${key}:${String(o.n)}/${String(o.total)}` : key, }), }; }); import FilePreview from "./FilePreview"; import { pushLayer, removeLayer } from "../../lib/layerStack"; import { closeKeyboardHelp, toggleKeyboardHelp, useKeyboardHelpCovered } from "../../lib/keymap"; const file = (id: number, preview: string | null, extra: Record = {}) => ({ id, status: "done", display_name: `File ${id}`, can_download: true, job_kind: "upload", shared: false, source_key: "upload", asset: { media_kind: "file", preview, container: "x", size_bytes: 4096, ...extra }, }) as unknown as LibraryFile; afterEach(() => { cleanup(); vi.restoreAllMocks(); }); function mount(files: LibraryFile[], fileId: number, onClose = vi.fn()) { const onFileChange = vi.fn(); const view = (list: LibraryFile[], id: number) => ( []} /> ); const r = render(view(files, fileId)); return { onFileChange, onClose, rerender: (l: LibraryFile[], id: number) => r.rerender(view(l, id)), }; } describe("FilePreview · what it shows", () => { it("an image as an image, from the sandboxed preview", () => { mount([file(1, "image")], 1); const img = screen.getByRole("img", { name: "File 1" }); expect(img.getAttribute("src")).toBe(api.previewUrl(1)); }); it("a PDF in a frame of the preview, the browser's own viewer", () => { mount([file(2, "pdf")], 2); const frame = document.querySelector("iframe")!; expect(frame.getAttribute("src")).toBe(api.previewUrl(2)); expect(frame.title).toBe("File 2"); }); it("a page's source as text, line by line — never as a page", async () => { vi.spyOn(api, "previewText").mockResolvedValue({ text: "

hi

\n", truncated: false, }); mount([file(3, "text")], 3); await screen.findByText("

hi

"); expect(screen.getByText("")).toBeTruthy(); expect(document.getElementById("x")).toBeNull(); expect(document.querySelector("script")).toBeNull(); expect(screen.getByText("files.preview.asText")).toBeTruthy(); }); it("numbers a file's lines, the newline that ends it making no line of its own", async () => { vi.spyOn(api, "previewText").mockResolvedValue({ text: "one\r\ntwo\n", truncated: false }); mount([file(8, "text")], 8); await screen.findByText("one"); expect([...document.querySelectorAll("li")].map((li) => li.textContent)).toEqual([ "1one", "2two", ]); }); it("lays out a long text's first lines only, and says where the rest is", async () => { // Every line is a row of the page: a log of tens of thousands would stall the viewer. const text = Array.from({ length: 6000 }, (_, i) => `line ${i + 1}`).join( String.fromCharCode(10), ); vi.spyOn(api, "previewText").mockResolvedValue({ text, truncated: false }); mount([file(9, "text")], 9); await screen.findByText("line 1"); expect(document.querySelectorAll("li")).toHaveLength(5000); expect(screen.getByText(/files\.preview\.firstLines/)).toBeTruthy(); expect(screen.getByText("files.preview.downloadWhole").closest("a")!.getAttribute("href")).toBe( api.fileUrl(9), ); }); it("says when it shows only the start of a long text", async () => { vi.spyOn(api, "previewText").mockResolvedValue({ text: "a", truncated: true }); mount([file(4, "text")], 4); expect(await screen.findByText("files.preview.truncated")).toBeTruthy(); }); it("a text the server refuses (binary after all) gets the no-preview card, not an error", async () => { vi.spyOn(api, "previewText").mockRejectedValue(new HttpError(415, "x", {})); mount([file(5, "text")], 5); expect(await screen.findByText("files.preview.none")).toBeTruthy(); expect(screen.queryByRole("alert")).toBeNull(); // A new tab would only show the refusal. expect(screen.queryByRole("link", { name: "files.preview.openInTab" })).toBeNull(); }); it("a text that fails to load says so, with a retry", async () => { vi.spyOn(api, "previewText").mockRejectedValue(new HttpError(500, "x", {})); mount([file(6, "text")], 6); expect(await screen.findByRole("alert", {}, { timeout: 3000 })).toBeTruthy(); expect(screen.getByRole("button", { name: "common.retry" })).toBeTruthy(); }); it("anything else: the card that offers the file itself", () => { mount([file(7, null)], 7); expect(screen.getByText("files.preview.none")).toBeTruthy(); const save = screen.getAllByRole("link", { name: "downloads.actions.download" }); expect(save.every((a) => a.getAttribute("href") === api.fileUrl(7))).toBe(true); // Nothing to open in a tab: the preview would refuse it. expect(screen.queryByRole("link", { name: "files.preview.openInTab" })).toBeNull(); }); }); describe("FilePreview · stepping", () => { const list = [file(1, "image"), file(2, "pdf"), file(3, null)]; it("← / → go to the files beside it, in the order given", () => { const { onFileChange } = mount(list, 2); expect(screen.getByText("files.preview.position:2/3")).toBeTruthy(); fireEvent.keyDown(window, { key: "ArrowRight" }); expect(onFileChange).toHaveBeenLastCalledWith(3); fireEvent.keyDown(window, { key: "ArrowLeft" }); expect(onFileChange).toHaveBeenLastCalledWith(1); }); it("a layer opened from it (a menu, a confirm) keeps its arrows — the viewer stays on its file", () => { const { onFileChange } = mount(list, 2); const confirm = pushLayer(); try { fireEvent.keyDown(window, { key: "ArrowRight" }); expect(onFileChange).not.toHaveBeenCalled(); } finally { removeLayer(confirm); } fireEvent.keyDown(window, { key: "ArrowRight" }); expect(onFileChange).toHaveBeenLastCalledWith(3); }); // …so the help sheet opened over that layer leaves them out, and one over the viewer lists them. it("tells the help sheet its keys wait while a layer sits above it", () => { function Covered() { return {[...useKeyboardHelpCovered()].join(",")}; } mount(list, 2); render(); const help = () => act(() => toggleKeyboardHelp()); const confirm = pushLayer(); try { help(); expect(screen.getByTestId("covered").textContent).toBe("fileViewer"); act(() => closeKeyboardHelp()); } finally { removeLayer(confirm); } help(); expect(screen.getByTestId("covered").textContent).toBe(""); act(() => closeKeyboardHelp()); }); it("a text line wider than the stage scrolls sideways under ← / →, it does not page", async () => { vi.spyOn(api, "previewText").mockResolvedValue({ text: "a very long line", truncated: false }); const { onFileChange } = mount([file(1, "image"), file(8, "text"), file(9, "text")], 8); await screen.findByText("a very long line"); const region = screen.getByRole("region"); Object.defineProperty(region, "scrollWidth", { value: 900 }); Object.defineProperty(region, "clientWidth", { value: 300 }); fireEvent.keyDown(region, { key: "ArrowRight" }); expect(onFileChange).not.toHaveBeenCalled(); }); it("a text whose lines fit pages under ← / →, even with the keyboard in it", async () => { vi.spyOn(api, "previewText").mockResolvedValue({ text: "short", truncated: false }); const { onFileChange } = mount([file(1, "image"), file(8, "text"), file(9, "text")], 8); await screen.findByText("short"); const region = screen.getByRole("region"); Object.defineProperty(region, "scrollWidth", { value: 300 }); Object.defineProperty(region, "clientWidth", { value: 300 }); fireEvent.keyDown(region, { key: "ArrowRight" }); expect(onFileChange).toHaveBeenLastCalledWith(9); }); it("Escape closes it — after a layer opened above it has taken its own", () => { const { onClose } = mount(list, 2); const menu = pushLayer(); try { fireEvent.keyDown(document, { key: "Escape" }); expect(onClose).not.toHaveBeenCalled(); } finally { removeLayer(menu); } fireEvent.keyDown(document, { key: "Escape" }); expect(onClose).toHaveBeenCalledTimes(1); }); it("stops at either end", () => { const { onFileChange } = mount(list, 1); fireEvent.keyDown(window, { key: "ArrowLeft" }); expect(onFileChange).not.toHaveBeenCalled(); expect( screen.getByRole("button", { name: "files.preview.prev" }).hasAttribute("disabled"), ).toBe(true); }); it("closes when the file it shows leaves the list", async () => { const { onClose, rerender } = mount(list, 2); expect(onClose).not.toHaveBeenCalled(); act(() => rerender([list[0]!, list[2]!], 2)); await waitFor(() => expect(onClose).toHaveBeenCalled()); }); });