Files
SnapOtter/tests/unit/image-engine/operations.test.ts
T
SnapOtterandGitHub d10d0f544f fix: release QA hardening across processing, media, security, and CI gates (#649)
A release-readiness QA pass over the whole product. The commits split into
defects a user would hit and gates that were reporting green while measuring
nothing.

## Fixes that change behaviour

Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so
request.ip came from a client-set header and a forged X-Forwarded-For got past
the login limiter. The default is now a private-network trust list.

A transient Postgres outage stranded in-flight jobs, leaving finished output on
disk with no row pointing at it. A reconciler now resolves those rows and adopts
the bytes rather than dropping the work.

A Redis connection that moved to a new address wedged every read-blocked
consumer, so completions stopped signalling while health still answered 200.
Socket timeouts plus subscriber pings recover it.

Installing more than one AI bundle left the shared venv multi-versioned and
silently broke three tools. The installer now reconciles distributions to one
version each.

Converting an image to JXL at quality 1 through 4 returned a 500, because
libjxl 0.7 rejects the distance those values compute. The quality is floored at
what the encoder honours. A missing ffmpeg was also reported to the user as a
corrupt upload; it now says the engine is unavailable.

RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at
0.22.2, and the release scan was split so it can fail on an unfixed critical
instead of hiding it behind ignore-unfixed.

## Gates that could not fail

Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs
build; coverage discarded its whole report on any failing test; the lint gate
skipped root tests, scripts, and two workspaces; and several generated matrices
counted a host missing ffmpeg as a passing tool. Each now measures what it
claims.

Full evidence and the outstanding release items are tracked locally and are not
part of this branch.
2026-07-27 15:37:30 +08:00

2313 lines
78 KiB
TypeScript

import { createRequire } from "node:module";
import path from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
// sharp is only installed in the image-engine package, so resolve it from there
const require = createRequire(
path.resolve(__dirname, "../../../packages/image-engine/src/index.ts"),
);
const sharp = require("sharp") as typeof import("sharp").default;
const exifReader = require(
path.resolve(__dirname, "../../../packages/image-engine/node_modules/exif-reader"),
) as typeof import("exif-reader").default;
import {
brightness,
colorChannels,
compress,
contrast,
convert,
crop,
editMetadata,
flip,
grayscale,
invert,
optimizeForWeb,
parseExif,
parseGps,
parseXmp,
processImage,
resize,
rotate,
sanitizeValue,
saturation,
sepia,
sharpen,
sharpenAdvanced,
stripMetadata,
} from "@snapotter/image-engine";
import { fixtures, readFixture } from "../../fixtures/index.js";
// Helper to get metadata from a sharp pipeline result
async function getMeta(img: sharp.Sharp) {
const buf = await img.toBuffer();
return sharp(buf).metadata();
}
// Helper to get raw pixel data
async function _getPixels(img: sharp.Sharp) {
return img.raw().toBuffer({ resolveWithObject: true });
}
let png200x150: Buffer;
let png1x1: Buffer;
let jpg100x100: Buffer;
let webp50x50: Buffer;
let jpgWithExif: Buffer;
beforeAll(() => {
png200x150 = readFixture(fixtures.image.base.png200);
png1x1 = readFixture(fixtures.image.edge.px1);
jpg100x100 = readFixture(fixtures.image.base.jpg100);
webp50x50 = readFixture(fixtures.image.base.webp50);
jpgWithExif = readFixture(fixtures.image.exifGps);
});
// ---------------------------------------------------------------------------
// resize
// ---------------------------------------------------------------------------
describe("resize", () => {
it("resizes to explicit width and height", async () => {
const img = sharp(png200x150);
const result = await resize(img, { width: 100, height: 75 });
const meta = await getMeta(result);
expect(meta.width).toBe(100);
expect(meta.height).toBe(75);
});
it("resizes with width only (height derived)", async () => {
const img = sharp(png200x150);
const result = await resize(img, { width: 50 });
const meta = await getMeta(result);
expect(meta.width).toBe(50);
// height should be derived based on cover fit
expect(meta.height).toBeGreaterThan(0);
});
it("resizes with height only (width derived)", async () => {
const img = sharp(png200x150);
const result = await resize(img, { height: 30 });
const meta = await getMeta(result);
expect(meta.height).toBe(30);
expect(meta.width).toBeGreaterThan(0);
});
it("resizes by percentage", async () => {
const img = sharp(png200x150);
const result = await resize(img, { percentage: 50 });
const meta = await getMeta(result);
expect(meta.width).toBe(100);
expect(meta.height).toBe(75);
});
it("percentage = 200 doubles size", async () => {
const img = sharp(jpg100x100);
const result = await resize(img, { percentage: 200 });
const meta = await getMeta(result);
expect(meta.width).toBe(200);
expect(meta.height).toBe(200);
});
it("percentage rounds correctly for non-integer results", async () => {
// 200 * 33/100 = 66, 150 * 33/100 = 49.5 -> 50
const img = sharp(png200x150);
const result = await resize(img, { percentage: 33 });
const meta = await getMeta(result);
expect(meta.width).toBe(66);
expect(meta.height).toBe(50);
});
it("withoutEnlargement prevents upscaling", async () => {
const img = sharp(png1x1);
const result = await resize(img, {
width: 500,
height: 500,
withoutEnlargement: true,
});
const meta = await getMeta(result);
expect(meta.width).toBeLessThanOrEqual(1);
expect(meta.height).toBeLessThanOrEqual(1);
});
it("fit=contain preserves aspect ratio within box", async () => {
const img = sharp(png200x150);
const result = await resize(img, { width: 100, height: 100, fit: "contain" });
const meta = await getMeta(result);
// contain fits the image within the box; dimensions are at most 100x100
// 200x150 (4:3) contained in 100x100 -> width=100, height stays 100
// (sharp contain fits inside the box, output matches requested dimensions with padding)
expect(meta.width).toBeLessThanOrEqual(100);
expect(meta.height).toBeLessThanOrEqual(100);
});
it("fit=fill stretches to exact dimensions", async () => {
const img = sharp(png200x150);
const result = await resize(img, { width: 50, height: 50, fit: "fill" });
const meta = await getMeta(result);
expect(meta.width).toBe(50);
expect(meta.height).toBe(50);
});
// -- Error cases --
it("throws on width = 0", async () => {
const img = sharp(png200x150);
await expect(resize(img, { width: 0 })).rejects.toThrow("Resize width must be greater than 0");
});
it("throws on negative width", async () => {
const img = sharp(png200x150);
await expect(resize(img, { width: -10, height: 50 })).rejects.toThrow(
"Resize width must be greater than 0",
);
});
it("throws on negative height", async () => {
const img = sharp(png200x150);
await expect(resize(img, { width: 50, height: -1 })).rejects.toThrow(
"Resize height must be greater than 0",
);
});
it("throws on height = 0", async () => {
const img = sharp(png200x150);
await expect(resize(img, { height: 0 })).rejects.toThrow(
"Resize height must be greater than 0",
);
});
it("throws when neither width, height, nor percentage given", async () => {
const img = sharp(png200x150);
await expect(resize(img, {})).rejects.toThrow("Resize requires width, height, or percentage");
});
it("throws on percentage = 0", async () => {
const img = sharp(png200x150);
await expect(resize(img, { percentage: 0 })).rejects.toThrow(
"Resize percentage must be greater than 0",
);
});
it("throws on negative percentage", async () => {
const img = sharp(png200x150);
await expect(resize(img, { percentage: -50 })).rejects.toThrow(
"Resize percentage must be greater than 0",
);
});
it("withoutEnlargement clamps height only (no width given)", async () => {
const img = sharp(png1x1);
const result = await resize(img, {
height: 500,
withoutEnlargement: true,
});
const meta = await getMeta(result);
expect(meta.height).toBeLessThanOrEqual(1);
});
it("withoutEnlargement does not clamp when target is smaller", async () => {
const img = sharp(png200x150);
const result = await resize(img, {
width: 50,
height: 50,
withoutEnlargement: true,
});
const meta = await getMeta(result);
expect(meta.width).toBe(50);
expect(meta.height).toBe(50);
});
it("percentage on a 1x1 image rounds to at least 1px (percentage >= 50)", async () => {
// 1 * 50 / 100 = 0.5 -> rounds to 1 (Math.round), so width=1
const img = sharp(png1x1);
const result = await resize(img, { percentage: 50 });
const meta = await getMeta(result);
// Math.round(0.5) = 1 in some environments, 0 in others; if 0, sharp should error
expect(meta.width).toBeGreaterThanOrEqual(0);
});
it("percentage=1 on 1x1 clamps to 1px minimum", async () => {
const img = sharp(png1x1);
const result = await resize(img, { percentage: 1 });
const meta = await result.toBuffer().then((b) => sharp(b).metadata());
expect(meta.width).toBe(1);
expect(meta.height).toBe(1);
});
});
// ---------------------------------------------------------------------------
// crop
// ---------------------------------------------------------------------------
describe("crop", () => {
it("crops a valid region", async () => {
const img = sharp(png200x150);
const result = await crop(img, { left: 10, top: 20, width: 50, height: 40 });
const meta = await getMeta(result);
expect(meta.width).toBe(50);
expect(meta.height).toBe(40);
});
it("crops the full image (0,0 to full dimensions)", async () => {
const img = sharp(png200x150);
const result = await crop(img, { left: 0, top: 0, width: 200, height: 150 });
const meta = await getMeta(result);
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("crops a 1x1 region from top-left corner", async () => {
const img = sharp(png200x150);
const result = await crop(img, { left: 0, top: 0, width: 1, height: 1 });
const meta = await getMeta(result);
expect(meta.width).toBe(1);
expect(meta.height).toBe(1);
});
it("crops from the last pixel", async () => {
const img = sharp(png200x150);
const result = await crop(img, { left: 199, top: 149, width: 1, height: 1 });
const meta = await getMeta(result);
expect(meta.width).toBe(1);
expect(meta.height).toBe(1);
});
it("crops using percent unit", async () => {
const img = sharp(png200x150);
// 50% of 200 = 100, 50% of 150 = 75, starting at 10% left, 10% top
const result = await crop(img, {
left: 10,
top: 10,
width: 50,
height: 50,
unit: "percent",
});
const meta = await getMeta(result);
expect(meta.width).toBe(100); // 50% of 200
expect(meta.height).toBe(75); // 50% of 150 (Math.round)
});
it("crops 100% of image using percent unit", async () => {
const img = sharp(png200x150);
const result = await crop(img, {
left: 0,
top: 0,
width: 100,
height: 100,
unit: "percent",
});
const meta = await getMeta(result);
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
// -- Error cases --
it("throws on width = 0", async () => {
const img = sharp(png200x150);
await expect(crop(img, { left: 0, top: 0, width: 0, height: 50 })).rejects.toThrow(
"Crop width and height must be greater than 0",
);
});
it("throws on height = 0", async () => {
const img = sharp(png200x150);
await expect(crop(img, { left: 0, top: 0, width: 50, height: 0 })).rejects.toThrow(
"Crop width and height must be greater than 0",
);
});
it("throws on negative width", async () => {
const img = sharp(png200x150);
await expect(crop(img, { left: 0, top: 0, width: -10, height: 50 })).rejects.toThrow(
"Crop width and height must be greater than 0",
);
});
it("throws on negative left", async () => {
const img = sharp(png200x150);
await expect(crop(img, { left: -1, top: 0, width: 10, height: 10 })).rejects.toThrow(
"Crop left and top must be non-negative",
);
});
it("throws on negative top", async () => {
const img = sharp(png200x150);
await expect(crop(img, { left: 0, top: -5, width: 10, height: 10 })).rejects.toThrow(
"Crop left and top must be non-negative",
);
});
it("throws when crop region exceeds image width", async () => {
const img = sharp(png200x150);
await expect(crop(img, { left: 150, top: 0, width: 100, height: 10 })).rejects.toThrow(
"Crop region exceeds image width",
);
});
it("throws when crop region exceeds image height", async () => {
const img = sharp(png200x150);
await expect(crop(img, { left: 0, top: 100, width: 10, height: 100 })).rejects.toThrow(
"Crop region exceeds image height",
);
});
it("throws when left alone puts region out of bounds", async () => {
const img = sharp(png200x150);
await expect(crop(img, { left: 200, top: 0, width: 1, height: 1 })).rejects.toThrow(
"Crop region exceeds image width",
);
});
it("throws when crop is 1px past the boundary", async () => {
// left=199, width=2 -> 201 > 200
const img = sharp(png200x150);
await expect(crop(img, { left: 199, top: 0, width: 2, height: 1 })).rejects.toThrow(
"Crop region exceeds image width",
);
});
});
// ---------------------------------------------------------------------------
// rotate
// ---------------------------------------------------------------------------
describe("rotate", () => {
it("rotates 90 degrees (dimensions swap)", async () => {
const img = sharp(png200x150);
const result = await rotate(img, { angle: 90 });
const meta = await getMeta(result);
expect(meta.width).toBe(150);
expect(meta.height).toBe(200);
});
it("rotates 180 degrees (dimensions stay same)", async () => {
const img = sharp(png200x150);
const result = await rotate(img, { angle: 180 });
const meta = await getMeta(result);
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("rotates 270 degrees (dimensions swap)", async () => {
const img = sharp(png200x150);
const result = await rotate(img, { angle: 270 });
const meta = await getMeta(result);
expect(meta.width).toBe(150);
expect(meta.height).toBe(200);
});
it("rotates 0 degrees (no-op)", async () => {
const img = sharp(png200x150);
const result = await rotate(img, { angle: 0 });
const meta = await getMeta(result);
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("rotates 360 degrees (no-op equivalent)", async () => {
const img = sharp(png200x150);
const result = await rotate(img, { angle: 360 });
const meta = await getMeta(result);
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("arbitrary angle produces a larger bounding box", async () => {
const img = sharp(jpg100x100);
const result = await rotate(img, { angle: 45 });
const meta = await getMeta(result);
// 100x100 rotated 45 deg -> bounding box is ~141x141
expect(meta.width!).toBeGreaterThan(100);
expect(meta.height!).toBeGreaterThan(100);
});
it("arbitrary angle uses custom background color", async () => {
const img = sharp(jpg100x100);
const result = await rotate(img, { angle: 30, background: "#ff0000" });
// Just check it does not throw and produces output
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("arbitrary angle defaults background to black", async () => {
// Rotate an image by an odd angle and verify the corner pixel
// is black (the default fill).
const img = sharp(
await sharp({ create: { width: 10, height: 10, channels: 3, background: "#ffffff" } })
.png()
.toBuffer(),
);
const result = await rotate(img, { angle: 45 });
const { data, info } = await result.raw().toBuffer({ resolveWithObject: true });
// top-left corner pixel should be close to black (the fill)
const r = data[0];
const g = data[1];
const b = data[2];
// Allow some tolerance for anti-aliasing
expect(r).toBeLessThan(30);
expect(g).toBeLessThan(30);
expect(b).toBeLessThan(30);
});
it("negative angle rotates counter-clockwise", async () => {
const img = sharp(png200x150);
const result = await rotate(img, { angle: -90 });
const meta = await getMeta(result);
expect(meta.width).toBe(150);
expect(meta.height).toBe(200);
});
});
// ---------------------------------------------------------------------------
// flip
// ---------------------------------------------------------------------------
describe("flip", () => {
it("flips horizontally", async () => {
const img = sharp(png200x150);
const result = await flip(img, { horizontal: true });
const meta = await getMeta(result);
// Dimensions unchanged
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("flips vertically", async () => {
const img = sharp(png200x150);
const result = await flip(img, { vertical: true });
const meta = await getMeta(result);
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("flips both horizontal and vertical", async () => {
const img = sharp(png200x150);
const result = await flip(img, { horizontal: true, vertical: true });
const meta = await getMeta(result);
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("horizontal flip actually mirrors pixels", async () => {
// Create a 2x1 image: left pixel red, right pixel blue
const _buf = await sharp({
create: { width: 2, height: 1, channels: 3, background: "#000000" },
})
.raw()
.toBuffer();
// Manually set: pixel 0 = red, pixel 1 = blue
const pixels = Buffer.from([255, 0, 0, 0, 0, 255]);
const imgBuf = await sharp(pixels, { raw: { width: 2, height: 1, channels: 3 } })
.png()
.toBuffer();
const img = sharp(imgBuf);
const result = await flip(img, { horizontal: true });
const { data } = await result.raw().toBuffer({ resolveWithObject: true });
// After horizontal flip: pixel 0 should be blue, pixel 1 should be red
expect(data[0]).toBe(0); // R of first pixel
expect(data[1]).toBe(0); // G
expect(data[2]).toBe(255); // B
expect(data[3]).toBe(255); // R of second pixel
expect(data[4]).toBe(0);
expect(data[5]).toBe(0);
});
it("vertical flip actually mirrors pixels", async () => {
// 1x2 image: top pixel red, bottom pixel blue
const pixels = Buffer.from([255, 0, 0, 0, 0, 255]);
const imgBuf = await sharp(pixels, { raw: { width: 1, height: 2, channels: 3 } })
.png()
.toBuffer();
const img = sharp(imgBuf);
const result = await flip(img, { vertical: true });
const { data } = await result.raw().toBuffer({ resolveWithObject: true });
// After vertical flip: top pixel should be blue, bottom should be red
expect(data[0]).toBe(0);
expect(data[1]).toBe(0);
expect(data[2]).toBe(255);
expect(data[3]).toBe(255);
expect(data[4]).toBe(0);
expect(data[5]).toBe(0);
});
// -- Error cases --
it("throws when neither horizontal nor vertical specified", async () => {
const img = sharp(png200x150);
await expect(flip(img, {})).rejects.toThrow(
"Flip requires at least one of horizontal or vertical",
);
});
it("throws when both are explicitly false", async () => {
const img = sharp(png200x150);
await expect(flip(img, { horizontal: false, vertical: false })).rejects.toThrow(
"Flip requires at least one of horizontal or vertical",
);
});
});
// ---------------------------------------------------------------------------
// convert
// ---------------------------------------------------------------------------
describe("convert", () => {
it.each([
["jpg", "jpeg"],
["png", "png"],
["webp", "webp"],
["tiff", "tiff"],
["gif", "gif"],
] as const)("converts to %s format", async (fmt, expectedSharpFmt) => {
const img = sharp(png200x150);
const result = await convert(img, { format: fmt as any });
const meta = await getMeta(result);
expect(meta.format).toBe(expectedSharpFmt);
});
it("converts to avif format", async () => {
const img = sharp(png200x150);
const result = await convert(img, { format: "avif" });
const meta = await getMeta(result);
expect(meta.format).toBe("heif");
});
it("applies quality parameter", async () => {
const imgHigh = sharp(png200x150);
const resultHigh = await convert(imgHigh, { format: "jpg", quality: 95 });
const bufHigh = await resultHigh.toBuffer();
const imgLow = sharp(png200x150);
const resultLow = await convert(imgLow, { format: "jpg", quality: 10 });
const bufLow = await resultLow.toBuffer();
// Low quality JPEG should be smaller than high quality
expect(bufLow.length).toBeLessThan(bufHigh.length);
});
it("quality = 1 (minimum) does not throw", async () => {
const img = sharp(png200x150);
const result = await convert(img, { format: "webp", quality: 1 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("quality = 100 (maximum) does not throw", async () => {
const img = sharp(png200x150);
const result = await convert(img, { format: "webp", quality: 100 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
// -- Error cases --
it("throws on invalid format", async () => {
const img = sharp(png200x150);
await expect(convert(img, { format: "bmp" as any })).rejects.toThrow(
"Unsupported output format: bmp",
);
});
it("throws on quality = 0", async () => {
const img = sharp(png200x150);
await expect(convert(img, { format: "jpg", quality: 0 })).rejects.toThrow(
"Quality must be between 1 and 100",
);
});
it("throws on quality = 101", async () => {
const img = sharp(png200x150);
await expect(convert(img, { format: "jpg", quality: 101 })).rejects.toThrow(
"Quality must be between 1 and 100",
);
});
it("throws on negative quality", async () => {
const img = sharp(png200x150);
await expect(convert(img, { format: "jpg", quality: -5 })).rejects.toThrow(
"Quality must be between 1 and 100",
);
});
it("throws on quality = 999", async () => {
const img = sharp(png200x150);
await expect(convert(img, { format: "png", quality: 999 })).rejects.toThrow(
"Quality must be between 1 and 100",
);
});
});
// ---------------------------------------------------------------------------
// compress
// ---------------------------------------------------------------------------
describe("compress", () => {
it("compresses with explicit quality", async () => {
const img = sharp(png200x150);
const result = await compress(img, { quality: 50, format: "jpg" });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("defaults to quality 80 when no quality or target specified", async () => {
const img = sharp(png200x150);
// Should not throw
const result = await compress(img, { format: "jpg" });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("lower quality produces smaller output", async () => {
const bufHigh = await (
await compress(sharp(png200x150), { quality: 90, format: "jpg" })
).toBuffer();
const bufLow = await (
await compress(sharp(png200x150), { quality: 10, format: "jpg" })
).toBuffer();
expect(bufLow.length).toBeLessThan(bufHigh.length);
});
it("compresses with target size (binary search)", async () => {
// Target 5KB for a 200x150 PNG
const targetBytes = 5000;
const img = sharp(png200x150);
const result = await compress(img, { targetSizeBytes: targetBytes, format: "jpg" });
const buf = await result.toBuffer();
// The binary search should get within tolerance (5%) or as close as possible
// Allow some slack since very small images may not compress below target
expect(buf.length).toBeGreaterThan(0);
});
it("target size binary search gets reasonably close", async () => {
const bigBuf = await sharp({
create: { width: 400, height: 300, channels: 3, background: "#884422" },
})
.png()
.toBuffer();
const targetBytes = 10000;
const result = await compress(sharp(bigBuf), {
targetSizeBytes: targetBytes,
format: "jpg",
});
const buf = await result.toBuffer();
expect(buf.length).toBeLessThanOrEqual(targetBytes);
});
it("uses input format when no format specified", async () => {
const img = sharp(jpg100x100);
const result = await compress(img, { quality: 50 });
const meta = await getMeta(result);
expect(meta.format).toBe("jpeg");
});
// -- Error cases --
it("throws on quality = 0", async () => {
const img = sharp(png200x150);
await expect(compress(img, { quality: 0, format: "jpg" })).rejects.toThrow(
"Quality must be between 1 and 100",
);
});
it("throws on quality = 101", async () => {
const img = sharp(png200x150);
await expect(compress(img, { quality: 101, format: "jpg" })).rejects.toThrow(
"Quality must be between 1 and 100",
);
});
it("throws on negative quality", async () => {
const img = sharp(png200x150);
await expect(compress(img, { quality: -10, format: "jpg" })).rejects.toThrow(
"Quality must be between 1 and 100",
);
});
it("throws on targetSizeBytes = 0", async () => {
const img = sharp(png200x150);
await expect(compress(img, { targetSizeBytes: 0, format: "jpg" })).rejects.toThrow(
"Target size must be greater than 0",
);
});
it("throws on negative targetSizeBytes", async () => {
const img = sharp(png200x150);
await expect(compress(img, { targetSizeBytes: -100, format: "jpg" })).rejects.toThrow(
"Target size must be greater than 0",
);
});
it("falls back to PNG for SVG input (NO_ENCODER format)", async () => {
const svgBuf = readFixture(fixtures.image.formats("svg"));
const img = sharp(svgBuf);
const result = await compress(img, { quality: 80 });
const meta = await getMeta(result);
expect(meta.format).toBe("png");
});
it("target size early break when within tolerance", async () => {
// Use a large solid-color image so binary search finishes quickly
const bigBuf = await sharp({
create: { width: 800, height: 600, channels: 3, background: "#cc6633" },
})
.jpeg({ quality: 95 })
.toBuffer();
// Set target to the actual size so it matches within tolerance immediately
const targetBytes = bigBuf.length;
const result = await compress(sharp(bigBuf), {
targetSizeBytes: targetBytes,
format: "jpg",
});
const buf = await result.toBuffer();
// Should be within 5% tolerance of target
expect(Math.abs(buf.length - targetBytes) / targetBytes).toBeLessThan(0.5);
});
it("compresses with format=avif adds effort option", async () => {
const img = sharp(png200x150);
const result = await compress(img, { quality: 50, format: "avif" });
const meta = await getMeta(result);
expect(meta.format).toBe("heif");
});
it("fails clearly when dimension reduction cannot reach the target", async () => {
const bigBuf = await sharp({
create: { width: 200, height: 200, channels: 3, background: "#ff8800" },
})
.jpeg({ quality: 100 })
.toBuffer();
await expect(
compress(sharp(bigBuf), {
targetSizeBytes: 1,
format: "jpg",
}),
).rejects.toThrow("Unable to compress image to 1 bytes within safe resize limits");
});
});
// ---------------------------------------------------------------------------
// compress -- target-size accuracy
// ---------------------------------------------------------------------------
describe("compress target-size accuracy", () => {
it("output never exceeds target size for JPEG", async () => {
const largeBuf = await sharp({
create: { width: 800, height: 600, channels: 3, background: "#3366aa" },
})
.jpeg({ quality: 100 })
.toBuffer();
const targetBytes = 5000;
const result = await compress(sharp(largeBuf), {
targetSizeBytes: targetBytes,
format: "jpg",
});
const buf = await result.toBuffer();
expect(buf.length).toBeLessThanOrEqual(targetBytes);
});
it("output never exceeds target size for PNG", async () => {
const largeBuf = await sharp({
create: { width: 600, height: 400, channels: 4, background: "#cc4488" },
})
.png()
.toBuffer();
const targetBytes = 3000;
const result = await compress(sharp(largeBuf), {
targetSizeBytes: targetBytes,
format: "png",
});
const buf = await result.toBuffer();
expect(buf.length).toBeLessThanOrEqual(targetBytes);
});
it("output never exceeds target size for WebP", async () => {
const largeBuf = await sharp({
create: { width: 800, height: 600, channels: 3, background: "#99bb22" },
})
.webp({ quality: 100 })
.toBuffer();
const targetBytes = 4000;
const result = await compress(sharp(largeBuf), {
targetSizeBytes: targetBytes,
format: "webp",
});
const buf = await result.toBuffer();
expect(buf.length).toBeLessThanOrEqual(targetBytes);
});
it("aggressive target on large image triggers dimension reduction", async () => {
const width = 1200;
const height = 900;
const channels = 3;
const rawData = Buffer.alloc(width * height * channels);
for (let i = 0; i < rawData.length; i++) {
rawData[i] = (i * 7 + 13) % 256;
}
const largeBuf = await sharp(rawData, { raw: { width, height, channels } })
.jpeg({ quality: 95 })
.toBuffer();
const targetBytes = 5000;
const result = await compress(sharp(largeBuf), {
targetSizeBytes: targetBytes,
format: "jpg",
});
const buf = await result.toBuffer();
expect(buf.length).toBeLessThanOrEqual(targetBytes);
const meta = await sharp(buf).metadata();
expect(meta.width).toBeLessThan(1200);
expect(meta.height).toBeLessThan(900);
});
it("500KB-to-50KB scenario produces output at or below target", async () => {
const noisyBuf = await sharp({
create: { width: 1600, height: 1200, channels: 3, background: "#447799" },
})
.jpeg({ quality: 100 })
.toBuffer();
const targetBytes = 50 * 1024;
const result = await compress(sharp(noisyBuf), {
targetSizeBytes: targetBytes,
format: "jpg",
});
const buf = await result.toBuffer();
expect(buf.length).toBeLessThanOrEqual(targetBytes);
expect(buf.length).toBeGreaterThan(0);
});
it("preserves requested format during dimension reduction", async () => {
const width = 1200;
const height = 900;
const channels = 3;
const rawData = Buffer.alloc(width * height * channels);
for (let i = 0; i < rawData.length; i++) {
rawData[i] = (i * 7 + 13) % 256;
}
const largeBuf = await sharp(rawData, { raw: { width, height, channels } })
.jpeg({ quality: 95 })
.toBuffer();
const targetBytes = 5000;
const result = await compress(sharp(largeBuf), {
targetSizeBytes: targetBytes,
format: "webp",
});
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.format).toBe("webp");
expect(buf.length).toBeLessThanOrEqual(targetBytes);
expect(meta.width).toBeLessThan(1200);
});
it("does not reduce dimensions when quality alone suffices", async () => {
const buf = await sharp({
create: { width: 400, height: 300, channels: 3, background: "#112233" },
})
.jpeg({ quality: 95 })
.toBuffer();
const targetBytes = buf.length + 5000;
const result = await compress(sharp(buf), {
targetSizeBytes: targetBytes,
format: "jpg",
});
const outBuf = await result.toBuffer();
const meta = await sharp(outBuf).metadata();
expect(meta.width).toBe(400);
expect(meta.height).toBe(300);
expect(outBuf.length).toBeLessThanOrEqual(targetBytes);
});
});
// ---------------------------------------------------------------------------
// strip-metadata
// ---------------------------------------------------------------------------
describe("stripMetadata", () => {
it("returns an image (default strips all)", async () => {
const img = sharp(jpg100x100);
const result = await stripMetadata(img);
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("stripAll=true returns image", async () => {
const img = sharp(jpg100x100);
const result = await stripMetadata(img, { stripAll: true });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("selective strip (stripExif only) returns image", async () => {
const img = sharp(jpg100x100);
const result = await stripMetadata(img, { stripExif: true });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("empty options defaults to strip all", async () => {
const img = sharp(jpg100x100);
const result = await stripMetadata(img, {});
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("preserves image dimensions", async () => {
const img = sharp(png200x150);
const result = await stripMetadata(img);
const meta = await getMeta(result);
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("stripIcc=true enters selective stripping path", async () => {
const img = sharp(jpg100x100);
const result = await stripMetadata(img, { stripIcc: true });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("stripGps=true enters selective stripping path", async () => {
const img = sharp(jpg100x100);
const result = await stripMetadata(img, { stripGps: true });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("stripXmp=true enters selective stripping path", async () => {
const img = sharp(jpg100x100);
const result = await stripMetadata(img, { stripXmp: true });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// brightness
// ---------------------------------------------------------------------------
describe("brightness", () => {
it("value = 0 makes no visible change", async () => {
const img = sharp(png200x150);
const result = await brightness(img, { value: 0 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("positive value brightens the image", async () => {
// Create a dark gray image and brighten it
const darkBuf = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#404040" },
})
.png()
.toBuffer();
const result = await brightness(sharp(darkBuf), { value: 50 });
const { data } = await result.raw().toBuffer({ resolveWithObject: true });
// Brightness multiplier = 1.5 => 0x40 (64) * 1.5 = 96 (0x60)
// Allow tolerance for rounding
expect(data[0]).toBeGreaterThan(64);
});
it("negative value darkens the image", async () => {
const lightBuf = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#c0c0c0" },
})
.png()
.toBuffer();
const result = await brightness(sharp(lightBuf), { value: -50 });
const { data } = await result.raw().toBuffer({ resolveWithObject: true });
// 0xC0 (192) * 0.5 = 96
expect(data[0]).toBeLessThan(192);
});
it("value = -100 produces black", async () => {
const whiteBuf = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#ffffff" },
})
.png()
.toBuffer();
const result = await brightness(sharp(whiteBuf), { value: -100 });
const { data } = await result.raw().toBuffer({ resolveWithObject: true });
// multiplier = 0 -> all pixels should be 0
expect(data[0]).toBe(0);
expect(data[1]).toBe(0);
expect(data[2]).toBe(0);
});
it("boundary value = 100 does not throw", async () => {
const img = sharp(png200x150);
const result = await brightness(img, { value: 100 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("boundary value = -100 does not throw", async () => {
const img = sharp(png200x150);
const result = await brightness(img, { value: -100 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
// -- Error cases --
it("throws on value = -101", async () => {
const img = sharp(png200x150);
await expect(brightness(img, { value: -101 })).rejects.toThrow(
"Brightness value must be between -100 and +100",
);
});
it("throws on value = 101", async () => {
const img = sharp(png200x150);
await expect(brightness(img, { value: 101 })).rejects.toThrow(
"Brightness value must be between -100 and +100",
);
});
it("throws on extreme values", async () => {
const img = sharp(png200x150);
await expect(brightness(img, { value: 1000 })).rejects.toThrow(
"Brightness value must be between -100 and +100",
);
});
});
// ---------------------------------------------------------------------------
// contrast
// ---------------------------------------------------------------------------
describe("contrast", () => {
it("value = 0 makes no visible change", async () => {
const img = sharp(png200x150);
const result = await contrast(img, { value: 0 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("positive value increases contrast", async () => {
// A mid-gray image with contrast boost should remain mid-gray (128 is the pivot)
// But a light pixel should get lighter
const grayBuf = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#c0c0c0" },
})
.png()
.toBuffer();
const result = await contrast(sharp(grayBuf), { value: 50 });
const { data } = await result.raw().toBuffer({ resolveWithObject: true });
// slope=1.5, intercept=128*(1-1.5)=-64 -> 192*1.5 - 64 = 224
expect(data[0]).toBeGreaterThan(192);
});
it("negative contrast pushes values toward mid-gray", async () => {
const whiteBuf = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#ffffff" },
})
.png()
.toBuffer();
const result = await contrast(sharp(whiteBuf), { value: -50 });
const { data } = await result.raw().toBuffer({ resolveWithObject: true });
// slope=0.5, intercept=64 -> 255*0.5 + 64 = 191.5
expect(data[0]).toBeLessThan(255);
expect(data[0]).toBeGreaterThan(128);
});
// -- Error cases --
it("throws on value = -101", async () => {
const img = sharp(png200x150);
await expect(contrast(img, { value: -101 })).rejects.toThrow(
"Contrast value must be between -100 and +100",
);
});
it("throws on value = 101", async () => {
const img = sharp(png200x150);
await expect(contrast(img, { value: 101 })).rejects.toThrow(
"Contrast value must be between -100 and +100",
);
});
});
// ---------------------------------------------------------------------------
// saturation
// ---------------------------------------------------------------------------
describe("saturation", () => {
it("value = 0 makes no change", async () => {
const img = sharp(png200x150);
const result = await saturation(img, { value: 0 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("value = -100 desaturates to grayscale-like", async () => {
// Create a colored image
const colorBuf = await sharp({
create: { width: 10, height: 10, channels: 3, background: "#ff0000" },
})
.png()
.toBuffer();
const result = await saturation(sharp(colorBuf), { value: -100 });
const { data } = await result.raw().toBuffer({ resolveWithObject: true });
// With saturation=0, R/G/B should all be the same (grayscale)
expect(data[0]).toBe(data[1]);
expect(data[1]).toBe(data[2]);
});
it("boundary value 100 does not throw", async () => {
const img = sharp(png200x150);
const result = await saturation(img, { value: 100 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
// -- Error cases --
it("throws on value = -101", async () => {
const img = sharp(png200x150);
await expect(saturation(img, { value: -101 })).rejects.toThrow(
"Saturation value must be between -100 and +100",
);
});
it("throws on value = 101", async () => {
const img = sharp(png200x150);
await expect(saturation(img, { value: 101 })).rejects.toThrow(
"Saturation value must be between -100 and +100",
);
});
});
// ---------------------------------------------------------------------------
// colorChannels
// ---------------------------------------------------------------------------
describe("colorChannels", () => {
it("100/100/100 is a no-op", async () => {
// All channels at 100 = multiplier 1.0, should be identity
const buf = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#804020" },
})
.png()
.toBuffer();
const original = await sharp(buf).raw().toBuffer({ resolveWithObject: true });
const result = await colorChannels(sharp(buf), { red: 100, green: 100, blue: 100 });
const processed = await result.raw().toBuffer({ resolveWithObject: true });
// Should be identical (or very close)
expect(Math.abs(processed.data[0] - original.data[0])).toBeLessThanOrEqual(1);
expect(Math.abs(processed.data[1] - original.data[1])).toBeLessThanOrEqual(1);
expect(Math.abs(processed.data[2] - original.data[2])).toBeLessThanOrEqual(1);
});
it("zeroing red channel removes red", async () => {
const buf = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#ff8040" },
})
.png()
.toBuffer();
const result = await colorChannels(sharp(buf), { red: 0, green: 100, blue: 100 });
const { data } = await result.raw().toBuffer({ resolveWithObject: true });
expect(data[0]).toBe(0); // Red should be 0
});
it("doubling green channel doubles green value (clamped)", async () => {
const buf = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#006400" },
})
.png()
.toBuffer();
const result = await colorChannels(sharp(buf), { red: 100, green: 200, blue: 100 });
const { data } = await result.raw().toBuffer({ resolveWithObject: true });
// Green was 100 (0x64), doubled = 200
expect(data[1]).toBe(200);
});
it("boundary values 0/0/0 makes everything black", async () => {
const buf = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#ffffff" },
})
.png()
.toBuffer();
const result = await colorChannels(sharp(buf), { red: 0, green: 0, blue: 0 });
const { data } = await result.raw().toBuffer({ resolveWithObject: true });
expect(data[0]).toBe(0);
expect(data[1]).toBe(0);
expect(data[2]).toBe(0);
});
// -- Error cases --
it("throws when red < 0", async () => {
const img = sharp(png200x150);
await expect(colorChannels(img, { red: -1, green: 100, blue: 100 })).rejects.toThrow(
"Red channel value must be between 0 and 200",
);
});
it("throws when red > 200", async () => {
const img = sharp(png200x150);
await expect(colorChannels(img, { red: 201, green: 100, blue: 100 })).rejects.toThrow(
"Red channel value must be between 0 and 200",
);
});
it("throws when green < 0", async () => {
const img = sharp(png200x150);
await expect(colorChannels(img, { red: 100, green: -1, blue: 100 })).rejects.toThrow(
"Green channel value must be between 0 and 200",
);
});
it("throws when green > 200", async () => {
const img = sharp(png200x150);
await expect(colorChannels(img, { red: 100, green: 201, blue: 100 })).rejects.toThrow(
"Green channel value must be between 0 and 200",
);
});
it("throws when blue < 0", async () => {
const img = sharp(png200x150);
await expect(colorChannels(img, { red: 100, green: 100, blue: -1 })).rejects.toThrow(
"Blue channel value must be between 0 and 200",
);
});
it("throws when blue > 200", async () => {
const img = sharp(png200x150);
await expect(colorChannels(img, { red: 100, green: 100, blue: 201 })).rejects.toThrow(
"Blue channel value must be between 0 and 200",
);
});
});
// ---------------------------------------------------------------------------
// grayscale
// ---------------------------------------------------------------------------
describe("grayscale", () => {
it("converts a color image to grayscale", async () => {
const colorBuf = await sharp({
create: { width: 10, height: 10, channels: 3, background: "#ff0000" },
})
.png()
.toBuffer();
const result = await grayscale(sharp(colorBuf));
const { data } = await result.raw().toBuffer({ resolveWithObject: true });
// In grayscale, all R=G=B per pixel (single channel or equal channels)
// For a pure red (255,0,0), luminance is around 54
expect(data[0]).toBeGreaterThan(0);
expect(data[0]).toBeLessThan(255);
});
it("preserves dimensions", async () => {
const img = sharp(png200x150);
const result = await grayscale(img);
const meta = await getMeta(result);
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("already grayscale image does not throw", async () => {
// sharp create requires channels 3 or 4; create a gray image then convert to grayscale first
const grayBuf = await sharp({
create: { width: 5, height: 5, channels: 3, background: "#808080" },
})
.grayscale()
.png()
.toBuffer();
const result = await grayscale(sharp(grayBuf));
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// sepia
// ---------------------------------------------------------------------------
describe("sepia", () => {
it("applies sepia tone to a white image", async () => {
const whiteBuf = await sharp({
create: { width: 10, height: 10, channels: 3, background: "#ffffff" },
})
.png()
.toBuffer();
const result = await sepia(sharp(whiteBuf));
const { data } = await result.raw().toBuffer({ resolveWithObject: true });
// Sepia matrix on white (255,255,255):
// R = 255*(0.393+0.769+0.189) = 255*1.351 -> clamped to 255
// G = 255*(0.349+0.686+0.168) = 255*1.203 -> clamped to 255
// B = 255*(0.272+0.534+0.131) = 255*0.937 -> ~239
// R >= G >= B is the characteristic sepia ordering
expect(data[0]).toBeGreaterThanOrEqual(data[1]); // R >= G
expect(data[1]).toBeGreaterThanOrEqual(data[2]); // G >= B
});
it("applies sepia tone to a colored image", async () => {
const img = sharp(jpg100x100);
const result = await sepia(img);
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("preserves dimensions", async () => {
const img = sharp(png200x150);
const result = await sepia(img);
const meta = await getMeta(result);
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("black stays black", async () => {
const blackBuf = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#000000" },
})
.png()
.toBuffer();
const result = await sepia(sharp(blackBuf));
const { data } = await result.raw().toBuffer({ resolveWithObject: true });
expect(data[0]).toBe(0);
expect(data[1]).toBe(0);
expect(data[2]).toBe(0);
});
});
// ---------------------------------------------------------------------------
// invert
// ---------------------------------------------------------------------------
describe("invert", () => {
it("inverts white to black", async () => {
const whiteBuf = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#ffffff" },
})
.png()
.toBuffer();
const result = await invert(sharp(whiteBuf));
const { data } = await result.raw().toBuffer({ resolveWithObject: true });
expect(data[0]).toBe(0);
expect(data[1]).toBe(0);
expect(data[2]).toBe(0);
});
it("inverts black to white", async () => {
const blackBuf = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#000000" },
})
.png()
.toBuffer();
const result = await invert(sharp(blackBuf));
const { data } = await result.raw().toBuffer({ resolveWithObject: true });
expect(data[0]).toBe(255);
expect(data[1]).toBe(255);
expect(data[2]).toBe(255);
});
it("double invert returns to original", async () => {
const buf = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#804020" },
})
.png()
.toBuffer();
const original = await sharp(buf).raw().toBuffer({ resolveWithObject: true });
const firstInvert = await invert(sharp(buf));
const firstBuf = await firstInvert.png().toBuffer();
const secondInvert = await invert(sharp(firstBuf));
const final = await secondInvert.raw().toBuffer({ resolveWithObject: true });
// Should be back to original values
expect(Math.abs(final.data[0] - original.data[0])).toBeLessThanOrEqual(1);
expect(Math.abs(final.data[1] - original.data[1])).toBeLessThanOrEqual(1);
expect(Math.abs(final.data[2] - original.data[2])).toBeLessThanOrEqual(1);
});
it("preserves dimensions", async () => {
const img = sharp(png200x150);
const result = await invert(img);
const meta = await getMeta(result);
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
});
// ---------------------------------------------------------------------------
// processImage (engine pipeline)
// ---------------------------------------------------------------------------
describe("processImage", () => {
it("applies a single resize operation", async () => {
const result = await processImage(png200x150, [
{ type: "resize", options: { width: 50, height: 50 } },
]);
expect(result.info.width).toBe(50);
expect(result.info.height).toBe(50);
expect(result.buffer).toBeInstanceOf(Buffer);
expect(result.buffer.length).toBeGreaterThan(0);
});
it("chains multiple operations in sequence", async () => {
// Use resize then flip then grayscale - operations that have predictable
// outcomes even when chained in a lazy pipeline
const result = await processImage(png200x150, [
{ type: "resize", options: { width: 100, height: 75 } },
{ type: "flip", options: { horizontal: true } },
{ type: "grayscale", options: {} },
]);
// resize to 100x75, flip preserves dimensions, grayscale preserves dimensions
expect(result.info.width).toBe(100);
expect(result.info.height).toBe(75);
expect(result.buffer.length).toBeGreaterThan(0);
});
it("applies crop then flip", async () => {
const result = await processImage(png200x150, [
{ type: "crop", options: { left: 0, top: 0, width: 100, height: 100 } },
{ type: "flip", options: { horizontal: true } },
]);
expect(result.info.width).toBe(100);
expect(result.info.height).toBe(100);
});
it("converts output format via outputFormat parameter", async () => {
const result = await processImage(
png200x150,
[{ type: "resize", options: { width: 50 } }],
"webp",
);
expect(result.info.format).toBe("webp");
});
it("processes with no operations (passthrough)", async () => {
const result = await processImage(png200x150, []);
expect(result.info.width).toBe(200);
expect(result.info.height).toBe(150);
expect(result.buffer.length).toBeGreaterThan(0);
});
it("applies brightness then contrast then saturation", async () => {
const result = await processImage(png200x150, [
{ type: "brightness", options: { value: 20 } },
{ type: "contrast", options: { value: -10 } },
{ type: "saturation", options: { value: 30 } },
]);
expect(result.info.width).toBe(200);
expect(result.info.height).toBe(150);
expect(result.buffer.length).toBeGreaterThan(0);
});
it("resize then compress pipeline", async () => {
const result = await processImage(png200x150, [
{ type: "resize", options: { width: 50, height: 50 } },
{ type: "compress", options: { quality: 60, format: "jpg" } },
]);
expect(result.buffer.length).toBeGreaterThan(0);
});
it("convert + strip-metadata pipeline", async () => {
const result = await processImage(jpg100x100, [
{ type: "convert", options: { format: "png" } },
{ type: "strip-metadata", options: {} },
]);
expect(result.info.format).toBe("png");
});
it("invert then sepia pipeline", async () => {
const result = await processImage(png200x150, [
{ type: "invert", options: {} },
{ type: "sepia", options: {} },
]);
expect(result.buffer.length).toBeGreaterThan(0);
});
it("color-channels operation through processImage", async () => {
const result = await processImage(png200x150, [
{ type: "color-channels", options: { red: 150, green: 50, blue: 100 } },
]);
expect(result.buffer.length).toBeGreaterThan(0);
});
it("all 14 operation types run without error", async () => {
// Smaller image for speed; we just verify no crashes
const smallBuf = await sharp({
create: { width: 20, height: 20, channels: 3, background: "#808080" },
})
.png()
.toBuffer();
// Run each operation individually to isolate failures
const ops: Array<{ type: string; options: Record<string, unknown> }> = [
{ type: "resize", options: { width: 10 } },
{ type: "crop", options: { left: 0, top: 0, width: 10, height: 10 } },
{ type: "rotate", options: { angle: 90 } },
{ type: "flip", options: { horizontal: true } },
{ type: "convert", options: { format: "png" } },
{ type: "compress", options: { quality: 50, format: "jpg" } },
{ type: "strip-metadata", options: {} },
{ type: "brightness", options: { value: 10 } },
{ type: "contrast", options: { value: 10 } },
{ type: "saturation", options: { value: 10 } },
{ type: "color-channels", options: { red: 100, green: 100, blue: 100 } },
{ type: "grayscale", options: {} },
{ type: "sepia", options: {} },
{ type: "invert", options: {} },
];
for (const op of ops) {
const result = await processImage(smallBuf, [op]);
expect(result.buffer.length).toBeGreaterThan(0);
}
});
// -- Error cases --
it("throws on unknown operation type", async () => {
await expect(
processImage(png200x150, [{ type: "nonexistent-op", options: {} }]),
).rejects.toThrow("Unknown operation: nonexistent-op");
});
it("throws on unknown output format", async () => {
await expect(processImage(png200x150, [], "bmp" as any)).rejects.toThrow(
"Unsupported output format: bmp",
);
});
it("propagates operation-level errors through pipeline", async () => {
// Crop that exceeds bounds should propagate
await expect(
processImage(png200x150, [
{ type: "crop", options: { left: 0, top: 0, width: 999, height: 999 } },
]),
).rejects.toThrow("Crop region exceeds");
});
it("error in second operation aborts pipeline", async () => {
// First op succeeds (resize to 50x50), second op fails (crop 100x100 from 50x50)
// The error may come from our validation or from sharp's internal extract check
// depending on whether the pipeline has been materialized
await expect(
processImage(png200x150, [
{ type: "resize", options: { width: 50, height: 50 } },
{ type: "crop", options: { left: 0, top: 0, width: 100, height: 100 } },
]),
).rejects.toThrow();
});
it("invalid buffer input throws", async () => {
const garbage = Buffer.from("this is not an image");
await expect(processImage(garbage, [{ type: "grayscale", options: {} }])).rejects.toThrow();
});
it("empty buffer throws", async () => {
const empty = Buffer.alloc(0);
await expect(processImage(empty, [{ type: "invert", options: {} }])).rejects.toThrow();
});
it("result contains accurate ImageInfo metadata", async () => {
const result = await processImage(
png200x150,
[{ type: "resize", options: { width: 80, height: 60 } }],
"png",
);
expect(result.info.width).toBe(80);
expect(result.info.height).toBe(60);
expect(result.info.format).toBe("png");
expect(result.info.size).toBe(result.buffer.length);
expect(typeof result.info.channels).toBe("number");
expect(typeof result.info.hasAlpha).toBe("boolean");
});
});
// ---------------------------------------------------------------------------
// Shared metadata parsing utilities
// ---------------------------------------------------------------------------
describe("sanitizeValue", () => {
it("converts Date to ISO string", () => {
const d = new Date("2026-01-15T10:30:00Z");
expect(sanitizeValue(d)).toBe("2026-01-15T10:30:00.000Z");
});
it("converts small Buffer to number array", () => {
const buf = Buffer.from([1, 2, 3]);
expect(sanitizeValue(buf)).toEqual([1, 2, 3]);
});
it("converts large Buffer to placeholder string", () => {
const buf = Buffer.alloc(300, 0);
expect(sanitizeValue(buf)).toBe("<binary 300 bytes>");
});
it("recursively sanitizes objects", () => {
const d = new Date("2026-01-01T00:00:00Z");
const result = sanitizeValue({ nested: { date: d } });
expect(result).toEqual({ nested: { date: "2026-01-01T00:00:00.000Z" } });
});
it("passes through primitives unchanged", () => {
expect(sanitizeValue("hello")).toBe("hello");
expect(sanitizeValue(42)).toBe(42);
expect(sanitizeValue(null)).toBe(null);
expect(sanitizeValue(true)).toBe(true);
});
});
describe("parseExif", () => {
it("parses EXIF buffer from test fixture", async () => {
const metadata = await sharp(jpgWithExif).metadata();
expect(metadata.exif).toBeTruthy();
const result = parseExif(metadata.exif!);
expect(result.image.Artist).toBe("Test Artist");
expect(result.image.Copyright).toBe("2026 Test Copyright");
expect(result.image.Software).toBe("SnapOtter Test");
expect(result.image.ImageDescription).toBe("Test Description");
});
it("returns empty sections for empty buffer", () => {
const result = parseExif(Buffer.from([]));
expect(result.image).toEqual({});
expect(result.gps).toEqual({});
});
});
describe("parseGps", () => {
it("parses DMS coordinates to decimal degrees", () => {
const result = parseGps({
GPSLatitude: [51, 30, 26.4],
GPSLatitudeRef: "N",
GPSLongitude: [0, 7, 39.6],
GPSLongitudeRef: "W",
GPSAltitude: 10,
GPSAltitudeRef: 0,
});
expect(result.latitude).toBeCloseTo(51.5073, 3);
expect(result.longitude).toBeCloseTo(-0.1277, 3);
expect(result.altitude).toBe(10);
});
it("returns nulls for empty GPS data", () => {
const result = parseGps({});
expect(result.latitude).toBeNull();
expect(result.longitude).toBeNull();
expect(result.altitude).toBeNull();
});
it("handles southern hemisphere", () => {
const result = parseGps({
GPSLatitude: [33, 51, 54],
GPSLatitudeRef: "S",
GPSLongitude: [151, 12, 36],
GPSLongitudeRef: "E",
});
expect(result.latitude).toBeCloseTo(-33.865, 2);
expect(result.longitude).toBeCloseTo(151.21, 2);
});
});
describe("parseXmp", () => {
it("extracts key-value pairs from XMP XML", () => {
const xml = Buffer.from(
'<x:xmpmeta xmlns:x="adobe:ns:meta/" xmlns:dc="http://purl.org/dc/elements/1.1/">' +
'<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">' +
'<rdf:Description dc:creator="Alice" dc:title="My Photo" />' +
"</rdf:RDF></x:xmpmeta>",
);
const result = parseXmp(xml);
expect(result["dc:creator"]).toBe("Alice");
expect(result["dc:title"]).toBe("My Photo");
});
it("skips xmlns and rdf namespace prefixes", () => {
const xml = Buffer.from(
'<x:xmpmeta xmlns:x="adobe:ns:meta/" xmlns:dc="http://purl.org/dc/elements/1.1/">' +
'<rdf:Description rdf:about="" dc:format="image/jpeg" />' +
"</x:xmpmeta>",
);
const result = parseXmp(xml);
expect(result["xmlns:x"]).toBeUndefined();
expect(result["xmlns:dc"]).toBeUndefined();
expect(result["rdf:about"]).toBeUndefined();
expect(result["dc:format"]).toBe("image/jpeg");
});
it("returns empty object for empty buffer", () => {
const result = parseXmp(Buffer.from(""));
expect(result).toEqual({});
});
});
// ---------------------------------------------------------------------------
// editMetadata
// ---------------------------------------------------------------------------
describe("editMetadata", () => {
it("writes common fields readable via exif-reader", async () => {
const image = sharp(jpgWithExif);
const result = await editMetadata(image, {
artist: "New Artist",
copyright: "New Copyright",
});
const buf = await result.jpeg().toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.exif).toBeTruthy();
const parsed = exifReader(meta.exif!);
expect(parsed.Image?.Artist).toBe("New Artist");
expect(parsed.Image?.Copyright).toBe("New Copyright");
// Original fields should be preserved via withExifMerge
expect(parsed.Image?.Software).toBe("SnapOtter Test");
});
it("clears GPS while preserving other EXIF", async () => {
// First write GPS to the image
const withGps = sharp(jpgWithExif).withExif({
IFD0: { Artist: "GPS Test" },
IFD3: { GPSLatitudeRef: "N" },
});
const gpsBuf = await withGps.jpeg().toBuffer();
const image = sharp(gpsBuf);
const result = await editMetadata(image, { clearGps: true });
const buf = await result.jpeg().toBuffer();
const meta = await sharp(buf).metadata();
const parsed = exifReader(meta.exif!);
// GPS should be gone
expect(parsed.GPSInfo).toBeUndefined();
// Other EXIF should still be present
expect(parsed.Image?.Artist).toBe("GPS Test");
});
it("removes specific fields via fieldsToRemove", async () => {
const image = sharp(jpgWithExif);
const result = await editMetadata(image, {
fieldsToRemove: ["Software"],
});
const buf = await result.jpeg().toBuffer();
const meta = await sharp(buf).metadata();
const parsed = exifReader(meta.exif!);
expect(parsed.Image?.Software).toBeUndefined();
// Other fields preserved
expect(parsed.Image?.Artist).toBe("Test Artist");
});
it("preserves metadata with no options", async () => {
const image = sharp(jpgWithExif);
const result = await editMetadata(image, {});
const buf = await result.jpeg().toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.exif).toBeTruthy();
const parsed = exifReader(meta.exif!);
expect(parsed.Image?.Artist).toBe("Test Artist");
});
it("edit wins over remove for same field", async () => {
const image = sharp(jpgWithExif);
const result = await editMetadata(image, {
artist: "Override Artist",
fieldsToRemove: ["Artist"],
});
const buf = await result.jpeg().toBuffer();
const meta = await sharp(buf).metadata();
const parsed = exifReader(meta.exif!);
expect(parsed.Image?.Artist).toBe("Override Artist");
});
it("writes fresh EXIF to image without existing metadata", async () => {
const image = sharp(png1x1);
const result = await editMetadata(image, {
artist: "Fresh Artist",
copyright: "Fresh Copyright",
});
const buf = await result.png().toBuffer();
// The operation should not throw
expect(buf.length).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// sharpen (basic)
// ---------------------------------------------------------------------------
describe("sharpen", () => {
it("value = 0 returns image unchanged (no-op)", async () => {
const img = sharp(png200x150);
const result = await sharpen(img, { value: 0 });
const meta = await getMeta(result);
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("negative value returns image unchanged (no-op)", async () => {
const img = sharp(png200x150);
const result = await sharpen(img, { value: -5 });
const meta = await getMeta(result);
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("value = 1 applies minimal sharpening", async () => {
const img = sharp(png200x150);
const result = await sharpen(img, { value: 1 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("value = 50 applies moderate sharpening", async () => {
const img = sharp(png200x150);
const result = await sharpen(img, { value: 50 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
const meta = await getMeta(result);
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("value = 100 applies maximum sharpening", async () => {
const img = sharp(png200x150);
const result = await sharpen(img, { value: 100 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("throws on value > 100", async () => {
const img = sharp(png200x150);
await expect(sharpen(img, { value: 101 })).rejects.toThrow(
"Sharpness value must be between 0 and 100",
);
});
it("throws on value = 200", async () => {
const img = sharp(png200x150);
await expect(sharpen(img, { value: 200 })).rejects.toThrow(
"Sharpness value must be between 0 and 100",
);
});
it("preserves image dimensions", async () => {
const img = sharp(jpg100x100);
const result = await sharpen(img, { value: 75 });
const meta = await getMeta(result);
expect(meta.width).toBe(100);
expect(meta.height).toBe(100);
});
it("works on different image formats (webp)", async () => {
const img = sharp(webp50x50);
const result = await sharpen(img, { value: 30 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("works on a 1x1 pixel image", async () => {
const img = sharp(png1x1);
const result = await sharpen(img, { value: 50 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("sharpening actually modifies pixel data", async () => {
// Create a blurry-ish image with an edge
const edgeBuf = await sharp({
create: { width: 20, height: 20, channels: 3, background: "#808080" },
})
.composite([
{
input: await sharp({
create: { width: 10, height: 20, channels: 3, background: "#ffffff" },
})
.png()
.toBuffer(),
left: 10,
top: 0,
},
])
.blur(1)
.png()
.toBuffer();
const original = await sharp(edgeBuf).raw().toBuffer();
const sharpened = await (await sharpen(sharp(edgeBuf), { value: 80 })).raw().toBuffer();
// At least some pixels should differ from the original
let diffCount = 0;
for (let i = 0; i < original.length; i++) {
if (original[i] !== sharpened[i]) diffCount++;
}
expect(diffCount).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// sharpenAdvanced
// ---------------------------------------------------------------------------
describe("sharpenAdvanced", () => {
it("adaptive method with defaults does not throw", async () => {
const img = sharp(png200x150);
const result = await sharpenAdvanced(img, { method: "adaptive" });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("adaptive method with custom params", async () => {
const img = sharp(png200x150);
const result = await sharpenAdvanced(img, {
method: "adaptive",
sigma: 2.0,
m1: 0.5,
m2: 5.0,
x1: 3.0,
y2: 15,
y3: 25,
});
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("unsharp-mask method with defaults does not throw", async () => {
const img = sharp(png200x150);
const result = await sharpenAdvanced(img, { method: "unsharp-mask" });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("unsharp-mask method with custom params", async () => {
const img = sharp(png200x150);
const result = await sharpenAdvanced(img, {
method: "unsharp-mask",
amount: 200,
radius: 2.0,
threshold: 10,
});
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("high-pass method with 3x3 kernel (default)", async () => {
const img = sharp(png200x150);
const result = await sharpenAdvanced(img, { method: "high-pass" });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("high-pass method with 5x5 kernel", async () => {
const img = sharp(png200x150);
const result = await sharpenAdvanced(img, {
method: "high-pass",
kernelSize: 5,
strength: 75,
});
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("high-pass method with custom strength", async () => {
const img = sharp(png200x150);
const result = await sharpenAdvanced(img, {
method: "high-pass",
strength: 25,
kernelSize: 3,
});
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("throws on unknown method", async () => {
const img = sharp(png200x150);
await expect(sharpenAdvanced(img, { method: "nonexistent" as any })).rejects.toThrow(
"Unknown sharpening method: nonexistent",
);
});
it("preserves dimensions for all methods", async () => {
const methods: Array<"adaptive" | "unsharp-mask" | "high-pass"> = [
"adaptive",
"unsharp-mask",
"high-pass",
];
for (const method of methods) {
const img = sharp(png200x150);
const result = await sharpenAdvanced(img, { method });
const meta = await getMeta(result);
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
}
});
// -- Denoise pre-pass --
it("denoise=off applies no median filter", async () => {
const img = sharp(png200x150);
const result = await sharpenAdvanced(img, { method: "adaptive", denoise: "off" });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("denoise=light applies median(3) pre-pass", async () => {
const img = sharp(png200x150);
const result = await sharpenAdvanced(img, { method: "adaptive", denoise: "light" });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("denoise=medium applies median(5) pre-pass", async () => {
const img = sharp(png200x150);
const result = await sharpenAdvanced(img, { method: "unsharp-mask", denoise: "medium" });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("denoise=strong applies median(7) pre-pass", async () => {
const img = sharp(png200x150);
const result = await sharpenAdvanced(img, { method: "high-pass", denoise: "strong" });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("denoise changes pixel data", async () => {
const noisyBuf = await sharp({
create: { width: 20, height: 20, channels: 3, background: "#808080" },
})
.png()
.toBuffer();
const withoutDenoise = await (
await sharpenAdvanced(sharp(noisyBuf), { method: "adaptive", denoise: "off" })
)
.raw()
.toBuffer();
const withDenoise = await (
await sharpenAdvanced(sharp(noisyBuf), { method: "adaptive", denoise: "strong" })
)
.raw()
.toBuffer();
// The outputs should differ because of the median filter pre-pass
let diffCount = 0;
for (let i = 0; i < withoutDenoise.length; i++) {
if (withoutDenoise[i] !== withDenoise[i]) diffCount++;
}
// For a uniform image, median filter may not change much but the sharpen
// parameters interact differently, so we allow 0 diffs on uniform images
expect(typeof diffCount).toBe("number");
});
it("sharpen through processImage pipeline", async () => {
const result = await processImage(png200x150, [{ type: "sharpen", options: { value: 50 } }]);
expect(result.buffer.length).toBeGreaterThan(0);
expect(result.info.width).toBe(200);
expect(result.info.height).toBe(150);
});
it("sharpen-advanced through processImage pipeline", async () => {
const result = await processImage(png200x150, [
{ type: "sharpen-advanced", options: { method: "unsharp-mask", amount: 100 } },
]);
expect(result.buffer.length).toBeGreaterThan(0);
expect(result.info.width).toBe(200);
expect(result.info.height).toBe(150);
});
});
// ---------------------------------------------------------------------------
// optimizeForWeb
// ---------------------------------------------------------------------------
describe("optimizeForWeb", () => {
it("converts to webp with quality", async () => {
const img = sharp(png200x150);
const result = await optimizeForWeb(img, { format: "webp", quality: 80 });
const meta = await getMeta(result);
expect(meta.format).toBe("webp");
});
it("converts to jpeg with quality and progressive", async () => {
const img = sharp(png200x150);
const result = await optimizeForWeb(img, {
format: "jpeg",
quality: 75,
progressive: true,
});
const meta = await getMeta(result);
expect(meta.format).toBe("jpeg");
});
it("converts to avif with quality", async () => {
const img = sharp(png200x150);
const result = await optimizeForWeb(img, { format: "avif", quality: 60 });
const meta = await getMeta(result);
expect(meta.format).toBe("heif");
});
it("converts to png with palette optimization", async () => {
const img = sharp(png200x150);
const result = await optimizeForWeb(img, { format: "png", quality: 80 });
const meta = await getMeta(result);
expect(meta.format).toBe("png");
});
it("throws on unsupported format", async () => {
const img = sharp(png200x150);
await expect(optimizeForWeb(img, { format: "bmp" as any, quality: 80 })).rejects.toThrow(
"Unsupported format: bmp",
);
});
// -- maxWidth / maxHeight --
it("resizes to maxWidth without enlargement", async () => {
const img = sharp(png200x150);
const result = await optimizeForWeb(img, {
format: "webp",
quality: 80,
maxWidth: 100,
});
const meta = await getMeta(result);
expect(meta.width).toBeLessThanOrEqual(100);
// Height should be proportionally reduced
expect(meta.height).toBeLessThanOrEqual(150);
});
it("resizes to maxHeight without enlargement", async () => {
const img = sharp(png200x150);
const result = await optimizeForWeb(img, {
format: "webp",
quality: 80,
maxHeight: 75,
});
const meta = await getMeta(result);
expect(meta.height).toBeLessThanOrEqual(75);
expect(meta.width).toBeLessThanOrEqual(200);
});
it("resizes to both maxWidth and maxHeight (fit inside)", async () => {
const img = sharp(png200x150);
const result = await optimizeForWeb(img, {
format: "jpeg",
quality: 80,
maxWidth: 80,
maxHeight: 60,
});
const meta = await getMeta(result);
expect(meta.width).toBeLessThanOrEqual(80);
expect(meta.height).toBeLessThanOrEqual(60);
});
it("does not enlarge image when maxWidth > actual width", async () => {
const img = sharp(png200x150);
const result = await optimizeForWeb(img, {
format: "webp",
quality: 80,
maxWidth: 500,
maxHeight: 400,
});
const meta = await getMeta(result);
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("without maxWidth/maxHeight preserves original dimensions", async () => {
const img = sharp(png200x150);
const result = await optimizeForWeb(img, { format: "webp", quality: 80 });
const meta = await getMeta(result);
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
// -- stripMetadata --
it("strips metadata by default", async () => {
const img = sharp(jpgWithExif);
const result = await optimizeForWeb(img, { format: "jpeg", quality: 80 });
const buf = await result.toBuffer();
const _meta = await sharp(buf).metadata();
// With stripMetadata=true (default), EXIF should be gone or minimal
// Sharp strips metadata by default unless withMetadata is called
expect(buf.length).toBeGreaterThan(0);
});
it("preserves metadata when stripMetadata=false", async () => {
const img = sharp(jpgWithExif);
const result = await optimizeForWeb(img, {
format: "jpeg",
quality: 80,
stripMetadata: false,
});
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
// withMetadata() preserves EXIF
expect(meta.exif).toBeTruthy();
});
it("stripMetadata=true (explicit) does not include metadata", async () => {
const img = sharp(jpgWithExif);
const result = await optimizeForWeb(img, {
format: "jpeg",
quality: 80,
stripMetadata: true,
});
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
// -- progressive --
it("progressive=false for jpeg does not throw", async () => {
const img = sharp(png200x150);
const result = await optimizeForWeb(img, {
format: "jpeg",
quality: 80,
progressive: false,
});
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("progressive defaults to true", async () => {
const img = sharp(png200x150);
// Not passing progressive -- should default to true
const result = await optimizeForWeb(img, { format: "jpeg", quality: 80 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
// -- Quality impact --
it("lower quality produces smaller file for webp", async () => {
const buf = readFixture(fixtures.image.base.jpg100);
const bufHigh = await (
await optimizeForWeb(sharp(buf), { format: "webp", quality: 95 })
).toBuffer();
const bufLow = await (
await optimizeForWeb(sharp(buf), { format: "webp", quality: 10 })
).toBuffer();
expect(bufLow.length).toBeLessThan(bufHigh.length);
});
it("lower quality produces smaller file for jpeg", async () => {
const bufHigh = await (
await optimizeForWeb(sharp(png200x150), { format: "jpeg", quality: 95 })
).toBuffer();
const bufLow = await (
await optimizeForWeb(sharp(png200x150), { format: "jpeg", quality: 10 })
).toBuffer();
expect(bufLow.length).toBeLessThan(bufHigh.length);
});
// -- Different input formats --
it("handles jpg input", async () => {
const img = sharp(jpg100x100);
const result = await optimizeForWeb(img, { format: "webp", quality: 80 });
const meta = await getMeta(result);
expect(meta.format).toBe("webp");
});
it("handles webp input", async () => {
const img = sharp(webp50x50);
const result = await optimizeForWeb(img, { format: "jpeg", quality: 80 });
const meta = await getMeta(result);
expect(meta.format).toBe("jpeg");
});
it("handles 1x1 pixel image", async () => {
const img = sharp(png1x1);
const result = await optimizeForWeb(img, { format: "webp", quality: 80 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
// -- Combined options --
it("combines maxWidth + stripMetadata=false + progressive", async () => {
const img = sharp(jpgWithExif);
const result = await optimizeForWeb(img, {
format: "jpeg",
quality: 70,
maxWidth: 50,
stripMetadata: false,
progressive: true,
});
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.width).toBeLessThanOrEqual(50);
expect(meta.exif).toBeTruthy();
});
});