Files
siftlode/frontend/src/components/FilePreview.dom.test.tsx
T
peter fb8fb980e5 feat(files): a file that is not media opens in a viewer — an image, a PDF, text as text
- GET /api/files/{id}/preview: an allow-list on the stored extension (downloads/preview.py) picks
  image / PDF / text; served inline under `CSP: sandbox; frame-ancestors 'self'`, the type from the
  table; HTML and SVG arrive as plain text; a text is its first 1 MiB (X-Preview-Truncated), sent
  as UTF-8 whatever it was written in (BOM, UTF-8, else cp1250); a NUL means binary: 415
- the list says which preview each file gets (asset.preview); FE/BE contract on kinds and header
- the viewer (shared dialog shell): ← / → through the list's such files in its order, Save, open in
  a tab, the file's menu; one stage height for every kind; a no-preview card offers the file
- the name, the card's picture and the menu's Preview open it; an image up to 8 MB is its own
  thumbnail
2026-10-12 15:28:31 +02:00

160 lines
6.1 KiB
TypeScript

// @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<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: Record<string, unknown>) =>
o && "n" in o ? `${key}:${String(o.n)}/${String(o.total)}` : key,
}),
};
});
import FilePreview from "./FilePreview";
const file = (id: number, preview: string | null, extra: Record<string, unknown> = {}) =>
({
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) => (
<QueryClientProvider client={new QueryClient()}>
<FilePreview
files={list}
fileId={id}
onFileChange={onFileChange}
onClose={onClose}
menuItems={() => []}
/>
</QueryClientProvider>
);
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: "<h1 id=x>hi</h1>\n<script>boom()</script>",
truncated: false,
});
mount([file(3, "text")], 3);
await screen.findByText("<h1 id=x>hi</h1>");
expect(screen.getByText("<script>boom()</script>")).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("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("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());
});
});