Files
SnapOtter/packages/image-engine/tests/compress-channels-adjust.test.ts
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

439 lines
18 KiB
TypeScript

import sharp from "sharp";
import { beforeAll, describe, expect, it } from "vitest";
import { brightness } from "../src/operations/brightness.js";
import { colorChannels } from "../src/operations/color-channels.js";
import { compress } from "../src/operations/compress.js";
import { contrast } from "../src/operations/contrast.js";
import { saturation } from "../src/operations/saturation.js";
import type { Sharp } from "../src/types.js";
// Mutation-killing tests for compress / color-channels / brightness / contrast /
// saturation. The existing operations.test.ts only asserts `buf.length > 0`
// (execution, not value), which lets encoder-option, arithmetic, and boundary
// mutants survive. These tests assert concrete effects: byte-size ordering
// across quality levels, exact per-channel raw bytes after recomb/linear, and
// direction + clamp + no-op behavior for the gamma-aware modulate() ops.
/** Deterministic seeded PRNG so noisy-photo bytes (and thus sizes) are stable. */
function makeRng(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state * 1103515245 + 12345) & 0x7fffffff;
return state / 0x7fffffff;
};
}
/**
* A fully random RGB image. Random pixels are incompressible, so JPEG/WebP/AVIF
* quality has a large, monotonic effect on output size (lower quality => fewer
* bytes), which is exactly what the size-ordering assertions rely on.
*/
async function noisyPhotoPng(width = 400, height = 400, seed = 987654321): Promise<Buffer> {
const rng = makeRng(seed);
const raw = Buffer.alloc(width * height * 3);
for (let i = 0; i < raw.length; i++) {
raw[i] = Math.floor(rng() * 256);
}
return sharp(raw, { raw: { width, height, channels: 3 } })
.png()
.toBuffer();
}
/** Solid-color PNG for exact per-channel math (recomb / linear / modulate). */
async function solidPng(r: number, g: number, b: number, size = 8): Promise<Buffer> {
return sharp({
create: { width: size, height: size, channels: 3, background: { r, g, b } },
})
.png()
.toBuffer();
}
/** First pixel's [R, G, B] after decoding a buffer back to raw. */
async function firstPixel(buffer: Buffer): Promise<[number, number, number]> {
const raw = await sharp(buffer).raw().toBuffer();
return [raw[0], raw[1], raw[2]];
}
async function outputFormat(buffer: Buffer): Promise<string> {
const meta = await sharp(buffer).metadata();
// Sharp reports AVIF as the heif container; normalize for assertions.
return meta.format === "heif" ? "avif" : (meta.format ?? "");
}
let photoPng: Buffer;
beforeAll(async () => {
photoPng = await noisyPhotoPng();
});
describe("compress: format selection", () => {
it("honors an explicit format for every encoder branch", async () => {
const src = await solidPng(120, 90, 60, 32);
for (const [format, expected] of [
["jpg", "jpeg"],
["png", "png"],
["webp", "webp"],
["avif", "avif"],
] as const) {
const out = await (await compress(sharp(src), { quality: 70, format })).toBuffer();
expect(await outputFormat(out)).toBe(expected);
}
});
it("defaults to the detected input format when none is given", async () => {
const pngOut = await (await compress(sharp(photoPng), { quality: 80 })).toBuffer();
expect(await outputFormat(pngOut)).toBe("png");
const jpegIn = await sharp(photoPng).jpeg({ quality: 95 }).toBuffer();
const jpegOut = await (await compress(sharp(jpegIn), { quality: 80 })).toBuffer();
expect(await outputFormat(jpegOut)).toBe("jpeg");
});
it("an explicit format overrides the detected input format", async () => {
// PNG in, AVIF requested out -> must not fall back to the input's png.
const out = await (await compress(sharp(photoPng), { quality: 50, format: "avif" })).toBuffer();
expect(await outputFormat(out)).toBe("avif");
});
it("falls back to PNG for inputs Sharp cannot encode (SVG)", async () => {
const svg = Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40">' +
'<rect width="40" height="40" fill="rgb(30,60,90)"/></svg>',
);
expect((await sharp(svg).metadata()).format).toBe("svg");
const out = await (await compress(sharp(svg), { quality: 80 })).toBuffer();
expect(await outputFormat(out)).toBe("png");
});
it.each(["bmp", "heic", "qoi", "../../outside"])(
"rejects a runtime-unsupported explicit format (%s)",
async (format) => {
await expect(
compress(sharp(photoPng), { quality: 80, format: format as "jpg" }),
).rejects.toThrow(`Unsupported compression format: ${format}`);
},
);
});
describe("compress: quality controls output size", () => {
// Random pixels make the ordering strict and wide, so a mutated quality
// number, a hardcoded quality, or a swapped-format branch changes the bytes.
it.each([
["jpg", "jpeg"],
["webp", "webp"],
["avif", "avif"],
] as const)("lower quality yields strictly smaller %s output", async (format) => {
const low = await (await compress(sharp(photoPng), { quality: 20, format })).toBuffer();
const mid = await (await compress(sharp(photoPng), { quality: 55, format })).toBuffer();
const high = await (await compress(sharp(photoPng), { quality: 90, format })).toBuffer();
expect(low.length).toBeLessThan(mid.length);
expect(mid.length).toBeLessThan(high.length);
});
it("uses the default quality (80) when quality is omitted", async () => {
// Default 80 must sit strictly between q20 and q100 in size: proves the
// `quality ?? 80` fallback feeds the encoder (not 0/undefined/100).
const q20 = await (await compress(sharp(photoPng), { quality: 20, format: "jpg" })).toBuffer();
const q100 = await (
await compress(sharp(photoPng), { quality: 100, format: "jpg" })
).toBuffer();
const dflt = await (await compress(sharp(photoPng), { format: "jpg" })).toBuffer();
expect(dflt.length).toBeGreaterThan(q20.length);
expect(dflt.length).toBeLessThan(q100.length);
});
});
describe("compress: quality clamp boundaries", () => {
it("accepts the inclusive edges q=1 and q=100", async () => {
await expect(compress(sharp(photoPng), { quality: 1, format: "jpg" })).resolves.toBeDefined();
await expect(compress(sharp(photoPng), { quality: 100, format: "jpg" })).resolves.toBeDefined();
});
it("rejects just outside the range: q=0 and q=101", async () => {
await expect(compress(sharp(photoPng), { quality: 0, format: "jpg" })).rejects.toThrow(
/between 1 and 100/,
);
await expect(compress(sharp(photoPng), { quality: 101, format: "jpg" })).rejects.toThrow(
/between 1 and 100/,
);
});
it.each([Number.NaN, Number.POSITIVE_INFINITY, 1.5])(
"rejects a non-finite or fractional quality (%s)",
async (quality) => {
await expect(compress(sharp(photoPng), { quality, format: "jpg" })).rejects.toThrow(
"Quality must be an integer between 1 and 100",
);
},
);
});
describe("compress: target size", () => {
it("rejects non-positive and unreachable targets", async () => {
await expect(compress(sharp(photoPng), { targetSizeBytes: 0, format: "jpg" })).rejects.toThrow(
/greater than 0/,
);
await expect(compress(sharp(photoPng), { targetSizeBytes: -5, format: "jpg" })).rejects.toThrow(
/greater than 0/,
);
await expect(compress(sharp(photoPng), { targetSizeBytes: 1, format: "jpg" })).rejects.toThrow(
"Unable to compress image to 1 bytes within safe resize limits",
);
});
it.each([Number.NaN, Number.POSITIVE_INFINITY, 1.5])(
"rejects a non-finite or fractional target (%s)",
async (targetSizeBytes) => {
await expect(compress(sharp(photoPng), { targetSizeBytes, format: "jpg" })).rejects.toThrow(
"Target size must be a positive integer",
);
},
);
it("hits a reachable target without downscaling", async () => {
// Target comfortably above the q=1 full-size floor: the quality search
// succeeds, dimensions stay full, and the result fits under the target.
const q1Full = (await sharp(photoPng).toFormat("jpeg", { quality: 1 }).toBuffer()).length;
const target = q1Full * 3;
const out = await (
await compress(sharp(photoPng), { targetSizeBytes: target, format: "jpg" })
).toBuffer();
const meta = await sharp(out).metadata();
expect(out.length).toBeLessThanOrEqual(target);
expect(meta.width).toBe(400);
expect(meta.height).toBe(400);
});
it("downscales when even q=1 at full size overshoots the target", async () => {
// Target below the q=1 full-size floor forces the resize fallback loop.
const q1Full = (await sharp(photoPng).toFormat("jpeg", { quality: 1 }).toBuffer()).length;
const target = Math.round(q1Full / 4);
const out = await (
await compress(sharp(photoPng), { targetSizeBytes: target, format: "jpg" })
).toBuffer();
const meta = await sharp(out).metadata();
expect(meta.width).toBeLessThan(400);
expect(meta.height).toBeLessThan(400);
// The fallback should still shrink the file well below the original.
expect(out.length).toBeLessThan(photoPng.length);
expect(out.length).toBeLessThanOrEqual(target);
});
it("a smaller target produces a smaller (or equal) file than a larger target", async () => {
const q1Full = (await sharp(photoPng).toFormat("jpeg", { quality: 1 }).toBuffer()).length;
const bigOut = await (
await compress(sharp(photoPng), { targetSizeBytes: q1Full * 6, format: "jpg" })
).toBuffer();
const smallOut = await (
await compress(sharp(photoPng), { targetSizeBytes: q1Full * 2, format: "jpg" })
).toBuffer();
expect(smallOut.length).toBeLessThanOrEqual(bigOut.length);
});
});
describe("colorChannels: exact per-channel recomb", () => {
// Distinct channel values expose any swapped matrix position or wrong divisor.
async function distinctInput(): Promise<Sharp> {
return sharp(await solidPng(10, 20, 30));
}
it("scales each channel by value/100 on the diagonal", async () => {
// red 150 -> x1.5 -> 15, green 100 -> x1.0 -> 20, blue 50 -> x0.5 -> 15.
const out = await (
await colorChannels(await distinctInput(), { red: 150, green: 100, blue: 50 })
).toBuffer();
const [r, g, b] = await firstPixel(out);
expect(r).toBe(15);
expect(g).toBe(20);
expect(b).toBe(15);
});
it("red=0 zeroes only the red channel", async () => {
const out = await (
await colorChannels(await distinctInput(), { red: 0, green: 100, blue: 100 })
).toBuffer();
const [r, g, b] = await firstPixel(out);
expect(r).toBe(0);
expect(g).toBe(20);
expect(b).toBe(30);
});
it("green=200 doubles only the green channel", async () => {
// green 20 -> x2.0 -> 40; red and blue unchanged (kept at x1.0).
const out = await (
await colorChannels(await distinctInput(), { red: 100, green: 200, blue: 100 })
).toBuffer();
const [r, g, b] = await firstPixel(out);
expect(r).toBe(10);
expect(g).toBe(40);
expect(b).toBe(30);
});
it("red=green=blue=100 is a no-op", async () => {
const out = await (
await colorChannels(await distinctInput(), { red: 100, green: 100, blue: 100 })
).toBuffer();
const [r, g, b] = await firstPixel(out);
expect([r, g, b]).toEqual([10, 20, 30]);
});
it("rejects channel values above 200 and below 0", async () => {
await expect(
colorChannels(await distinctInput(), { red: 201, green: 100, blue: 100 }),
).rejects.toThrow(/Red channel/);
await expect(
colorChannels(await distinctInput(), { red: 100, green: -1, blue: 100 }),
).rejects.toThrow(/Green channel/);
await expect(
colorChannels(await distinctInput(), { red: 100, green: 100, blue: 201 }),
).rejects.toThrow(/Blue channel/);
});
it("accepts the inclusive edges 0 and 200", async () => {
await expect(
colorChannels(await distinctInput(), { red: 0, green: 0, blue: 0 }),
).resolves.toBeDefined();
await expect(
colorChannels(await distinctInput(), { red: 200, green: 200, blue: 200 }),
).resolves.toBeDefined();
});
});
describe("brightness: direction, clamps, no-op", () => {
// modulate() is gamma-aware, so exact values aren't naive multiplies; assert
// direction relative to the source and the exact 0/255 clamp endpoints.
async function grayInput(level = 100): Promise<Sharp> {
return sharp(await solidPng(level, level, level));
}
it("+50 brightens above the source value", async () => {
const out = await (await brightness(await grayInput(100), { value: 50 })).toBuffer();
const [r] = await firstPixel(out);
expect(r).toBeGreaterThan(100);
});
it("-50 darkens below the source value", async () => {
const out = await (await brightness(await grayInput(100), { value: -50 })).toBuffer();
const [r] = await firstPixel(out);
expect(r).toBeLessThan(100);
});
it("value=0 is an exact no-op (multiplier 1.0)", async () => {
const out = await (await brightness(await grayInput(100), { value: 0 })).toBuffer();
const [r, g, b] = await firstPixel(out);
expect([r, g, b]).toEqual([100, 100, 100]);
});
it("value=-100 drives the image to black (multiplier 0)", async () => {
const out = await (await brightness(await grayInput(100), { value: -100 })).toBuffer();
const [r, g, b] = await firstPixel(out);
expect([r, g, b]).toEqual([0, 0, 0]);
});
it("value=+100 doubling clamps a bright input at 255", async () => {
// 200 * 2.0 = 400 -> clamp to 255. Confirms the +value/100 -> mult 2 mapping.
const out = await (
await brightness(sharp(await solidPng(200, 200, 200)), { value: 100 })
).toBuffer();
const [r, g, b] = await firstPixel(out);
expect([r, g, b]).toEqual([255, 255, 255]);
});
it("rejects values outside -100..100 at both edges", async () => {
await expect(brightness(await grayInput(), { value: 101 })).rejects.toThrow();
await expect(brightness(await grayInput(), { value: -101 })).rejects.toThrow();
});
it("accepts the inclusive edges -100 and 100", async () => {
await expect(brightness(await grayInput(), { value: -100 })).resolves.toBeDefined();
await expect(brightness(await grayInput(), { value: 100 })).resolves.toBeDefined();
});
});
describe("contrast: exact linear transform around 128", () => {
// contrast() is a deterministic linear(slope, intercept), so assert exact
// output bytes. slope = 1 + value/100, intercept = 128 * (1 - slope).
async function twoTone(): Promise<Sharp> {
// Two pixels: 64 (below mid) and 192 (above mid).
return sharp(Buffer.from([64, 64, 64, 192, 192, 192]), {
raw: { width: 2, height: 1, channels: 3 },
});
}
it("value=+100 (slope 2) pushes values away from the midpoint and clamps", async () => {
// 64 -> 2*64-128 = 0; 192 -> 2*192-128 = 256 -> clamp 255.
const raw = await (await contrast(await twoTone(), { value: 100 })).raw().toBuffer();
expect(raw[0]).toBe(0);
expect(raw[3]).toBe(255);
});
it("value=-50 (slope 0.5) pulls values toward the midpoint", async () => {
// slope 0.5, intercept 128*(1-0.5)=64. 64 -> 96; 192 -> 160.
const raw = await (await contrast(await twoTone(), { value: -50 })).raw().toBuffer();
expect(raw[0]).toBe(96);
expect(raw[3]).toBe(160);
});
it("value=0 is an exact no-op (slope 1, intercept 0)", async () => {
const raw = await (await contrast(await twoTone(), { value: 0 })).raw().toBuffer();
expect(raw[0]).toBe(64);
expect(raw[3]).toBe(192);
});
it("the midpoint (128) is a fixed point for any slope", async () => {
// Kills intercept-formula mutants: 128*(1+slope) or a sign flip would move it.
for (const value of [100, -50, 50, -100]) {
const mid = sharp(Buffer.from([128, 128, 128]), {
raw: { width: 1, height: 1, channels: 3 },
});
const raw = await (await contrast(mid, { value })).raw().toBuffer();
expect(raw[0]).toBe(128);
}
});
it("rejects values outside -100..100 at both edges", async () => {
await expect(contrast(await twoTone(), { value: 101 })).rejects.toThrow();
await expect(contrast(await twoTone(), { value: -101 })).rejects.toThrow();
});
});
describe("saturation: desaturation, widening, no-op", () => {
async function coloredInput(): Promise<Sharp> {
return sharp(await solidPng(200, 50, 90));
}
it("value=-100 fully desaturates (R == G == B)", async () => {
const out = await (await saturation(await coloredInput(), { value: -100 })).toBuffer();
const [r, g, b] = await firstPixel(out);
expect(r).toBe(g);
expect(g).toBe(b);
});
it("value=0 is an exact no-op (multiplier 1.0)", async () => {
const before = await coloredInput();
const original = await before.clone().raw().toBuffer();
const out = await (await saturation(before, { value: 0 })).toBuffer();
const after = await sharp(out).raw().toBuffer();
expect(Buffer.compare(original, after)).toBe(0);
});
it("value=+100 widens the channel spread versus the source", async () => {
const [r0, g0, b0] = await firstPixel(await solidPng(200, 50, 90));
const sourceSpread = Math.max(r0, g0, b0) - Math.min(r0, g0, b0);
const out = await (await saturation(await coloredInput(), { value: 100 })).toBuffer();
const [r, g, b] = await firstPixel(out);
const outSpread = Math.max(r, g, b) - Math.min(r, g, b);
expect(outSpread).toBeGreaterThan(sourceSpread);
});
it("rejects values outside -100..100 at both edges", async () => {
await expect(saturation(await coloredInput(), { value: 101 })).rejects.toThrow();
await expect(saturation(await coloredInput(), { value: -101 })).rejects.toThrow();
});
it("accepts the inclusive edges -100 and 100", async () => {
await expect(saturation(await coloredInput(), { value: -100 })).resolves.toBeDefined();
await expect(saturation(await coloredInput(), { value: 100 })).resolves.toBeDefined();
});
});