mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
241 lines
12 KiB
TypeScript
241 lines
12 KiB
TypeScript
import sharp from "sharp";
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
import { compress } from "../src/operations/compress.js";
|
|
|
|
// Targeted mutation-killing tests for src/operations/compress.ts.
|
|
//
|
|
// The target-size path runs a binary search over JPEG quality and, when the
|
|
// quality-1 floor still overshoots, a downscale loop. Every expected byte size
|
|
// and dimension below was measured against the real Sharp encoder (deterministic
|
|
// for these fixed inputs) rather than guessed: a mutation that shifts the
|
|
// converged quality by even one step changes the exact output size, and a
|
|
// mutation to the downscale loop changes the exact output dimensions. Asserting
|
|
// those exact values is what distinguishes correct code from each mutant.
|
|
|
|
// Deterministic LCG so the pixel content (and therefore every compressed size)
|
|
// is stable across runs and machines.
|
|
function seededPhoto(
|
|
width: number,
|
|
height: number,
|
|
seed: number,
|
|
freqX: number,
|
|
freqY: number,
|
|
noise: number,
|
|
): Promise<Buffer> {
|
|
const channels = 3;
|
|
const data = Buffer.alloc(width * height * channels);
|
|
let state = seed;
|
|
const rnd = (): number => {
|
|
state = (state * 1103515245 + 12345) & 0x7fffffff;
|
|
return state / 0x7fffffff;
|
|
};
|
|
const clamp = (v: number): number => Math.max(0, Math.min(255, v));
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const idx = (y * width + x) * channels;
|
|
const base = Math.sin(x / freqX) * 60 + Math.cos(y / freqY) * 60 + 128;
|
|
data[idx] = clamp(base + (rnd() - 0.5) * noise);
|
|
data[idx + 1] = clamp(base * 0.8 + (rnd() - 0.5) * noise);
|
|
data[idx + 2] = clamp(base * 0.6 + (rnd() - 0.5) * noise);
|
|
}
|
|
}
|
|
return sharp(data, { raw: { width, height, channels } }).png().toBuffer();
|
|
}
|
|
|
|
async function outputInfo(
|
|
result: sharp.Sharp,
|
|
): Promise<{ size: number; width: number; height: number }> {
|
|
const buf = await result.toBuffer();
|
|
const meta = await sharp(buf).metadata();
|
|
return { size: buf.length, width: meta.width ?? 0, height: meta.height ?? 0 };
|
|
}
|
|
|
|
// 500x500 photo-like fixture: JPEG quality meaningfully changes its compressed
|
|
// size across the whole 1..100 range, so the binary search actually converges.
|
|
// Measured reference points (deterministic):
|
|
// full-dim JPEG size: q1=2709, q49=44941, q50=45470, q51=45976, q69=69425, q100=295415
|
|
let photo500: Buffer;
|
|
|
|
beforeAll(async () => {
|
|
photo500 = await seededPhoto(500, 500, 123456789, 12, 9, 90);
|
|
});
|
|
|
|
describe("compress quality guard (L45)", () => {
|
|
// `if (q < 1 || q > 100)` has two operands, each with its own `-> false` mutant.
|
|
// quality=101 trips ONLY the upper bound, so the `q > 100 -> false` mutant stops
|
|
// throwing while correct throws. quality=0 trips ONLY the lower bound, so the
|
|
// `q < 1 -> false` mutant stops throwing. Both breaches are needed to kill both.
|
|
it("throws for quality just above the max (101)", async () => {
|
|
await expect(compress(sharp(photo500), { quality: 101, format: "jpg" })).rejects.toThrow(
|
|
"Quality must be between 1 and 100",
|
|
);
|
|
});
|
|
|
|
it("throws for quality just below the min (0)", async () => {
|
|
await expect(compress(sharp(photo500), { quality: 0, format: "jpg" })).rejects.toThrow(
|
|
"Quality must be between 1 and 100",
|
|
);
|
|
});
|
|
|
|
it("accepts the max boundary quality (100) without throwing", async () => {
|
|
const result = await compress(sharp(photo500), { quality: 100, format: "jpg" });
|
|
const info = await outputInfo(result);
|
|
expect(info.width).toBe(500);
|
|
expect(info.height).toBe(500);
|
|
});
|
|
|
|
it("accepts the min boundary quality (1) without throwing", async () => {
|
|
const result = await compress(sharp(photo500), { quality: 1, format: "jpg" });
|
|
const info = await outputInfo(result);
|
|
expect(info.width).toBe(500);
|
|
expect(info.height).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe("compress target-size binary search (L64, L65, L71, L74, L76)", () => {
|
|
// The q=50 encode is exactly 45470 bytes. With `resultSize <= targetBytes`
|
|
// (correct) a target of 45470 accepts q=50; the L71 `<= -> <` mutant rejects
|
|
// it (size not strictly < target) and settles for q=49 (44941). The L74
|
|
// `low = mid + 1 -> mid - 1` mutant also fails to hold q=50. Only exact-size
|
|
// assertion (not `<= target`) separates them.
|
|
it("lands exactly on the quality whose size equals the target (kills L71 + L74)", async () => {
|
|
const target = 45470;
|
|
const result = await compress(sharp(photo500), { targetSizeBytes: target, format: "jpg" });
|
|
const info = await outputInfo(result);
|
|
expect(info.size).toBe(45470);
|
|
expect(info.size).toBeLessThanOrEqual(target);
|
|
expect(info.width).toBe(500);
|
|
expect(info.height).toBe(500);
|
|
});
|
|
|
|
// Multi-iteration converge where the answer sits at q=49 (44941). The L64 loop
|
|
// bound `low <= high -> low < high` and the L76 `high = mid - 1 -> mid + 1`
|
|
// mutant both diverge to a different final quality/size here.
|
|
it("converges over several iterations to the exact best quality (kills L64 bound + L76)", async () => {
|
|
const target = 45000;
|
|
const result = await compress(sharp(photo500), { targetSizeBytes: target, format: "jpg" });
|
|
const info = await outputInfo(result);
|
|
expect(info.size).toBe(44941);
|
|
expect(info.size).toBeLessThanOrEqual(target);
|
|
expect(info.width).toBe(500);
|
|
expect(info.height).toBe(500);
|
|
});
|
|
|
|
// Low-quality region: correct converges to q=11 (9220). The L76 `high = mid - 1
|
|
// -> mid + 1` mutation (search moves the wrong way when overshooting) lands q=9,
|
|
// a different exact size. The L65 `(low+high)/2 -> (low-high)/2` midpoint mutant
|
|
// collapses every probe to q=1 and can never reach 9220.
|
|
it("drives the search downward to a low quality and stays full-dimension (kills L65 + L76)", async () => {
|
|
const target = 10000;
|
|
const result = await compress(sharp(photo500), { targetSizeBytes: target, format: "jpg" });
|
|
const info = await outputInfo(result);
|
|
expect(info.size).toBe(9220);
|
|
expect(info.size).toBeLessThanOrEqual(target);
|
|
expect(info.width).toBe(500);
|
|
expect(info.height).toBe(500);
|
|
});
|
|
|
|
// Mid-range target the search reaches at full dimensions (q=32, 29271). A broken
|
|
// midpoint or update rule diverges from this exact size.
|
|
it("resolves a mid-range target to its exact converged size (kills L65 midpoint)", async () => {
|
|
const target = 30000;
|
|
const result = await compress(sharp(photo500), { targetSizeBytes: target, format: "jpg" });
|
|
const info = await outputInfo(result);
|
|
expect(info.size).toBe(29271);
|
|
expect(info.size).toBeLessThanOrEqual(target);
|
|
expect(info.width).toBe(500);
|
|
expect(info.height).toBe(500);
|
|
});
|
|
|
|
// Target sits one byte above the quality-1 floor (floor is 2709). Correct code
|
|
// finds q=2 at full dimensions and never scales. The L76 `high = mid - 1 ->
|
|
// mid + 1` mutant fails to find ANY full-dim quality here, which forces it into
|
|
// the downscale path and shrinks the output below 500x500. Asserting full
|
|
// dimensions therefore also guards the "search converged, not scaled" boundary.
|
|
it("hits the near-floor target at full dimensions without scaling (kills L76 direction)", async () => {
|
|
const target = 2800;
|
|
const result = await compress(sharp(photo500), { targetSizeBytes: target, format: "jpg" });
|
|
const info = await outputInfo(result);
|
|
expect(info.size).toBe(2709);
|
|
expect(info.size).toBeLessThanOrEqual(target);
|
|
expect(info.width).toBe(500);
|
|
expect(info.height).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe("compress tolerance early-break (L73)", () => {
|
|
// Target 70000 is reachable within the 1% tolerance at q=69 (69425), so correct
|
|
// code takes the `(target - size)/target <= tolerance` break at the optimum.
|
|
// The L73 Conditional (`-> true`, break on the first accepted probe) and the
|
|
// Equality flip (`<= -> >=`) both bail out early at q=51 (45976), wasting ~35%
|
|
// of the byte budget. Asserting the exact converged size distinguishes them.
|
|
it("breaks at the in-tolerance optimum rather than the first accepted probe (kills L73)", async () => {
|
|
const target = 70000;
|
|
const result = await compress(sharp(photo500), { targetSizeBytes: target, format: "jpg" });
|
|
const info = await outputInfo(result);
|
|
expect(info.size).toBe(69425);
|
|
expect(info.size).toBeLessThanOrEqual(target);
|
|
// Within 1% tolerance of the target: proves the early-break path is exercised,
|
|
// not merely a full 12-iteration convergence.
|
|
expect((target - info.size) / target).toBeLessThanOrEqual(0.01);
|
|
expect(info.width).toBe(500);
|
|
expect(info.height).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe("compress downscale pass (L110, L117)", () => {
|
|
// Target 1355 is below the full-dim quality-1 floor (2709), so the search fails
|
|
// full-dim and enters the downscale loop. It first finds a valid quality at the
|
|
// 211x211 pass (q=5, 1334 bytes) and returns there via `if (q !== null)`. The
|
|
// L117 mutants change that: `q === null` / `-> true` return on the FIRST pass
|
|
// (375x375) instead, and `-> false` never returns from the loop and falls
|
|
// through to the 50x50 floor. Exact output dimensions pin the correct branch.
|
|
it("returns at the first downscale pass that finds a quality (kills L117)", async () => {
|
|
const target = 1355;
|
|
const result = await compress(sharp(photo500), { targetSizeBytes: target, format: "jpg" });
|
|
const info = await outputInfo(result);
|
|
expect(info.width).toBe(211);
|
|
expect(info.height).toBe(211);
|
|
expect(info.size).toBeLessThanOrEqual(target);
|
|
});
|
|
|
|
// Impossibly small target on a 20x40 source forces the downscale loop to the
|
|
// dimension floor. The passes are 15x30, 11x23, then 8x17 which trips
|
|
// `newWidth < 10 || newHeight < 10` on the WIDTH axis (8 < 10, height 17 is not).
|
|
// Correct code breaks and returns the last good pass, 11x23. The Logical
|
|
// `|| -> &&` mutant does NOT break at 8x17 (both axes not < 10) and shrinks to
|
|
// 6x13; the whole-condition `-> false` mutant never breaks and shrinks to 2x4;
|
|
// the `newWidth < 10 -> false` operand mutant loses the width guard so 8x17 no
|
|
// longer breaks. All three change the exact output dimensions.
|
|
it("fails clearly when the width axis reaches the floor before the target is met", async () => {
|
|
const asymmetric = await seededPhoto(20, 40, 55555, 3, 2, 120);
|
|
await expect(
|
|
compress(sharp(asymmetric), { targetSizeBytes: 1, format: "jpg" }),
|
|
).rejects.toThrow("Unable to compress image to 1 bytes within safe resize limits");
|
|
});
|
|
|
|
// Transposed source (40x20): the passes are 30x15, 23x11, then 17x8 which trips
|
|
// the SAME guard but on the HEIGHT axis (8 < 10, width 17 is not). Correct code
|
|
// returns the last good pass, 23x11. The `newHeight < 10 -> false` operand
|
|
// mutant loses the height guard, so 17x8 no longer breaks and the output shrinks
|
|
// further. The width-axis case above cannot catch this operand; only a
|
|
// height-limited source can.
|
|
it("fails clearly when the height axis reaches the floor before the target is met", async () => {
|
|
const asymmetric = await seededPhoto(40, 20, 55555, 3, 2, 120);
|
|
await expect(
|
|
compress(sharp(asymmetric), { targetSizeBytes: 1, format: "jpg" }),
|
|
).rejects.toThrow("Unable to compress image to 1 bytes within safe resize limits");
|
|
});
|
|
|
|
// A 13x13 source scales to exactly 10x10 on the first pass. With `< 10`
|
|
// (correct) that is NOT below the floor, so the loop continues and the final
|
|
// fallback returns 10x10. The L110 Equality `< -> <=` mutant treats 10 as below
|
|
// the floor, breaks on pass 1, and returns the un-scaled 13x13 instead.
|
|
it("does not claim success when the 10px floor still exceeds the target", async () => {
|
|
const tiny = await seededPhoto(13, 13, 987654321, 3, 2, 120);
|
|
await expect(compress(sharp(tiny), { targetSizeBytes: 1, format: "jpg" })).rejects.toThrow(
|
|
"Unable to compress image to 1 bytes within safe resize limits",
|
|
);
|
|
});
|
|
});
|