1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 createComposerImageThumbnail,
5 compressImageForStash,
6 compressImageToByteLimit,
7 dataUrlToFile,
8 isHeicImageFile,
9 MAX_COMPRESSIBLE_SOURCE_BYTES,
10 MAX_STASH_IMAGE_DATA_URL_CHARS,
11 prepareImageForAttachment,
12} from "./imageCompression";
13
14import type { SnapShotSource } from "@t3tools/contracts";
15import { hydrateImagesFromPersisted } from "../composerDraftStore";
16import { resizeSnapShotSource } from "./snapShotSource";
17
18const mocks = vi.hoisted(() => ({
19 heicTo: vi.fn(),
20}));
21
22vi.mock("heic-to/csp", () => ({
23 heicTo: mocks.heicTo,
24}));
25
26/**
27 * jsdom has no real canvas/codec, so the re-encode path is exercised with
28 * stubbed `createImageBitmap` + `OffscreenCanvas`. The encoder stub returns a
29 * payload whose size scales with quality, mirroring how a real JPEG encoder
30 * shrinks as quality drops — enough to verify the ladder logic and budget
31 * enforcement without pulling in a native canvas.
32 */
33
34const originalCreateImageBitmap = globalThis.createImageBitmap;
35const originalOffscreenCanvas = globalThis.OffscreenCanvas;
36
37beforeEach(() => {
38 vi.stubGlobal(
39 "FileReader",
40 class extends EventTarget {
41 result: string | null = null;
42 error: unknown = null;
43 async readAsDataURL(blob: Blob) {
44 try {
45 const bytes = await blob.arrayBuffer();
46 this.result = `data:${blob.type || "application/octet-stream"};base64,${Buffer.from(bytes).toString("base64")}`;
47 this.dispatchEvent(new Event("load"));
48 } catch (error) {
49 this.error = error;
50 this.dispatchEvent(new Event("error"));
51 }
52 }
53 },
54 );
55});
56
57function makeFile(sizeBytes: number, type = "image/png"): File {
58 return new File([new Uint8Array(sizeBytes).fill(7)], "shot.png", { type });
59}
60
61function makeHeicFile(options?: {
62 name?: string;
63 type?: string;
64 width?: number;
65 height?: number;
66 lastModified?: number;
67}): File {
68 const encoder = new TextEncoder();
69 const makeBox = (name: string, ...contents: Uint8Array[]) => {
70 const bytes = new Uint8Array(8 + contents.reduce((size, content) => size + content.length, 0));
71 new DataView(bytes.buffer).setUint32(0, bytes.length);
72 bytes.set(encoder.encode(name), 4);
73 let offset = 8;
74 for (const content of contents) {
75 bytes.set(content, offset);
76 offset += content.length;
77 }
78 return bytes;
79 };
80
81 const dimensions = new Uint8Array(12);
82 const view = new DataView(dimensions.buffer);
83 view.setUint32(4, options?.width ?? 4000);
84 view.setUint32(8, options?.height ?? 3000);
85 const properties = makeBox("iprp", makeBox("ipco", makeBox("ispe", dimensions)));
86
87 return new File(
88 [
89 makeBox("ftyp", encoder.encode("heic"), new Uint8Array(4)),
90 makeBox("meta", new Uint8Array(4), properties),
91 ],
92 options?.name ?? "photo.heic",
93 {
94 type: options?.type ?? "image/heic",
95 ...(options?.lastModified !== undefined ? { lastModified: options.lastModified } : {}),
96 },
97 );
98}
99
100/**
101 * Installs a fake bitmap + canvas whose encoded size follows `sizeForQuality`.
102 * `supportsWebp: false` makes `convertToBlob` hand back a differently-typed
103 * blob for WebP requests, which is how a real browser signals it cannot
104 * encode that format.
105 */
106function stubCanvasPipeline(
107 sizeForQuality: (quality: number) => number,
108 options?: { supportsWebp?: boolean; htmlCanvas?: boolean },
109) {
110 const supportsWebp = options?.supportsWebp ?? true;
111 const close = vi.fn();
112 const fillRect = vi.fn();
113 vi.stubGlobal(
114 "createImageBitmap",
115 vi.fn(async () => ({ width: 4000, height: 3000, close })),
116 );
117 vi.stubGlobal(
118 "OffscreenCanvas",
119 class {
120 constructor(
121 public width: number,
122 public height: number,
123 ) {}
124 getContext() {
125 return {
126 fillStyle: "",
127 fillRect,
128 drawImage: vi.fn(),
129 };
130 }
131 async convertToBlob({ type, quality }: { type: string; quality: number }) {
132 const resolvedType = type === "image/webp" && !supportsWebp ? "image/png" : type;
133 return new Blob([new Uint8Array(sizeForQuality(quality))], { type: resolvedType });
134 }
135 },
136 );
137 if (options?.htmlCanvas) {
138 const Canvas = globalThis.OffscreenCanvas;
139 class HtmlCanvas extends Canvas {
140 toBlob(callback: BlobCallback, type: string, quality: number) {
141 void this.convertToBlob({ type, quality }).then(callback);
142 }
143 }
144 vi.stubGlobal("OffscreenCanvas", undefined);
145 vi.stubGlobal("HTMLCanvasElement", HtmlCanvas);
146 vi.stubGlobal("document", { createElement: () => new HtmlCanvas(0, 0) });
147 }
148 return { close, fillRect };
149}
150
151afterEach(() => {
152 mocks.heicTo.mockReset();
153 vi.unstubAllGlobals();
154 globalThis.createImageBitmap = originalCreateImageBitmap;
155 globalThis.OffscreenCanvas = originalOffscreenCanvas;
156});
157
158describe("composer image thumbnails", () => {
159 it("decodes a tall original once and caches a bounded center crop", async () => {
160 const close = vi.fn();
161 const bitmap = { width: 2304, height: 32766, close };
162 const decode = vi.fn(async () => bitmap);
163 const drawImage = vi.fn();
164 const dimensions: number[][] = [];
165 vi.stubGlobal("createImageBitmap", decode);
166 vi.stubGlobal(
167 "OffscreenCanvas",
168 class {
169 constructor(width: number, height: number) {
170 dimensions.push([width, height]);
171 }
172 getContext() {
173 return { drawImage };
174 }
175 async convertToBlob() {
176 return new Blob(["thumbnail"], { type: "image/png" });
177 }
178 },
179 );
180 const original = new File(["original bytes"], "tall.png", { type: "image/png" });
181 const [first, second] = await Promise.all([
182 createComposerImageThumbnail(original),
183 createComposerImageThumbnail(original),
184 ]);
185 expect(first).toBe("data:image/png;base64,dGh1bWJuYWls");
186 expect(second).toBe(first);
187 expect(await createComposerImageThumbnail(original)).toBe(first);
188 expect(decode).toHaveBeenCalledExactlyOnceWith(original);
189 expect(dimensions).toEqual([[256, 256]]);
190 expect(drawImage).toHaveBeenCalledWith(bitmap, 0, 15231, 2304, 2304, 0, 0, 256, 256);
191 expect(close).toHaveBeenCalledOnce();
192 expect(await original.text()).toBe("original bytes");
193 });
194
195 it("releases the decoded image when thumbnail encoding fails", async () => {
196 const close = vi.fn();
197 vi.stubGlobal(
198 "createImageBitmap",
199 vi.fn(async () => ({ width: 500, height: 500, close })),
200 );
201 vi.stubGlobal(
202 "OffscreenCanvas",
203 class {
204 getContext() {
205 return { drawImage: vi.fn() };
206 }
207 async convertToBlob() {
208 throw new Error("encoder unavailable");
209 }
210 },
211 );
212 expect(await createComposerImageThumbnail(makeFile(5))).toBeNull();
213 expect(close).toHaveBeenCalledOnce();
214 });
215});
216
217describe("dataUrlToFile", () => {
218 it("decodes a captured image without a fetch request", async () => {
219 const file = dataUrlToFile("data:image/png;base64,AAEC/w==", "window.png", "image/png");
220
221 expect(file.name).toBe("window.png");
222 expect(file.type).toBe("image/png");
223 expect([...new Uint8Array(await file.arrayBuffer())]).toEqual([0, 1, 2, 255]);
224 });
225});
226
227describe("compressImageForStash", () => {
228 it.each([true, false])(
229 "encodes with HTML canvas when WebP support is %s",
230 async (supportsWebp) => {
231 const { close } = stubCanvasPipeline(() => 120_000, { htmlCanvas: true, supportsWebp });
232
233 const result = await compressImageToByteLimit(makeFile(2_000_000), 1_000_000);
234
235 expect(result.ok && result.file.type).toBe(supportsWebp ? "image/webp" : "image/jpeg");
236 expect(result.ok && result.file.size).toBe(120_000);
237 expect(close).toHaveBeenCalledOnce();
238 },
239 );
240
241 it("keeps the exact data URL budget boundary and original bytes", async () => {
242 const original = makeFile(3);
243 const dataUrl = "data:image/png;base64,BwcH";
244
245 const result = await compressImageForStash(original, dataUrl.length);
246
247 expect(result.ok && result.image.dataUrl).toBe(dataUrl);
248 expect(result.ok && result.image.recompressed).toBe(false);
249 expect(await compressImageForStash(original, dataUrl.length - 1)).toEqual({
250 ok: false,
251 reason: "too-large",
252 });
253 });
254
255 it("reports unreadable if reading a small image fails", async () => {
256 const original = makeFile(3);
257 vi.spyOn(original, "arrayBuffer").mockRejectedValue(new Error("read failed"));
258
259 expect(await compressImageForStash(original)).toEqual({ ok: false, reason: "unreadable" });
260 });
261
262 it("compresses an oversized source before reading it as a data URL", async () => {
263 stubCanvasPipeline(() => 120_000);
264 const original = makeFile(4_000_000);
265 const read = vi.spyOn(original, "arrayBuffer").mockRejectedValue(new Error("unneeded read"));
266
267 const result = await compressImageForStash(original);
268
269 expect(result.ok && result.image.recompressed).toBe(true);
270 expect(read).not.toHaveBeenCalled();
271 });
272
273 it("stores a small image verbatim without re-encoding", async () => {
274 const bitmapSpy = vi.fn();
275 vi.stubGlobal("createImageBitmap", bitmapSpy);
276
277 const result = await compressImageForStash(makeFile(1024));
278
279 expect(result.ok).toBe(true);
280 expect(result.ok && result.image.recompressed).toBe(false);
281 expect(result.ok && result.image.mimeType).toBe("image/png");
282 expect(result.ok && result.image.dataUrl.startsWith("data:image/png")).toBe(true);
283 // Untouched payloads must not pay for a decode.
284 expect(bitmapSpy).not.toHaveBeenCalled();
285 });
286
287 it("re-encodes an oversized image to WebP within the budget", async () => {
288 // Comfortably under budget at the very first quality step.
289 const { close, fillRect } = stubCanvasPipeline(() => 120_000);
290
291 const result = await compressImageForStash(makeFile(4_000_000));
292
293 expect(result.ok).toBe(true);
294 expect(result.ok && result.image.recompressed).toBe(true);
295 expect(result.ok && result.image.mimeType).toBe("image/webp");
296 expect(result.ok && result.image.dataUrl.length <= MAX_STASH_IMAGE_DATA_URL_CHARS).toBe(true);
297 // sizeBytes should describe the re-encoded payload, not the 4MB original.
298 expect(result.ok && result.image.sizeBytes).toBeLessThan(4_000_000);
299 // WebP keeps alpha, so no white matte should be painted.
300 expect(fillRect).not.toHaveBeenCalled();
301 expect(close).toHaveBeenCalled();
302 });
303
304 it("falls back to JPEG with a white matte when WebP encoding is unavailable", async () => {
305 const { fillRect } = stubCanvasPipeline(() => 120_000, { supportsWebp: false });
306
307 const result = await compressImageForStash(makeFile(4_000_000));
308
309 expect(result.ok && result.image.recompressed).toBe(true);
310 expect(result.ok && result.image.mimeType).toBe("image/jpeg");
311 // JPEG has no alpha, so transparent regions must be matted white.
312 expect(fillRect).toHaveBeenCalled();
313 });
314
315 it("steps quality down until the encoded image fits", async () => {
316 // Only the lowest quality step (0.68) lands under the budget.
317 const { close } = stubCanvasPipeline((quality) => (quality <= 0.68 ? 400_000 : 3_000_000));
318
319 const result = await compressImageForStash(makeFile(9_000_000));
320
321 expect(result.ok && result.image.recompressed).toBe(true);
322 expect(result.ok && result.image.dataUrl.length <= MAX_STASH_IMAGE_DATA_URL_CHARS).toBe(true);
323 expect(close).toHaveBeenCalled();
324 });
325
326 it("reports too-large when even the smallest encoding overflows the budget", async () => {
327 const { close } = stubCanvasPipeline(() => 8_000_000);
328
329 const result = await compressImageForStash(makeFile(9_000_000));
330
331 expect(result).toEqual({ ok: false, reason: "too-large" });
332 // The bitmap must still be released on the give-up path.
333 expect(close).toHaveBeenCalled();
334 });
335
336 it("reports too-large for an oversized image when the browser cannot re-encode", async () => {
337 vi.stubGlobal("createImageBitmap", undefined);
338 vi.stubGlobal("OffscreenCanvas", undefined);
339
340 expect(await compressImageForStash(makeFile(4_000_000))).toEqual({
341 ok: false,
342 reason: "too-large",
343 });
344 });
345
346 it("reports unreadable when the image fails to decode", async () => {
347 vi.stubGlobal(
348 "createImageBitmap",
349 vi.fn(async () => {
350 throw new Error("corrupt image");
351 }),
352 );
353 vi.stubGlobal(
354 "OffscreenCanvas",
355 class {
356 getContext() {
357 return null;
358 }
359 },
360 );
361
362 expect(await compressImageForStash(makeFile(4_000_000))).toEqual({
363 ok: false,
364 reason: "unreadable",
365 });
366 });
367
368 it("compressImageToByteLimit passes small files through byte-for-byte", async () => {
369 const bitmapSpy = vi.fn();
370 vi.stubGlobal("createImageBitmap", bitmapSpy);
371
372 const original = makeFile(1024);
373 const result = await compressImageToByteLimit(original, 10 * 1024 * 1024);
374
375 expect(result.ok).toBe(true);
376 expect(result.ok && result.recompressed).toBe(false);
377 // Pass-through must be the same File object, not a copy.
378 expect(result.ok && result.file).toBe(original);
379 expect(bitmapSpy).not.toHaveBeenCalled();
380 });
381
382 it("compressImageToByteLimit re-encodes an oversized file under the byte cap", async () => {
383 stubCanvasPipeline(() => 200_000);
384
385 const result = await compressImageToByteLimit(makeFile(2_000_000), 1_000_000);
386
387 expect(result.ok).toBe(true);
388 expect(result.ok && result.recompressed).toBe(true);
389 expect(result.ok && result.file.type).toBe("image/webp");
390 // The re-encoded name must match the new container format.
391 expect(result.ok && result.file.name).toBe("shot.webp");
392 expect(result.ok && result.file.size).toBeLessThanOrEqual(1_000_000);
393 });
394
395 it("compressImageToByteLimit refuses sources above the decode-safety ceiling", async () => {
396 const bitmapSpy = vi.fn();
397 vi.stubGlobal("createImageBitmap", bitmapSpy);
398
399 const result = await compressImageToByteLimit(
400 makeFile(MAX_COMPRESSIBLE_SOURCE_BYTES + 1),
401 10 * 1024 * 1024,
402 );
403
404 expect(result).toEqual({ ok: false, reason: "too-large" });
405 // The whole point of the ceiling is to never decode such a file.
406 expect(bitmapSpy).not.toHaveBeenCalled();
407 });
408
409 it("compressImageToByteLimit reports too-large when no encoding fits", async () => {
410 const { close } = stubCanvasPipeline(() => 3_000_000);
411
412 const result = await compressImageToByteLimit(makeFile(2_000_000), 1_000_000);
413
414 expect(result).toEqual({ ok: false, reason: "too-large" });
415 expect(close).toHaveBeenCalled();
416 });
417
418 it("shrinks below the source size when the image is already under MAX_DIMENSION", async () => {
419 // A small-but-heavy source (e.g. a dense PNG): only a real downscale can
420 // get it under budget, since quality alone is stubbed to never suffice.
421 let smallestRequested = Number.POSITIVE_INFINITY;
422 const close = vi.fn();
423 vi.stubGlobal(
424 "createImageBitmap",
425 vi.fn(async () => ({ width: 800, height: 600, close })),
426 );
427 vi.stubGlobal(
428 "OffscreenCanvas",
429 class {
430 constructor(
431 public width: number,
432 public height: number,
433 ) {
434 smallestRequested = Math.min(smallestRequested, width);
435 }
436 getContext() {
437 return { fillStyle: "", fillRect: vi.fn(), drawImage: vi.fn() };
438 }
439 async convertToBlob({ type }: { type: string; quality: number }) {
440 // Only a genuinely downscaled pass fits the budget.
441 const size = smallestRequested < 800 ? 100_000 : 5_000_000;
442 return new Blob([new Uint8Array(size)], { type });
443 }
444 },
445 );
446
447 const result = await compressImageForStash(makeFile(4_000_000));
448
449 expect(result.ok).toBe(true);
450 // Fallback passes must scale off the bitmap, not a fixed 2048 ceiling
451 // that would never go below an 800px source.
452 expect(smallestRequested).toBeLessThan(800);
453 });
454});
455
456describe("HEIC attachment preparation", () => {
457 it("recognizes HEIC and HEIF MIME types and case-insensitive file extensions", () => {
458 expect(isHeicImageFile({ name: "photo.bin", type: "image/heic" })).toBe(true);
459 expect(isHeicImageFile({ name: "photo.bin", type: "image/heif" })).toBe(true);
460 expect(isHeicImageFile({ name: "photo.heic", type: "image/heic-sequence" })).toBe(false);
461 expect(isHeicImageFile({ name: "photo.heif", type: "image/heif-sequence" })).toBe(false);
462 expect(isHeicImageFile({ name: "IMG_1234.HEIC", type: "" })).toBe(true);
463 expect(isHeicImageFile({ name: "photo.heif", type: "application/octet-stream" })).toBe(true);
464 expect(isHeicImageFile({ name: "photo.png", type: "image/png" })).toBe(false);
465 expect(isHeicImageFile({ name: "photo.heic", type: "image/png" })).toBe(false);
466 expect(isHeicImageFile({ name: "photo.heif", type: "image/jpeg" })).toBe(false);
467 });
468
469 it("converts a HEIC photo with a missing MIME type into a named JPEG", async () => {
470 const original = makeHeicFile({
471 name: "IMG_1234.HEIC",
472 type: "",
473 lastModified: 123,
474 });
475 mocks.heicTo.mockResolvedValueOnce(
476 new Blob([new Uint8Array([4, 5, 6, 7])], { type: "image/jpeg" }),
477 );
478
479 const result = await prepareImageForAttachment(original, 1024);
480
481 expect(mocks.heicTo).toHaveBeenCalledWith({
482 blob: original,
483 type: "image/jpeg",
484 quality: 0.92,
485 });
486 expect(result.ok && result.file.name).toBe("IMG_1234.jpg");
487 expect(result.ok && result.file.type).toBe("image/jpeg");
488 expect(result.ok && result.file.size).toBe(4);
489 expect(result.ok && result.file.lastModified).toBe(123);
490 expect(result.ok && result.recompressed).toBe(true);
491 });
492
493 it("keeps oversized converted photos in JPEG format while shrinking them", async () => {
494 const original = makeHeicFile({
495 name: "photo.heif",
496 type: "image/heif",
497 });
498 mocks.heicTo.mockResolvedValueOnce(
499 new Blob([new Uint8Array(2_000_000)], { type: "image/jpeg" }),
500 );
501 const { fillRect } = stubCanvasPipeline(() => 200_000);
502
503 const result = await prepareImageForAttachment(original, 1_000_000);
504
505 expect(result.ok && result.file.name).toBe("photo.jpg");
506 expect(result.ok && result.file.type).toBe("image/jpeg");
507 expect(result.ok && result.file.size).toBeLessThanOrEqual(1_000_000);
508 expect(fillRect).toHaveBeenCalled();
509 });
510
511 it("compresses JPEG intermediates above the source safety ceiling", async () => {
512 const original = makeHeicFile({
513 name: "large.heic",
514 type: "image/heic",
515 });
516 mocks.heicTo.mockResolvedValueOnce(
517 new Blob([new Uint8Array(MAX_COMPRESSIBLE_SOURCE_BYTES + 1)], {
518 type: "image/jpeg",
519 }),
520 );
521 const { close } = stubCanvasPipeline(() => 200_000);
522
523 const result = await prepareImageForAttachment(original, 1_000_000);
524
525 expect(result.ok && result.file.name).toBe("large.jpg");
526 expect(result.ok && result.file.type).toBe("image/jpeg");
527 expect(result.ok && result.file.size).toBeLessThanOrEqual(1_000_000);
528 expect(close).toHaveBeenCalled();
529 });
530
531 it.each([
532 { label: "24 MP", width: 5712, height: 4284 },
533 { label: "48 MP", width: 8064, height: 6048 },
534 ])("accepts $label HEIC photos", async ({ width, height }) => {
535 const original = makeHeicFile({ width, height });
536 mocks.heicTo.mockResolvedValueOnce(new Blob(["jpeg"], { type: "image/jpeg" }));
537
538 const result = await prepareImageForAttachment(original, 1024);
539
540 expect(result.ok && result.file.type).toBe("image/jpeg");
541 expect(mocks.heicTo).toHaveBeenCalledOnce();
542 });
543
544 it("rejects oversized HEIC dimensions before loading the decoder", async () => {
545 const original = makeHeicFile({ width: 16_000, height: 4001 });
546
547 expect(await prepareImageForAttachment(original, 1024)).toEqual({
548 ok: false,
549 reason: "too-large",
550 });
551 expect(mocks.heicTo).not.toHaveBeenCalled();
552 });
553
554 it("rejects invalid HEIC metadata before loading the decoder", async () => {
555 const original = new File([new Uint8Array([1, 2, 3])], "broken.heic", {
556 type: "image/heic",
557 });
558
559 expect(await prepareImageForAttachment(original, 1024)).toEqual({
560 ok: false,
561 reason: "unreadable",
562 });
563 expect(mocks.heicTo).not.toHaveBeenCalled();
564 });
565
566 it("reports unreadable when HEIC decoding fails", async () => {
567 const original = makeHeicFile({
568 name: "broken.heic",
569 type: "image/heic",
570 });
571 mocks.heicTo.mockRejectedValueOnce(new Error("Invalid HEIC image"));
572
573 expect(await prepareImageForAttachment(original, 1024)).toEqual({
574 ok: false,
575 reason: "unreadable",
576 });
577 });
578
579 it("rejects unsafe HEIC sources before loading the decoder", async () => {
580 const original = new File(["photo"], "large.heic", { type: "image/heic" });
581 Object.defineProperty(original, "size", { value: MAX_COMPRESSIBLE_SOURCE_BYTES + 1 });
582
583 expect(await prepareImageForAttachment(original, 1024)).toEqual({
584 ok: false,
585 reason: "too-large",
586 });
587 expect(mocks.heicTo).not.toHaveBeenCalled();
588 });
589
590 it("leaves supported images untouched without loading the HEIC decoder", async () => {
591 const original = makeFile(1024);
592
593 const result = await prepareImageForAttachment(original, 2048);
594
595 expect(result.ok && result.file).toBe(original);
596 expect(result.ok && result.recompressed).toBe(false);
597 expect(mocks.heicTo).not.toHaveBeenCalled();
598 });
599});
600
601describe("attachment max edge", () => {
602 const u32 = (n: number) => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
603 const u16 = (n: number) => [(n >>> 8) & 0xff, n & 0xff];
604 const pngHeader = (width: number, height: number) => [
605 0x89,
606 0x50,
607 0x4e,
608 0x47,
609 0x0d,
610 0x0a,
611 0x1a,
612 0x0a,
613 ...u32(13),
614 0x49,
615 0x48,
616 0x44,
617 0x52,
618 ...u32(width),
619 ...u32(height),
620 8,
621 6,
622 0,
623 0,
624 0,
625 ];
626 const jpegHeader = (width: number, height: number) => [
627 0xff,
628 0xd8,
629 0xff,
630 0xc0,
631 ...u16(17),
632 8,
633 ...u16(height),
634 ...u16(width),
635 ];
636 const image = (header: number[], name: string, type: string) =>
637 new File([new Uint8Array([...header, ...new Uint8Array(2048).fill(7)])], name, { type });
638
639 /** Records the decoded size, the canvas sizes, and the encode calls. */
640 function stubDecoder(bitmapSize: { width: number; height: number }) {
641 const decode = vi.fn(async () => ({ ...bitmapSize, close: vi.fn() }));
642 const canvases: Array<{ width: number; height: number }> = [];
643 const encodes: Array<{ type: string; quality: number }> = [];
644 const fillRect = vi.fn();
645 vi.stubGlobal("createImageBitmap", decode);
646 vi.stubGlobal(
647 "OffscreenCanvas",
648 class {
649 constructor(width: number, height: number) {
650 canvases.push({ width, height });
651 }
652 getContext() {
653 return { fillRect, drawImage: vi.fn() };
654 }
655 async convertToBlob(options: { type: string; quality: number }) {
656 encodes.push(options);
657 return new Blob([new Uint8Array(500_000)], { type: options.type });
658 }
659 },
660 );
661 return { decode, canvases, encodes, fillRect };
662 }
663
664 it("scales a large PNG screenshot under the byte cap to 2000px and keeps it a PNG", async () => {
665 const { decode, canvases, encodes, fillRect } = stubDecoder({ width: 3024, height: 1964 });
666 const original = image(pngHeader(3024, 1964), "shot.png", "image/png");
667
668 const result = await prepareImageForAttachment(original, 10 * 1024 * 1024);
669
670 expect(decode).toHaveBeenCalledExactlyOnceWith(original, { imageOrientation: "from-image" });
671 expect(canvases).toEqual([{ width: 2000, height: 1299 }]);
672 expect(encodes.map(({ type }) => type)).toEqual(["image/png"]);
673 // PNG keeps alpha, so nothing paints a matte behind it.
674 expect(fillRect).not.toHaveBeenCalled();
675 expect(result.ok && result.recompressed).toBe(true);
676 expect(result.ok && result.imageSize).toEqual({ width: 2000, height: 1299 });
677 expect(result.ok && result.file.name).toBe("shot.png");
678 expect(result.ok && result.file.type).toBe("image/png");
679 });
680
681 it("scales a rotated JPEG by its displayed size and keeps it a JPEG", async () => {
682 // Stored landscape, displayed portrait after the EXIF rotation.
683 const { canvases, encodes } = stubDecoder({ width: 3024, height: 4032 });
684 const original = image(jpegHeader(4032, 3024), "photo.jpg", "image/jpeg");
685
686 const result = await prepareImageForAttachment(original, 10 * 1024 * 1024);
687
688 expect(canvases).toEqual([{ width: 1500, height: 2000 }]);
689 expect(encodes).toEqual([{ type: "image/jpeg", quality: 0.92 }]);
690 expect(result.ok && result.file.type).toBe("image/jpeg");
691 expect(result.ok && result.imageSize).toEqual({ width: 1500, height: 2000 });
692 });
693
694 it.each([
695 { label: "a 2000px PNG", header: pngHeader(2000, 1200), type: "image/png" },
696 {
697 label: "a 3000px GIF",
698 header: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0xb8, 0x0b, 0x10, 0x04],
699 type: "image/gif",
700 },
701 ])("passes $label through without decoding it", async ({ header, type }) => {
702 const { decode } = stubDecoder({ width: 3000, height: 1040 });
703 const original = image(header, "image", type);
704
705 const result = await prepareImageForAttachment(original, 10 * 1024 * 1024);
706
707 expect(result.ok && result.file).toBe(original);
708 expect(result.ok && result.recompressed).toBe(false);
709 expect(decode).not.toHaveBeenCalled();
710 });
711});
712
713describe("snapshot coordinates after compression", () => {
714 const source: SnapShotSource = {
715 kind: "snap-shot",
716 capturedAt: "2026-09-01T00:00:00.000Z",
717 appName: "Editor",
718 windowTitle: "main.ts",
719 accessibility: {
720 format: "element-tree",
721 coordinateSpace: "captured-image",
722 imageSize: { width: 2560, height: 1600 },
723 truncated: false,
724 root: {
725 role: "window",
726 bounds: { x: 0, y: 0, width: 2560, height: 1600 },
727 children: [
728 {
729 role: "button",
730 name: "Save",
731 bounds: { x: 2400, y: 1400, width: 100, height: 100 },
732 children: [],
733 },
734 { role: "static_text", name: "Untitled", bounds: null, children: [] },
735 ],
736 },
737 },
738 };
739
740 it.each(["stash", "delivery"])(
741 "rescales the source and restores it with the compressed %s image",
742 async (path) => {
743 stubCanvasPipeline(() => 100);
744 vi.stubGlobal(
745 "createImageBitmap",
746 vi.fn(async () => ({ width: 2560, height: 1600, close: vi.fn() })),
747 );
748 const original = makeFile(2000);
749 const compressed =
750 path === "stash"
751 ? await compressImageForStash(original, 1000)
752 : await compressImageToByteLimit(original, 1000);
753 expect(compressed.ok).toBe(true);
754 if (!compressed.ok) throw new Error("Compression failed");
755 const image =
756 "image" in compressed
757 ? compressed.image
758 : {
759 ...compressed,
760 mimeType: compressed.file.type,
761 sizeBytes: compressed.file.size,
762 dataUrl: `data:${compressed.file.type};base64,${Buffer.from(await compressed.file.arrayBuffer()).toString("base64")}`,
763 };
764 expect(image.imageSize).toEqual({ width: 2000, height: 1250 });
765 const resized = resizeSnapShotSource(source, image.imageSize);
766 const [restored] = hydrateImagesFromPersisted([
767 {
768 id: "capture",
769 name: "window.webp",
770 mimeType: image.mimeType,
771 sizeBytes: image.sizeBytes,
772 dataUrl: image.dataUrl,
773 source: resized,
774 },
775 ]);
776 expect(restored?.source?.accessibility).toMatchObject({
777 imageSize: { width: 2000, height: 1250 },
778 root: {
779 bounds: { x: 0, y: 0, width: 2000, height: 1250 },
780 children: [{ bounds: { x: 1875, y: 1094, width: 78, height: 78 } }, { bounds: null }],
781 },
782 });
783 expect(source.accessibility).toMatchObject({ imageSize: { width: 2560, height: 1600 } });
784 },
785 );
786
787 it("keeps uncompressed sources unchanged", () => {
788 expect(resizeSnapShotSource(source)).toBe(source);
789 });
790});