1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts";
3
4const files = new Map<string, { base64: string; deleted: boolean; text?: string }>();
5
6const clipboard = vi.hoisted(() => ({
7 hasImageAsync: vi.fn(),
8 getImageAsync: vi.fn(),
9 hasStringAsync: vi.fn(),
10 getStringAsync: vi.fn(),
11}));
12
13vi.mock("expo-clipboard", () => clipboard);
14
15/** A native image renderer that reports the source size the test gives it. */
16const manipulator = vi.hoisted(() => {
17 const state = { sourceSize: { width: 0, height: 0 } };
18 const manipulate = vi.fn((source: string | { width: number; height: number }) => {
19 let size = typeof source === "string" ? state.sourceSize : source;
20 const context = {
21 resize: vi.fn((next: { width: number; height: number }) => {
22 size = next;
23 return context;
24 }),
25 renderAsync: vi.fn(async () => ({
26 ...size,
27 release: vi.fn(),
28 saveAsync: vi.fn(async (options: { format: string }) => ({
29 uri: `file:///cache/rendered.${options.format}`,
30 base64: `cmVuZGVyZWQ=`,
31 })),
32 })),
33 };
34 return context;
35 });
36 return { state, manipulate };
37});
38
39vi.mock("expo-image-manipulator", () => ({
40 ImageManipulator: { manipulate: manipulator.manipulate },
41 SaveFormat: { JPEG: "jpeg", PNG: "png" },
42}));
43
44vi.mock("expo-file-system", () => ({
45 File: class {
46 readonly uri: string;
47 readonly name: string;
48 readonly parentDirectory: { readonly uri: string };
49
50 constructor(parent: string | { readonly uri: string }, name?: string) {
51 const parentUri = typeof parent === "string" ? parent : parent.uri;
52 this.uri = name ? `${parentUri}/${name}` : parentUri;
53 this.name = name ?? this.uri.split("/").at(-1) ?? "file";
54 this.parentDirectory = { uri: this.uri.slice(0, -(this.name.length + 1)) };
55 }
56
57 get exists(): boolean {
58 return files.has(this.uri) && files.get(this.uri)?.deleted === false;
59 }
60
61 async base64(): Promise<string> {
62 const entry = files.get(this.uri);
63 if (!entry || entry.deleted) {
64 throw new Error("missing file");
65 }
66 return entry.base64;
67 }
68
69 delete(): void {
70 const entry = files.get(this.uri);
71 if (entry) {
72 entry.deleted = true;
73 }
74 }
75
76 create(): void {
77 files.set(this.uri, { base64: "", deleted: false });
78 }
79
80 write(text: string): void {
81 files.set(this.uri, { base64: "", deleted: false, text });
82 }
83
84 moveSync(destination: { readonly uri: string }): void {
85 const entry = files.get(this.uri);
86 if (!entry) throw new Error("missing staged file");
87 files.set(destination.uri, entry);
88 files.delete(this.uri);
89 }
90 },
91 Directory: class {
92 readonly uri: string;
93
94 constructor(parent: string, name: string) {
95 this.uri = `${parent}/${name}`;
96 }
97
98 create(): void {}
99 },
100 Paths: { document: "file:///documents" },
101}));
102
103vi.mock("./uuid", () => ({
104 uuidv4: () => "attachment-id",
105}));
106
107import {
108 convertPastedImagesToAttachments,
109 createPastedTextComposerAttachment,
110 isOwnedPastedImageUri,
111 pasteComposerClipboard,
112} from "./composerImages";
113
114describe("composer clipboard paste", () => {
115 beforeEach(() => {
116 vi.clearAllMocks();
117 clipboard.hasImageAsync.mockResolvedValue(false);
118 clipboard.hasStringAsync.mockResolvedValue(true);
119 clipboard.getStringAsync.mockResolvedValue("clipboard text");
120 clipboard.getImageAsync.mockResolvedValue({ data: "data:image/png;base64,aGVsbG8=" });
121 });
122
123 it("returns only the image when the clipboard contains both image and text", async () => {
124 clipboard.hasImageAsync.mockResolvedValue(true);
125 const result = await pasteComposerClipboard({ existingCount: 0 });
126 expect(result).toEqual({
127 images: [expect.objectContaining({ type: "image", name: "pasted-image.png" })],
128 text: null,
129 error: null,
130 });
131 expect(clipboard.getStringAsync).not.toHaveBeenCalled();
132 });
133
134 it("does not paste alternate text when the image cannot fit", async () => {
135 clipboard.hasImageAsync.mockResolvedValue(true);
136 expect(
137 await pasteComposerClipboard({ existingCount: PROVIDER_SEND_TURN_MAX_ATTACHMENTS }),
138 ).toEqual({ images: [], text: null, error: expect.stringContaining("up to") });
139 expect(clipboard.getStringAsync).not.toHaveBeenCalled();
140 });
141
142 it("returns plain text without image chips", async () => {
143 expect(await pasteComposerClipboard({ existingCount: 0 })).toEqual({
144 images: [],
145 text: "clipboard text",
146 error: null,
147 });
148 });
149
150 it("reports an empty text clipboard", async () => {
151 clipboard.getStringAsync.mockResolvedValue("");
152 expect(await pasteComposerClipboard({ existingCount: 0 })).toEqual({
153 images: [],
154 text: null,
155 error: "Clipboard is empty.",
156 });
157 });
158});
159
160describe("oversized pasted images", () => {
161 const u32 = (n: number) => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
162 const pngDataUrl = (width: number, height: number) =>
163 `data:image/png;base64,${Buffer.from([
164 0x89,
165 0x50,
166 0x4e,
167 0x47,
168 0x0d,
169 0x0a,
170 0x1a,
171 0x0a,
172 ...u32(13),
173 0x49,
174 0x48,
175 0x44,
176 0x52,
177 ...u32(width),
178 ...u32(height),
179 8,
180 6,
181 0,
182 0,
183 0,
184 ]).toString("base64")}`;
185
186 beforeEach(() => {
187 vi.clearAllMocks();
188 clipboard.hasImageAsync.mockResolvedValue(true);
189 });
190
191 it("scales a clipboard screenshot over 2000px to 2000px and keeps it a PNG", async () => {
192 manipulator.state.sourceSize = { width: 3024, height: 1964 };
193 const pasted = pngDataUrl(3024, 1964);
194 clipboard.getImageAsync.mockResolvedValue({ data: pasted });
195
196 const result = await pasteComposerClipboard({ existingCount: 0 });
197
198 expect(manipulator.manipulate).toHaveBeenCalledWith(pasted);
199 const resize = manipulator.manipulate.mock.results.at(-1)?.value.resize;
200 expect(resize).toHaveBeenCalledWith({ width: 2000, height: 1299 });
201 expect(result.images).toEqual([
202 expect.objectContaining({
203 mimeType: "image/png",
204 dataUrl: "data:image/png;base64,cmVuZGVyZWQ=",
205 previewUri: "data:image/png;base64,cmVuZGVyZWQ=",
206 }),
207 ]);
208 });
209
210 it("keeps a 2000px clipboard image byte for byte without rendering it", async () => {
211 const pasted = pngDataUrl(2000, 1200);
212 clipboard.getImageAsync.mockResolvedValue({ data: pasted });
213
214 const result = await pasteComposerClipboard({ existingCount: 0 });
215
216 expect(manipulator.manipulate).not.toHaveBeenCalled();
217 expect(result.images).toEqual([expect.objectContaining({ dataUrl: pasted })]);
218 });
219});
220
221describe("native pasted image cleanup", () => {
222 beforeEach(() => {
223 files.clear();
224 });
225
226 it("recognizes only files created in the native composer paste directory", () => {
227 expect(
228 isOwnedPastedImageUri(
229 "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/id.png",
230 ),
231 ).toBe(true);
232 expect(isOwnedPastedImageUri("file:///private/var/mobile/photos/id.png")).toBe(false);
233 expect(isOwnedPastedImageUri("https://example.com/t3-composer-paste/id.png")).toBe(false);
234 });
235
236 it("converts owned files to data-backed previews and deletes the source", async () => {
237 const uri =
238 "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/id.png";
239 files.set(uri, { base64: "aGVsbG8=", deleted: false });
240
241 const attachments = await convertPastedImagesToAttachments({
242 uris: [uri],
243 existingCount: 0,
244 });
245
246 expect(attachments).toEqual([
247 expect.objectContaining({
248 dataUrl: "data:image/png;base64,aGVsbG8=",
249 previewUri: "data:image/png;base64,aGVsbG8=",
250 }),
251 ]);
252 expect(files.get(uri)?.deleted).toBe(true);
253 });
254
255 it("deletes rejected and overflow owned files without deleting user-owned files", async () => {
256 const rejected =
257 "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/bad.png";
258 const overflow =
259 "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/overflow.png";
260 const userOwned = "file:///private/var/mobile/photos/library.png";
261 files.set(rejected, { base64: "", deleted: false });
262 files.set(overflow, { base64: "aGVsbG8=", deleted: false });
263 files.set(userOwned, { base64: "aGVsbG8=", deleted: false });
264
265 await convertPastedImagesToAttachments({
266 uris: [rejected, overflow, userOwned],
267 existingCount: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1,
268 });
269
270 expect(files.get(rejected)?.deleted).toBe(true);
271 expect(files.get(overflow)?.deleted).toBe(true);
272 expect(files.get(userOwned)?.deleted).toBe(false);
273 });
274
275 it("persists folded text unchanged in the app-owned attachment directory", async () => {
276 const text = "first line\nUnicode: 🙂\n";
277 const attachment = await createPastedTextComposerAttachment({
278 text,
279 name: "pasted-text.txt",
280 maxBytes: 1024,
281 });
282
283 expect(attachment).toEqual({
284 id: "attachment-id",
285 type: "file",
286 name: "pasted-text.txt",
287 mimeType: "text/plain;charset=utf-8",
288 sizeBytes: new TextEncoder().encode(text).byteLength,
289 fileUri: "file:///documents/t3-composer-attachments/attachment-id-pasted-text.txt",
290 source: { _tag: "pasted-text" },
291 });
292 expect(files.get(attachment.fileUri)?.text).toBe(text);
293 });
294});
295
296describe("composerStripAttachments", () => {
297 const image = {
298 id: "img-1",
299 type: "image" as const,
300 name: "shot.jpg",
301 mimeType: "image/jpeg",
302 sizeBytes: 10,
303 dataUrl: "",
304 };
305 const video = {
306 id: "vid-1",
307 type: "file" as const,
308 name: "clip.mp4",
309 mimeType: "video/mp4",
310 sizeBytes: 20,
311 fileUri: "file:///clip.mp4",
312 };
313 const doc = {
314 id: "doc-1",
315 type: "file" as const,
316 name: "notes.txt",
317 mimeType: "text/plain",
318 sizeBytes: 5,
319 fileUri: "file:///notes.txt",
320 };
321
322 it("keeps media, because a thumbnail is the only way to see it", async () => {
323 const { composerStripAttachments } = await import("./composerImages");
324 const kept = composerStripAttachments([image, video] as never);
325 expect(kept.map((a) => a.id)).toEqual(["img-1", "vid-1"]);
326 });
327
328 it("never shows a non-media file above the composer", async () => {
329 const { composerStripAttachments } = await import("./composerImages");
330 // A document reads as its inline chip. A tile with a generic glyph says less than the
331 // chip does, so it is not a fallback worth having, chip present or not.
332 expect(composerStripAttachments([doc] as never)).toEqual([]);
333 });
334
335 it("keeps media beside a document rather than dropping the whole strip", async () => {
336 const { composerStripAttachments } = await import("./composerImages");
337 expect(composerStripAttachments([doc, image, video] as never).map((a) => a.id)).toEqual([
338 "img-1",
339 "vid-1",
340 ]);
341 });
342
343 it("treats a picture picked through the document picker as media", async () => {
344 const { composerStripAttachments } = await import("./composerImages");
345 // The document picker types every pick as a plain file; what it *is* decides the strip.
346 const pickedImage = {
347 id: "pick-1",
348 type: "file" as const,
349 name: "photo.png",
350 mimeType: "image/png",
351 sizeBytes: 30,
352 fileUri: "file:///photo.png",
353 };
354 expect(composerStripAttachments([pickedImage] as never).map((a) => a.id)).toEqual(["pick-1"]);
355 });
356});
357
358describe("composerAttachmentInlineUri", () => {
359 it("offers the inline bytes of a picture that owns no file", async () => {
360 const { composerAttachmentInlineUri } = await import("./composerImages");
361 // The photo library and the clipboard both produce this shape. Its `attachmentId` is a
362 // local draft id, so a remote asset lookup for it can only fail.
363 expect(
364 composerAttachmentInlineUri({
365 id: "img-1",
366 type: "image",
367 name: "IMG_0111.jpg",
368 mimeType: "image/jpeg",
369 sizeBytes: 9_100_000,
370 dataUrl: "data:image/jpeg;base64,AAAA",
371 previewUri: "ph://asset",
372 } as never),
373 ).toBe("data:image/jpeg;base64,AAAA");
374 });
375
376 it("falls back to the preview when a picture kept only its asset uri", async () => {
377 const { composerAttachmentInlineUri } = await import("./composerImages");
378 expect(
379 composerAttachmentInlineUri({
380 id: "img-2",
381 type: "image",
382 name: "IMG_0112.jpg",
383 mimeType: "image/jpeg",
384 sizeBytes: 10,
385 previewUri: "ph://asset",
386 } as never),
387 ).toBe("ph://asset");
388 });
389
390 it("leaves a file-backed attachment to the retain-lease path", async () => {
391 const { composerAttachmentInlineUri } = await import("./composerImages");
392 expect(
393 composerAttachmentInlineUri({
394 id: "img-3",
395 type: "image",
396 name: "IMG_0113.jpg",
397 mimeType: "image/jpeg",
398 sizeBytes: 10,
399 fileUri: "file:///owned.jpg",
400 previewUri: "file:///owned.jpg",
401 } as never),
402 ).toBeUndefined();
403 });
404
405 it("has nothing to offer for a plain file or a missing attachment", async () => {
406 const { composerAttachmentInlineUri } = await import("./composerImages");
407 expect(composerAttachmentInlineUri(undefined)).toBeUndefined();
408 expect(
409 composerAttachmentInlineUri({
410 id: "doc-1",
411 type: "file",
412 name: "notes.txt",
413 mimeType: "text/plain",
414 sizeBytes: 4,
415 fileUri: "file:///notes.txt",
416 } as never),
417 ).toBeUndefined();
418 });
419});