mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
A 202 means the sync window expired while the job was still running. Tests treated it as a terminal pass: `if (isAsyncFallback(res)) return;` checked the envelope and returned, asserting nothing about the outcome and leaving the job running into the next test, which is the leak cancelAcceptedJobAndWait exists to prevent. Because the window only expires under load, coverage tracked runner load. On CI 44 tests took this path and verified nothing; the same tests on a dev machine asserted in full (one measured 6.4s locally against 31s on CI). settleAsyncFallback waits for a terminal state and asserts the job finished, and that a failure carries a message rather than being a crash. A clean failure stays valid, since the exotic-format fixtures are meant to be rejected. All 82 call sites moved over. per-fork-env no longer floors SYNC_WAIT_MS, so forcing it to 0 drives every request through its 202 path. 570 tests were validated that way and matched their normal-window results exactly. The 29-34s band dropped from 44 tests (23.4% of test time) to 6 (3.0%). Total test time rose 5.8% and CI wall went 12.8 to 13.1 min: the forks were doing real work during that wait, so this buys determinism, not speed. Per-shard totals unchanged at 9903 tests, 9435 passed, 468 skipped.
496 lines
12 KiB
TypeScript
496 lines
12 KiB
TypeScript
/**
|
|
* Cross-format matrix integration test.
|
|
*
|
|
* For each supported input format, verifies that the core non-AI tools
|
|
* (resize, crop, rotate, convert, compress, color-adjustments, sharpening,
|
|
* info, optimize-for-web, border, watermark-text, image-to-base64,
|
|
* image-enhancement, strip-metadata, replace-color, text-overlay,
|
|
* color-palette) work correctly via the API.
|
|
*
|
|
* Some formats (PSD, EXR, HDR, TGA, DNG, ICO, JXL) require CLI decoders
|
|
* (ImageMagick / dcraw) that may not be installed in every test environment.
|
|
* For those formats, the test accepts either 200 or 422 and documents the
|
|
* reason.
|
|
*
|
|
* HEIC/HEIF require libheif-examples to decode. If unavailable, the API
|
|
* returns 422 which the test accepts.
|
|
*/
|
|
|
|
/*
|
|
* Shared preamble for format-matrix-1..4.test.ts.
|
|
*
|
|
* The suite used to live in a single format-matrix.test.ts. Vitest shards by
|
|
* file and runs one file's tests serially inside a single fork, so that one
|
|
* file set the wall-clock floor for the whole Integration job. Splitting the
|
|
* describe blocks across four part files lets them run in parallel; this module
|
|
* holds the fixtures, tool table, and helpers they all share.
|
|
*/
|
|
|
|
import { afterAll, beforeAll } from "vitest";
|
|
import {
|
|
buildTestApp,
|
|
createMultipartPayload,
|
|
loginAsAdmin,
|
|
type TestApp,
|
|
} from "../test-server.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Format sample definitions
|
|
// ---------------------------------------------------------------------------
|
|
export interface FormatSample {
|
|
name: string;
|
|
file: string;
|
|
mime: string;
|
|
/** True if format requires CLI decoder (ImageMagick / dcraw) */
|
|
needsCliDecoder: boolean;
|
|
/** True if format requires libheif decoder */
|
|
needsHeifDecoder: boolean;
|
|
/**
|
|
* True if Sharp may fail to read metadata for this format during
|
|
* validation, causing a 400 response. This happens for formats like
|
|
* BMP (some variants) and JXL where Sharp support is incomplete.
|
|
*/
|
|
mayFailValidation: boolean;
|
|
}
|
|
|
|
export const FORMAT_SAMPLES: FormatSample[] = [
|
|
{
|
|
name: "JPEG",
|
|
file: "sample.jpg",
|
|
mime: "image/jpeg",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "PNG",
|
|
file: "sample.png",
|
|
mime: "image/png",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "WebP",
|
|
file: "sample.webp",
|
|
mime: "image/webp",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "GIF",
|
|
file: "sample.gif",
|
|
mime: "image/gif",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "AVIF",
|
|
file: "sample.avif",
|
|
mime: "image/avif",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "TIFF",
|
|
file: "sample.tiff",
|
|
mime: "image/tiff",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "BMP",
|
|
file: "sample.bmp",
|
|
mime: "image/bmp",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: true,
|
|
},
|
|
{
|
|
name: "HEIC",
|
|
file: "sample.heic",
|
|
mime: "image/heic",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: true,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "HEIF",
|
|
file: "sample.heif",
|
|
mime: "image/heif",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: true,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "SVG",
|
|
file: "sample.svg",
|
|
mime: "image/svg+xml",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "ICO",
|
|
file: "sample.ico",
|
|
mime: "image/x-icon",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "PSD",
|
|
file: "sample.psd",
|
|
mime: "image/vnd.adobe.photoshop",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "EXR",
|
|
file: "sample.exr",
|
|
mime: "image/x-exr",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "HDR",
|
|
file: "sample.hdr",
|
|
mime: "image/vnd.radiance",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "TGA",
|
|
file: "sample.tga",
|
|
mime: "image/x-tga",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "DNG",
|
|
file: "sample.dng",
|
|
mime: "image/x-adobe-dng",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "JXL",
|
|
file: "sample.jxl",
|
|
mime: "image/jxl",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: true,
|
|
},
|
|
{
|
|
name: "SVGZ",
|
|
file: "sample.svgz",
|
|
mime: "image/svg+xml",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: true,
|
|
},
|
|
{
|
|
name: "JP2",
|
|
file: "sample.jp2",
|
|
mime: "image/jp2",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "EPS",
|
|
file: "sample.eps",
|
|
mime: "application/postscript",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "PPM",
|
|
file: "sample.ppm",
|
|
mime: "image/x-portable-pixmap",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "PGM",
|
|
file: "sample.pgm",
|
|
mime: "image/x-portable-graymap",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "PBM",
|
|
file: "sample.pbm",
|
|
mime: "image/x-portable-bitmap",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "DDS",
|
|
file: "sample.dds",
|
|
mime: "image/vnd.ms-dds",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "CUR",
|
|
file: "sample.cur",
|
|
mime: "image/x-icon",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "DPX",
|
|
file: "sample.dpx",
|
|
mime: "image/x-dpx",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "FITS",
|
|
file: "sample.fits",
|
|
mime: "image/fits",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "APNG",
|
|
file: "sample.apng",
|
|
mime: "image/apng",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "QOI",
|
|
file: "sample.qoi",
|
|
mime: "image/x-qoi",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
];
|
|
|
|
/** Number of part files the cross-format matrix is spread across. */
|
|
export const CROSS_MATRIX_PART_COUNT = 2;
|
|
|
|
/** Striped so the parts partition FORMAT_SAMPLES exactly. */
|
|
export function formatSamplesForPart(part: number): FormatSample[] {
|
|
return FORMAT_SAMPLES.filter((_, i) => i % CROSS_MATRIX_PART_COUNT === part - 1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tool definitions — settings each tool requires
|
|
// ---------------------------------------------------------------------------
|
|
export interface ToolDef {
|
|
/** Tool route name (maps to /api/v1/tools/<id>) */
|
|
id: string;
|
|
/** Display name for test output */
|
|
label: string;
|
|
/** Settings JSON sent as the "settings" multipart field */
|
|
settings: Record<string, unknown>;
|
|
/**
|
|
* How to verify a successful (200) response.
|
|
* "download" = standard {downloadUrl, processedSize} shape
|
|
* "info" = metadata JSON {width, height, fileSize, format}
|
|
* "base64" = {results, errors} shape from image-to-base64
|
|
* "palette" = {colors, count} shape from color-palette
|
|
* "pdf" = {downloadUrl, processedSize, pages} shape from image-to-pdf
|
|
*/
|
|
responseType: "download" | "info" | "base64" | "palette" | "pdf";
|
|
}
|
|
|
|
export const TOOLS: ToolDef[] = [
|
|
{
|
|
id: "resize",
|
|
label: "Resize",
|
|
settings: { width: 50, height: 50 },
|
|
responseType: "download",
|
|
},
|
|
{
|
|
id: "crop",
|
|
label: "Crop",
|
|
settings: { width: 10, height: 10, left: 0, top: 0 },
|
|
responseType: "download",
|
|
},
|
|
{
|
|
id: "rotate",
|
|
label: "Rotate",
|
|
settings: { angle: 90 },
|
|
responseType: "download",
|
|
},
|
|
{
|
|
id: "convert",
|
|
label: "Convert to PNG",
|
|
settings: { format: "png" },
|
|
responseType: "download",
|
|
},
|
|
{
|
|
id: "compress",
|
|
label: "Compress",
|
|
settings: { mode: "quality", quality: 60 },
|
|
responseType: "download",
|
|
},
|
|
{
|
|
id: "adjust-colors",
|
|
label: "Color adjustments",
|
|
settings: { brightness: 10, contrast: 5 },
|
|
responseType: "download",
|
|
},
|
|
{
|
|
id: "sharpening",
|
|
label: "Sharpening",
|
|
settings: { method: "adaptive" },
|
|
responseType: "download",
|
|
},
|
|
{
|
|
id: "info",
|
|
label: "Info (metadata)",
|
|
settings: {},
|
|
responseType: "info",
|
|
},
|
|
{
|
|
id: "optimize-for-web",
|
|
label: "Optimize for web",
|
|
settings: { format: "webp", quality: 75 },
|
|
responseType: "download",
|
|
},
|
|
{
|
|
id: "border",
|
|
label: "Border",
|
|
settings: { borderWidth: 5, borderColor: "#FF0000" },
|
|
responseType: "download",
|
|
},
|
|
{
|
|
id: "watermark-text",
|
|
label: "Watermark text",
|
|
settings: { text: "TEST", fontSize: 16, opacity: 50 },
|
|
responseType: "download",
|
|
},
|
|
{
|
|
id: "image-to-base64",
|
|
label: "Image to Base64",
|
|
settings: {},
|
|
responseType: "base64",
|
|
},
|
|
{
|
|
id: "image-enhancement",
|
|
label: "Image enhancement",
|
|
settings: { mode: "auto", intensity: 50 },
|
|
responseType: "download",
|
|
},
|
|
{
|
|
id: "strip-metadata",
|
|
label: "Strip metadata",
|
|
settings: { stripAll: true },
|
|
responseType: "download",
|
|
},
|
|
{
|
|
id: "replace-color",
|
|
label: "Replace color",
|
|
settings: { sourceColor: "#FF0000", targetColor: "#00FF00", tolerance: 30 },
|
|
responseType: "download",
|
|
},
|
|
{
|
|
id: "text-overlay",
|
|
label: "Text overlay",
|
|
settings: { text: "TEST", fontSize: 16, position: "bottom" },
|
|
responseType: "download",
|
|
},
|
|
{
|
|
id: "color-palette",
|
|
label: "Color palette",
|
|
settings: {},
|
|
responseType: "palette",
|
|
},
|
|
{
|
|
id: "image-to-pdf",
|
|
label: "Image to PDF",
|
|
settings: { pageSize: "A4", orientation: "portrait", margin: 20 },
|
|
responseType: "pdf",
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Status codes we accept for formats that may lack decoder support */
|
|
export const ACCEPTABLE_FALLBACK_CODES = [200, 202, 400, 422];
|
|
|
|
export function needsFallback(fmt: FormatSample): boolean {
|
|
return fmt.needsCliDecoder || fmt.needsHeifDecoder || fmt.mayFailValidation;
|
|
}
|
|
|
|
/**
|
|
* Build multipart payload for a tool request.
|
|
* Info route does not use a "settings" field; image-to-base64 uses its own
|
|
* settings parsing; everything else uses the standard factory shape.
|
|
*/
|
|
export function buildPayload(
|
|
fmt: FormatSample,
|
|
tool: ToolDef,
|
|
buffer: Buffer,
|
|
): { body: Buffer; contentType: string } {
|
|
const fields: Array<{
|
|
name: string;
|
|
filename?: string;
|
|
contentType?: string;
|
|
content: Buffer | string;
|
|
}> = [
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: buffer,
|
|
},
|
|
];
|
|
|
|
// Info route ignores the settings field; the others need it
|
|
if (tool.responseType !== "info" || Object.keys(tool.settings).length > 0) {
|
|
fields.push({
|
|
name: "settings",
|
|
content: JSON.stringify(tool.settings),
|
|
});
|
|
}
|
|
|
|
return createMultipartPayload(fields);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared state
|
|
// ---------------------------------------------------------------------------
|
|
let testApp: TestApp;
|
|
export let app: TestApp["app"];
|
|
export let adminToken: string;
|
|
|
|
/** Registers the per-file app lifecycle each part file shares. */
|
|
export function setupMatrixApp(): void {
|
|
beforeAll(async () => {
|
|
testApp = await buildTestApp();
|
|
app = testApp.app;
|
|
adminToken = await loginAsAdmin(app);
|
|
}, 30_000);
|
|
|
|
afterAll(async () => {
|
|
await testApp.cleanup();
|
|
}, 10_000);
|
|
}
|