Skip to content
Source
Sign inSign up

composerImages.test.ts

351 lines11.7 KB

1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import {
3 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
4 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
5} from "@t3tools/contracts";
6​
7const files = new Map<
8 string,
9 { base64: string; deleted: boolean; text?: string; mimeType?: string }
10>();
11​
12const manipulator = vi.hoisted(() => {
13 const state = { sourceSize: { width: 0, height: 0 }, renderedBase64: "cmVuZGVyZWQ=" };
14 const manipulate = vi.fn((source: string | { width: number; height: number }) => {
15 let size = typeof source === "string" ? state.sourceSize : source;
16 const context = {
17 resize: vi.fn((next: { width: number; height: number }) => {
18 size = next;
19 return context;
20 }),
21 renderAsync: vi.fn(async () => ({
22 ...size,
23 release: vi.fn(),
24 saveAsync: vi.fn(async (options: { format: string }) => {
25 const uri = `file:///cache/rendered.${options.format}`;
26 files.set(uri, { base64: state.renderedBase64, deleted: false });
27 return { uri, base64: state.renderedBase64 };
28 }),
29 })),
30 };
31 return context;
32 });
33 return { state, manipulate };
34});
35​
36vi.mock("expo-image-manipulator", () => ({
37 ImageManipulator: { manipulate: manipulator.manipulate },
38 SaveFormat: { JPEG: "jpeg", PNG: "png" },
39}));
40​
41vi.mock("expo-file-system", () => ({
42 File: class {
43 readonly uri: string;
44 readonly name: string;
45 readonly parentDirectory: { readonly uri: string };
46​
47 constructor(parent: string | { readonly uri: string }, name?: string) {
48 const parentUri = typeof parent === "string" ? parent : parent.uri;
49 this.uri = name ? `${parentUri}/${name}` : parentUri;
50 this.name = name ?? this.uri.split("/").at(-1) ?? "file";
51 this.parentDirectory = { uri: this.uri.slice(0, -(this.name.length + 1)) };
52 }
53​
54 get exists(): boolean {
55 return files.has(this.uri) && files.get(this.uri)?.deleted === false;
56 }
57​
58 get type(): string {
59 return files.get(this.uri)?.mimeType ?? "";
60 }
61​
62 get size(): number | null {
63 const entry = files.get(this.uri);
64 return entry && !entry.deleted
65 ? Math.floor((entry.base64.replace(/=+$/, "").length * 3) / 4)
66 : null;
67 }
68​
69 async copy(destination: { readonly uri: string }): Promise<void> {
70 const entry = files.get(this.uri);
71 if (!entry) throw new Error("missing file");
72 files.set(destination.uri, { ...entry });
73 }
74​
75 async base64(): Promise<string> {
76 const entry = files.get(this.uri);
77 if (!entry || entry.deleted) {
78 throw new Error("missing file");
79 }
80 return entry.base64;
81 }
82​
83 delete(): void {
84 const entry = files.get(this.uri);
85 if (entry) {
86 entry.deleted = true;
87 }
88 }
89​
90 create(): void {
91 files.set(this.uri, { base64: "", deleted: false });
92 }
93​
94 write(text: string): void {
95 files.set(this.uri, { base64: "", deleted: false, text });
96 }
97​
98 moveSync(destination: { readonly uri: string }): void {
99 const entry = files.get(this.uri);
100 if (!entry) throw new Error("missing staged file");
101 files.set(destination.uri, entry);
102 files.delete(this.uri);
103 }
104 },
105 Directory: class {
106 readonly uri: string;
107​
108 constructor(parent: string, name: string) {
109 this.uri = `${parent}/${name}`;
110 }
111​
112 create(): void {}
113 },
114 FileMode: { ReadOnly: "r", WriteOnly: "w" },
115 Paths: { document: "file:///documents" },
116}));
117​
118vi.mock("./uuid", () => ({
119 uuidv4: () => "attachment-id",
120}));
121​
122import { convertPastedImagesToAttachments, persistComposerAttachmentFile } from "./composerImages";
123​
124function oversizedPng(): string {
125 const png = Buffer.alloc(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1);
126 Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(png);
127 png.write("IHDR", 12);
128 png.writeUInt32BE(2000, 16);
129 png.writeUInt32BE(2000, 20);
130 return png.toString("base64");
132​
133describe("native pasted image cleanup", () => {
134 beforeEach(() => {
135 files.clear();
136 manipulator.state.renderedBase64 = "cmVuZGVyZWQ=";
137 });
138​
139 it("preserves the reported image type of extensionless Android content URIs", async () => {
140 const uri = "content://media/external/images/media/42";
141 files.set(uri, { base64: "aGVsbG8=", deleted: false, mimeType: "image/jpeg" });
142​
143 const { attachments } = await convertPastedImagesToAttachments({
144 uris: [uri],
145 existingCount: 0,
146 });
147​
148 expect(attachments).toEqual([
149 expect.objectContaining({
150 name: "pasted-image.jpeg",
151 mimeType: "image/jpeg",
152 dataUrl: "data:image/jpeg;base64,aGVsbG8=",
153 previewUri: "data:image/jpeg;base64,aGVsbG8=",
154 }),
155 ]);
156 expect(files.get(uri)?.deleted).toBe(false);
157 });
158​
159 it("renders unsupported images to JPEG and removes the cache file after attaching", async () => {
160 const uri = "content://media/external/images/media/43";
161 files.set(uri, { base64: "b3JpZ2luYWw=", deleted: false, mimeType: "image/heic" });
162 manipulator.state.sourceSize = { width: 100, height: 100 };
163​
164 const { attachments } = await convertPastedImagesToAttachments({
165 uris: [uri],
166 existingCount: 0,
167 });
168​
169 expect(attachments).toEqual([
170 expect.objectContaining({
171 name: "pasted-image.jpeg",
172 mimeType: "image/jpeg",
173 dataUrl: "data:image/jpeg;base64,cmVuZGVyZWQ=",
174 previewUri: "data:image/jpeg;base64,cmVuZGVyZWQ=",
175 }),
176 ]);
177 expect(files.get("file:///cache/rendered.jpeg")?.deleted).toBe(true);
178 expect(files.get(uri)?.deleted).toBe(false);
179 });
180​
181 it("removes the rendered JPEG when its bytes exceed the image limit", async () => {
182 const uri = "content://media/external/images/media/43";
183 files.set(uri, { base64: "b3JpZ2luYWw=", deleted: false, mimeType: "image/heic" });
184 manipulator.state.sourceSize = { width: 100, height: 100 };
185 manipulator.state.renderedBase64 = "A".repeat(
186 Math.ceil((PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1) / 3) * 4,
187 );
188​
189 const { attachments } = await convertPastedImagesToAttachments({
190 uris: [uri],
191 existingCount: 0,
192 });
193​
194 expect(attachments).toEqual([]);
195 expect(files.get("file:///cache/rendered.jpeg")?.deleted).toBe(true);
196 expect(files.get(uri)?.deleted).toBe(false);
197 });
198​
199 it("converts owned files to data-backed previews and deletes the source", async () => {
200 const uri =
201 "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/id.png";
202 files.set(uri, { base64: "aGVsbG8=", deleted: false });
203​
204 const { attachments } = await convertPastedImagesToAttachments({
205 uris: [uri],
206 existingCount: 0,
207 });
208​
209 expect(attachments).toEqual([
210 expect.objectContaining({
211 dataUrl: "data:image/png;base64,aGVsbG8=",
212 previewUri: "data:image/png;base64,aGVsbG8=",
213 }),
214 ]);
215 expect(files.get(uri)?.deleted).toBe(true);
216 });
217​
218 it("deletes rejected and overflow owned files without deleting user-owned files", async () => {
219 const rejected =
220 "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/bad.png";
221 const overflow =
222 "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/overflow.png";
223 const userOwned = "file:///private/var/mobile/photos/library.png";
224 files.set(rejected, { base64: "", deleted: false });
225 files.set(overflow, { base64: "aGVsbG8=", deleted: false });
226 files.set(userOwned, { base64: "aGVsbG8=", deleted: false });
227​
228 await convertPastedImagesToAttachments({
229 uris: [rejected, overflow, userOwned],
230 existingCount: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1,
231 });
232​
233 expect(files.get(rejected)?.deleted).toBe(true);
234 expect(files.get(overflow)?.deleted).toBe(true);
235 expect(files.get(userOwned)?.deleted).toBe(false);
236 });
237​
238 it("re-renders an over-limit paste whose dimensions already fit, like the photo picker", async () => {
239 const uri =
240 "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/photo.png";
241 files.set(uri, { base64: oversizedPng(), deleted: false });
242 manipulator.state.sourceSize = { width: 2000, height: 2000 };
243​
244 const { attachments } = await convertPastedImagesToAttachments({
245 uris: [uri],
246 existingCount: 0,
247 });
248​
249 expect(attachments).toHaveLength(1);
250 expect(attachments[0]?.sizeBytes).toBeLessThanOrEqual(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES);
251 expect(files.get("file:///cache/rendered.jpeg")?.deleted).toBe(true);
252 });
253​
254 it("keeps the readable images of a paste and still reports the one it dropped", async () => {
255 const gone =
256 "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/gone.png";
257 const kept =
258 "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/kept.png";
259 files.set(kept, { base64: "aGVsbG8=", deleted: false });
260​
261 const result = await convertPastedImagesToAttachments({ uris: [gone, kept], existingCount: 0 });
262​
263 expect(result.attachments).toEqual([
264 expect.objectContaining({ dataUrl: "data:image/png;base64,aGVsbG8=" }),
265 ]);
266 expect(result.error).toBe("Could not read a pasted image.");
267 });
268​
269 it("reports the size limit when even the JPEG re-render is too large", async () => {
270 const uri =
271 "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/huge.png";
272 files.set(uri, { base64: oversizedPng(), deleted: false });
273 manipulator.state.sourceSize = { width: 2000, height: 2000 };
274 manipulator.state.renderedBase64 = oversizedPng();
275​
276 const result = await convertPastedImagesToAttachments({ uris: [uri], existingCount: 0 });
277​
278 expect(result).toEqual({
279 attachments: [],
280 error: "A pasted image exceeds the 10 MB attachment limit.",
281 });
282 expect(files.get(uri)?.deleted).toBe(true);
283 });
284});
285​
286describe("persistComposerAttachmentFile", () => {
287 beforeEach(() => {
288 files.clear();
289 });
290​
291 it("stores picked files under a name Android's java.net.URI accepts", async () => {
292 const source = "file:///cache/DocumentPicker/Report [draft].pdf";
293 files.set(source, { base64: "JVBERg==", deleted: false });
294​
295 const fileUri = await persistComposerAttachmentFile(source, "Report [draft].pdf");
296​
297 // expo-file-system encodes spaces and `%` but leaves `[`, `]`, `|` and `^` raw.
298 expect(fileUri.split("/").at(-1)).toMatch(/^[\w\-.!~*'();:@&=+$, %]+\.pdf$/);
299 });
300});
301​
302describe("composerStripAttachments", () => {
303 const image = {
304 id: "img-1",
305 type: "image" as const,
306 name: "shot.jpg",
307 mimeType: "image/jpeg",
308 sizeBytes: 10,
309 dataUrl: "",
310 };
311 const video = {
312 id: "vid-1",
313 type: "file" as const,
314 name: "clip.mp4",
315 mimeType: "video/mp4",
316 sizeBytes: 20,
317 fileUri: "file:///clip.mp4",
318 };
319 const doc = {
320 id: "doc-1",
321 type: "file" as const,
322 name: "notes.txt",
323 mimeType: "text/plain",
324 sizeBytes: 5,
325 fileUri: "file:///notes.txt",
326 };
327​
328 it("keeps media beside a document rather than dropping the whole strip", async () => {
329 const { composerStripAttachments } = await import("./composerImages");
330 expect(composerStripAttachments([doc, image, video] as never).map((a) => a.id)).toEqual([
331 "img-1",
332 "vid-1",
333 ]);
334 });
335​
336 it("keeps oversized document-picked images visible while sending them as files", async () => {
337 const { composerStripAttachments, isComposerImageAttachment } =
338 await import("./composerImages");
339 const oversizedImage = {
340 ...video,
341 id: "large-image",
342 name: "large.png",
343 mimeType: "image/png",
344 fileUri: "file:///large.png",
345 sizeBytes: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1,
346 };
347​
348 expect(composerStripAttachments([oversizedImage])).toEqual([oversizedImage]);
349 expect(isComposerImageAttachment(oversizedImage)).toBe(false);
350 });
351});