Files
ihasmail/web/src/lib/__tests__/preview.test.ts
T
jcoffey-dev 984f0474e3 Look at a file without downloading it first
Files could only hand you the bytes: double-clicking a picture put it on
disk and left you to find it. The viewer for this already existed --
images, PDFs and text, in the attachment preview in MessageView -- it was
just wired to one screen. It is now a component both screens use.

Two things it needed before it was any use on Files. The type detection
falls back to the file name: an upload carries whatever the browser
guessed, which for anything unusual is application/octet-stream, so the
old exact-type check saw nothing to show in a .md that had just been
uploaded. And text is read with fetch, which ignores Content-Disposition,
so Markdown previews even though the server will not serve it inline.

Whether we can show a file and whether the server will serve it inline
are separate questions, and lib/preview.ts answers them separately:
`openableInTab` mirrors the isInlineSafe allowlist in the blob route,
because navigating to a blob the server will not inline just starts a
download. SVG is left out of both -- it carries script, and how to show
one safely is its own question, not a detail of a file lister.

Printing goes with it. A picture or a text file prints from the dialog
with everything else dropped; a PDF prints itself from its own iframe,
since the page around it cannot paginate someone else's document. Hiding
`.app` alone was not enough there -- `#root` kept its height and printed a
blank first page, the same trap as the message card.
2026-09-01 20:18:51 -07:00

81 lines
3.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { openableInTab, previewKind } from "@/lib/preview";
describe("previewKind", () => {
it("goes by the declared type when there is one", () => {
expect(previewKind("image/png", "photo.png")).toBe("image");
expect(previewKind("application/pdf", "invoice.pdf")).toBe("pdf");
expect(previewKind("text/plain", "notes.txt")).toBe("text");
expect(previewKind("text/markdown", "README.md")).toBe("text");
expect(previewKind("application/json", "data.json")).toBe("text");
expect(previewKind("image/png; charset=binary", "photo.png")).toBe("image");
});
it("falls back to the name when the type is a generic wrapper", () => {
// What an upload gets when the browser cannot guess -- files.ts stores
// `f.type || "application/octet-stream"`, so this is the common case for
// anything unusual, and it is what made the old exact-type check useless
// on real uploads.
expect(previewKind("application/octet-stream", "README.md")).toBe("text");
expect(previewKind("application/octet-stream", "shot.PNG")).toBe("image");
expect(previewKind("application/octet-stream", "report.pdf")).toBe("pdf");
expect(previewKind("", "notes.txt")).toBe("text");
expect(previewKind(null, "deploy.sh")).toBe("text");
expect(previewKind(undefined, undefined)).toBeNull();
});
it("does not let the name override a type the server was specific about", () => {
// A .txt served as a zip is a zip. Guessing from the name here would be
// taking the sender's word for the extension over the server's for the
// bytes.
expect(previewKind("application/zip", "archive.txt")).toBeNull();
expect(previewKind("video/mp4", "clip.txt")).toBeNull();
});
it("leaves SVG alone", () => {
// It carries script and the server refuses to serve it inline; it stays a
// download until that is decided deliberately.
expect(previewKind("image/svg+xml", "logo.svg")).toBeNull();
expect(previewKind("application/octet-stream", "logo.svg")).toBeNull();
});
it("has nothing to show for the rest", () => {
expect(previewKind("application/zip", "backup.zip")).toBeNull();
expect(previewKind("application/vnd.openxmlformats-officedocument.wordprocessingml.document", "letter.docx")).toBeNull();
});
});
describe("openableInTab", () => {
/*
* This mirrors `isInlineSafe` in server/src/app.ts. If the two drift, the
* "open in a new tab" button silently starts downloading instead, because
* the server sends Content-Disposition: attachment for anything not on its
* list. These cases are the list.
*/
it("matches what the server will serve inline", () => {
expect(openableInTab("image/png")).toBe(true);
expect(openableInTab("video/mp4")).toBe(true);
expect(openableInTab("audio/mpeg")).toBe(true);
expect(openableInTab("application/pdf")).toBe(true);
expect(openableInTab("text/plain; charset=utf-8")).toBe(true);
expect(openableInTab("text/calendar")).toBe(true);
expect(openableInTab("text/vcard")).toBe(true);
});
it("refuses what the server will not", () => {
expect(openableInTab("image/svg+xml")).toBe(false);
expect(openableInTab("text/html")).toBe(false);
expect(openableInTab("text/markdown")).toBe(false);
expect(openableInTab("application/json")).toBe(false);
expect(openableInTab("application/octet-stream")).toBe(false);
expect(openableInTab(null)).toBe(false);
});
it("is narrower than what we can show ourselves", () => {
// Markdown is the case that proves the two questions are different: the
// dialog reads it with fetch, which ignores Content-Disposition.
expect(previewKind("text/markdown", "README.md")).toBe("text");
expect(openableInTab("text/markdown")).toBe(false);
});
});