mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(image-tools): surface Sharp encode failures instead of "Error: Error" (#534)
Wrap convert and gif-tools process functions so a Sharp .toBuffer() failure carries an authored SafeError title (and the original as cause) rather than a scrubbed "Error: Error".
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { isSafeMessageError, isToolInputError, SafeError } from "@snapotter/shared";
|
||||
import type { ToolProcessCtx } from "../routes/tool-factory.js";
|
||||
|
||||
type ImageProcess<T> = (
|
||||
inputBuffer: Buffer,
|
||||
settings: T,
|
||||
filename: string,
|
||||
ctx?: ToolProcessCtx,
|
||||
) => Promise<{ buffer: Buffer; filename: string; contentType: string }>;
|
||||
|
||||
/**
|
||||
* Wrap an image tool's process function so an otherwise-opaque failure (most
|
||||
* often a Sharp `.toBuffer()` that throws an empty-message Error) surfaces a
|
||||
* safe, authored title instead of "Error: Error".
|
||||
*
|
||||
* The API's Sentry scrubber replaces any non-SafeError message with a type-only
|
||||
* value (see `rebuildErrorValue`), so a bare Sharp failure is undiagnosable.
|
||||
* Re-throwing as a SafeError makes the title survive while the original error is
|
||||
* kept as `cause`, preserving its stack and exact location. Errors we already
|
||||
* author (SafeError) or that flag bad user input (ToolInputError) pass through
|
||||
* untouched so their class is not masked.
|
||||
*/
|
||||
export function withImageEncodeContext<T>(
|
||||
message: string,
|
||||
codeOf: (settings: T) => string,
|
||||
process: ImageProcess<T>,
|
||||
): ImageProcess<T> {
|
||||
return async (inputBuffer, settings, filename, ctx) => {
|
||||
try {
|
||||
return await process(inputBuffer, settings, filename, ctx);
|
||||
} catch (err) {
|
||||
if (isSafeMessageError(err) || isToolInputError(err)) throw err;
|
||||
throw new SafeError(message, {
|
||||
kind: "bug",
|
||||
code: codeOf(settings),
|
||||
cause: err instanceof Error ? err : new Error(String(err)),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
encodeTga,
|
||||
} from "../../lib/format-encoders.js";
|
||||
import { encodeHeic } from "../../lib/heic-converter.js";
|
||||
import { withImageEncodeContext } from "../../lib/image-error.js";
|
||||
import { isSvgBuffer } from "../../lib/svg-sanitize.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
@@ -104,62 +105,66 @@ export function registerConvert(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "convert",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
// CLI-encoded formats bypass Sharp entirely
|
||||
const cliEncoder = CLI_ENCODERS[settings.format];
|
||||
if (cliEncoder) {
|
||||
const outputBuffer = await cliEncoder(inputBuffer, settings.quality);
|
||||
process: withImageEncodeContext<z.infer<typeof settingsSchema>>(
|
||||
"Image conversion failed",
|
||||
(s) => s.format,
|
||||
async (inputBuffer, settings, filename) => {
|
||||
// CLI-encoded formats bypass Sharp entirely
|
||||
const cliEncoder = CLI_ENCODERS[settings.format];
|
||||
if (cliEncoder) {
|
||||
const outputBuffer = await cliEncoder(inputBuffer, settings.quality);
|
||||
const ext = extname(filename);
|
||||
const baseName = ext ? filename.slice(0, -ext.length) : filename;
|
||||
const contentType = FORMAT_CONTENT_TYPES[settings.format] || "application/octet-stream";
|
||||
return {
|
||||
buffer: outputBuffer,
|
||||
filename: `${baseName}.${settings.format}`,
|
||||
contentType,
|
||||
};
|
||||
}
|
||||
|
||||
const inputExt = extname(filename).toLowerCase().replace(".", "");
|
||||
const sharpOpts: SharpOptions = isSvgBuffer(inputBuffer) ? { density: 300 } : {};
|
||||
// Preserve animation frames when both input and output are animatable formats
|
||||
if (ANIMATABLE_FORMATS.has(inputExt) && ANIMATABLE_FORMATS.has(settings.format)) {
|
||||
sharpOpts.animated = true;
|
||||
}
|
||||
const image = sharp(inputBuffer, sharpOpts);
|
||||
|
||||
let buffer: Buffer;
|
||||
if (settings.format === "psd") {
|
||||
const pngBuffer = await image.png().toBuffer();
|
||||
const id = randomUUID();
|
||||
const inputPath = join(tmpdir(), `psd-enc-in-${id}.png`);
|
||||
const outputPath = join(tmpdir(), `psd-enc-out-${id}.psd`);
|
||||
try {
|
||||
await writeFile(inputPath, pngBuffer);
|
||||
const cmd = await findMagickCmd();
|
||||
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `psd:${outputPath}`]), {
|
||||
timeout: 120_000,
|
||||
});
|
||||
buffer = await readFile(outputPath);
|
||||
} finally {
|
||||
await rm(inputPath, { force: true }).catch(() => {});
|
||||
await rm(outputPath, { force: true }).catch(() => {});
|
||||
}
|
||||
} else if (settings.format === "heic" || settings.format === "heif") {
|
||||
const pngBuffer = await image.png().toBuffer();
|
||||
buffer = await encodeHeic(pngBuffer, settings.quality);
|
||||
} else {
|
||||
const result = await convert(image, settings as Parameters<typeof convert>[1]);
|
||||
buffer = await result.toBuffer();
|
||||
}
|
||||
|
||||
// Change filename extension to match the output format
|
||||
const ext = extname(filename);
|
||||
const baseName = ext ? filename.slice(0, -ext.length) : filename;
|
||||
const outputFilename = `${baseName}.${settings.format}`;
|
||||
|
||||
const contentType = FORMAT_CONTENT_TYPES[settings.format] || "application/octet-stream";
|
||||
return {
|
||||
buffer: outputBuffer,
|
||||
filename: `${baseName}.${settings.format}`,
|
||||
contentType,
|
||||
};
|
||||
}
|
||||
|
||||
const inputExt = extname(filename).toLowerCase().replace(".", "");
|
||||
const sharpOpts: SharpOptions = isSvgBuffer(inputBuffer) ? { density: 300 } : {};
|
||||
// Preserve animation frames when both input and output are animatable formats
|
||||
if (ANIMATABLE_FORMATS.has(inputExt) && ANIMATABLE_FORMATS.has(settings.format)) {
|
||||
sharpOpts.animated = true;
|
||||
}
|
||||
const image = sharp(inputBuffer, sharpOpts);
|
||||
|
||||
let buffer: Buffer;
|
||||
if (settings.format === "psd") {
|
||||
const pngBuffer = await image.png().toBuffer();
|
||||
const id = randomUUID();
|
||||
const inputPath = join(tmpdir(), `psd-enc-in-${id}.png`);
|
||||
const outputPath = join(tmpdir(), `psd-enc-out-${id}.psd`);
|
||||
try {
|
||||
await writeFile(inputPath, pngBuffer);
|
||||
const cmd = await findMagickCmd();
|
||||
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `psd:${outputPath}`]), {
|
||||
timeout: 120_000,
|
||||
});
|
||||
buffer = await readFile(outputPath);
|
||||
} finally {
|
||||
await rm(inputPath, { force: true }).catch(() => {});
|
||||
await rm(outputPath, { force: true }).catch(() => {});
|
||||
}
|
||||
} else if (settings.format === "heic" || settings.format === "heif") {
|
||||
const pngBuffer = await image.png().toBuffer();
|
||||
buffer = await encodeHeic(pngBuffer, settings.quality);
|
||||
} else {
|
||||
const result = await convert(image, settings as Parameters<typeof convert>[1]);
|
||||
buffer = await result.toBuffer();
|
||||
}
|
||||
|
||||
// Change filename extension to match the output format
|
||||
const ext = extname(filename);
|
||||
const baseName = ext ? filename.slice(0, -ext.length) : filename;
|
||||
const outputFilename = `${baseName}.${settings.format}`;
|
||||
|
||||
const contentType = FORMAT_CONTENT_TYPES[settings.format] || "application/octet-stream";
|
||||
|
||||
return { buffer, filename: outputFilename, contentType };
|
||||
},
|
||||
return { buffer, filename: outputFilename, contentType };
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { zipSync } from "fflate";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { withImageEncodeContext } from "../../lib/image-error.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
/**
|
||||
@@ -146,163 +147,167 @@ export function registerGifTools(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "gif-tools",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const baseName = filename.replace(/\.[^.]+$/, "");
|
||||
const loop = settings.loop;
|
||||
process: withImageEncodeContext<z.infer<typeof settingsSchema>>(
|
||||
"GIF processing failed",
|
||||
(s) => s.mode,
|
||||
async (inputBuffer, settings, filename) => {
|
||||
const baseName = filename.replace(/\.[^.]+$/, "");
|
||||
const loop = settings.loop;
|
||||
|
||||
switch (settings.mode) {
|
||||
case "resize": {
|
||||
const image = sharp(inputBuffer, { animated: true });
|
||||
switch (settings.mode) {
|
||||
case "resize": {
|
||||
const image = sharp(inputBuffer, { animated: true });
|
||||
|
||||
if (settings.percentage) {
|
||||
const meta = await image.metadata();
|
||||
const w = Math.round(((meta.width ?? 0) * settings.percentage) / 100);
|
||||
const h = Math.round(
|
||||
((meta.pageHeight ?? meta.height ?? 0) * settings.percentage) / 100,
|
||||
);
|
||||
image.resize(w || undefined, h || undefined, { fit: "inside" });
|
||||
} else if (settings.width || settings.height) {
|
||||
image.resize(settings.width, settings.height, { fit: "inside" });
|
||||
}
|
||||
if (settings.percentage) {
|
||||
const meta = await image.metadata();
|
||||
const w = Math.round(((meta.width ?? 0) * settings.percentage) / 100);
|
||||
const h = Math.round(
|
||||
((meta.pageHeight ?? meta.height ?? 0) * settings.percentage) / 100,
|
||||
);
|
||||
image.resize(w || undefined, h || undefined, { fit: "inside" });
|
||||
} else if (settings.width || settings.height) {
|
||||
image.resize(settings.width, settings.height, { fit: "inside" });
|
||||
}
|
||||
|
||||
const buffer = await image.gif({ loop }).toBuffer();
|
||||
return { buffer, filename, contentType: "image/gif" };
|
||||
}
|
||||
|
||||
case "optimize": {
|
||||
const buffer = await sharp(inputBuffer, { animated: true })
|
||||
.gif({
|
||||
effort: settings.effort,
|
||||
colours: settings.colors,
|
||||
dither: settings.dither,
|
||||
loop,
|
||||
})
|
||||
.toBuffer();
|
||||
return { buffer, filename, contentType: "image/gif" };
|
||||
}
|
||||
|
||||
case "speed": {
|
||||
const meta = await sharp(inputBuffer, { animated: true }).metadata();
|
||||
const origDelays = meta.delay ?? Array(meta.pages ?? 1).fill(100);
|
||||
const newDelays = origDelays.map((d: number) =>
|
||||
Math.max(20, Math.round(d / settings.speedFactor)),
|
||||
);
|
||||
|
||||
const buffer = await sharp(inputBuffer, { animated: true })
|
||||
.gif({ delay: newDelays, loop })
|
||||
.toBuffer();
|
||||
return { buffer, filename, contentType: "image/gif" };
|
||||
}
|
||||
|
||||
case "reverse": {
|
||||
const meta = await sharp(inputBuffer, { animated: true }).metadata();
|
||||
const pageCount = meta.pages ?? 1;
|
||||
const delays = [...(meta.delay ?? Array(pageCount).fill(100))];
|
||||
|
||||
if (pageCount <= 1) {
|
||||
const buffer = await sharp(inputBuffer).gif({ loop }).toBuffer();
|
||||
const buffer = await image.gif({ loop }).toBuffer();
|
||||
return { buffer, filename, contentType: "image/gif" };
|
||||
}
|
||||
|
||||
delays.reverse();
|
||||
|
||||
// Apply optional speed adjustment (used when "Also adjust speed" is checked)
|
||||
if (settings.speedFactor !== 1.0) {
|
||||
for (let i = 0; i < delays.length; i++) {
|
||||
delays[i] = Math.max(20, Math.round(delays[i] / settings.speedFactor));
|
||||
}
|
||||
}
|
||||
|
||||
// Extract each frame as a single-frame GIF with the correct delay,
|
||||
// then combine into a multi-frame GIF at the binary level.
|
||||
// This avoids going through raw pixel data, which loses the
|
||||
// page-height metadata that sharp/libvips needs for animation.
|
||||
const frameGifs: Buffer[] = [];
|
||||
for (let i = pageCount - 1; i >= 0; i--) {
|
||||
const frameBuf = await sharp(inputBuffer, { page: i })
|
||||
.gif({ delay: [delays[pageCount - 1 - i]], loop })
|
||||
case "optimize": {
|
||||
const buffer = await sharp(inputBuffer, { animated: true })
|
||||
.gif({
|
||||
effort: settings.effort,
|
||||
colours: settings.colors,
|
||||
dither: settings.dither,
|
||||
loop,
|
||||
})
|
||||
.toBuffer();
|
||||
frameGifs.push(frameBuf);
|
||||
return { buffer, filename, contentType: "image/gif" };
|
||||
}
|
||||
|
||||
const buffer = assembleAnimatedGif(frameGifs, loop);
|
||||
return { buffer, filename, contentType: "image/gif" };
|
||||
}
|
||||
case "speed": {
|
||||
const meta = await sharp(inputBuffer, { animated: true }).metadata();
|
||||
const origDelays = meta.delay ?? Array(meta.pages ?? 1).fill(100);
|
||||
const newDelays = origDelays.map((d: number) =>
|
||||
Math.max(20, Math.round(d / settings.speedFactor)),
|
||||
);
|
||||
|
||||
const buffer = await sharp(inputBuffer, { animated: true })
|
||||
.gif({ delay: newDelays, loop })
|
||||
.toBuffer();
|
||||
return { buffer, filename, contentType: "image/gif" };
|
||||
}
|
||||
|
||||
case "reverse": {
|
||||
const meta = await sharp(inputBuffer, { animated: true }).metadata();
|
||||
const pageCount = meta.pages ?? 1;
|
||||
const delays = [...(meta.delay ?? Array(pageCount).fill(100))];
|
||||
|
||||
if (pageCount <= 1) {
|
||||
const buffer = await sharp(inputBuffer).gif({ loop }).toBuffer();
|
||||
return { buffer, filename, contentType: "image/gif" };
|
||||
}
|
||||
|
||||
delays.reverse();
|
||||
|
||||
// Apply optional speed adjustment (used when "Also adjust speed" is checked)
|
||||
if (settings.speedFactor !== 1.0) {
|
||||
for (let i = 0; i < delays.length; i++) {
|
||||
delays[i] = Math.max(20, Math.round(delays[i] / settings.speedFactor));
|
||||
}
|
||||
}
|
||||
|
||||
// Extract each frame as a single-frame GIF with the correct delay,
|
||||
// then combine into a multi-frame GIF at the binary level.
|
||||
// This avoids going through raw pixel data, which loses the
|
||||
// page-height metadata that sharp/libvips needs for animation.
|
||||
const frameGifs: Buffer[] = [];
|
||||
for (let i = pageCount - 1; i >= 0; i--) {
|
||||
const frameBuf = await sharp(inputBuffer, { page: i })
|
||||
.gif({ delay: [delays[pageCount - 1 - i]], loop })
|
||||
.toBuffer();
|
||||
frameGifs.push(frameBuf);
|
||||
}
|
||||
|
||||
const buffer = assembleAnimatedGif(frameGifs, loop);
|
||||
return { buffer, filename, contentType: "image/gif" };
|
||||
}
|
||||
|
||||
case "extract": {
|
||||
if (settings.extractMode === "single") {
|
||||
const frame = sharp(inputBuffer, { page: settings.frameNumber });
|
||||
const ext = settings.extractFormat;
|
||||
const buffer =
|
||||
ext === "webp" ? await frame.webp().toBuffer() : await frame.png().toBuffer();
|
||||
const outName = `${baseName}_frame${settings.frameNumber}.${ext}`;
|
||||
return {
|
||||
buffer,
|
||||
filename: outName,
|
||||
contentType: ext === "webp" ? "image/webp" : "image/png",
|
||||
};
|
||||
}
|
||||
|
||||
// Range or All
|
||||
const meta = await sharp(inputBuffer).metadata();
|
||||
const pageCount = meta.pages ?? 1;
|
||||
const start = settings.extractMode === "all" ? 0 : settings.frameStart;
|
||||
const end =
|
||||
settings.extractMode === "all"
|
||||
? pageCount - 1
|
||||
: Math.min(settings.frameEnd ?? pageCount - 1, pageCount - 1);
|
||||
|
||||
case "extract": {
|
||||
if (settings.extractMode === "single") {
|
||||
const frame = sharp(inputBuffer, { page: settings.frameNumber });
|
||||
const ext = settings.extractFormat;
|
||||
const buffer =
|
||||
ext === "webp" ? await frame.webp().toBuffer() : await frame.png().toBuffer();
|
||||
const outName = `${baseName}_frame${settings.frameNumber}.${ext}`;
|
||||
const files: Record<string, Uint8Array> = {};
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
const frame = sharp(inputBuffer, { page: i });
|
||||
const buf =
|
||||
ext === "webp" ? await frame.webp().toBuffer() : await frame.png().toBuffer();
|
||||
files[`frame_${String(i).padStart(4, "0")}.${ext}`] = new Uint8Array(buf);
|
||||
}
|
||||
|
||||
const zipData = zipSync(files);
|
||||
const zipBuffer = Buffer.from(zipData);
|
||||
return {
|
||||
buffer,
|
||||
filename: outName,
|
||||
contentType: ext === "webp" ? "image/webp" : "image/png",
|
||||
buffer: zipBuffer,
|
||||
filename: `${baseName}_frames.zip`,
|
||||
contentType: "application/zip",
|
||||
};
|
||||
}
|
||||
|
||||
// Range or All
|
||||
const meta = await sharp(inputBuffer).metadata();
|
||||
const pageCount = meta.pages ?? 1;
|
||||
const start = settings.extractMode === "all" ? 0 : settings.frameStart;
|
||||
const end =
|
||||
settings.extractMode === "all"
|
||||
? pageCount - 1
|
||||
: Math.min(settings.frameEnd ?? pageCount - 1, pageCount - 1);
|
||||
case "rotate": {
|
||||
const meta = await sharp(inputBuffer, { animated: true }).metadata();
|
||||
const pageCount = meta.pages ?? 1;
|
||||
const delays = meta.delay ?? Array(pageCount).fill(100);
|
||||
|
||||
const ext = settings.extractFormat;
|
||||
const files: Record<string, Uint8Array> = {};
|
||||
// Sharp cannot rotate multi-page images directly, so process
|
||||
// each frame individually and reassemble the animation.
|
||||
const frameGifs: Buffer[] = [];
|
||||
for (let i = 0; i < pageCount; i++) {
|
||||
let frame = sharp(inputBuffer, { page: i });
|
||||
if (settings.angle) {
|
||||
frame = frame.rotate(settings.angle);
|
||||
}
|
||||
if (settings.flipV) {
|
||||
frame = frame.flip();
|
||||
}
|
||||
if (settings.flipH) {
|
||||
frame = frame.flop();
|
||||
}
|
||||
const frameBuf = await frame.gif({ delay: [delays[i]], loop }).toBuffer();
|
||||
frameGifs.push(frameBuf);
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
const frame = sharp(inputBuffer, { page: i });
|
||||
const buf =
|
||||
ext === "webp" ? await frame.webp().toBuffer() : await frame.png().toBuffer();
|
||||
files[`frame_${String(i).padStart(4, "0")}.${ext}`] = new Uint8Array(buf);
|
||||
const buffer = pageCount > 1 ? assembleAnimatedGif(frameGifs, loop) : frameGifs[0];
|
||||
return { buffer, filename, contentType: "image/gif" };
|
||||
}
|
||||
|
||||
const zipData = zipSync(files);
|
||||
const zipBuffer = Buffer.from(zipData);
|
||||
return {
|
||||
buffer: zipBuffer,
|
||||
filename: `${baseName}_frames.zip`,
|
||||
contentType: "application/zip",
|
||||
};
|
||||
}
|
||||
|
||||
case "rotate": {
|
||||
const meta = await sharp(inputBuffer, { animated: true }).metadata();
|
||||
const pageCount = meta.pages ?? 1;
|
||||
const delays = meta.delay ?? Array(pageCount).fill(100);
|
||||
|
||||
// Sharp cannot rotate multi-page images directly, so process
|
||||
// each frame individually and reassemble the animation.
|
||||
const frameGifs: Buffer[] = [];
|
||||
for (let i = 0; i < pageCount; i++) {
|
||||
let frame = sharp(inputBuffer, { page: i });
|
||||
if (settings.angle) {
|
||||
frame = frame.rotate(settings.angle);
|
||||
}
|
||||
if (settings.flipV) {
|
||||
frame = frame.flip();
|
||||
}
|
||||
if (settings.flipH) {
|
||||
frame = frame.flop();
|
||||
}
|
||||
const frameBuf = await frame.gif({ delay: [delays[i]], loop }).toBuffer();
|
||||
frameGifs.push(frameBuf);
|
||||
default: {
|
||||
const buffer = await sharp(inputBuffer, { animated: true }).gif({ loop }).toBuffer();
|
||||
return { buffer, filename, contentType: "image/gif" };
|
||||
}
|
||||
|
||||
const buffer = pageCount > 1 ? assembleAnimatedGif(frameGifs, loop) : frameGifs[0];
|
||||
return { buffer, filename, contentType: "image/gif" };
|
||||
}
|
||||
|
||||
default: {
|
||||
const buffer = await sharp(inputBuffer, { animated: true }).gif({ loop }).toBuffer();
|
||||
return { buffer, filename, contentType: "image/gif" };
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { isSafeMessageError, markToolInputError, SafeError } from "@snapotter/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withImageEncodeContext } from "../../../apps/api/src/lib/image-error.js";
|
||||
|
||||
interface Settings {
|
||||
format: string;
|
||||
}
|
||||
const settings: Settings = { format: "webp" };
|
||||
const input = Buffer.from("");
|
||||
|
||||
describe("withImageEncodeContext", () => {
|
||||
it("returns the process result unchanged when it succeeds", async () => {
|
||||
const wrapped = withImageEncodeContext(
|
||||
"Image conversion failed",
|
||||
(s: Settings) => s.format,
|
||||
async () => ({ buffer: Buffer.from("ok"), filename: "out.webp", contentType: "image/webp" }),
|
||||
);
|
||||
const result = await wrapped(input, settings, "in.png");
|
||||
expect(result.filename).toBe("out.webp");
|
||||
});
|
||||
|
||||
it("wraps an opaque encode failure in a SafeError with the target format as code", async () => {
|
||||
// Sharp .toBuffer() failures throw an Error whose message is scrubbed to
|
||||
// type-only ("Error: Error") in Sentry; the wrapper must author a title.
|
||||
const sharpErr = new Error("");
|
||||
const wrapped = withImageEncodeContext(
|
||||
"Image conversion failed",
|
||||
(s: Settings) => s.format,
|
||||
async () => {
|
||||
throw sharpErr;
|
||||
},
|
||||
);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await wrapped(input, settings, "in.png");
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
|
||||
expect(isSafeMessageError(caught)).toBe(true);
|
||||
expect((caught as SafeError).message).toBe("Image conversion failed");
|
||||
expect((caught as SafeError).kind).toBe("bug");
|
||||
expect((caught as SafeError).code).toBe("webp");
|
||||
// Original error kept so its stack/location survives in the cause chain.
|
||||
expect((caught as SafeError).cause).toBe(sharpErr);
|
||||
});
|
||||
|
||||
it("passes an already-authored SafeError through unchanged (no double-wrap)", async () => {
|
||||
const inner = new SafeError("Process killed (out of memory)", { kind: "operational" });
|
||||
const wrapped = withImageEncodeContext(
|
||||
"Image conversion failed",
|
||||
() => "webp",
|
||||
async () => {
|
||||
throw inner;
|
||||
},
|
||||
);
|
||||
await expect(wrapped(input, settings, "in.png")).rejects.toBe(inner);
|
||||
});
|
||||
|
||||
it("passes a ToolInputError through unchanged (stays a 400, not a masked bug)", async () => {
|
||||
const inputErr = markToolInputError(new Error("Unsupported input"));
|
||||
const wrapped = withImageEncodeContext(
|
||||
"Image conversion failed",
|
||||
() => "webp",
|
||||
async () => {
|
||||
throw inputErr;
|
||||
},
|
||||
);
|
||||
await expect(wrapped(input, settings, "in.png")).rejects.toBe(inputErr);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user