1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 filePreviewKind,
5 decodeFilePreviewText,
6 FILE_TEXT_PREVIEW_MAX_BYTES,
7 hostPreviewMimeTypeFromExtension,
8 isWorkspaceFontPreviewPath,
9 isWorkspaceImagePreviewPath,
10 isWorkspacePreviewEntryPath,
11 mediaKindFromPath,
12 officePreviewKind,
13} from "./filePreview.ts";
14
15describe("workspace file previews", () => {
16 it.each([
17 ["zip", "archive", "application/zip"],
18 ["jar", "archive", "application/java-archive"],
19 ["tar", "archive", "application/x-tar"],
20 ["gz", "archive", "application/gzip"],
21 ["tgz", "archive", "application/gzip"],
22 ["sqlite", "sqlite", "application/vnd.sqlite3"],
23 ["sqlite3", "sqlite", "application/vnd.sqlite3"],
24 ["db", "sqlite", "application/vnd.sqlite3"],
25 ])("recognizes binary preview %s", (extension, kind, mimeType) => {
26 const name = `files#draft/sample.${extension.toUpperCase()}`;
27 for (const genericMime of [
28 "",
29 "application/octet-stream",
30 "binary/octet-stream",
31 "application/unknown",
32 " APPLICATION/UNKNOWN ; charset=utf-8",
33 "text/plain",
34 ]) {
35 expect(filePreviewKind({ name, mimeType: genericMime })).toBe(kind);
36 }
37 expect(filePreviewKind({ name: "download", mimeType })).toBe(kind);
38 expect(isWorkspacePreviewEntryPath(name)).toBe(true);
39 expect(isWorkspacePreviewEntryPath(`${name}?download=1`)).toBe(false);
40 expect(hostPreviewMimeTypeFromExtension(`.${extension}`)).toBe(mimeType);
41 });
42 it("previews a TypeScript file the OS labels as MPEG transport stream as text", () => {
43 expect(filePreviewKind({ name: "clamp.ts", mimeType: "video/mp2t" })).toBe("text");
44 });
45 it("recognizes compound archives and MIME aliases", () => {
46 expect(filePreviewKind({ name: "archive.tar.gz" })).toBe("archive");
47 expect(filePreviewKind({ name: "download", mimeType: "application/x-gzip" })).toBe("archive");
48 expect(filePreviewKind({ name: "download", mimeType: "application/x-sqlite3" })).toBe("sqlite");
49 expect(filePreviewKind({ name: "misleading.zip", mimeType: "application/pdf" })).toBe("pdf");
50 });
51 it.each(["ttf", "otf", "woff", "woff2"])(
52 "previews %s fonts through signed host assets",
53 (extension) => {
54 expect(
55 filePreviewKind({ name: `sample.${extension}`, mimeType: "application/octet-stream" }),
56 ).toBe("font");
57 expect(filePreviewKind({ name: "download", mimeType: `font/${extension}` })).toBe("font");
58 expect(isWorkspaceFontPreviewPath(`/tmp/sample.${extension.toUpperCase()}`)).toBe(true);
59 expect(isWorkspacePreviewEntryPath(`sample.${extension}`)).toBe(true);
60 expect(hostPreviewMimeTypeFromExtension(`.${extension}`)).toBe(`font/${extension}`);
61 expect(isWorkspaceFontPreviewPath(`sample.${extension}?download=1`)).toBe(false);
62 expect(filePreviewKind({ name: `sample.${extension}`, mimeType: "application/zip" })).toBe(
63 "archive",
64 );
65 },
66 );
67 it.each([
68 [
69 "quarterly-report.DOCX",
70 "docx",
71 "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
72 ],
73 [
74 "finance#q4/operating-plan.xlsx",
75 "xlsx",
76 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
77 ],
78 [
79 "slides?review/quarterly-review.pptx",
80 "pptx",
81 "application/vnd.openxmlformats-officedocument.presentationml.presentation",
82 ],
83 ["notes.odt", "odt", "application/vnd.oasis.opendocument.text"],
84 ["budget.ods", "ods", "application/vnd.oasis.opendocument.spreadsheet"],
85 ["deck.odp", "odp", "application/vnd.oasis.opendocument.presentation"],
86 ["notes.rtf", "rtf", "application/rtf"],
87 ["guide.epub", "epub", "application/epub+zip"],
88 ["analysis.ipynb", "ipynb", "application/x-ipynb+json"],
89 ])("recognizes Office assets at literal path %s", (name, kind, mimeType) => {
90 expect(officePreviewKind({ name })).toBe(kind);
91 for (const genericMime of [
92 "",
93 "application/octet-stream",
94 "binary/octet-stream",
95 "application/unknown",
96 " APPLICATION/UNKNOWN ; charset=utf-8",
97 "text/plain",
98 ]) {
99 expect(filePreviewKind({ name, mimeType: genericMime })).toBe(kind);
100 }
101 expect(isWorkspacePreviewEntryPath(name)).toBe(true);
102 expect(hostPreviewMimeTypeFromExtension(`.${kind}`)).toBe(mimeType);
103 expect(filePreviewKind({ name: "download", mimeType })).toBe(kind);
104 expect(filePreviewKind({ name, mimeType: "application/zip" })).toBe("archive");
105 });
106 // regression: #10311 previews classify by the literal filename, not URL-like suffixes
107 it.each([
108 "image.png.ts",
109 "image.png#notes.txt",
110 "image%2Epng",
111 "report.docx#notes.txt",
112 "report.xlsx?download=1",
113 "slides%2Epptx",
114 "report.docm",
115 "guide.epub?download=1",
116 "analysis.ipynb#notes.txt",
117 "report.odt.ts",
118 "slides.ppt",
119 ])("rejects non-preview path %s", (path) => {
120 expect(isWorkspacePreviewEntryPath(path)).toBe(false);
121 });
122
123 it.each(["picture.bmp", "photo.heic", "photo.heif", "scan.tif", "scan.TIFF"])(
124 "recognizes image preview path %s",
125 (path) => {
126 expect(isWorkspaceImagePreviewPath(path)).toBe(true);
127 expect(isWorkspacePreviewEntryPath(path)).toBe(true);
128 },
129 );
130
131 it("serves audio in place from the host like video and browser documents", () => {
132 expect(hostPreviewMimeTypeFromExtension(".m4a")).toBe("audio/mp4");
133 expect(hostPreviewMimeTypeFromExtension(".weba")).toBe("audio/webm");
134 expect(hostPreviewMimeTypeFromExtension(".mp4")).toBe("video/mp4");
135 expect(hostPreviewMimeTypeFromExtension(".txt")).toBeNull();
136 expect(hostPreviewMimeTypeFromExtension(".constructor")).toBeNull();
137 expect(hostPreviewMimeTypeFromExtension(".toString")).toBeNull();
138 expect(hostPreviewMimeTypeFromExtension(".__proto__")).toBeNull();
139 expect(hostPreviewMimeTypeFromExtension(".webm-audio")).toBe("audio/webm");
140 });
141});
142
143describe("media path parsing", () => {
144 // regression: #9023 media kind comes from the decoded pathname, ignoring query and hash
145 it.each([
146 ["https://cdn.example/clip.webm?download=1#t=2", "video"],
147 ["https://example.com/download?name=recording.mp4", null],
148 ["images%2Fresult%2Epng", "image"],
149 ["images/result.png%23secret.txt", null],
150 ["/tmp/tone.MP3", "audio"],
151 ["./tone.wav", "audio"],
152 ["tone.ogg", "audio"],
153 ["tone.m4a", "audio"],
154 ["tone.flac", "audio"],
155 ["tone.aac", "audio"],
156 ["tone.opus", "audio"],
157 ["tone.webm-audio", "audio"],
158 ["tone.webm", "video"],
159 ["https://example.com/tone%2Emp3?download=1", "audio"],
160 ["data.csv", null],
161 ["report.pdf", null],
162 ["report.docx", null],
163 ["archive.zip", null],
164 ])("classifies the decoded pathname of %s", (source, kind) => {
165 expect(mediaKindFromPath(source)).toBe(kind);
166 });
167});
168
169describe("attachment preview classification", () => {
170 it.each([
171 ["example.json", "application/octet-stream", "text"],
172 ["report.pdf", "application/pdf", "pdf"],
173 ["page.HTML", "", "html"],
174 ["diagram.mmd", "", "mermaid"],
175 ["diagram.MERMAID", "text/plain", "mermaid"],
176 ["download", "text/vnd.mermaid", "mermaid"],
177 ["source-code.otf", "application/vnd.oasis.opendocument.formula-template", "font"],
178 ["download", "text/rtf", "rtf"],
179 ["analysis.IPYNB", "application/json", "ipynb"],
180 ["clip.weba", "", "audio"],
181 ["image.bmp", "application/octet-stream", "image"],
182 ["photo.heic", "application/octet-stream", "image"],
183 ["scan.tiff", "application/octet-stream", "image"],
184 ["recording.mp3", "", "audio"],
185 ["recording.webm-audio", "", "audio"],
186 ["recording.webm", "audio/webm", "audio"],
187 ["payload", "application/problem+json", "text"],
188 ["archive.zip", "application/zip", "archive"],
189 ["misleading.json", "application/pdf", "pdf"],
190 ["misleading.pdf", "application/zip", "archive"],
191 ])("classifies %s (%s) as %s", (name, mimeType, expected) => {
192 expect(filePreviewKind({ name, mimeType })).toBe(expected);
193 });
194 it("rejects binary and invalid UTF-8 data", () => {
195 expect(() => decodeFilePreviewText(new Uint8Array([65, 0, 66]))).toThrow("binary");
196 expect(() => decodeFilePreviewText(new Uint8Array([255]))).toThrow("UTF-8");
197 });
198 it.each(["doc", "xls", "ppt", "7z", "rar"])(
199 "retains the binary fallback for %s, which opencode does not render",
200 (extension) => {
201 expect(filePreviewKind({ name: `sample.${extension}` })).toBe("unsupported");
202 },
203 );
204 it("does not corrupt a multi-byte character at the preview boundary", () => {
205 const bytes = new Uint8Array(FILE_TEXT_PREVIEW_MAX_BYTES + 1).fill(97);
206 bytes[FILE_TEXT_PREVIEW_MAX_BYTES - 1] = 0xe2;
207 bytes[FILE_TEXT_PREVIEW_MAX_BYTES] = 0x82;
208 const preview = decodeFilePreviewText(bytes);
209 expect(preview.truncated).toBe(true);
210 expect(preview.text.endsWith("�")).toBe(false);
211 expect(preview.text.length).toBe(FILE_TEXT_PREVIEW_MAX_BYTES - 1);
212 });
213});