mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
test: split the three oversized format-matrix specs (#651)
Vitest shards by file and runs a file's tests serially in one fork, so a single spec set the floor for the whole Integration job no matter how many shards or forks it got. Cost-aware sharding (#650) balanced the shards but could not get under that floor. Split the three specs that exceeded it: format-matrix-comprehensive (1365s) into 4 by describe, format-matrix (1130s) into 4 with Cross-format matrix striped over FORMAT_SAMPLES, and format-matrix-generated (779s) into 3 striped over TOOLS. Largest spec is now 370s. Each preamble moved verbatim into a sibling .shared.ts exposing setupMatrixApp(). Integration shards went from 20m59s/17m55s/16m33s/9m19s to 11m44s/12m31s/10m37s/11m1s. Coverage checked, not assumed: the set of test names collected by vitest list is byte-identical across the split, 2151 before and 2151 after. Per-shard totals matched the baseline exactly at 9903 tests, 9435 passed, 468 skipped.
This commit is contained in:
@@ -27,11 +27,19 @@
|
||||
* Refresh by parsing per-test durations out of the Integration job logs.
|
||||
*/
|
||||
export const FILE_COST_SECONDS: Record<string, number> = {
|
||||
"tests/integration/generated/format-matrix-comprehensive.test.ts": 1365,
|
||||
"tests/integration/generated/format-matrix.test.ts": 1130,
|
||||
"tests/integration/generated/format-matrix-generated.test.ts": 779,
|
||||
"tests/integration/generated/format-matrix-exotic.test.ts": 370,
|
||||
"tests/integration/generated/format-matrix-comprehensive-2.test.ts": 348,
|
||||
"tests/integration/generated/format-matrix-comprehensive-1.test.ts": 343,
|
||||
"tests/integration/generated/format-matrix-comprehensive-3.test.ts": 343,
|
||||
"tests/integration/generated/format-matrix-comprehensive-4.test.ts": 343,
|
||||
"tests/integration/generated/format-matrix-expanded.test.ts": 319,
|
||||
"tests/integration/generated/format-matrix-1.test.ts": 309,
|
||||
"tests/integration/generated/format-matrix-2.test.ts": 309,
|
||||
"tests/integration/generated/format-matrix-generated-1.test.ts": 269,
|
||||
"tests/integration/generated/format-matrix-generated-2.test.ts": 269,
|
||||
"tests/integration/generated/format-matrix-generated-3.test.ts": 269,
|
||||
"tests/integration/generated/format-matrix-3.test.ts": 267,
|
||||
"tests/integration/generated/format-matrix-4.test.ts": 261,
|
||||
"tests/integration/tools/image/image-enhancement.test.ts": 226,
|
||||
"tests/integration/generated/new-formats.test.ts": 202,
|
||||
"tests/integration/generated/format-matrix-multimodal.test.ts": 126,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Cross-format matrix, part 1 of 2 (see format-matrix.shared.ts).
|
||||
*
|
||||
* Vitest shards by file and runs a file's tests serially in one fork, so the
|
||||
* cross-format matrix is striped across two part files that together cover
|
||||
* every entry of FORMAT_SAMPLES exactly once.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { apiToolPath } from "@snapotter/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fixtureDir } from "../../fixtures/index.js";
|
||||
import {
|
||||
ACCEPTABLE_FALLBACK_CODES,
|
||||
adminToken,
|
||||
app,
|
||||
buildPayload,
|
||||
formatSamplesForPart,
|
||||
isAsyncFallback,
|
||||
needsFallback,
|
||||
setupMatrixApp,
|
||||
TOOLS,
|
||||
} from "./format-matrix.shared.js";
|
||||
|
||||
setupMatrixApp();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-format matrix: every tool x every format
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Cross-format matrix", () => {
|
||||
for (const fmt of formatSamplesForPart(1)) {
|
||||
describe(`${fmt.name} input (${fmt.file})`, () => {
|
||||
const fixturePath = join(fixtureDir.formats, fmt.file);
|
||||
|
||||
for (const tool of TOOLS) {
|
||||
// Skip "Convert to PNG" when input is already PNG (no-op conversion)
|
||||
if (tool.id === "convert" && fmt.name === "PNG") continue;
|
||||
|
||||
const perTestTimeout =
|
||||
(fmt.needsHeifDecoder || fmt.needsCliDecoder) && tool.id === "image-enhancement"
|
||||
? 300_000
|
||||
: fmt.needsHeifDecoder || fmt.needsCliDecoder
|
||||
? 180_000
|
||||
: tool.id === "image-enhancement"
|
||||
? 120_000
|
||||
: 60_000; // 2× SYNC_WAIT_MS so a 202 fallback never races the Vitest timeout
|
||||
|
||||
it(
|
||||
`${tool.label}`,
|
||||
async () => {
|
||||
if (!existsSync(fixturePath)) return;
|
||||
|
||||
const buffer = readFileSync(fixturePath);
|
||||
const { body: payload, contentType } = buildPayload(fmt, tool, buffer);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: apiToolPath(tool.id),
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Assert status code
|
||||
// ------------------------------------------------------------------
|
||||
// A heavy encode may fall back to async (202) under CI load -- accept it.
|
||||
if (isAsyncFallback(res)) return;
|
||||
|
||||
if (needsFallback(fmt)) {
|
||||
// Formats with optional decoders: accept success or graceful error
|
||||
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
|
||||
} else {
|
||||
// Core formats must always succeed
|
||||
expect(res.statusCode).toBe(200);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// If successful, validate the response shape
|
||||
// ------------------------------------------------------------------
|
||||
if (res.statusCode === 200) {
|
||||
const body = JSON.parse(res.body);
|
||||
|
||||
switch (tool.responseType) {
|
||||
case "download":
|
||||
expect(body.downloadUrl).toBeDefined();
|
||||
expect(typeof body.downloadUrl).toBe("string");
|
||||
expect(body.processedSize).toBeGreaterThan(0);
|
||||
expect(body.originalSize).toBeGreaterThan(0);
|
||||
break;
|
||||
|
||||
case "info":
|
||||
expect(body.width).toBeGreaterThan(0);
|
||||
expect(body.height).toBeGreaterThan(0);
|
||||
expect(body.fileSize).toBeGreaterThan(0);
|
||||
expect(body.format).toBeDefined();
|
||||
expect(body.channels).toBeGreaterThan(0);
|
||||
break;
|
||||
|
||||
case "base64":
|
||||
// image-to-base64 returns { results: [...], errors: [...] }
|
||||
expect(Array.isArray(body.results)).toBe(true);
|
||||
expect(body.results.length + body.errors.length).toBeGreaterThan(0);
|
||||
if (body.results.length > 0) {
|
||||
const r = body.results[0];
|
||||
expect(r.base64).toBeDefined();
|
||||
expect(typeof r.base64).toBe("string");
|
||||
expect(r.base64.length).toBeGreaterThan(0);
|
||||
expect(r.dataUri).toMatch(/^data:/);
|
||||
expect(r.width).toBeGreaterThan(0);
|
||||
expect(r.height).toBeGreaterThan(0);
|
||||
}
|
||||
break;
|
||||
|
||||
case "palette":
|
||||
// color-palette returns { colors: string[], count: number }
|
||||
expect(Array.isArray(body.colors)).toBe(true);
|
||||
expect(body.colors.length).toBeGreaterThan(0);
|
||||
expect(body.count).toBeGreaterThan(0);
|
||||
// Each color should be a hex string
|
||||
for (const color of body.colors) {
|
||||
expect(color).toMatch(/^#[0-9a-f]{6}$/);
|
||||
}
|
||||
break;
|
||||
|
||||
case "pdf":
|
||||
// image-to-pdf returns { downloadUrl, processedSize, pages }
|
||||
expect(body.downloadUrl).toBeDefined();
|
||||
expect(typeof body.downloadUrl).toBe("string");
|
||||
expect(body.processedSize).toBeGreaterThan(0);
|
||||
expect(body.pages).toBeGreaterThanOrEqual(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// If the API returned an error, verify it is a clean JSON error
|
||||
// (not a raw crash / stack trace / HTML error page)
|
||||
// ------------------------------------------------------------------
|
||||
if (res.statusCode >= 400) {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
}
|
||||
},
|
||||
perTestTimeout,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Cross-format matrix, part 2 of 2 (see format-matrix.shared.ts).
|
||||
*
|
||||
* Vitest shards by file and runs a file's tests serially in one fork, so the
|
||||
* cross-format matrix is striped across two part files that together cover
|
||||
* every entry of FORMAT_SAMPLES exactly once.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { apiToolPath } from "@snapotter/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fixtureDir } from "../../fixtures/index.js";
|
||||
import {
|
||||
ACCEPTABLE_FALLBACK_CODES,
|
||||
adminToken,
|
||||
app,
|
||||
buildPayload,
|
||||
formatSamplesForPart,
|
||||
isAsyncFallback,
|
||||
needsFallback,
|
||||
setupMatrixApp,
|
||||
TOOLS,
|
||||
} from "./format-matrix.shared.js";
|
||||
|
||||
setupMatrixApp();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-format matrix: every tool x every format
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Cross-format matrix", () => {
|
||||
for (const fmt of formatSamplesForPart(2)) {
|
||||
describe(`${fmt.name} input (${fmt.file})`, () => {
|
||||
const fixturePath = join(fixtureDir.formats, fmt.file);
|
||||
|
||||
for (const tool of TOOLS) {
|
||||
// Skip "Convert to PNG" when input is already PNG (no-op conversion)
|
||||
if (tool.id === "convert" && fmt.name === "PNG") continue;
|
||||
|
||||
const perTestTimeout =
|
||||
(fmt.needsHeifDecoder || fmt.needsCliDecoder) && tool.id === "image-enhancement"
|
||||
? 300_000
|
||||
: fmt.needsHeifDecoder || fmt.needsCliDecoder
|
||||
? 180_000
|
||||
: tool.id === "image-enhancement"
|
||||
? 120_000
|
||||
: 60_000; // 2× SYNC_WAIT_MS so a 202 fallback never races the Vitest timeout
|
||||
|
||||
it(
|
||||
`${tool.label}`,
|
||||
async () => {
|
||||
if (!existsSync(fixturePath)) return;
|
||||
|
||||
const buffer = readFileSync(fixturePath);
|
||||
const { body: payload, contentType } = buildPayload(fmt, tool, buffer);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: apiToolPath(tool.id),
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Assert status code
|
||||
// ------------------------------------------------------------------
|
||||
// A heavy encode may fall back to async (202) under CI load -- accept it.
|
||||
if (isAsyncFallback(res)) return;
|
||||
|
||||
if (needsFallback(fmt)) {
|
||||
// Formats with optional decoders: accept success or graceful error
|
||||
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
|
||||
} else {
|
||||
// Core formats must always succeed
|
||||
expect(res.statusCode).toBe(200);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// If successful, validate the response shape
|
||||
// ------------------------------------------------------------------
|
||||
if (res.statusCode === 200) {
|
||||
const body = JSON.parse(res.body);
|
||||
|
||||
switch (tool.responseType) {
|
||||
case "download":
|
||||
expect(body.downloadUrl).toBeDefined();
|
||||
expect(typeof body.downloadUrl).toBe("string");
|
||||
expect(body.processedSize).toBeGreaterThan(0);
|
||||
expect(body.originalSize).toBeGreaterThan(0);
|
||||
break;
|
||||
|
||||
case "info":
|
||||
expect(body.width).toBeGreaterThan(0);
|
||||
expect(body.height).toBeGreaterThan(0);
|
||||
expect(body.fileSize).toBeGreaterThan(0);
|
||||
expect(body.format).toBeDefined();
|
||||
expect(body.channels).toBeGreaterThan(0);
|
||||
break;
|
||||
|
||||
case "base64":
|
||||
// image-to-base64 returns { results: [...], errors: [...] }
|
||||
expect(Array.isArray(body.results)).toBe(true);
|
||||
expect(body.results.length + body.errors.length).toBeGreaterThan(0);
|
||||
if (body.results.length > 0) {
|
||||
const r = body.results[0];
|
||||
expect(r.base64).toBeDefined();
|
||||
expect(typeof r.base64).toBe("string");
|
||||
expect(r.base64.length).toBeGreaterThan(0);
|
||||
expect(r.dataUri).toMatch(/^data:/);
|
||||
expect(r.width).toBeGreaterThan(0);
|
||||
expect(r.height).toBeGreaterThan(0);
|
||||
}
|
||||
break;
|
||||
|
||||
case "palette":
|
||||
// color-palette returns { colors: string[], count: number }
|
||||
expect(Array.isArray(body.colors)).toBe(true);
|
||||
expect(body.colors.length).toBeGreaterThan(0);
|
||||
expect(body.count).toBeGreaterThan(0);
|
||||
// Each color should be a hex string
|
||||
for (const color of body.colors) {
|
||||
expect(color).toMatch(/^#[0-9a-f]{6}$/);
|
||||
}
|
||||
break;
|
||||
|
||||
case "pdf":
|
||||
// image-to-pdf returns { downloadUrl, processedSize, pages }
|
||||
expect(body.downloadUrl).toBeDefined();
|
||||
expect(typeof body.downloadUrl).toBe("string");
|
||||
expect(body.processedSize).toBeGreaterThan(0);
|
||||
expect(body.pages).toBeGreaterThanOrEqual(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// If the API returned an error, verify it is a clean JSON error
|
||||
// (not a raw crash / stack trace / HTML error page)
|
||||
// ------------------------------------------------------------------
|
||||
if (res.statusCode >= 400) {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
}
|
||||
},
|
||||
perTestTimeout,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Cross-format matrix, part 3 of 4 (see format-matrix.shared.ts).
|
||||
*
|
||||
* Holds the multipage-TIFF, exotic-format resilience, enhancement-analysis and
|
||||
* strip-metadata-inspection blocks. Split out of format-matrix.test.ts because
|
||||
* vitest shards by file and runs a file's tests serially in one fork.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { apiToolPath } from "@snapotter/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fixtureDir, fixtures } from "../../fixtures/index.js";
|
||||
import { createMultipartPayload } from "../test-server.js";
|
||||
import {
|
||||
adminToken,
|
||||
app,
|
||||
buildPayload,
|
||||
FORMAT_SAMPLES,
|
||||
isAsyncFallback,
|
||||
setupMatrixApp,
|
||||
TOOLS,
|
||||
} from "./format-matrix.shared.js";
|
||||
|
||||
setupMatrixApp();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge-case matrix: multipage TIFF
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Multipage TIFF handling", () => {
|
||||
const multipagePath = fixtures.image.multipageTiff;
|
||||
|
||||
for (const tool of TOOLS) {
|
||||
it(`${tool.label} handles multipage TIFF`, async () => {
|
||||
if (!existsSync(multipagePath)) return;
|
||||
|
||||
const buffer = readFileSync(multipagePath);
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "multipage.tiff",
|
||||
contentType: "image/tiff",
|
||||
content: buffer,
|
||||
},
|
||||
...(tool.responseType !== "info"
|
||||
? [{ name: "settings", content: JSON.stringify(tool.settings) }]
|
||||
: []),
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: apiToolPath(tool.id),
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
// Multipage TIFF should either succeed or return a clean error
|
||||
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
const body = JSON.parse(res.body);
|
||||
|
||||
if (tool.responseType === "info") {
|
||||
expect(body.width).toBeGreaterThan(0);
|
||||
expect(body.height).toBeGreaterThan(0);
|
||||
// Multipage TIFFs should report pages > 1
|
||||
if (body.pages !== undefined) {
|
||||
expect(body.pages).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
} else if (tool.responseType === "base64") {
|
||||
expect(Array.isArray(body.results)).toBe(true);
|
||||
} else if (tool.responseType === "palette") {
|
||||
expect(Array.isArray(body.colors)).toBe(true);
|
||||
expect(body.count).toBeGreaterThan(0);
|
||||
} else if (tool.responseType === "pdf") {
|
||||
expect(body.downloadUrl).toBeDefined();
|
||||
expect(body.processedSize).toBeGreaterThan(0);
|
||||
expect(body.pages).toBeGreaterThanOrEqual(1);
|
||||
} else {
|
||||
expect(body.downloadUrl).toBeDefined();
|
||||
expect(body.processedSize).toBeGreaterThan(0);
|
||||
}
|
||||
} else {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.error).toBeDefined();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error resilience: exotic formats must return clean errors, never crash
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Exotic format error resilience", () => {
|
||||
const EXOTIC_FORMATS = FORMAT_SAMPLES.filter((f) => f.needsCliDecoder);
|
||||
|
||||
// Tools that actually process the image (not just read metadata)
|
||||
const PROCESSING_TOOLS = TOOLS.filter(
|
||||
(t) =>
|
||||
t.responseType === "download" || t.responseType === "palette" || t.responseType === "pdf",
|
||||
);
|
||||
|
||||
for (const fmt of EXOTIC_FORMATS) {
|
||||
for (const tool of PROCESSING_TOOLS) {
|
||||
it(`${fmt.name} + ${tool.label}: returns JSON error (no crash)`, {
|
||||
timeout: 120_000,
|
||||
}, async () => {
|
||||
const fixturePath = join(fixtureDir.formats, fmt.file);
|
||||
if (!existsSync(fixturePath)) return;
|
||||
|
||||
const buffer = readFileSync(fixturePath);
|
||||
const { body: payload, contentType } = buildPayload(fmt, tool, buffer);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: apiToolPath(tool.id),
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
// Must not crash (500) — either succeed or return a clean error
|
||||
if (isAsyncFallback(res)) return;
|
||||
expect(res.statusCode).not.toBe(500);
|
||||
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
||||
|
||||
// Response must always be valid JSON
|
||||
const body = JSON.parse(res.body);
|
||||
if (res.statusCode >= 400) {
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
expect(body.error.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Image enhancement analysis: dedicated /analyze endpoint
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Image enhancement analysis across formats", () => {
|
||||
// Core formats that Sharp can read natively
|
||||
const ANALYZABLE_FORMATS = FORMAT_SAMPLES.filter(
|
||||
(f) => !f.needsCliDecoder && !f.needsHeifDecoder && !f.mayFailValidation,
|
||||
);
|
||||
|
||||
for (const fmt of ANALYZABLE_FORMATS) {
|
||||
it(`analyzes ${fmt.name} and returns correction recommendations`, async () => {
|
||||
const fixturePath = join(fixtureDir.formats, fmt.file);
|
||||
if (!existsSync(fixturePath)) return;
|
||||
|
||||
const buffer = readFileSync(fixturePath);
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: fmt.file,
|
||||
contentType: fmt.mime,
|
||||
content: buffer,
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/image-enhancement/analyze",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const body = JSON.parse(res.body);
|
||||
// Analysis should return corrections object
|
||||
expect(body.corrections).toBeDefined();
|
||||
expect(typeof body.corrections).toBe("object");
|
||||
});
|
||||
}
|
||||
|
||||
// Exotic formats: should not crash, return clean error or succeed
|
||||
const EXOTIC_FORMATS = FORMAT_SAMPLES.filter((f) => f.needsCliDecoder);
|
||||
|
||||
for (const fmt of EXOTIC_FORMATS) {
|
||||
it(`${fmt.name} analyze: returns clean response (no crash)`, async () => {
|
||||
const fixturePath = join(fixtureDir.formats, fmt.file);
|
||||
if (!existsSync(fixturePath)) return;
|
||||
|
||||
const buffer = readFileSync(fixturePath);
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: fmt.file,
|
||||
contentType: fmt.mime,
|
||||
content: buffer,
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/image-enhancement/analyze",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
expect(res.statusCode).not.toBe(500);
|
||||
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
||||
|
||||
const body = JSON.parse(res.body);
|
||||
if (res.statusCode >= 400) {
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Strip-metadata inspect: dedicated /inspect endpoint
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Strip-metadata inspection across formats", () => {
|
||||
// Core formats that Sharp can read natively
|
||||
const INSPECTABLE_FORMATS = FORMAT_SAMPLES.filter(
|
||||
(f) => !f.needsCliDecoder && !f.needsHeifDecoder && !f.mayFailValidation,
|
||||
);
|
||||
|
||||
for (const fmt of INSPECTABLE_FORMATS) {
|
||||
it(`inspects ${fmt.name} metadata`, async () => {
|
||||
const fixturePath = join(fixtureDir.formats, fmt.file);
|
||||
if (!existsSync(fixturePath)) return;
|
||||
|
||||
const buffer = readFileSync(fixturePath);
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: fmt.file,
|
||||
contentType: fmt.mime,
|
||||
content: buffer,
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/strip-metadata/inspect",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.filename).toBeDefined();
|
||||
expect(body.fileSize).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,680 @@
|
||||
/**
|
||||
* Cross-format matrix, part 4 of 4 (see format-matrix.shared.ts).
|
||||
*
|
||||
* Holds the conversion, watermark-image and image-to-pdf blocks. Split out of
|
||||
* format-matrix.test.ts because vitest shards by file and runs a file's tests
|
||||
* serially in one fork.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fixtureDir, fixtures } from "../../fixtures/index.js";
|
||||
import { createMultipartPayload } from "../test-server.js";
|
||||
import {
|
||||
ACCEPTABLE_FALLBACK_CODES,
|
||||
adminToken,
|
||||
app,
|
||||
FORMAT_SAMPLES,
|
||||
isAsyncFallback,
|
||||
needsFallback,
|
||||
setupMatrixApp,
|
||||
} from "./format-matrix.shared.js";
|
||||
|
||||
setupMatrixApp();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Format-specific convert matrix: convert between various output formats
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Cross-format conversion matrix", () => {
|
||||
const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif"];
|
||||
|
||||
// Only test core Sharp-readable formats for conversion (skip exotic ones)
|
||||
const CONVERTIBLE_INPUTS = FORMAT_SAMPLES.filter(
|
||||
(f) => !f.needsCliDecoder && !f.needsHeifDecoder && !f.mayFailValidation,
|
||||
);
|
||||
|
||||
for (const fmt of CONVERTIBLE_INPUTS) {
|
||||
for (const outFmt of OUTPUT_FORMATS) {
|
||||
// Skip identity conversions
|
||||
const inputLower = fmt.name.toLowerCase();
|
||||
if (inputLower === outFmt) continue;
|
||||
if (inputLower === "jpeg" && outFmt === "jpg") continue;
|
||||
|
||||
const testTimeout = outFmt === "avif" ? 120_000 : 120_000;
|
||||
it(`${fmt.name} -> ${outFmt}`, { timeout: testTimeout }, async () => {
|
||||
const fixturePath = join(fixtureDir.formats, fmt.file);
|
||||
if (!existsSync(fixturePath)) return;
|
||||
|
||||
const buffer = readFileSync(fixturePath);
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: fmt.file,
|
||||
contentType: fmt.mime,
|
||||
content: buffer,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ format: outFmt }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/convert",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (isAsyncFallback(res)) return;
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.downloadUrl).toBeDefined();
|
||||
expect(body.downloadUrl).toContain(`.${outFmt}`);
|
||||
expect(body.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watermark-image cross-format matrix
|
||||
//
|
||||
// watermark-image requires TWO file uploads (file + watermark), so it cannot
|
||||
// use the standard TOOLS/buildPayload path. We test each input format as the
|
||||
// main image with a fixed PNG watermark, and also each format as the watermark
|
||||
// image with a fixed PNG main image.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Watermark-image cross-format matrix", () => {
|
||||
// Use the PNG fixture as the known-good counterpart
|
||||
const PNG_PATH = fixtures.image.formats("png");
|
||||
|
||||
describe("format as main image (watermark is PNG)", () => {
|
||||
for (const fmt of FORMAT_SAMPLES) {
|
||||
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : 60_000;
|
||||
|
||||
it(
|
||||
`${fmt.name} main image with PNG watermark`,
|
||||
async () => {
|
||||
const fixturePath = join(fixtureDir.formats, fmt.file);
|
||||
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
|
||||
|
||||
const mainBuffer = readFileSync(fixturePath);
|
||||
const wmBuffer = readFileSync(PNG_PATH);
|
||||
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: fmt.file,
|
||||
contentType: fmt.mime,
|
||||
content: mainBuffer,
|
||||
},
|
||||
{
|
||||
name: "watermark",
|
||||
filename: "sample.png",
|
||||
contentType: "image/png",
|
||||
content: wmBuffer,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({
|
||||
position: "bottom-right",
|
||||
opacity: 50,
|
||||
scale: 25,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/watermark-image",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (isAsyncFallback(res)) return;
|
||||
if (needsFallback(fmt)) {
|
||||
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
|
||||
} else {
|
||||
expect(res.statusCode).toBe(200);
|
||||
}
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.downloadUrl).toBeDefined();
|
||||
expect(typeof body.downloadUrl).toBe("string");
|
||||
expect(body.processedSize).toBeGreaterThan(0);
|
||||
expect(body.originalSize).toBeGreaterThan(0);
|
||||
} else {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
}
|
||||
},
|
||||
perTestTimeout,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
describe("format as watermark image (main is PNG)", () => {
|
||||
for (const fmt of FORMAT_SAMPLES) {
|
||||
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : 60_000;
|
||||
|
||||
it(
|
||||
`PNG main image with ${fmt.name} watermark`,
|
||||
async () => {
|
||||
const fixturePath = join(fixtureDir.formats, fmt.file);
|
||||
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
|
||||
|
||||
const mainBuffer = readFileSync(PNG_PATH);
|
||||
const wmBuffer = readFileSync(fixturePath);
|
||||
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "sample.png",
|
||||
contentType: "image/png",
|
||||
content: mainBuffer,
|
||||
},
|
||||
{
|
||||
name: "watermark",
|
||||
filename: fmt.file,
|
||||
contentType: fmt.mime,
|
||||
content: wmBuffer,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({
|
||||
position: "center",
|
||||
opacity: 75,
|
||||
scale: 30,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/watermark-image",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (isAsyncFallback(res)) return;
|
||||
if (needsFallback(fmt)) {
|
||||
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
|
||||
} else {
|
||||
expect(res.statusCode).toBe(200);
|
||||
}
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.downloadUrl).toBeDefined();
|
||||
expect(typeof body.downloadUrl).toBe("string");
|
||||
expect(body.processedSize).toBeGreaterThan(0);
|
||||
} else {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
}
|
||||
},
|
||||
perTestTimeout,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
describe("watermark positions across core formats", () => {
|
||||
const POSITIONS = ["center", "top-left", "top-right", "bottom-left", "bottom-right"] as const;
|
||||
|
||||
// Only test core Sharp-readable formats for position coverage
|
||||
const CORE_FORMATS = FORMAT_SAMPLES.filter(
|
||||
(f) => !f.needsCliDecoder && !f.needsHeifDecoder && !f.mayFailValidation,
|
||||
);
|
||||
|
||||
for (const fmt of CORE_FORMATS) {
|
||||
for (const position of POSITIONS) {
|
||||
it(`${fmt.name} with position=${position}`, async () => {
|
||||
const fixturePath = join(fixtureDir.formats, fmt.file);
|
||||
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
|
||||
|
||||
const mainBuffer = readFileSync(fixturePath);
|
||||
const wmBuffer = readFileSync(PNG_PATH);
|
||||
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: fmt.file,
|
||||
contentType: fmt.mime,
|
||||
content: mainBuffer,
|
||||
},
|
||||
{
|
||||
name: "watermark",
|
||||
filename: "sample.png",
|
||||
contentType: "image/png",
|
||||
content: wmBuffer,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ position, opacity: 50, scale: 20 }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/watermark-image",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (isAsyncFallback(res)) return;
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.downloadUrl).toBeDefined();
|
||||
expect(body.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Image-to-PDF cross-format conversion matrix
|
||||
//
|
||||
// Verifies that each input format can be converted to PDF, with various
|
||||
// page size and orientation combinations.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Image-to-PDF cross-format matrix", () => {
|
||||
describe("single image conversion across formats", () => {
|
||||
for (const fmt of FORMAT_SAMPLES) {
|
||||
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : 60_000;
|
||||
|
||||
it(
|
||||
`converts ${fmt.name} to PDF`,
|
||||
async () => {
|
||||
const fixturePath = join(fixtureDir.formats, fmt.file);
|
||||
if (!existsSync(fixturePath)) return;
|
||||
|
||||
const buffer = readFileSync(fixturePath);
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: fmt.file,
|
||||
contentType: fmt.mime,
|
||||
content: buffer,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({
|
||||
pageSize: "A4",
|
||||
orientation: "portrait",
|
||||
margin: 20,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/image-to-pdf",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (isAsyncFallback(res)) return;
|
||||
if (needsFallback(fmt)) {
|
||||
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
|
||||
} else {
|
||||
expect(res.statusCode).toBe(200);
|
||||
}
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.downloadUrl).toBeDefined();
|
||||
expect(typeof body.downloadUrl).toBe("string");
|
||||
expect(body.processedSize).toBeGreaterThan(0);
|
||||
expect(body.pages).toBe(1);
|
||||
} else {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
}
|
||||
},
|
||||
perTestTimeout,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
describe("page size and orientation variations per core format", () => {
|
||||
const PAGE_CONFIGS = [
|
||||
{ pageSize: "A4", orientation: "portrait" },
|
||||
{ pageSize: "A4", orientation: "landscape" },
|
||||
{ pageSize: "Letter", orientation: "portrait" },
|
||||
{ pageSize: "A3", orientation: "landscape" },
|
||||
{ pageSize: "A5", orientation: "portrait" },
|
||||
] as const;
|
||||
|
||||
// Only test core Sharp-readable formats for page config coverage
|
||||
const CORE_FORMATS = FORMAT_SAMPLES.filter(
|
||||
(f) => !f.needsCliDecoder && !f.needsHeifDecoder && !f.mayFailValidation,
|
||||
);
|
||||
|
||||
for (const fmt of CORE_FORMATS) {
|
||||
for (const cfg of PAGE_CONFIGS) {
|
||||
it(`${fmt.name} -> ${cfg.pageSize} ${cfg.orientation}`, async () => {
|
||||
const fixturePath = join(fixtureDir.formats, fmt.file);
|
||||
if (!existsSync(fixturePath)) return;
|
||||
|
||||
const buffer = readFileSync(fixturePath);
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: fmt.file,
|
||||
contentType: fmt.mime,
|
||||
content: buffer,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({
|
||||
pageSize: cfg.pageSize,
|
||||
orientation: cfg.orientation,
|
||||
margin: 20,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/image-to-pdf",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (isAsyncFallback(res)) return;
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.downloadUrl).toBeDefined();
|
||||
expect(body.processedSize).toBeGreaterThan(0);
|
||||
expect(body.pages).toBe(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("multi-format PDF (mixed inputs in one document)", () => {
|
||||
// Only combine core formats that Sharp can read natively
|
||||
const CORE_FORMATS = FORMAT_SAMPLES.filter(
|
||||
(f) => !f.needsCliDecoder && !f.needsHeifDecoder && !f.mayFailValidation,
|
||||
);
|
||||
|
||||
// Test pairing each core format with PNG as a 2-page PDF
|
||||
for (const fmt of CORE_FORMATS) {
|
||||
// Skip PNG paired with PNG (redundant)
|
||||
if (fmt.name === "PNG") continue;
|
||||
|
||||
it(`${fmt.name} + PNG as 2-page PDF`, async () => {
|
||||
const fixturePath = join(fixtureDir.formats, fmt.file);
|
||||
const pngPath = fixtures.image.formats("png");
|
||||
if (!existsSync(fixturePath) || !existsSync(pngPath)) return;
|
||||
|
||||
const fmtBuffer = readFileSync(fixturePath);
|
||||
const pngBuffer = readFileSync(pngPath);
|
||||
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: fmt.file,
|
||||
contentType: fmt.mime,
|
||||
content: fmtBuffer,
|
||||
},
|
||||
{
|
||||
name: "file",
|
||||
filename: "sample.png",
|
||||
contentType: "image/png",
|
||||
content: pngBuffer,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ pageSize: "A4" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/image-to-pdf",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (isAsyncFallback(res)) return;
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.pages).toBe(2);
|
||||
expect(body.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("multipage TIFF to PDF", () => {
|
||||
const multipagePath = fixtures.image.multipageTiff;
|
||||
|
||||
it("converts multipage TIFF to PDF", async () => {
|
||||
if (!existsSync(multipagePath)) return;
|
||||
|
||||
const buffer = readFileSync(multipagePath);
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "multipage.tiff",
|
||||
contentType: "image/tiff",
|
||||
content: buffer,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ pageSize: "A4" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/image-to-pdf",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.downloadUrl).toBeDefined();
|
||||
expect(body.processedSize).toBeGreaterThan(0);
|
||||
expect(body.pages).toBeGreaterThanOrEqual(1);
|
||||
} else {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.error).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("exotic format error resilience for image-to-pdf", () => {
|
||||
const EXOTIC_FORMATS = FORMAT_SAMPLES.filter((f) => f.needsCliDecoder);
|
||||
|
||||
for (const fmt of EXOTIC_FORMATS) {
|
||||
it(`${fmt.name} -> PDF: returns clean response (no crash)`, async () => {
|
||||
const fixturePath = join(fixtureDir.formats, fmt.file);
|
||||
if (!existsSync(fixturePath)) return;
|
||||
|
||||
const buffer = readFileSync(fixturePath);
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: fmt.file,
|
||||
contentType: fmt.mime,
|
||||
content: buffer,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ pageSize: "A4" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/image-to-pdf",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
// Must not crash with 500
|
||||
if (isAsyncFallback(res)) return;
|
||||
if (isAsyncFallback(res)) return;
|
||||
expect(res.statusCode).not.toBe(500);
|
||||
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
||||
|
||||
const body = JSON.parse(res.body);
|
||||
if (res.statusCode >= 400) {
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
expect(body.error.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watermark-image exotic format error resilience
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Watermark-image exotic format error resilience", () => {
|
||||
const EXOTIC_FORMATS = FORMAT_SAMPLES.filter((f) => f.needsCliDecoder);
|
||||
const PNG_PATH = fixtures.image.formats("png");
|
||||
|
||||
describe("exotic format as main image", () => {
|
||||
for (const fmt of EXOTIC_FORMATS) {
|
||||
it(`${fmt.name} main + PNG watermark: no crash`, async () => {
|
||||
const fixturePath = join(fixtureDir.formats, fmt.file);
|
||||
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
|
||||
|
||||
const mainBuffer = readFileSync(fixturePath);
|
||||
const wmBuffer = readFileSync(PNG_PATH);
|
||||
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: fmt.file,
|
||||
contentType: fmt.mime,
|
||||
content: mainBuffer,
|
||||
},
|
||||
{
|
||||
name: "watermark",
|
||||
filename: "sample.png",
|
||||
contentType: "image/png",
|
||||
content: wmBuffer,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ position: "center", opacity: 50, scale: 25 }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/watermark-image",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
expect(res.statusCode).not.toBe(500);
|
||||
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
||||
|
||||
const body = JSON.parse(res.body);
|
||||
if (res.statusCode >= 400) {
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
expect(body.error.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("exotic format as watermark image", () => {
|
||||
for (const fmt of EXOTIC_FORMATS) {
|
||||
it(`PNG main + ${fmt.name} watermark: no crash`, async () => {
|
||||
const fixturePath = join(fixtureDir.formats, fmt.file);
|
||||
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
|
||||
|
||||
const mainBuffer = readFileSync(PNG_PATH);
|
||||
const wmBuffer = readFileSync(fixturePath);
|
||||
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "sample.png",
|
||||
contentType: "image/png",
|
||||
content: mainBuffer,
|
||||
},
|
||||
{
|
||||
name: "watermark",
|
||||
filename: fmt.file,
|
||||
contentType: fmt.mime,
|
||||
content: wmBuffer,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ position: "bottom-right", opacity: 75, scale: 30 }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/watermark-image",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (isAsyncFallback(res)) return;
|
||||
expect(res.statusCode).not.toBe(500);
|
||||
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
||||
|
||||
const body = JSON.parse(res.body);
|
||||
if (res.statusCode >= 400) {
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
expect(body.error.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Comprehensive cross-format parameterized integration tests, part 1 of 4.
|
||||
*
|
||||
* Split from format-matrix-comprehensive.test.ts so the parts shard and run in
|
||||
* parallel; shared setup and helpers live in
|
||||
* ./format-matrix-comprehensive.shared.ts.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fixtureDir } from "../../fixtures/index.js";
|
||||
import { createMultipartPayload } from "../test-server.js";
|
||||
import {
|
||||
ACCEPTABLE_FALLBACK_CODES,
|
||||
adminToken,
|
||||
app,
|
||||
assertDownloadResponse,
|
||||
callTool,
|
||||
type FormatDef,
|
||||
getTimeout,
|
||||
needsFallback,
|
||||
PRIMARY_FORMATS,
|
||||
setupMatrixApp,
|
||||
} from "./format-matrix-comprehensive.shared.js";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
setupMatrixApp();
|
||||
|
||||
describe("Strip-metadata across all 16 primary formats", () => {
|
||||
for (const fmt of PRIMARY_FORMATS) {
|
||||
it(
|
||||
`strips metadata from ${fmt.name}`,
|
||||
async () => {
|
||||
const res = await callTool("strip-metadata", fmt, { stripAll: true });
|
||||
if (!res) return;
|
||||
const body = assertDownloadResponse(res, fmt);
|
||||
|
||||
// For core formats, verify that stripping metadata produces output
|
||||
// (it may or may not reduce size depending on whether the fixture
|
||||
// contains EXIF data)
|
||||
if (body) {
|
||||
expect(body.processedSize).toBeGreaterThan(0);
|
||||
}
|
||||
},
|
||||
getTimeout(fmt),
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the inspect endpoint also works across formats
|
||||
describe("strip-metadata inspect endpoint", () => {
|
||||
for (const fmt of PRIMARY_FORMATS) {
|
||||
it(
|
||||
`inspects ${fmt.name}`,
|
||||
async () => {
|
||||
const fixturePath = join(fixtureDir.formats, fmt.file);
|
||||
if (!existsSync(fixturePath)) return;
|
||||
|
||||
const buffer = readFileSync(fixturePath);
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: fmt.file, contentType: fmt.mime, content: buffer },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/strip-metadata/inspect",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (needsFallback(fmt)) {
|
||||
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
|
||||
} else {
|
||||
expect(res.statusCode).toBe(200);
|
||||
}
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.filename).toBeDefined();
|
||||
expect(body.fileSize).toBeGreaterThan(0);
|
||||
}
|
||||
},
|
||||
getTimeout(fmt),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 9. INFO -- 16 formats with deeper metadata assertions
|
||||
// =========================================================================
|
||||
|
||||
describe("Image enhancement across all 16 primary formats", () => {
|
||||
const ENHANCE_CONFIGS = [
|
||||
{ label: "auto mode, intensity 50", settings: { mode: "auto", intensity: 50 } },
|
||||
{ label: "portrait mode, intensity 75", settings: { mode: "portrait", intensity: 75 } },
|
||||
{ label: "low-light mode, intensity 40", settings: { mode: "low-light", intensity: 40 } },
|
||||
] as const;
|
||||
|
||||
for (const cfg of ENHANCE_CONFIGS) {
|
||||
describe(`enhancement ${cfg.label}`, () => {
|
||||
for (const fmt of PRIMARY_FORMATS) {
|
||||
it(
|
||||
`${fmt.name}`,
|
||||
async () => {
|
||||
const res = await callTool("image-enhancement", fmt, { ...cfg.settings });
|
||||
if (!res) return;
|
||||
assertDownloadResponse(res, fmt);
|
||||
},
|
||||
getTimeout(fmt, "image-enhancement"),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Enhancement analyze endpoint across all 16 formats
|
||||
describe("analyze endpoint", () => {
|
||||
for (const fmt of PRIMARY_FORMATS) {
|
||||
it(
|
||||
`analyzes ${fmt.name}`,
|
||||
async () => {
|
||||
const fixturePath = join(fixtureDir.formats, fmt.file);
|
||||
if (!existsSync(fixturePath)) return;
|
||||
|
||||
const buffer = readFileSync(fixturePath);
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: fmt.file, contentType: fmt.mime, content: buffer },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/image-enhancement/analyze",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (needsFallback(fmt)) {
|
||||
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
||||
} else {
|
||||
expect(res.statusCode).toBe(200);
|
||||
}
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.corrections).toBeDefined();
|
||||
expect(typeof body.corrections).toBe("object");
|
||||
} else {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
}
|
||||
},
|
||||
getTimeout(fmt),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 12. BORDER -- 16 formats x 2 border styles
|
||||
// =========================================================================
|
||||
|
||||
describe("Animated GIF handling", () => {
|
||||
const gifFmt: FormatDef = {
|
||||
name: "GIF",
|
||||
file: "sample.gif",
|
||||
mime: "image/gif",
|
||||
needsCliDecoder: false,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
};
|
||||
|
||||
const TOOLS_FOR_GIF = [
|
||||
{ id: "resize", settings: { width: 32, height: 32 } },
|
||||
{ id: "crop", settings: { width: 10, height: 10, left: 0, top: 0 } },
|
||||
{ id: "rotate", settings: { angle: 180 } },
|
||||
{ id: "convert", settings: { format: "png" } },
|
||||
{ id: "compress", settings: { mode: "quality", quality: 50 } },
|
||||
{ id: "info", settings: {} },
|
||||
{ id: "border", settings: { borderWidth: 3, borderColor: "#00FF00" } },
|
||||
{ id: "strip-metadata", settings: { stripAll: true } },
|
||||
{ id: "optimize-for-web", settings: { format: "webp", quality: 60 } },
|
||||
{ id: "image-enhancement", settings: { mode: "auto", intensity: 30 } },
|
||||
{ id: "adjust-colors", settings: { brightness: 5 } },
|
||||
{ id: "sharpening", settings: { method: "adaptive" } },
|
||||
];
|
||||
|
||||
for (const tool of TOOLS_FOR_GIF) {
|
||||
it(`${tool.id}: processes without crash`, async () => {
|
||||
const res = await callTool(tool.id, gifFmt, tool.settings);
|
||||
if (!res) return;
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const body = JSON.parse(res.body);
|
||||
if (tool.id === "info") {
|
||||
expect(body.width).toBeGreaterThan(0);
|
||||
expect(body.height).toBeGreaterThan(0);
|
||||
} else {
|
||||
expect(body.downloadUrl || body.processedSize).toBeDefined();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 21. SVG SPECIAL HANDLING: vector format through raster tools
|
||||
// =========================================================================
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Comprehensive cross-format parameterized integration tests, part 2 of 4.
|
||||
*
|
||||
* Split from format-matrix-comprehensive.test.ts so the parts shard and run in
|
||||
* parallel; shared setup and helpers live in
|
||||
* ./format-matrix-comprehensive.shared.ts.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMultipartPayload } from "../test-server.js";
|
||||
import {
|
||||
ACCEPTABLE_FALLBACK_CODES,
|
||||
adminToken,
|
||||
app,
|
||||
assertDownloadResponse,
|
||||
CORE_FORMATS,
|
||||
callTool,
|
||||
getTimeout,
|
||||
isAsyncFallback,
|
||||
needsFallback,
|
||||
PRIMARY_FORMATS,
|
||||
setupMatrixApp,
|
||||
} from "./format-matrix-comprehensive.shared.js";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
setupMatrixApp();
|
||||
|
||||
describe("Resize across all 16 primary formats", () => {
|
||||
const RESIZE_CONFIGS = [
|
||||
{ label: "50x50 contain", settings: { width: 50, height: 50, fit: "contain" } },
|
||||
{ label: "100 wide (height auto)", settings: { width: 100 } },
|
||||
{ label: "50% percentage", settings: { percentage: 50 } },
|
||||
] as const;
|
||||
|
||||
for (const cfg of RESIZE_CONFIGS) {
|
||||
describe(`resize ${cfg.label}`, () => {
|
||||
for (const fmt of PRIMARY_FORMATS) {
|
||||
it(
|
||||
`${fmt.name}`,
|
||||
async () => {
|
||||
const res = await callTool("resize", fmt, { ...cfg.settings });
|
||||
if (!res) return;
|
||||
assertDownloadResponse(res, fmt);
|
||||
},
|
||||
getTimeout(fmt),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 2. CROP -- 16 formats x 2 crop modes
|
||||
// =========================================================================
|
||||
|
||||
describe("Convert: 16 formats -> 3 output targets", () => {
|
||||
const OUTPUT_TARGETS = [
|
||||
{ label: "JPEG", format: "jpg", ext: ".jpg" },
|
||||
{ label: "PNG", format: "png", ext: ".png" },
|
||||
{ label: "WebP", format: "webp", ext: ".webp" },
|
||||
] as const;
|
||||
|
||||
for (const target of OUTPUT_TARGETS) {
|
||||
describe(`convert to ${target.label}`, () => {
|
||||
for (const fmt of PRIMARY_FORMATS) {
|
||||
// Skip identity conversions
|
||||
if (fmt.name === "JPEG" && target.format === "jpg") continue;
|
||||
if (fmt.name === "PNG" && target.format === "png") continue;
|
||||
if (fmt.name === "WebP" && target.format === "webp") continue;
|
||||
|
||||
it(
|
||||
`${fmt.name} -> ${target.label}`,
|
||||
async () => {
|
||||
const res = await callTool("convert", fmt, { format: target.format });
|
||||
if (!res) return;
|
||||
if (isAsyncFallback(res)) return;
|
||||
|
||||
if (needsFallback(fmt)) {
|
||||
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
|
||||
} else {
|
||||
expect(res.statusCode).toBe(200);
|
||||
}
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.downloadUrl).toBeDefined();
|
||||
expect(body.downloadUrl).toContain(target.ext);
|
||||
expect(body.processedSize).toBeGreaterThan(0);
|
||||
} else {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
}
|
||||
},
|
||||
getTimeout(fmt),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 5. COMPRESS -- 16 formats x 2 compression modes
|
||||
// =========================================================================
|
||||
|
||||
describe("Compress across all 16 primary formats", () => {
|
||||
const COMPRESS_CONFIGS = [
|
||||
{ label: "quality mode (q=60)", settings: { mode: "quality", quality: 60 } },
|
||||
{ label: "quality mode (q=30)", settings: { mode: "quality", quality: 30 } },
|
||||
] as const;
|
||||
|
||||
for (const cfg of COMPRESS_CONFIGS) {
|
||||
describe(`compress ${cfg.label}`, () => {
|
||||
for (const fmt of PRIMARY_FORMATS) {
|
||||
it(
|
||||
`${fmt.name}`,
|
||||
async () => {
|
||||
const res = await callTool("compress", fmt, { ...cfg.settings });
|
||||
if (!res) return;
|
||||
assertDownloadResponse(res, fmt);
|
||||
},
|
||||
getTimeout(fmt),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 6. ADJUST-COLORS -- 16 formats x 3 adjustment combos
|
||||
// =========================================================================
|
||||
|
||||
describe("Convert round-trip: format -> WebP -> PNG (core formats)", () => {
|
||||
for (const fmt of CORE_FORMATS) {
|
||||
// Skip formats that are already WebP or PNG (not a meaningful round-trip)
|
||||
if (fmt.name === "WebP" || fmt.name === "PNG") continue;
|
||||
|
||||
it(`${fmt.name} -> WebP -> PNG`, async () => {
|
||||
// Step 1: Convert to WebP
|
||||
const toWebpRes = await callTool("convert", fmt, { format: "webp" });
|
||||
if (!toWebpRes) return;
|
||||
expect(toWebpRes.statusCode).toBe(200);
|
||||
|
||||
const webpBody = JSON.parse(toWebpRes.body);
|
||||
expect(webpBody.downloadUrl).toContain(".webp");
|
||||
|
||||
// Step 2: Download the WebP output
|
||||
const downloadRes = await app.inject({
|
||||
method: "GET",
|
||||
url: webpBody.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(downloadRes.statusCode).toBe(200);
|
||||
|
||||
const webpBuffer = Buffer.from(downloadRes.rawPayload);
|
||||
expect(webpBuffer.length).toBeGreaterThan(0);
|
||||
|
||||
// Step 3: Convert WebP to PNG
|
||||
const { body: toPngPayload, contentType: toPngCt } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "intermediate.webp",
|
||||
contentType: "image/webp",
|
||||
content: webpBuffer,
|
||||
},
|
||||
{ name: "settings", content: JSON.stringify({ format: "png" }) },
|
||||
]);
|
||||
|
||||
const toPngRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/convert",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": toPngCt,
|
||||
},
|
||||
body: toPngPayload,
|
||||
});
|
||||
|
||||
expect(toPngRes.statusCode).toBe(200);
|
||||
const pngBody = JSON.parse(toPngRes.body);
|
||||
expect(pngBody.downloadUrl).toContain(".png");
|
||||
expect(pngBody.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 15. ALL 12 TOOLS x each format -- no 500 crashes
|
||||
//
|
||||
// The main safety net: every combination must never crash the server.
|
||||
// This uses a flat loop to generate 16 x 12 = 192 assertions.
|
||||
// =========================================================================
|
||||
|
||||
describe("No-crash matrix: 16 formats x 12 tools", () => {
|
||||
const TOOLS_WITH_SETTINGS: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
settings: Record<string, unknown>;
|
||||
}> = [
|
||||
{ id: "resize", label: "Resize", settings: { width: 50, height: 50 } },
|
||||
{ id: "crop", label: "Crop", settings: { width: 10, height: 10, left: 0, top: 0 } },
|
||||
{ id: "rotate", label: "Rotate", settings: { angle: 90 } },
|
||||
{ id: "convert", label: "Convert", settings: { format: "png" } },
|
||||
{ id: "compress", label: "Compress", settings: { mode: "quality", quality: 60 } },
|
||||
{ id: "adjust-colors", label: "Adjust colors", settings: { brightness: 10, contrast: 5 } },
|
||||
{ id: "sharpening", label: "Sharpening", settings: { method: "adaptive" } },
|
||||
{ id: "strip-metadata", label: "Strip metadata", settings: { stripAll: true } },
|
||||
{ id: "info", label: "Info", settings: {} },
|
||||
{
|
||||
id: "optimize-for-web",
|
||||
label: "Optimize for web",
|
||||
settings: { format: "webp", quality: 75 },
|
||||
},
|
||||
{ id: "image-enhancement", label: "Enhancement", settings: { mode: "auto", intensity: 50 } },
|
||||
{ id: "border", label: "Border", settings: { borderWidth: 5, borderColor: "#FF0000" } },
|
||||
];
|
||||
|
||||
for (const fmt of PRIMARY_FORMATS) {
|
||||
describe(`${fmt.name}`, () => {
|
||||
for (const tool of TOOLS_WITH_SETTINGS) {
|
||||
// Skip identity conversion
|
||||
if (tool.id === "convert" && fmt.name === "PNG") continue;
|
||||
|
||||
it(
|
||||
`${tool.label}: no crash`,
|
||||
async () => {
|
||||
const res = await callTool(tool.id, fmt, tool.settings);
|
||||
if (!res) return;
|
||||
|
||||
// Must never return 500
|
||||
expect(res.statusCode, `${tool.label} + ${fmt.name}: got ${res.statusCode}`).not.toBe(
|
||||
500,
|
||||
);
|
||||
|
||||
// A heavy encode may fall back to async (202) under CI load -- accept it.
|
||||
if (isAsyncFallback(res)) return;
|
||||
|
||||
// Must be a recognized status code
|
||||
if (needsFallback(fmt)) {
|
||||
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
|
||||
} else {
|
||||
expect(res.statusCode).toBe(200);
|
||||
}
|
||||
|
||||
// Response must always be valid JSON
|
||||
const body = JSON.parse(res.body);
|
||||
expect(typeof body).toBe("object");
|
||||
|
||||
// If error, verify clean error shape
|
||||
if (res.statusCode >= 400) {
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
expect(body.error.length).toBeGreaterThan(0);
|
||||
}
|
||||
},
|
||||
getTimeout(fmt, tool.id),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 16. EXTENDED CONVERT MATRIX: core formats -> 5 output formats
|
||||
//
|
||||
// Tests AVIF, TIFF, GIF as additional conversion targets beyond the
|
||||
// JPEG/PNG/WebP matrix tested above.
|
||||
// =========================================================================
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Comprehensive cross-format parameterized integration tests, part 3 of 4.
|
||||
*
|
||||
* Split from format-matrix-comprehensive.test.ts so the parts shard and run in
|
||||
* parallel; shared setup and helpers live in
|
||||
* ./format-matrix-comprehensive.shared.ts.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMultipartPayload } from "../test-server.js";
|
||||
import {
|
||||
ACCEPTABLE_FALLBACK_CODES,
|
||||
adminToken,
|
||||
app,
|
||||
assertDownloadResponse,
|
||||
CORE_FORMATS,
|
||||
callTool,
|
||||
type FormatDef,
|
||||
getTimeout,
|
||||
needsFallback,
|
||||
PRIMARY_FORMATS,
|
||||
setupMatrixApp,
|
||||
} from "./format-matrix-comprehensive.shared.js";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
setupMatrixApp();
|
||||
|
||||
describe("Color adjustments across all 16 primary formats", () => {
|
||||
const COLOR_CONFIGS = [
|
||||
{
|
||||
label: "brightness +20, contrast +10",
|
||||
settings: { brightness: 20, contrast: 10 },
|
||||
},
|
||||
{
|
||||
label: "saturation +30",
|
||||
settings: { saturation: 30 },
|
||||
},
|
||||
{
|
||||
label: "brightness -10, saturation -15, contrast +5",
|
||||
settings: { brightness: -10, saturation: -15, contrast: 5 },
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const cfg of COLOR_CONFIGS) {
|
||||
describe(`adjust-colors ${cfg.label}`, () => {
|
||||
for (const fmt of PRIMARY_FORMATS) {
|
||||
it(
|
||||
`${fmt.name}`,
|
||||
async () => {
|
||||
const res = await callTool("adjust-colors", fmt, { ...cfg.settings });
|
||||
if (!res) return;
|
||||
assertDownloadResponse(res, fmt);
|
||||
},
|
||||
getTimeout(fmt),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 7. SHARPENING -- 16 formats x 2 methods
|
||||
// =========================================================================
|
||||
|
||||
describe("Info (metadata extraction) across all 16 primary formats", () => {
|
||||
for (const fmt of PRIMARY_FORMATS) {
|
||||
it(
|
||||
`extracts metadata from ${fmt.name}`,
|
||||
async () => {
|
||||
const res = await callTool("info", fmt, {});
|
||||
if (!res) return;
|
||||
|
||||
if (needsFallback(fmt)) {
|
||||
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
|
||||
} else {
|
||||
expect(res.statusCode).toBe(200);
|
||||
}
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.width).toBeGreaterThan(0);
|
||||
expect(body.height).toBeGreaterThan(0);
|
||||
expect(body.fileSize).toBeGreaterThan(0);
|
||||
expect(body.format).toBeDefined();
|
||||
expect(typeof body.format).toBe("string");
|
||||
expect(body.channels).toBeGreaterThan(0);
|
||||
// colorSpace is optional but if present should be a string
|
||||
if (body.colorSpace !== undefined) {
|
||||
expect(typeof body.colorSpace).toBe("string");
|
||||
}
|
||||
} else {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
}
|
||||
},
|
||||
getTimeout(fmt),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 10. OPTIMIZE-FOR-WEB -- 16 formats x 2 target output formats
|
||||
// =========================================================================
|
||||
|
||||
describe("Optimize-for-web across all 16 primary formats", () => {
|
||||
const WEB_CONFIGS = [
|
||||
{ label: "webp q75", settings: { format: "webp", quality: 75 } },
|
||||
{ label: "avif q60", settings: { format: "avif", quality: 60 } },
|
||||
] as const;
|
||||
|
||||
for (const cfg of WEB_CONFIGS) {
|
||||
describe(`optimize-for-web ${cfg.label}`, () => {
|
||||
for (const fmt of PRIMARY_FORMATS) {
|
||||
it(
|
||||
`${fmt.name}`,
|
||||
async () => {
|
||||
const res = await callTool("optimize-for-web", fmt, { ...cfg.settings });
|
||||
if (!res) return;
|
||||
assertDownloadResponse(res, fmt);
|
||||
},
|
||||
getTimeout(fmt),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 11. IMAGE-ENHANCEMENT -- 16 formats x 3 enhancement modes
|
||||
// =========================================================================
|
||||
|
||||
describe("Chained operations: resize then compress (core formats)", () => {
|
||||
for (const fmt of CORE_FORMATS) {
|
||||
it(`${fmt.name}: resize 100x100 -> compress q50`, async () => {
|
||||
// Step 1: Resize
|
||||
const resizeRes = await callTool("resize", fmt, { width: 100, height: 100 });
|
||||
if (!resizeRes) return;
|
||||
expect(resizeRes.statusCode).toBe(200);
|
||||
|
||||
const resizeBody = JSON.parse(resizeRes.body);
|
||||
expect(resizeBody.downloadUrl).toBeDefined();
|
||||
|
||||
// Step 2: Download the resized file and compress it
|
||||
const downloadRes = await app.inject({
|
||||
method: "GET",
|
||||
url: resizeBody.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(downloadRes.statusCode).toBe(200);
|
||||
|
||||
const resizedBuffer = Buffer.from(downloadRes.rawPayload);
|
||||
expect(resizedBuffer.length).toBeGreaterThan(0);
|
||||
|
||||
// Step 3: Compress the resized output
|
||||
const { body: compressPayload, contentType: compressCt } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: `resized-${fmt.file}`,
|
||||
contentType: (downloadRes.headers["content-type"] as string) || fmt.mime,
|
||||
content: resizedBuffer,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ mode: "quality", quality: 50 }),
|
||||
},
|
||||
]);
|
||||
|
||||
const compressRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/compress",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": compressCt,
|
||||
},
|
||||
body: compressPayload,
|
||||
});
|
||||
|
||||
expect(compressRes.statusCode).toBe(200);
|
||||
const compressBody = JSON.parse(compressRes.body);
|
||||
expect(compressBody.downloadUrl).toBeDefined();
|
||||
expect(compressBody.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 14. CONVERT ROUND-TRIP: core format -> WebP -> PNG
|
||||
// =========================================================================
|
||||
|
||||
describe("Exotic format error shape verification", () => {
|
||||
const EXOTIC_FORMATS = PRIMARY_FORMATS.filter((f) => f.needsCliDecoder);
|
||||
|
||||
const TOOLS_TO_CHECK = [
|
||||
{ id: "resize", settings: { width: 50, height: 50 } },
|
||||
{ id: "crop", settings: { width: 10, height: 10, left: 0, top: 0 } },
|
||||
{ id: "compress", settings: { mode: "quality", quality: 60 } },
|
||||
{ id: "sharpening", settings: { method: "adaptive" } },
|
||||
{ id: "image-enhancement", settings: { mode: "auto", intensity: 50 } },
|
||||
{ id: "border", settings: { borderWidth: 5, borderColor: "#FF0000" } },
|
||||
];
|
||||
|
||||
for (const fmt of EXOTIC_FORMATS) {
|
||||
for (const tool of TOOLS_TO_CHECK) {
|
||||
it(
|
||||
`${fmt.name} + ${tool.id}: clean JSON error`,
|
||||
async () => {
|
||||
const res = await callTool(tool.id, fmt, tool.settings);
|
||||
if (!res) return;
|
||||
|
||||
expect(res.statusCode).not.toBe(500);
|
||||
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
||||
|
||||
// Response must be valid JSON (not HTML, not raw text)
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = JSON.parse(res.body);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`${fmt.name} + ${tool.id}: response is not valid JSON: ${res.body.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (res.statusCode >= 400) {
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
// Error message should not contain raw stack trace indicators
|
||||
const errorStr = body.error as string;
|
||||
expect(errorStr).not.toContain("at Object.");
|
||||
expect(errorStr).not.toContain("at Module.");
|
||||
expect(errorStr).not.toContain("node_modules");
|
||||
}
|
||||
},
|
||||
getTimeout(fmt),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 19. HEIC/HEIF SPECIFIC: graceful handling when libheif unavailable
|
||||
// =========================================================================
|
||||
|
||||
describe("HEIC/HEIF graceful handling", () => {
|
||||
const HEIF_FORMATS = PRIMARY_FORMATS.filter((f) => f.needsHeifDecoder);
|
||||
|
||||
const CORE_TOOLS = [
|
||||
{ id: "resize", settings: { width: 50, height: 50 } },
|
||||
{ id: "crop", settings: { width: 10, height: 10, left: 0, top: 0 } },
|
||||
{ id: "rotate", settings: { angle: 90 } },
|
||||
{ id: "convert", settings: { format: "png" } },
|
||||
{ id: "compress", settings: { mode: "quality", quality: 60 } },
|
||||
{ id: "adjust-colors", settings: { brightness: 10 } },
|
||||
{ id: "sharpening", settings: { method: "adaptive" } },
|
||||
{ id: "strip-metadata", settings: { stripAll: true } },
|
||||
{ id: "info", settings: {} },
|
||||
{ id: "optimize-for-web", settings: { format: "webp", quality: 75 } },
|
||||
{ id: "image-enhancement", settings: { mode: "auto", intensity: 50 } },
|
||||
{ id: "border", settings: { borderWidth: 5, borderColor: "#FF0000" } },
|
||||
];
|
||||
|
||||
for (const fmt of HEIF_FORMATS) {
|
||||
describe(`${fmt.name}`, () => {
|
||||
for (const tool of CORE_TOOLS) {
|
||||
it(
|
||||
`${tool.id}: no crash`,
|
||||
async () => {
|
||||
const res = await callTool(tool.id, fmt, tool.settings);
|
||||
if (!res) return;
|
||||
|
||||
// Must never crash
|
||||
expect(res.statusCode).not.toBe(500);
|
||||
|
||||
// Accept success (200) or clean error (400/422)
|
||||
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
||||
|
||||
const body = JSON.parse(res.body);
|
||||
if (res.statusCode >= 400) {
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
}
|
||||
},
|
||||
tool.id === "image-enhancement" ? 300_000 : 180_000,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 20. ANIMATED GIF: verify tools handle it without crashing
|
||||
// =========================================================================
|
||||
|
||||
describe("SVG through raster tools", () => {
|
||||
const svgFmt: FormatDef = {
|
||||
name: "SVG",
|
||||
file: "sample.svg",
|
||||
mime: "image/svg+xml",
|
||||
needsCliDecoder: false,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
};
|
||||
|
||||
const SVG_TOOLS = [
|
||||
{ id: "resize", settings: { width: 100, height: 100 } },
|
||||
{ id: "crop", settings: { width: 50, height: 50, left: 0, top: 0 } },
|
||||
{ id: "rotate", settings: { angle: 90 } },
|
||||
{ id: "convert", settings: { format: "png" } },
|
||||
{ id: "compress", settings: { mode: "quality", quality: 60 } },
|
||||
{ id: "info", settings: {} },
|
||||
{ id: "border", settings: { borderWidth: 5, borderColor: "#333333" } },
|
||||
{ id: "adjust-colors", settings: { brightness: 10 } },
|
||||
{ id: "sharpening", settings: { method: "adaptive" } },
|
||||
{ id: "optimize-for-web", settings: { format: "webp", quality: 75 } },
|
||||
{ id: "image-enhancement", settings: { mode: "auto", intensity: 50 } },
|
||||
{ id: "strip-metadata", settings: { stripAll: true } },
|
||||
];
|
||||
|
||||
for (const tool of SVG_TOOLS) {
|
||||
it(`${tool.id}: handles SVG input`, async () => {
|
||||
const res = await callTool(tool.id, svgFmt, tool.settings);
|
||||
if (!res) return;
|
||||
|
||||
// SVG should either be rasterized and processed (200) or cleanly rejected
|
||||
expect(res.statusCode).not.toBe(500);
|
||||
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
||||
|
||||
const body = JSON.parse(res.body);
|
||||
if (res.statusCode === 200) {
|
||||
if (tool.id === "info") {
|
||||
expect(body.width).toBeGreaterThan(0);
|
||||
expect(body.height).toBeGreaterThan(0);
|
||||
} else {
|
||||
expect(body.downloadUrl || body.processedSize).toBeDefined();
|
||||
}
|
||||
} else {
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 22. ICO SPECIAL HANDLING: multi-size format
|
||||
// =========================================================================
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Comprehensive cross-format parameterized integration tests, part 4 of 4.
|
||||
*
|
||||
* Split from format-matrix-comprehensive.test.ts so the parts shard and run in
|
||||
* parallel; shared setup and helpers live in
|
||||
* ./format-matrix-comprehensive.shared.ts.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
assertDownloadResponse,
|
||||
CORE_FORMATS,
|
||||
callTool,
|
||||
type FormatDef,
|
||||
getTimeout,
|
||||
isAsyncFallback,
|
||||
PRIMARY_FORMATS,
|
||||
setupMatrixApp,
|
||||
} from "./format-matrix-comprehensive.shared.js";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
setupMatrixApp();
|
||||
|
||||
describe("Crop across all 16 primary formats", () => {
|
||||
const CROP_CONFIGS = [
|
||||
{ label: "10x10 px at origin", settings: { width: 10, height: 10, left: 0, top: 0 } },
|
||||
{ label: "50x50 px at 5,5", settings: { width: 50, height: 50, left: 5, top: 5 } },
|
||||
] as const;
|
||||
|
||||
for (const cfg of CROP_CONFIGS) {
|
||||
describe(`crop ${cfg.label}`, () => {
|
||||
for (const fmt of PRIMARY_FORMATS) {
|
||||
it(
|
||||
`${fmt.name}`,
|
||||
async () => {
|
||||
const res = await callTool("crop", fmt, { ...cfg.settings });
|
||||
if (!res) return;
|
||||
assertDownloadResponse(res, fmt);
|
||||
},
|
||||
getTimeout(fmt),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 3. ROTATE -- 16 formats x 4 angles + flip combos
|
||||
// =========================================================================
|
||||
|
||||
describe("Rotate across all 16 primary formats", () => {
|
||||
const ROTATE_CONFIGS = [
|
||||
{ label: "90 degrees", settings: { angle: 90 } },
|
||||
{ label: "180 degrees", settings: { angle: 180 } },
|
||||
{ label: "270 degrees", settings: { angle: 270 } },
|
||||
{ label: "horizontal flip", settings: { angle: 0, horizontal: true } },
|
||||
] as const;
|
||||
|
||||
for (const cfg of ROTATE_CONFIGS) {
|
||||
describe(`rotate ${cfg.label}`, () => {
|
||||
for (const fmt of PRIMARY_FORMATS) {
|
||||
it(
|
||||
`${fmt.name}`,
|
||||
async () => {
|
||||
const res = await callTool("rotate", fmt, { ...cfg.settings });
|
||||
if (!res) return;
|
||||
assertDownloadResponse(res, fmt);
|
||||
},
|
||||
getTimeout(fmt),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 4. CONVERT -- each of the 16 formats -> JPEG, PNG, WebP
|
||||
// =========================================================================
|
||||
|
||||
describe("Sharpening across all 16 primary formats", () => {
|
||||
const SHARPEN_CONFIGS = [
|
||||
{ label: "adaptive method", settings: { method: "adaptive" } },
|
||||
{ label: "unsharp-mask method", settings: { method: "unsharp-mask" } },
|
||||
] as const;
|
||||
|
||||
for (const cfg of SHARPEN_CONFIGS) {
|
||||
describe(`sharpening ${cfg.label}`, () => {
|
||||
for (const fmt of PRIMARY_FORMATS) {
|
||||
it(
|
||||
`${fmt.name}`,
|
||||
async () => {
|
||||
const res = await callTool("sharpening", fmt, { ...cfg.settings });
|
||||
if (!res) return;
|
||||
assertDownloadResponse(res, fmt);
|
||||
},
|
||||
getTimeout(fmt),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 8. STRIP-METADATA -- 16 formats + verify stripped output has fewer bytes
|
||||
// =========================================================================
|
||||
|
||||
describe("Border across all 16 primary formats", () => {
|
||||
const BORDER_CONFIGS = [
|
||||
{ label: "5px red", settings: { borderWidth: 5, borderColor: "#FF0000" } },
|
||||
{ label: "10px blue", settings: { borderWidth: 10, borderColor: "#0000FF" } },
|
||||
] as const;
|
||||
|
||||
for (const cfg of BORDER_CONFIGS) {
|
||||
describe(`border ${cfg.label}`, () => {
|
||||
for (const fmt of PRIMARY_FORMATS) {
|
||||
it(
|
||||
`${fmt.name}`,
|
||||
async () => {
|
||||
const res = await callTool("border", fmt, { ...cfg.settings });
|
||||
if (!res) return;
|
||||
assertDownloadResponse(res, fmt);
|
||||
},
|
||||
getTimeout(fmt),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 13. CHAINED OPERATIONS: core formats through resize then compress
|
||||
// =========================================================================
|
||||
|
||||
describe("Extended conversion targets (core formats)", () => {
|
||||
const EXTENDED_TARGETS = [
|
||||
{ format: "avif", ext: ".avif" },
|
||||
{ format: "tiff", ext: ".tiff" },
|
||||
{ format: "gif", ext: ".gif" },
|
||||
] as const;
|
||||
|
||||
for (const target of EXTENDED_TARGETS) {
|
||||
describe(`convert to ${target.format}`, () => {
|
||||
for (const fmt of CORE_FORMATS) {
|
||||
// Skip identity conversions
|
||||
if (fmt.name.toLowerCase() === target.format) continue;
|
||||
if (fmt.name === "AVIF" && target.format === "avif") continue;
|
||||
|
||||
const testTimeout = target.format === "avif" || target.format === "gif" ? 120_000 : 120_000;
|
||||
it(`${fmt.name} -> ${target.format}`, { timeout: testTimeout }, async () => {
|
||||
const res = await callTool("convert", fmt, { format: target.format });
|
||||
if (!res) return;
|
||||
if (isAsyncFallback(res)) return;
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.downloadUrl).toBeDefined();
|
||||
expect(body.downloadUrl).toContain(target.ext);
|
||||
expect(body.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 17. INFO CONSISTENCY: dimensions are consistent with resize output
|
||||
//
|
||||
// For core formats: get info, then resize to specific dimensions and
|
||||
// verify the resize succeeded (demonstrates info output is meaningful).
|
||||
// =========================================================================
|
||||
|
||||
describe("Info consistency check (core formats)", () => {
|
||||
for (const fmt of CORE_FORMATS) {
|
||||
it(`${fmt.name}: info dimensions are positive`, async () => {
|
||||
const res = await callTool("info", fmt, {});
|
||||
if (!res) return;
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.width).toBeGreaterThan(0);
|
||||
expect(body.height).toBeGreaterThan(0);
|
||||
|
||||
// Verify we can resize to half the original dimensions
|
||||
const halfW = Math.max(1, Math.floor(body.width / 2));
|
||||
const halfH = Math.max(1, Math.floor(body.height / 2));
|
||||
|
||||
const resizeRes = await callTool("resize", fmt, { width: halfW, height: halfH });
|
||||
if (!resizeRes) return;
|
||||
expect(resizeRes.statusCode).toBe(200);
|
||||
|
||||
const resizeBody = JSON.parse(resizeRes.body);
|
||||
expect(resizeBody.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 18. EXOTIC FORMAT ERROR SHAPE VERIFICATION
|
||||
//
|
||||
// Exotic formats that fail should return structured errors, not raw
|
||||
// stack traces or HTML error pages. This is a deeper check than the
|
||||
// no-crash matrix.
|
||||
// =========================================================================
|
||||
|
||||
describe("ICO (multi-size format) through tools", () => {
|
||||
const icoFmt: FormatDef = {
|
||||
name: "ICO",
|
||||
file: "sample.ico",
|
||||
mime: "image/x-icon",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
};
|
||||
|
||||
const ICO_TOOLS = [
|
||||
{ id: "resize", settings: { width: 32, height: 32 } },
|
||||
{ id: "convert", settings: { format: "png" } },
|
||||
{ id: "info", settings: {} },
|
||||
{ id: "border", settings: { borderWidth: 2, borderColor: "#000000" } },
|
||||
{ id: "image-enhancement", settings: { mode: "auto", intensity: 50 } },
|
||||
];
|
||||
|
||||
for (const tool of ICO_TOOLS) {
|
||||
it(`${tool.id}: handles ICO input (may need ImageMagick)`, async () => {
|
||||
const res = await callTool(tool.id, icoFmt, tool.settings);
|
||||
if (!res) return;
|
||||
|
||||
// ICO requires CLI decoder; accept success or clean error
|
||||
expect(res.statusCode).not.toBe(500);
|
||||
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
||||
|
||||
const body = JSON.parse(res.body);
|
||||
if (res.statusCode >= 400) {
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
}
|
||||
}, 180_000);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Comprehensive cross-format parameterized integration tests.
|
||||
*
|
||||
* Targets the 16 primary formats x 12 core tools with deeper
|
||||
* parameterized coverage using describe.each / test.each patterns:
|
||||
*
|
||||
* Formats (16): JPEG, PNG, WebP, AVIF, HEIC, HEIF, GIF, BMP, TIFF,
|
||||
* SVG, PSD, DNG, TGA, EXR, HDR, ICO
|
||||
*
|
||||
* Tools (12): resize, crop, rotate, convert, compress,
|
||||
* adjust-colors, sharpening, strip-metadata, info,
|
||||
* optimize-for-web, image-enhancement, border
|
||||
*
|
||||
* Each tool section uses test.each to run the same assertion against every
|
||||
* format. This complements format-matrix.test.ts by adding:
|
||||
* - Per-tool settings variations (e.g. multiple resize dimensions,
|
||||
* multiple rotation angles, multiple compression modes)
|
||||
* - Convert target matrix (each format -> JPEG, PNG, WebP)
|
||||
* - Output content-type verification for download responses
|
||||
* - Deeper assertion on info/metadata responses
|
||||
* - Chained processing: resize then compress a single format
|
||||
*
|
||||
* Exotic formats (PSD, DNG, TGA, EXR, HDR, ICO) and HEIC/HEIF may lack
|
||||
* CLI decoders or libheif. Tests accept 200, 400, or 422 for those and
|
||||
* verify the error shape when not 200. Core formats must return 200. *
|
||||
* SPLIT NOTE: this preamble lives in its own module because the original
|
||||
* single spec file was split into format-matrix-comprehensive-1..4.test.ts.
|
||||
* Vitest shards by FILE and runs one file's tests serially inside a single
|
||||
* fork, so one huge spec set the floor for the whole Integration CI job.
|
||||
* Four part files run in parallel; this module holds everything they share.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { apiToolPath } from "@snapotter/shared";
|
||||
import { afterAll, beforeAll, expect } from "vitest";
|
||||
import { fixtureDir } from "../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../test-server.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Format definitions for the 16 primary formats
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface FormatDef {
|
||||
name: string;
|
||||
file: string;
|
||||
mime: string;
|
||||
/** Requires CLI decoder (ImageMagick / dcraw) -- may not be installed */
|
||||
needsCliDecoder: boolean;
|
||||
/** Requires libheif decoder -- may not be installed */
|
||||
needsHeifDecoder: boolean;
|
||||
/** Sharp may fail validation for this format */
|
||||
mayFailValidation: boolean;
|
||||
}
|
||||
|
||||
export const PRIMARY_FORMATS: FormatDef[] = [
|
||||
{
|
||||
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: "AVIF",
|
||||
file: "sample.avif",
|
||||
mime: "image/avif",
|
||||
needsCliDecoder: false,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "GIF",
|
||||
file: "sample.gif",
|
||||
mime: "image/gif",
|
||||
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: "SVG",
|
||||
file: "sample.svg",
|
||||
mime: "image/svg+xml",
|
||||
needsCliDecoder: false,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
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: "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: "DNG",
|
||||
file: "sample.dng",
|
||||
mime: "image/x-adobe-dng",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "TGA",
|
||||
file: "sample.tga",
|
||||
mime: "image/x-tga",
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
export const CORE_FORMATS = PRIMARY_FORMATS.filter(
|
||||
(f) => !f.needsCliDecoder && !f.needsHeifDecoder && !f.mayFailValidation,
|
||||
);
|
||||
|
||||
export const ACCEPTABLE_FALLBACK_CODES = [200, 202, 400, 422];
|
||||
|
||||
export function needsFallback(fmt: FormatDef): boolean {
|
||||
return fmt.needsCliDecoder || fmt.needsHeifDecoder || fmt.mayFailValidation;
|
||||
}
|
||||
|
||||
/**
|
||||
* A CPU-heavy encode can exceed the sync window (SYNC_WAIT_MS, 30s in tests)
|
||||
* under parallel CI load and fall back to async: 202 {jobId, async: true}. Per
|
||||
* the documented 200-or-202 contract that is a legitimate "accepted & processing"
|
||||
* outcome -- the worker runs the same process fn either way -- not a failure.
|
||||
* Returns true (validating the async body shape) when the response is that
|
||||
* fallback, so callers can treat it as a pass.
|
||||
*/
|
||||
export function isAsyncFallback(res: { statusCode: number; body: string }): boolean {
|
||||
if (res.statusCode !== 202) return false;
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.async).toBe(true);
|
||||
expect(body.jobId).toBeDefined();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getTimeout(fmt: FormatDef, toolId?: string): number | undefined {
|
||||
if ((fmt.needsHeifDecoder || fmt.needsCliDecoder) && toolId === "image-enhancement")
|
||||
return 300_000;
|
||||
if (fmt.needsHeifDecoder || fmt.needsCliDecoder) return 180_000;
|
||||
if (toolId === "image-enhancement") return 120_000;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared app state
|
||||
// ---------------------------------------------------------------------------
|
||||
export let testApp: TestApp;
|
||||
export let app: TestApp["app"];
|
||||
export let adminToken: string;
|
||||
|
||||
/** Registers the shared beforeAll/afterAll for a part file. */
|
||||
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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: send a tool request and return the response
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function callTool(toolId: string, fmt: FormatDef, settings: Record<string, unknown>) {
|
||||
const fixturePath = join(fixtureDir.formats, fmt.file);
|
||||
if (!existsSync(fixturePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buffer = readFileSync(fixturePath);
|
||||
const fields: Array<{
|
||||
name: string;
|
||||
filename?: string;
|
||||
contentType?: string;
|
||||
content: Buffer | string;
|
||||
}> = [{ name: "file", filename: fmt.file, contentType: fmt.mime, content: buffer }];
|
||||
|
||||
if (Object.keys(settings).length > 0) {
|
||||
fields.push({ name: "settings", content: JSON.stringify(settings) });
|
||||
}
|
||||
|
||||
const { body: payload, contentType } = createMultipartPayload(fields);
|
||||
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: apiToolPath(toolId),
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a standard download response shape (used by most tools).
|
||||
* For fallback formats, accepts 200/400/422. For core formats, expects 200.
|
||||
*/
|
||||
export function assertDownloadResponse(res: { statusCode: number; body: string }, fmt: FormatDef) {
|
||||
if (isAsyncFallback(res)) return undefined;
|
||||
if (needsFallback(fmt)) {
|
||||
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
|
||||
} else {
|
||||
expect(res.statusCode).toBe(200);
|
||||
}
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.downloadUrl).toBeDefined();
|
||||
expect(typeof body.downloadUrl).toBe("string");
|
||||
expect(body.processedSize).toBeGreaterThan(0);
|
||||
expect(body.originalSize).toBeGreaterThan(0);
|
||||
return body;
|
||||
}
|
||||
|
||||
// Error path: verify clean JSON error
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
expect(body.error.length).toBeGreaterThan(0);
|
||||
return null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
import { registerToolFormatMatrix } from "./format-matrix-generated.shared.js";
|
||||
|
||||
registerToolFormatMatrix(1);
|
||||
@@ -0,0 +1,3 @@
|
||||
import { registerToolFormatMatrix } from "./format-matrix-generated.shared.js";
|
||||
|
||||
registerToolFormatMatrix(2);
|
||||
@@ -0,0 +1,3 @@
|
||||
import { registerToolFormatMatrix } from "./format-matrix-generated.shared.js";
|
||||
|
||||
registerToolFormatMatrix(3);
|
||||
@@ -0,0 +1,191 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { apiToolPath, TOOLS } from "@snapotter/shared";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { getRegisteredToolIds, getToolConfig } from "../../../apps/api/src/routes/tool-factory.js";
|
||||
import { fixtureDir } from "../../fixtures/index.js";
|
||||
import {
|
||||
defaultSettingsFor,
|
||||
TOOL_SETTINGS_OVERRIDES,
|
||||
} from "../../helpers/tool-default-settings.js";
|
||||
import { cancelAcceptedJobAndWait } from "../settle-job.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../test-server.js";
|
||||
|
||||
/**
|
||||
* Registry-generated tool x format matrix.
|
||||
*
|
||||
* Every registered tool is exercised against every input format fixture with
|
||||
* its minimal valid settings. The invariant is the factory's error contract:
|
||||
* success (200/202), clean rejection (400/413/415/422), or AI-not-installed
|
||||
* (501). A 500 or an undecodable "successful" output is a bug.
|
||||
*
|
||||
* PR runs use the core web formats; FULL_MATRIX=1 (nightly) unlocks all
|
||||
* fixtures in tests/fixtures/image/formats/.
|
||||
*
|
||||
* Split across format-matrix-generated-{1,2,3}.test.ts because vitest shards by
|
||||
* file and runs a file's tests serially in one fork, so a single 800s spec set
|
||||
* the floor for the whole Integration job. `toolsForPart` stripes TOOLS by
|
||||
* modulo, which partitions them by construction: every tool lands in exactly
|
||||
* one part, and the describe name is identical in each so the full test-name
|
||||
* set is unchanged.
|
||||
*/
|
||||
|
||||
const CORE_FORMATS = [
|
||||
"sample.png",
|
||||
"sample.jpg",
|
||||
"sample.webp",
|
||||
"sample.gif",
|
||||
"sample.svg",
|
||||
"sample.heic",
|
||||
];
|
||||
|
||||
const fixtureFiles = process.env.FULL_MATRIX
|
||||
? readdirSync(fixtureDir.formats).filter((f) => !f.startsWith("."))
|
||||
: CORE_FORMATS;
|
||||
|
||||
const ALLOWED_STATUSES = new Set([200, 202, 400, 413, 415, 422, 501]);
|
||||
|
||||
/**
|
||||
* Raster content types this libvips/Sharp build is guaranteed to decode. Used
|
||||
* to decide whether a 200 image response should be pixel-verified. Anything
|
||||
* else (PDF, JSON, ZIP, SVG, or a niche raster like BMP/PSD streamed back
|
||||
* untouched) carries an honest content-type we don't attempt to decode.
|
||||
*/
|
||||
const SHARP_DECODABLE_TYPES = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"image/tiff",
|
||||
"image/avif",
|
||||
]);
|
||||
|
||||
export const MATRIX_PART_COUNT = 3;
|
||||
|
||||
/** Tools belonging to one part. Striped, so the parts partition TOOLS exactly. */
|
||||
export function toolsForPart(part: number): typeof TOOLS {
|
||||
return TOOLS.filter((_, index) => index % MATRIX_PART_COUNT === part - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the matrix for one part. The registry-wide schema guards are
|
||||
* global assertions rather than per-tool ones, so they run in part 1 only.
|
||||
*/
|
||||
export function registerToolFormatMatrix(part: number): void {
|
||||
describe("tool x format matrix (generated)", () => {
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
if (part === 1) {
|
||||
it("settings overrides only reference registered tools", () => {
|
||||
const registered = new Set(getRegisteredToolIds());
|
||||
for (const toolId of Object.keys(TOOL_SETTINGS_OVERRIDES)) {
|
||||
expect(registered.has(toolId), `override for unknown tool "${toolId}"`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("default settings are valid for every registered tool", () => {
|
||||
const invalid: string[] = [];
|
||||
for (const toolId of getRegisteredToolIds()) {
|
||||
const config = getToolConfig(toolId);
|
||||
if (!config) continue;
|
||||
const result = config.settingsSchema.safeParse(defaultSettingsFor(toolId));
|
||||
if (!result.success) {
|
||||
invalid.push(
|
||||
`${toolId}: ${result.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
invalid,
|
||||
`tools needing TOOL_SETTINGS_OVERRIDES entries:\n${invalid.join("\n")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const tool of toolsForPart(part)) {
|
||||
const toolId = tool.id;
|
||||
it(`${toolId} handles every input format cleanly`, async () => {
|
||||
for (const fixture of fixtureFiles) {
|
||||
const content = readFileSync(join(fixtureDir.formats, fixture));
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: fixture, contentType: "application/octet-stream", content },
|
||||
{ name: "settings", content: JSON.stringify(defaultSettingsFor(toolId)) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: apiToolPath(toolId),
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
// Custom-route tools can 404 on the standard path; covered elsewhere.
|
||||
if (res.statusCode === 404) return;
|
||||
|
||||
expect(
|
||||
ALLOWED_STATUSES.has(res.statusCode),
|
||||
`${toolId} x ${fixture}: status ${res.statusCode}: ${res.body.slice(0, 300)}`,
|
||||
).toBe(true);
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
const resType = (res.headers["content-type"]?.toString() ?? "").split(";")[0];
|
||||
if (resType !== "application/json") {
|
||||
// Tools like bulk-rename/favicon/split stream a ZIP directly.
|
||||
if (resType === "application/zip") {
|
||||
expect(
|
||||
res.rawPayload.subarray(0, 2).toString("latin1"),
|
||||
`${toolId} x ${fixture}: ZIP response is not a ZIP`,
|
||||
).toBe("PK");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const payload = JSON.parse(res.body) as { downloadUrl?: string };
|
||||
if (!payload.downloadUrl) continue;
|
||||
const dl = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: payload.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(dl.statusCode, `${toolId} x ${fixture}: download failed`).toBe(200);
|
||||
const outType = (dl.headers["content-type"]?.toString() ?? "").split(";")[0];
|
||||
// Allowlist of raster types this libvips build is guaranteed to
|
||||
// decode. An allowlist (vs the old denylist) is robust to tools that
|
||||
// legitimately stream back niche formats untouched (e.g. edit-metadata
|
||||
// writing tags in place on a BMP/PSD): those carry an honest
|
||||
// content-type we simply don't pixel-verify, rather than being
|
||||
// misread as a corrupt JPEG.
|
||||
const sharpDecodable = SHARP_DECODABLE_TYPES.has(outType);
|
||||
if (sharpDecodable) {
|
||||
// The processed output must actually decode; a corrupt "success" is a bug.
|
||||
const meta = await sharp(dl.rawPayload).metadata();
|
||||
expect(meta.width, `${toolId} x ${fixture}: output not decodable`).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (res.statusCode === 202 && (toolId === "ocr" || toolId === "ocr-pdf")) {
|
||||
const payload = JSON.parse(res.body) as { jobId?: string };
|
||||
expect(payload.jobId).toBeDefined();
|
||||
await cancelAcceptedJobAndWait(payload.jobId as string, "ai");
|
||||
}
|
||||
}
|
||||
// The nightly avif converters run many slow encodes per test; a busy runner
|
||||
// intermittently overran the old 240s cap, so allow the job's full budget.
|
||||
}, 600_000);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { apiToolPath, TOOLS } from "@snapotter/shared";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { getRegisteredToolIds, getToolConfig } from "../../../apps/api/src/routes/tool-factory.js";
|
||||
import { fixtureDir } from "../../fixtures/index.js";
|
||||
import {
|
||||
defaultSettingsFor,
|
||||
TOOL_SETTINGS_OVERRIDES,
|
||||
} from "../../helpers/tool-default-settings.js";
|
||||
import { cancelAcceptedJobAndWait } from "../settle-job.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../test-server.js";
|
||||
|
||||
/**
|
||||
* Registry-generated tool x format matrix.
|
||||
*
|
||||
* Every registered tool is exercised against every input format fixture with
|
||||
* its minimal valid settings. The invariant is the factory's error contract:
|
||||
* success (200/202), clean rejection (400/413/415/422), or AI-not-installed
|
||||
* (501). A 500 or an undecodable "successful" output is a bug.
|
||||
*
|
||||
* PR runs use the core web formats; FULL_MATRIX=1 (nightly) unlocks all
|
||||
* fixtures in tests/fixtures/image/formats/.
|
||||
*/
|
||||
|
||||
const CORE_FORMATS = [
|
||||
"sample.png",
|
||||
"sample.jpg",
|
||||
"sample.webp",
|
||||
"sample.gif",
|
||||
"sample.svg",
|
||||
"sample.heic",
|
||||
];
|
||||
|
||||
const fixtureFiles = process.env.FULL_MATRIX
|
||||
? readdirSync(fixtureDir.formats).filter((f) => !f.startsWith("."))
|
||||
: CORE_FORMATS;
|
||||
|
||||
const ALLOWED_STATUSES = new Set([200, 202, 400, 413, 415, 422, 501]);
|
||||
|
||||
/**
|
||||
* Raster content types this libvips/Sharp build is guaranteed to decode. Used
|
||||
* to decide whether a 200 image response should be pixel-verified. Anything
|
||||
* else (PDF, JSON, ZIP, SVG, or a niche raster like BMP/PSD streamed back
|
||||
* untouched) carries an honest content-type we don't attempt to decode.
|
||||
*/
|
||||
const SHARP_DECODABLE_TYPES = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"image/tiff",
|
||||
"image/avif",
|
||||
]);
|
||||
|
||||
describe("tool x format matrix (generated)", () => {
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
it("settings overrides only reference registered tools", () => {
|
||||
const registered = new Set(getRegisteredToolIds());
|
||||
for (const toolId of Object.keys(TOOL_SETTINGS_OVERRIDES)) {
|
||||
expect(registered.has(toolId), `override for unknown tool "${toolId}"`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("default settings are valid for every registered tool", () => {
|
||||
const invalid: string[] = [];
|
||||
for (const toolId of getRegisteredToolIds()) {
|
||||
const config = getToolConfig(toolId);
|
||||
if (!config) continue;
|
||||
const result = config.settingsSchema.safeParse(defaultSettingsFor(toolId));
|
||||
if (!result.success) {
|
||||
invalid.push(
|
||||
`${toolId}: ${result.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
invalid,
|
||||
`tools needing TOOL_SETTINGS_OVERRIDES entries:\n${invalid.join("\n")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
for (const tool of TOOLS) {
|
||||
const toolId = tool.id;
|
||||
it(`${toolId} handles every input format cleanly`, async () => {
|
||||
for (const fixture of fixtureFiles) {
|
||||
const content = readFileSync(join(fixtureDir.formats, fixture));
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: fixture, contentType: "application/octet-stream", content },
|
||||
{ name: "settings", content: JSON.stringify(defaultSettingsFor(toolId)) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: apiToolPath(toolId),
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
// Custom-route tools can 404 on the standard path; covered elsewhere.
|
||||
if (res.statusCode === 404) return;
|
||||
|
||||
expect(
|
||||
ALLOWED_STATUSES.has(res.statusCode),
|
||||
`${toolId} x ${fixture}: status ${res.statusCode}: ${res.body.slice(0, 300)}`,
|
||||
).toBe(true);
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
const resType = (res.headers["content-type"]?.toString() ?? "").split(";")[0];
|
||||
if (resType !== "application/json") {
|
||||
// Tools like bulk-rename/favicon/split stream a ZIP directly.
|
||||
if (resType === "application/zip") {
|
||||
expect(
|
||||
res.rawPayload.subarray(0, 2).toString("latin1"),
|
||||
`${toolId} x ${fixture}: ZIP response is not a ZIP`,
|
||||
).toBe("PK");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const payload = JSON.parse(res.body) as { downloadUrl?: string };
|
||||
if (!payload.downloadUrl) continue;
|
||||
const dl = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: payload.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(dl.statusCode, `${toolId} x ${fixture}: download failed`).toBe(200);
|
||||
const outType = (dl.headers["content-type"]?.toString() ?? "").split(";")[0];
|
||||
// Allowlist of raster types this libvips build is guaranteed to
|
||||
// decode. An allowlist (vs the old denylist) is robust to tools that
|
||||
// legitimately stream back niche formats untouched (e.g. edit-metadata
|
||||
// writing tags in place on a BMP/PSD): those carry an honest
|
||||
// content-type we simply don't pixel-verify, rather than being
|
||||
// misread as a corrupt JPEG.
|
||||
const sharpDecodable = SHARP_DECODABLE_TYPES.has(outType);
|
||||
if (sharpDecodable) {
|
||||
// The processed output must actually decode; a corrupt "success" is a bug.
|
||||
const meta = await sharp(dl.rawPayload).metadata();
|
||||
expect(meta.width, `${toolId} x ${fixture}: output not decodable`).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (res.statusCode === 202 && (toolId === "ocr" || toolId === "ocr-pdf")) {
|
||||
const payload = JSON.parse(res.body) as { jobId?: string };
|
||||
expect(payload.jobId).toBeDefined();
|
||||
await cancelAcceptedJobAndWait(payload.jobId as string, "ai");
|
||||
}
|
||||
}
|
||||
// The nightly avif converters run many slow encodes per test; a busy runner
|
||||
// intermittently overran the old 240s cap, so allow the job's full budget.
|
||||
}, 600_000);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* 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, expect } 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* A heavy encode can exceed SYNC_WAIT_MS (30s in tests) under parallel CI load
|
||||
* and fall back to 202 {jobId, async: true}. Per the 200-or-202 API contract
|
||||
* that is a legitimate "accepted & processing" outcome, not a failure.
|
||||
* Returns true (validating the async body shape) so callers can early-return.
|
||||
*/
|
||||
export function isAsyncFallback(res: { statusCode: number; body: string }): boolean {
|
||||
if (res.statusCode !== 202) return false;
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.async).toBe(true);
|
||||
expect(body.jobId).toBeDefined();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,12 +19,14 @@ function integrationSpecs(): string[] {
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
const HEAVYWEIGHTS = [
|
||||
"tests/integration/generated/format-matrix-comprehensive.test.ts",
|
||||
"tests/integration/generated/format-matrix.test.ts",
|
||||
"tests/integration/generated/format-matrix-generated.test.ts",
|
||||
"tests/integration/generated/format-matrix-exotic.test.ts",
|
||||
];
|
||||
/**
|
||||
* The four costliest specs, read from the table rather than hardcoded so this
|
||||
* survives specs being split, renamed, or reweighted.
|
||||
*/
|
||||
const HEAVYWEIGHTS = Object.entries(FILE_COST_SECONDS)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 4)
|
||||
.map(([file]) => file);
|
||||
|
||||
describe("partitionByCost", () => {
|
||||
// The whole point of the helper: a shard must never silently drop a spec.
|
||||
@@ -95,9 +97,10 @@ describe("partitionByCost", () => {
|
||||
});
|
||||
|
||||
describe("costOf", () => {
|
||||
it("returns the measured cost for a known-heavy spec", () => {
|
||||
const known = "tests/integration/generated/format-matrix-comprehensive.test.ts";
|
||||
expect(costOf(known)).toBe(FILE_COST_SECONDS[known]);
|
||||
it("returns the measured cost for every listed spec", () => {
|
||||
for (const [file, seconds] of Object.entries(FILE_COST_SECONDS)) {
|
||||
expect(costOf(file)).toBe(seconds);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to a default for unmeasured specs", () => {
|
||||
@@ -105,7 +108,7 @@ describe("partitionByCost", () => {
|
||||
});
|
||||
|
||||
it("matches regardless of leading slash or absolute prefix", () => {
|
||||
const known = "tests/integration/generated/format-matrix.test.ts";
|
||||
const known = HEAVYWEIGHTS[0];
|
||||
expect(costOf(`/${known}`)).toBe(costOf(known));
|
||||
expect(costOf(path.join(repoRoot, known))).toBe(costOf(known));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user