Skip to content
Source
Sign inSign up

imageCompression.test.ts

556 lines19.1 KB

1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2​
3import {
4 compressImageForStash,
5 compressImageToByteLimit,
6 createComposerImageThumbnail,
7 inheritComposerImageThumbnail,
8 MAX_COMPRESSIBLE_SOURCE_BYTES,
9 MAX_STASH_IMAGE_DATA_URL_CHARS,
10 prepareImageForAttachment,
11 readComposerImageThumbnail,
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​
26const originalCreateImageBitmap = globalThis.createImageBitmap;
27const originalOffscreenCanvas = globalThis.OffscreenCanvas;
28​
29beforeEach(() => {
30 vi.stubGlobal(
31 "FileReader",
32 class extends EventTarget {
33 result: string | null = null;
34 error: unknown = null;
35 async readAsDataURL(blob: Blob) {
36 try {
37 const bytes = await blob.arrayBuffer();
38 this.result = `data:${blob.type || "application/octet-stream"};base64,${Buffer.from(bytes).toString("base64")}`;
39 this.dispatchEvent(new Event("load"));
40 } catch (error) {
41 this.error = error;
42 this.dispatchEvent(new Event("error"));
43 }
44 }
45 },
46 );
47});
48​
49function makeFile(sizeBytes: number, type = "image/png"): File {
50 return new File([new Uint8Array(sizeBytes).fill(7)], "shot.png", { type });
52​
53function makeHeicFile(options?: {
54 name?: string;
55 type?: string;
56 width?: number;
57 height?: number;
58 lastModified?: number;
59}): File {
60 const encoder = new TextEncoder();
61 const makeBox = (name: string, ...contents: Uint8Array[]) => {
62 const bytes = new Uint8Array(8 + contents.reduce((size, content) => size + content.length, 0));
63 new DataView(bytes.buffer).setUint32(0, bytes.length);
64 bytes.set(encoder.encode(name), 4);
65 let offset = 8;
66 for (const content of contents) {
67 bytes.set(content, offset);
68 offset += content.length;
69 }
70 return bytes;
71 };
72​
73 const dimensions = new Uint8Array(12);
74 const view = new DataView(dimensions.buffer);
75 view.setUint32(4, options?.width ?? 4000);
76 view.setUint32(8, options?.height ?? 3000);
77 const properties = makeBox("iprp", makeBox("ipco", makeBox("ispe", dimensions)));
78​
79 return new File(
80 [
81 makeBox("ftyp", encoder.encode("heic"), new Uint8Array(4)),
82 makeBox("meta", new Uint8Array(4), properties),
83 ],
84 options?.name ?? "photo.heic",
85 {
86 type: options?.type ?? "image/heic",
87 ...(options?.lastModified !== undefined ? { lastModified: options.lastModified } : {}),
88 },
89 );
91​
92function stubCanvasPipeline(
93 sizeForQuality: (quality: number) => number,
94 options?: { supportsWebp?: boolean; htmlCanvas?: boolean },
95) {
96 const supportsWebp = options?.supportsWebp ?? true;
97 const close = vi.fn();
98 const fillRect = vi.fn();
99 const canvasSizes: Array<{ width: number; height: number }> = [];
100 vi.stubGlobal(
101 "createImageBitmap",
102 vi.fn(async () => ({ width: 4000, height: 3000, close })),
103 );
104 vi.stubGlobal(
105 "OffscreenCanvas",
106 class {
107 constructor(
108 public width: number,
109 public height: number,
110 ) {
111 canvasSizes.push({ width, height });
112 }
113 getContext() {
114 return {
115 fillStyle: "",
116 fillRect,
117 drawImage: vi.fn(),
118 };
119 }
120 async convertToBlob({ type, quality }: { type: string; quality: number }) {
121 const resolvedType = type === "image/webp" && !supportsWebp ? "image/png" : type;
122 return new Blob([new Uint8Array(sizeForQuality(quality))], { type: resolvedType });
123 }
124 },
125 );
126 if (options?.htmlCanvas) {
127 const Canvas = globalThis.OffscreenCanvas;
128 class HtmlCanvas extends Canvas {
129 toBlob(callback: BlobCallback, type: string, quality: number) {
130 void this.convertToBlob({ type, quality }).then(callback);
131 }
132 }
133 vi.stubGlobal("OffscreenCanvas", undefined);
134 vi.stubGlobal("HTMLCanvasElement", HtmlCanvas);
135 vi.stubGlobal("document", { createElement: () => new HtmlCanvas(0, 0) });
136 }
137 return { close, fillRect, canvasSizes };
139​
140afterEach(() => {
141 mocks.heicTo.mockReset();
142 vi.unstubAllGlobals();
143 globalThis.createImageBitmap = originalCreateImageBitmap;
144 globalThis.OffscreenCanvas = originalOffscreenCanvas;
145});
146​
147describe("compressImageForStash", () => {
148 it("keeps the exact data URL budget boundary and original bytes", async () => {
149 const original = makeFile(3);
150 const dataUrl = "data:image/png;base64,BwcH";
151​
152 const result = await compressImageForStash(original, dataUrl.length);
153​
154 expect(result.ok && result.image.dataUrl).toBe(dataUrl);
155 expect(result.ok && result.image.recompressed).toBe(false);
156 expect(await compressImageForStash(original, dataUrl.length - 1)).toEqual({
157 ok: false,
158 reason: "too-large",
159 });
160 });
161​
162 it("compresses an oversized source before reading it as a data URL", async () => {
163 stubCanvasPipeline(() => 120_000);
164 const original = makeFile(4_000_000);
165 const read = vi.spyOn(original, "arrayBuffer").mockRejectedValue(new Error("unneeded read"));
166​
167 const result = await compressImageForStash(original);
168​
169 expect(result.ok && result.image.recompressed).toBe(true);
170 expect(read).not.toHaveBeenCalled();
171 });
172​
173 it("re-encodes an oversized image to WebP within the budget", async () => {
174 const { close, fillRect } = stubCanvasPipeline(() => 120_000);
175​
176 const result = await compressImageForStash(makeFile(4_000_000));
177​
178 expect(result.ok).toBe(true);
179 expect(result.ok && result.image.recompressed).toBe(true);
180 expect(result.ok && result.image.mimeType).toBe("image/webp");
181 expect(result.ok && result.image.dataUrl.length <= MAX_STASH_IMAGE_DATA_URL_CHARS).toBe(true);
182 expect(result.ok && result.image.sizeBytes).toBeLessThan(4_000_000);
183 expect(fillRect).not.toHaveBeenCalled();
184 expect(close).toHaveBeenCalled();
185 });
186​
187 it("falls back to JPEG with a white matte when WebP encoding is unavailable", async () => {
188 const { fillRect } = stubCanvasPipeline(() => 120_000, { supportsWebp: false });
189​
190 const result = await compressImageForStash(makeFile(4_000_000));
191​
192 expect(result.ok && result.image.recompressed).toBe(true);
193 expect(result.ok && result.image.mimeType).toBe("image/jpeg");
194 expect(fillRect).toHaveBeenCalled();
195 });
196​
197 it("steps quality down until the encoded image fits", async () => {
198 const { close } = stubCanvasPipeline((quality) => (quality <= 0.68 ? 400_000 : 3_000_000));
199​
200 const result = await compressImageForStash(makeFile(9_000_000));
201​
202 expect(result.ok && result.image.recompressed).toBe(true);
203 expect(result.ok && result.image.dataUrl.length <= MAX_STASH_IMAGE_DATA_URL_CHARS).toBe(true);
204 expect(close).toHaveBeenCalled();
205 });
206​
207 it("reports too-large when even the smallest encoding overflows the budget", async () => {
208 const { close } = stubCanvasPipeline(() => 8_000_000);
209​
210 const result = await compressImageForStash(makeFile(9_000_000));
211​
212 expect(result).toEqual({ ok: false, reason: "too-large" });
213 expect(close).toHaveBeenCalled();
214 });
215​
216 it("reports unreadable when the image fails to decode", async () => {
217 vi.stubGlobal(
218 "createImageBitmap",
219 vi.fn(async () => {
220 throw new Error("corrupt image");
221 }),
222 );
223 vi.stubGlobal(
224 "OffscreenCanvas",
225 class {
226 getContext() {
227 return null;
228 }
229 },
230 );
231​
232 expect(await compressImageForStash(makeFile(4_000_000))).toEqual({
233 ok: false,
234 reason: "unreadable",
235 });
236 });
237​
238 it("compressImageToByteLimit refuses sources above the decode-safety ceiling", async () => {
239 const bitmapSpy = vi.fn();
240 vi.stubGlobal("createImageBitmap", bitmapSpy);
241​
242 const result = await compressImageToByteLimit(
243 makeFile(MAX_COMPRESSIBLE_SOURCE_BYTES + 1),
244 10 * 1024 * 1024,
245 );
246​
247 expect(result).toEqual({ ok: false, reason: "too-large" });
248 expect(bitmapSpy).not.toHaveBeenCalled();
249 });
250});
251​
252describe("composer image thumbnails", () => {
253 it("gives the compressed file its source's thumbnail without decoding it again", async () => {
254 stubCanvasPipeline(() => 3);
255 const source = makeFile(3);
256 const compressed = makeFile(2, "image/webp");
257 // Compression can finish before the placeholder's thumbnail does.
258 const pendingThumbnail = createComposerImageThumbnail(source);
259​
260 await inheritComposerImageThumbnail(source, compressed);
261​
262 const sourceThumbnail = await pendingThumbnail;
263 expect(sourceThumbnail).toMatch(/^data:image\/png;base64,/);
264 expect(readComposerImageThumbnail(compressed)).toBe(sourceThumbnail);
265 expect(await createComposerImageThumbnail(compressed)).toBe(sourceThumbnail);
266 expect(createImageBitmap).toHaveBeenCalledTimes(1);
267 });
268});
269​
270describe("HEIC attachment preparation", () => {
271 it("shares the native decode and shows its thumbnail before JPEG encoding finishes", async () => {
272 const { close } = stubCanvasPipeline(() => 3);
273 let finishEncoding!: (blob: Blob) => void;
274 const jpeg = new Promise<Blob>((resolve) => {
275 finishEncoding = resolve;
276 });
277 const Canvas = globalThis.OffscreenCanvas;
278 vi.stubGlobal(
279 "OffscreenCanvas",
280 class extends Canvas {
281 override convertToBlob(options?: ImageEncodeOptions) {
282 return options?.type === "image/jpeg" ? jpeg : super.convertToBlob(options);
283 }
284 },
285 );
286 const original = makeHeicFile();
287 const preparation = prepareImageForAttachment(original, 1024);
288 const queued = prepareImageForAttachment(makeHeicFile({ name: "next.heic" }), 1024);
289 const results = Promise.all([preparation, queued]);
290 try {
291 const thumbnail = await createComposerImageThumbnail(original);
292 expect(thumbnail).toMatch(/^data:image\/png;base64,/);
293 expect(mocks.heicTo).not.toHaveBeenCalled();
294 expect(createImageBitmap).toHaveBeenCalledTimes(1);
295 expect(close).not.toHaveBeenCalled();
296 } finally {
297 finishEncoding(new Blob([new Uint8Array(4)], { type: "image/jpeg" }));
298 await results;
299 }
300 const [result, next] = await results;
301 expect(result.ok && result.file.type).toBe("image/jpeg");
302 expect(result.ok && result.file.size).toBe(4);
303 expect(next.ok).toBe(true);
304 expect(close).toHaveBeenCalledTimes(2);
305 });
306​
307 it("converts a HEIC photo with a missing MIME type into a named JPEG", async () => {
308 const original = makeHeicFile({
309 name: "IMG_1234.HEIC",
310 type: "",
311 lastModified: 123,
312 });
313 const { close } = stubCanvasPipeline(() => 4);
314 vi.mocked(createImageBitmap).mockRejectedValue(new Error("HEIC is not supported natively"));
315 mocks.heicTo.mockResolvedValueOnce({ width: 4000, height: 3000, close });
316​
317 const result = await prepareImageForAttachment(original, 1024);
318​
319 expect(mocks.heicTo).toHaveBeenCalledWith({
320 blob: original,
321 type: "bitmap",
322 });
323 expect(result.ok && result.file.name).toBe("IMG_1234.jpg");
324 expect(result.ok && result.file.type).toBe("image/jpeg");
325 expect(result.ok && result.file.size).toBe(4);
326 expect(result.ok && result.file.lastModified).toBe(123);
327 expect(result.ok && result.recompressed).toBe(true);
328 });
329​
330 it("resizes decoded HEIC pixels before encoding the JPEG", async () => {
331 const original = makeHeicFile({
332 name: "large.heic",
333 type: "image/heic",
334 });
335 const { close, canvasSizes } = stubCanvasPipeline(() => 200_000);
336 vi.mocked(createImageBitmap).mockRejectedValue(new Error("HEIC is not supported natively"));
337 mocks.heicTo.mockResolvedValueOnce({ width: 4000, height: 3000, close });
338​
339 const result = await prepareImageForAttachment(original, 1_000_000);
340​
341 expect(result.ok && result.file.name).toBe("large.jpg");
342 expect(result.ok && result.file.type).toBe("image/jpeg");
343 expect(result.ok && result.file.size).toBeLessThanOrEqual(1_000_000);
344 expect(close).toHaveBeenCalledTimes(1);
345 expect(createImageBitmap).toHaveBeenCalledTimes(1);
346 expect(canvasSizes).toEqual([
347 { width: 2000, height: 1500 },
348 { width: 256, height: 256 },
349 ]);
350 });
351​
352 it("rejects oversized HEIC dimensions before loading the decoder", async () => {
353 const original = makeHeicFile({ width: 16_000, height: 4001 });
354​
355 expect(await prepareImageForAttachment(original, 1024)).toEqual({
356 ok: false,
357 reason: "too-large",
358 });
359 expect(mocks.heicTo).not.toHaveBeenCalled();
360 });
361​
362 it("rejects unsafe HEIC sources before loading the decoder", async () => {
363 const original = new File(["photo"], "large.heic", { type: "image/heic" });
364 Object.defineProperty(original, "size", { value: MAX_COMPRESSIBLE_SOURCE_BYTES + 1 });
365​
366 expect(await prepareImageForAttachment(original, 1024)).toEqual({
367 ok: false,
368 reason: "too-large",
369 });
370 expect(mocks.heicTo).not.toHaveBeenCalled();
371 });
372});
373​
374describe("attachment max edge", () => {
375 const u32 = (n: number) => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
376 const u16 = (n: number) => [(n >>> 8) & 0xff, n & 0xff];
377 const pngHeader = (width: number, height: number) => [
378 0x89,
379 0x50,
380 0x4e,
381 0x47,
382 0x0d,
383 0x0a,
384 0x1a,
385 0x0a,
386 ...u32(13),
387 0x49,
388 0x48,
389 0x44,
390 0x52,
391 ...u32(width),
392 ...u32(height),
393 8,
394 6,
395 0,
396 0,
397 0,
398 ];
399 const jpegHeader = (width: number, height: number) => [
400 0xff,
401 0xd8,
402 0xff,
403 0xc0,
404 ...u16(17),
405 8,
406 ...u16(height),
407 ...u16(width),
408 ];
409 const image = (header: number[], name: string, type: string) =>
410 new File([new Uint8Array([...header, ...new Uint8Array(2048).fill(7)])], name, { type });
411​
412 function stubDecoder(bitmapSize: { width: number; height: number }) {
413 const decode = vi.fn(async () => ({ ...bitmapSize, close: vi.fn() }));
414 const canvases: Array<{ width: number; height: number }> = [];
415 const encodes: Array<{ type: string; quality: number }> = [];
416 const fillRect = vi.fn();
417 vi.stubGlobal("createImageBitmap", decode);
418 vi.stubGlobal(
419 "OffscreenCanvas",
420 class {
421 constructor(width: number, height: number) {
422 canvases.push({ width, height });
423 }
424 getContext() {
425 return { fillRect, drawImage: vi.fn() };
426 }
427 async convertToBlob(options: { type: string; quality: number }) {
428 encodes.push(options);
429 return new Blob([new Uint8Array(500_000)], { type: options.type });
430 }
431 },
432 );
433 return { decode, canvases, encodes, fillRect };
434 }
435​
436 it("scales a large PNG screenshot under the byte cap to 2000px and keeps it a PNG", async () => {
437 const { decode, canvases, encodes, fillRect } = stubDecoder({ width: 3024, height: 1964 });
438 const original = image(pngHeader(3024, 1964), "shot.png", "image/png");
439​
440 const result = await prepareImageForAttachment(original, 10 * 1024 * 1024);
441​
442 expect(decode).toHaveBeenCalledExactlyOnceWith(original, { imageOrientation: "from-image" });
443 expect(canvases).toEqual([{ width: 2000, height: 1299 }]);
444 expect(encodes.map(({ type }) => type)).toEqual(["image/png"]);
445 expect(fillRect).not.toHaveBeenCalled();
446 expect(result.ok && result.recompressed).toBe(true);
447 expect(result.ok && result.imageSize).toEqual({ width: 2000, height: 1299 });
448 expect(result.ok && result.file.name).toBe("shot.png");
449 expect(result.ok && result.file.type).toBe("image/png");
450 });
451​
452 it("scales a rotated JPEG by its displayed size and keeps it a JPEG", async () => {
453 const { canvases, encodes } = stubDecoder({ width: 3024, height: 4032 });
454 const original = image(jpegHeader(4032, 3024), "photo.jpg", "image/jpeg");
455​
456 const result = await prepareImageForAttachment(original, 10 * 1024 * 1024);
457​
458 expect(canvases).toEqual([{ width: 1500, height: 2000 }]);
459 expect(encodes).toEqual([{ type: "image/jpeg", quality: 0.92 }]);
460 expect(result.ok && result.file.type).toBe("image/jpeg");
461 expect(result.ok && result.imageSize).toEqual({ width: 1500, height: 2000 });
462 });
463​
464 it.each([
465 { label: "a 2000px PNG", header: pngHeader(2000, 1200), type: "image/png" },
466 {
467 label: "a 3000px GIF",
468 header: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0xb8, 0x0b, 0x10, 0x04],
469 type: "image/gif",
470 },
471 ])("passes $label through without decoding it", async ({ header, type }) => {
472 const { decode } = stubDecoder({ width: 3000, height: 1040 });
473 const original = image(header, "image", type);
474​
475 const result = await prepareImageForAttachment(original, 10 * 1024 * 1024);
476​
477 expect(result.ok && result.file).toBe(original);
478 expect(result.ok && result.recompressed).toBe(false);
479 expect(decode).not.toHaveBeenCalled();
480 });
481});
482​
483describe("snapshot coordinates after compression", () => {
484 const source: SnapShotSource = {
485 kind: "snap-shot",
486 capturedAt: "2026-09-01T00:00:00.000Z",
487 appName: "Editor",
488 windowTitle: "main.ts",
489 accessibility: {
490 format: "element-tree",
491 coordinateSpace: "captured-image",
492 imageSize: { width: 2560, height: 1600 },
493 truncated: false,
494 root: {
495 role: "window",
496 bounds: { x: 0, y: 0, width: 2560, height: 1600 },
497 children: [
498 {
499 role: "button",
500 name: "Save",
501 bounds: { x: 2400, y: 1400, width: 100, height: 100 },
502 children: [],
503 },
504 { role: "static_text", name: "Untitled", bounds: null, children: [] },
505 ],
506 },
507 },
508 };
509​
510 it.each(["stash", "delivery"])(
511 "rescales the source and restores it with the compressed %s image",
512 async (path) => {
513 stubCanvasPipeline(() => 100);
514 vi.stubGlobal(
515 "createImageBitmap",
516 vi.fn(async () => ({ width: 2560, height: 1600, close: vi.fn() })),
517 );
518 const original = makeFile(2000);
519 const compressed =
520 path === "stash"
521 ? await compressImageForStash(original, 1000)
522 : await compressImageToByteLimit(original, 1000);
523 expect(compressed.ok).toBe(true);
524 if (!compressed.ok) throw new Error("Compression failed");
525 const image =
526 "image" in compressed
527 ? compressed.image
528 : {
529 ...compressed,
530 mimeType: compressed.file.type,
531 sizeBytes: compressed.file.size,
532 dataUrl: `data:${compressed.file.type};base64,${Buffer.from(await compressed.file.arrayBuffer()).toString("base64")}`,
533 };
534 expect(image.imageSize).toEqual({ width: 2000, height: 1250 });
535 const resized = resizeSnapShotSource(source, image.imageSize);
536 const [restored] = hydrateImagesFromPersisted([
537 {
538 id: "capture",
539 name: "window.webp",
540 mimeType: image.mimeType,
541 sizeBytes: image.sizeBytes,
542 dataUrl: image.dataUrl,
543 source: resized,
544 },
545 ]);
546 expect(restored?.source?.accessibility).toMatchObject({
547 imageSize: { width: 2000, height: 1250 },
548 root: {
549 bounds: { x: 0, y: 0, width: 2000, height: 1250 },
550 children: [{ bounds: { x: 1875, y: 1094, width: 78, height: 78 } }, { bounds: null }],
551 },
552 });
553 expect(source.accessibility).toMatchObject({ imageSize: { width: 2560, height: 1600 } });
554 },
555 );
556});