- 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
160 lines
6.1 KiB
TypeScript
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());
|
|
});
|
|
});
|