From ac09ec00e8f524b5069cb94f631eda761159d522 Mon Sep 17 00:00:00 2001 From: Paul Frederiksen Date: Mon, 4 May 2026 05:06:26 +0000 Subject: [PATCH] fix(media): tolerate missing image optimizer for in-limit images --- src/media/web-media.test.ts | 47 +++++++++++++++++++++++++++++++++++++ src/media/web-media.ts | 37 ++++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/media/web-media.test.ts b/src/media/web-media.test.ts index 146ae374b65..634b1763fe0 100644 --- a/src/media/web-media.test.ts +++ b/src/media/web-media.test.ts @@ -163,6 +163,53 @@ describe("loadWebMedia", () => { ); }); + async function withUnavailableImageOptimizer(fn: () => Promise): Promise { + vi.resetModules(); + vi.doMock("./image-ops.js", () => ({ + convertHeicToJpeg: vi.fn(async (buffer: Buffer) => buffer), + hasAlphaChannel: vi.fn(async () => { + throw new Error( + "Optional dependency sharp is required for image attachment processing | Cannot find package 'sharp' imported from image-ops.js", + ); + }), + optimizeImageToPng: vi.fn(async () => { + throw new Error("should not optimize png"); + }), + resizeToJpeg: vi.fn(async () => { + throw new Error("should not resize jpeg"); + }), + })); + try { + return await fn(); + } finally { + vi.doUnmock("./image-ops.js"); + vi.resetModules(); + } + } + + it("sends an in-limit original image when optional sharp optimization is unavailable", async () => { + await withUnavailableImageOptimizer(async () => { + const { loadWebMedia: loadWebMediaWithMissingOptimizer } = await import("./web-media.js"); + const result = await loadWebMediaWithMissingOptimizer( + tinyPngFile, + createLocalWebMediaOptions(), + ); + expect(result.kind).toBe("image"); + expect(result.contentType).toBe("image/png"); + expect(result.fileName).toBe("tiny.png"); + expect(result.buffer.equals(Buffer.from(TINY_PNG_BASE64, "base64"))).toBe(true); + }); + }); + + it("does not bypass the size cap when optional sharp optimization is unavailable", async () => { + await withUnavailableImageOptimizer(async () => { + const { loadWebMedia: loadWebMediaWithMissingOptimizer } = await import("./web-media.js"); + await expect( + loadWebMediaWithMissingOptimizer(tinyPngFile, { maxBytes: 8, localRoots: [fixtureRoot] }), + ).rejects.toThrow(/Optional dependency sharp is required/); + }); + }); + it("resolves relative local media paths against the provided workspace directory", async () => { const result = await loadWebMedia("chart.png", { maxBytes: 1024 * 1024, diff --git a/src/media/web-media.ts b/src/media/web-media.ts index e7bf75c440e..f77661680ff 100644 --- a/src/media/web-media.ts +++ b/src/media/web-media.ts @@ -205,6 +205,23 @@ function formatCapReduce(label: string, cap: number, size: number): string { return `${label} could not be reduced below ${formatMb(cap, 0)}MB (got ${formatMb(size)}MB)`; } +function isOptionalImageOptimizerUnavailable(err: unknown): boolean { + const messages: string[] = []; + let current: unknown = err; + while (current instanceof Error) { + messages.push(current.message); + current = current.cause; + } + const detail = messages.join("\n").toLowerCase(); + return ( + detail.includes("optional dependency sharp is required") || + detail.includes("cannot find package 'sharp'") || + detail.includes('cannot find package "sharp"') || + detail.includes("cannot find module 'sharp'") || + detail.includes('cannot find module "sharp"') + ); +} + function isHeicSource(opts: { contentType?: string; fileName?: string }): boolean { if (opts.contentType && HEIC_MIME_RE.test(opts.contentType.trim())) { return true; @@ -392,7 +409,25 @@ async function loadWebMediaInternal( meta?: { contentType?: string; fileName?: string }, ) => { const originalSize = buffer.length; - const optimized = await optimizeImageWithFallback({ buffer, cap, meta }); + let optimized: OptimizedImage; + try { + optimized = await optimizeImageWithFallback({ buffer, cap, meta }); + } catch (err) { + if (isOptionalImageOptimizerUnavailable(err) && buffer.length <= cap) { + if (shouldLogVerbose()) { + logVerbose( + `Image optimizer unavailable; sending original ${formatMb(buffer.length)}MB media without optimization`, + ); + } + return { + buffer, + contentType: meta?.contentType, + kind: "image" as const, + fileName: meta?.fileName, + }; + } + throw err; + } logOptimizedImage({ originalSize, optimized }); if (optimized.buffer.length > cap) {