fix: repair docker validation QA tooling, dispatcher crash-accounting, and image-enhancement RAW hang (#391)

Found and fixed during a full local Docker build validation (amd64/arm64, all
four fleet targets, AI bundle installs, QA harness) and the follow-up bug
sweep requested afterward. None of the affected scripts run in CI, so these
had been silently broken indefinitely.

- docker/feature-manifest.json: pythonVersion was a flat "3.11", but the
  amd64 base (Ubuntu 24.04) ships Python 3.12 while arm64 (Debian bookworm)
  ships 3.11. Changed to a per-arch object matching the file's existing
  convention.
- tests/qa/api-sweep.mts and verify-ai.mts: bare "@snapotter/shared" import
  can't resolve since tests/ is not a pnpm workspace member, making both
  silently unrunnable via their own documented command on any fresh
  checkout. Switched to a relative import.
- tests/qa/generate-ledger.mts: wrote to docs/qa/ without creating the
  directory first; docs/ is gitignored except COMMUNITY_GUIDE.md, so a fresh
  checkout threw ENOENT.
- Seven QA Playwright spec files (input-preview, settings,
  settings-extended, multifile, output-preview, pipeline-ui, smoke) had
  ~115 fixture() calls using directory names that don't exist. Resolved
  every call programmatically against the real fixture tree.
- packages/ai/src/bridge.ts: AI dispatcher restart (happens on every bundle
  install) was falsely counted as a crash, risking permanent dispatcher
  disable after enough legitimate restarts within the crash window. Added a
  shuttingDown flag checked at all three recordCrash() call sites.
- packages/image-engine/src/operations/auto-enhance.ts: image-enhancement
  hung 40+ seconds on large RAW photos (confirmed on a real 20.2MP file) in
  Sharp's .clahe() step, whose cost scales with total pixel count regardless
  of tile size. Added a 16-megapixel cap above which CLAHE is skipped;
  verified against the real file (40+s -> 2.0s) with no regression to other
  RAW formats or normal-sized images. Fixing this surfaced a second,
  smaller bug where the saturation step's CLAHE compensation boost was
  keyed off the raw toggle instead of whether CLAHE actually ran.
- Two QA-harness robustness gaps closed per "fix everything, even the small
  bugs": the passport-photo/erase-object input-preview tests now skip
  cleanly with a clear reason on a container without their AI bundle
  installed, and docker-compose.qa.yml's hardcoded project/container name
  (the actual root cause of a mid-validation container swap between two
  concurrent sessions) is now parameterized via QA_PROJECT_NAME.

Full validation report is local-only per repo convention.
This commit is contained in:
SnapOtter
2026-07-02 14:21:13 +08:00
committed by GitHub
parent 7e01d3637e
commit bd1838e40b
17 changed files with 1110 additions and 151 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifestVersion": 2,
"imageVersion": "2.0.0",
"pythonVersion": "3.11",
"pythonVersion": { "amd64": "3.12", "arm64": "3.11" },
"basePackages": ["numpy==1.26.4", "Pillow==12.2.0", "opencv-python-headless==4.10.0.84"],
"bundleRepo": "deepsafe/feature-bundles",
"bundles": {
+14 -3
View File
@@ -137,6 +137,7 @@ export class PythonDispatcher {
private crashes = 0;
private lastCrashTs = 0;
private backoffEnd = 0;
private shuttingDown = false;
constructor(opts: { profile: "ai" | "docs" }) {
this.profile = opts.profile;
@@ -174,6 +175,7 @@ export class PythonDispatcher {
private startChild(): ChildProcess | null {
if (this.childFailed) return null;
this.shuttingDown = false;
try {
const proc = spawn(getPythonPath(), [resolve(PYTHON_DIR, "dispatcher.py")], {
@@ -190,7 +192,13 @@ export class PythonDispatcher {
req.reject(new Error("Python dispatcher stdin closed unexpectedly"));
this.pending.delete(id);
}
this.recordCrash();
// An intentional shutdown() ends stdin then SIGTERMs the child, which
// can surface here as an EPIPE/ERR_STREAM_DESTROYED. That is not a
// crash -- counting it would let repeated legitimate restarts (e.g.
// shutdownDispatcher() on every AI bundle install) trip the crash
// limit and permanently disable the dispatcher. Guard mirrors the
// "close" handler below.
if (!this.shuttingDown) this.recordCrash();
this.child = null;
this.childReady = false;
}
@@ -284,7 +292,9 @@ export class PythonDispatcher {
console.error(`[bridge] Dispatcher error: ${err.message} (code: ${err.code})`);
if (err.code === "ENOENT") {
this.childFailed = true;
} else {
} else if (!this.shuttingDown) {
// Skip crash accounting when we initiated the teardown (shutdown()
// sets shuttingDown before killing the child); mirrors "close".
this.recordCrash();
}
for (const [id, req] of this.pending.entries()) {
@@ -300,7 +310,7 @@ export class PythonDispatcher {
req.reject(new Error("Python dispatcher exited unexpectedly"));
this.pending.delete(id);
}
if (code !== 0) {
if (code !== 0 && !this.shuttingDown) {
this.recordCrash();
}
this.child = null;
@@ -531,6 +541,7 @@ export class PythonDispatcher {
*/
shutdown(): void {
if (this.child && !this.child.killed) {
this.shuttingDown = true;
this.child.stdin?.end();
this.child.kill("SIGTERM");
this.child = null;
@@ -9,6 +9,12 @@ import type {
SharpMetadata,
} from "../types.js";
/**
* Above this pixel count, CLAHE is skipped in applyCorrections() -- see the
* comment at its call site for why.
*/
const MAX_CLAHE_PIXELS = 16_000_000;
/**
* Preset multipliers applied to auto-computed corrections.
* Each value scales the corresponding correction (1.0 = unchanged).
@@ -223,17 +229,28 @@ export function applyCorrections(
const scale = intensity / 50;
let result = image;
// Tracks whether .clahe() actually ran (not just whether the toggle allowed
// it) -- Step 5 below applies a compensation boost keyed off this, and it
// needs to stay correct now that CLAHE can also be skipped by image size.
let claheApplied = false;
// Step 1: CLAHE - adaptive local contrast enhancement
// maxSlope must be an integer (Sharp requirement); skip for tiny images
// maxSlope must be an integer (Sharp requirement); skip for tiny images.
// CLAHE's cost scales with total pixel count regardless of tile size (tile
// size only bounds granularity, not the per-pixel histogram/interpolation
// work), so it's also skipped above MAX_CLAHE_PIXELS -- a 5504x3672 (20MP)
// real-world RAW photo measured 40+ seconds in this step alone versus ~1s
// for every other correction combined. The other six corrections below
// still apply at full resolution regardless of size.
if (toggles.contrast !== false) {
const maxSlope = clamp(Math.round(1.0 + (intensity / 100) * 4.0 * presets.clahe), 1, 10);
const w = imageSize?.width ?? 64;
const h = imageSize?.height ?? 64;
const tileW = clamp(Math.round(w / 8), 8, 256);
const tileH = clamp(Math.round(h / 8), 8, 256);
if (maxSlope >= 2 && w >= tileW && h >= tileH) {
if (maxSlope >= 2 && w >= tileW && h >= tileH && w * h <= MAX_CLAHE_PIXELS) {
result = result.clahe({ width: tileW, height: tileH, maxSlope });
claheApplied = true;
}
}
@@ -272,7 +289,7 @@ export function applyCorrections(
// Step 5: Saturation (with small CLAHE compensation boost)
if (toggles.saturation !== false) {
const adj = corrections.saturation * presets.saturation * scale;
const claheCompensation = toggles.contrast !== false && intensity > 10 ? 0.05 : 0;
const claheCompensation = claheApplied && intensity > 10 ? 0.05 : 0;
const satMul = 1 + adj / 100 + claheCompensation;
if (Math.abs(satMul - 1) > 0.02) {
result = result.modulate({ saturation: clamp(satMul, 0.2, 3.0) });
+73 -31
View File
@@ -12,7 +12,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { apiToolPath } from "@snapotter/shared";
import { apiToolPath } from "../../packages/shared/src/constants.js";
// ── Config ────────────────────────────────────────────────────────
const BASE = "http://localhost:13499";
@@ -48,7 +48,13 @@ interface SweepResult {
note: string;
}
type Classification = "pass" | "expected-reject" | "suspicious-reject" | "bug" | "skipped" | "needs-review";
type Classification =
| "pass"
| "expected-reject"
| "suspicious-reject"
| "bug"
| "skipped"
| "needs-review";
// ── Load tools + settings ─────────────────────────────────────────
@@ -137,7 +143,13 @@ function resolveFixture(ext: string, modality: string): string | null {
}
// Fallback: try all dirs
for (const dir of [FIXTURES_FORMATS, FIXTURES_MEDIA_VIDEO, FIXTURES_MEDIA_AUDIO, FIXTURES_DOCS, FIXTURES_DATA]) {
for (const dir of [
FIXTURES_FORMATS,
FIXTURES_MEDIA_VIDEO,
FIXTURES_MEDIA_AUDIO,
FIXTURES_DOCS,
FIXTURES_DATA,
]) {
for (const prefix of ["sample", "tiny"]) {
const p = join(dir, `${prefix}.${bare}`);
if (existsSync(p)) return p;
@@ -194,7 +206,10 @@ function detectSignature(data: Buffer): string | null {
if (data.length < off + sig.bytes.length) continue;
let match = true;
for (let i = 0; i < sig.bytes.length; i++) {
if (data[off + i] !== sig.bytes[i]) { match = false; break; }
if (data[off + i] !== sig.bytes[i]) {
match = false;
break;
}
}
if (match) return sig.name;
}
@@ -266,7 +281,10 @@ function verifyOutput(data: Buffer, contentType: string): { ok: boolean; detail:
// ── SSE polling for async jobs ────────────────────────────────────
async function pollJobSSE(jobId: string, timeoutMs: number): Promise<{
async function pollJobSSE(
jobId: string,
timeoutMs: number,
): Promise<{
status: "completed" | "failed" | "timeout";
error?: string;
result?: Record<string, unknown>;
@@ -277,7 +295,10 @@ async function pollJobSSE(jobId: string, timeoutMs: number): Promise<{
while (Date.now() < deadline) {
try {
const controller = new AbortController();
const fetchTimeout = setTimeout(() => controller.abort(), Math.min(30_000, deadline - Date.now()));
const fetchTimeout = setTimeout(
() => controller.abort(),
Math.min(30_000, deadline - Date.now()),
);
const res = await fetch(url, {
signal: controller.signal,
@@ -299,7 +320,9 @@ async function pollJobSSE(jobId: string, timeoutMs: number): Promise<{
while (Date.now() < deadline) {
const readTimeout = Math.min(30_000, deadline - Date.now());
const readPromise = reader.read();
const timeoutPromise = sleep(readTimeout).then(() => ({ done: true, value: undefined } as const));
const timeoutPromise = sleep(readTimeout).then(
() => ({ done: true, value: undefined }) as const,
);
const chunk = await Promise.race([readPromise, timeoutPromise]);
if (chunk.done) break;
@@ -336,7 +359,9 @@ async function pollJobSSE(jobId: string, timeoutMs: number): Promise<{
reader.cancel().catch(() => {});
return {
status: "failed",
error: data.errors?.map((e: { error: string }) => e.error).join("; ") || "batch failed",
error:
data.errors?.map((e: { error: string }) => e.error).join("; ") ||
"batch failed",
};
}
}
@@ -400,10 +425,19 @@ async function fetchAsyncOutput(jobId: string): Promise<{
// Fallback: try common output filenames
const commonNames = [
"output.mp4", "output.webm", "output.mkv", "output.avi",
"output.mp3", "output.wav", "output.ogg",
"output.png", "output.jpg", "output.webp",
"output.pdf", "output.txt", "output.json",
"output.mp4",
"output.webm",
"output.mkv",
"output.avi",
"output.mp3",
"output.wav",
"output.ogg",
"output.png",
"output.jpg",
"output.webp",
"output.pdf",
"output.txt",
"output.json",
"output.zip",
];
@@ -421,9 +455,7 @@ async function fetchAsyncOutput(jobId: string): Promise<{
};
}
}
} catch {
continue;
}
} catch {}
}
return { found: false };
@@ -474,9 +506,7 @@ async function main() {
const startTime = Date.now();
for (const tool of tools) {
const formats = tool.isAI
? [aiRepresentativeFormat(tool)]
: [...tool.acceptedInputs]; // clone to avoid mutation
const formats = tool.isAI ? [aiRepresentativeFormat(tool)] : [...tool.acceptedInputs]; // clone to avoid mutation
// Deduplicate aliases (e.g. .jpg and .jpeg resolve to same fixture)
const seenFixtures = new Set<string>();
@@ -514,7 +544,11 @@ async function main() {
}
seenFixtures.add(fixture);
const timeoutMs = tool.isAI ? AI_TIMEOUT_MS : (tool.executionHint === "long" ? LONG_TIMEOUT_MS : FAST_TIMEOUT_MS);
const timeoutMs = tool.isAI
? AI_TIMEOUT_MS
: tool.executionHint === "long"
? LONG_TIMEOUT_MS
: FAST_TIMEOUT_MS;
const settings = defaultSettingsFor(tool.id);
const filename = fixture.split("/").pop()!;
@@ -561,9 +595,13 @@ async function main() {
// ── 4xx: legitimate rejection ────────────────────────
if (statusCode >= 400 && statusCode < 500) {
let body = "";
try { body = await res.text(); } catch {}
try {
body = await res.text();
} catch {}
let parsed: { error?: string; details?: string } = {};
try { parsed = JSON.parse(body); } catch {}
try {
parsed = JSON.parse(body);
} catch {}
const msg = parsed.error || parsed.details || body.slice(0, 200);
// Check if this format is in the tool's own acceptedInputs
@@ -593,7 +631,9 @@ async function main() {
// ── 5xx: server error = BUG ──────────────────────────
if (statusCode >= 500) {
let body = "";
try { body = await res.text(); } catch {}
try {
body = await res.text();
} catch {}
const r: SweepResult = {
tool: tool.id,
format: ext,
@@ -617,7 +657,9 @@ async function main() {
format: ext,
status: 200,
outputOk: isZip && buf.length > 2,
note: isZip ? `pass: ZIP stream (${buf.length} bytes)` : "BUG: ZIP content-type but invalid header",
note: isZip
? `pass: ZIP stream (${buf.length} bytes)`
: "BUG: ZIP content-type but invalid header",
};
results.push(r);
if (isZip && buf.length > 2) {
@@ -635,7 +677,7 @@ async function main() {
if (statusCode === 200 && resContentType === "application/json") {
let json: Record<string, unknown>;
try {
json = await res.json() as Record<string, unknown>;
json = (await res.json()) as Record<string, unknown>;
} catch (e) {
const r: SweepResult = {
tool: tool.id,
@@ -769,7 +811,9 @@ async function main() {
// ── 202: async job ───────────────────────────────────
if (statusCode === 202) {
let json: { jobId?: string; async?: boolean } = {};
try { json = await res.json() as typeof json; } catch {}
try {
json = (await res.json()) as typeof json;
} catch {}
const jobId = json.jobId;
if (!jobId) {
@@ -895,7 +939,9 @@ async function main() {
// ── Unexpected status code ───────────────────────────
let body = "";
try { body = await res.text(); } catch {}
try {
body = await res.text();
} catch {}
const r: SweepResult = {
tool: tool.id,
format: ext,
@@ -907,7 +953,6 @@ async function main() {
bugs.push(r);
bugCount++;
console.log(` [BUG] unexpected status ${statusCode}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const isTimeout = msg.includes("abort") || msg.includes("timeout");
@@ -932,10 +977,7 @@ async function main() {
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(
join(OUT_DIR, "api-sweep-results.json"),
JSON.stringify(results, null, 2),
);
writeFileSync(join(OUT_DIR, "api-sweep-results.json"), JSON.stringify(results, null, 2));
// ── Write findings markdown ───────────────────────────────────
+10 -4
View File
@@ -4,12 +4,18 @@
# clashing with a native dev server on 1349.
# docker compose -f tests/qa/docker-compose.qa.yml up -d
# open http://localhost:13499
name: snapotter-qa
#
# Project/container names default to snapotter-qa. Two sessions on the same host
# running this file verbatim at the same time will silently steal each other's
# container (last `up` wins, no error) since container_name is fixed rather than
# derived from the project name. If you need a second concurrent stack, override:
# QA_PROJECT_NAME=snapotter-qa-2 docker compose -f tests/qa/docker-compose.qa.yml up -d
name: ${QA_PROJECT_NAME:-snapotter-qa}
services:
app:
image: snapotter/snapotter:latest
container_name: snapotter-qa
container_name: ${QA_PROJECT_NAME:-snapotter-qa}
ports:
- "13499:1349"
volumes:
@@ -45,7 +51,7 @@ services:
postgres:
image: postgres:17-alpine
container_name: snapotter-qa-postgres
container_name: ${QA_PROJECT_NAME:-snapotter-qa}-postgres
environment:
POSTGRES_USER: snapotter
POSTGRES_PASSWORD: snapotter
@@ -61,7 +67,7 @@ services:
redis:
image: redis:8-alpine
container_name: snapotter-qa-redis
container_name: ${QA_PROJECT_NAME:-snapotter-qa}-redis
command: ["redis-server", "--maxmemory-policy", "noeviction", "--appendonly", "yes"]
volumes:
- qa-redisdata:/data
+2 -1
View File
@@ -1,7 +1,7 @@
// Generates the master coverage ledger (157 rows, complete by construction) from
// tools-meta.json. Discovery shards update cells; any "pending" cell at the end is
// an explicit, surfaced gap. Run: npx tsx tests/qa/generate-ledger.mts
import { readFileSync, writeFileSync } from "node:fs";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -41,6 +41,7 @@ const tools = meta.map((t) => ({
}));
const out = path.join(dir, "..", "..", "docs", "qa", "coverage-ledger.json");
mkdirSync(path.dirname(out), { recursive: true });
writeFileSync(
out,
`${JSON.stringify(
+52 -24
View File
@@ -198,7 +198,7 @@ test.describe("Image browser-native input preview", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "resize");
await uploadFiles(page, fixture("formats", filename));
await uploadFiles(page, fixture("image", "formats", filename));
await assertImageRendered(page, filename, `input preview ${ext}`);
await assertNoBrokenImages(page);
assertClean(issues, ext);
@@ -249,7 +249,7 @@ test.describe("Image server-decode input preview", () => {
test.setTimeout(90_000);
const issues = instrument(page);
await gotoTool(page, "resize");
await uploadFiles(page, fixture("formats", filename));
await uploadFiles(page, fixture("image", "formats", filename));
const status = await assertServerDecodePreview(page, filename);
expect(
["rendered", "fallback"],
@@ -278,7 +278,7 @@ test.describe("Video native input preview", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "convert-video");
await uploadFiles(page, fixture("media", filename));
await uploadFiles(page, fixture("video", "formats", filename));
await assertVideoPreview(page);
assertClean(issues, ext);
});
@@ -314,7 +314,7 @@ test.describe("Video non-native input preview", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "convert-video");
await uploadFiles(page, fixture("media", filename));
await uploadFiles(page, fixture("video", "formats", filename));
await assertVideoNonNativePreview(page);
assertClean(issues, ext);
});
@@ -342,7 +342,7 @@ test.describe("Audio decodable input preview", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "convert-audio");
await uploadFiles(page, fixture("media", filename));
await uploadFiles(page, fixture("audio", "formats", filename));
await assertAudioPreview(page);
assertClean(issues, ext);
});
@@ -368,7 +368,7 @@ test.describe("Audio undecodable input preview (graceful fallback)", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "convert-audio");
await uploadFiles(page, fixture("media", filename));
await uploadFiles(page, fixture("audio", "formats", filename));
// F8: WaveSurfer error/timeout surfaces a graceful "cannot be previewed"
// message instead of a dead disabled play button.
const fallback = page.getByText(/cannot be previewed in the browser/i).first();
@@ -390,7 +390,7 @@ test.describe("Document PDF input preview", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "rotate-pdf");
await uploadFiles(page, fixture("documents", "tiny.pdf"));
await uploadFiles(page, fixture("document", "formats", "tiny.pdf"));
await assertDocumentPreview(page);
assertClean(issues, "pdf");
});
@@ -416,7 +416,7 @@ test.describe("Document non-PDF input preview (word-to-pdf)", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "word-to-pdf");
await uploadFiles(page, fixture("documents", filename));
await uploadFiles(page, fixture("document", "formats", filename));
await assertNonPdfDocumentPreview(page);
await assertNoBrokenImages(page);
assertClean(issues, ext);
@@ -437,7 +437,7 @@ test.describe("Document non-PDF input preview (excel-to-pdf)", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "excel-to-pdf");
await uploadFiles(page, fixture("documents", filename));
await uploadFiles(page, fixture("document", "formats", filename));
await assertNonPdfDocumentPreview(page);
await assertNoBrokenImages(page);
assertClean(issues, ext);
@@ -458,7 +458,7 @@ test.describe("Document non-PDF input preview (powerpoint-to-pdf)", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "powerpoint-to-pdf");
await uploadFiles(page, fixture("documents", filename));
await uploadFiles(page, fixture("document", "formats", filename));
await assertNonPdfDocumentPreview(page);
await assertNoBrokenImages(page);
assertClean(issues, ext);
@@ -478,7 +478,7 @@ test.describe("Document non-PDF input preview (html-to-pdf)", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "html-to-pdf");
await uploadFiles(page, fixture("documents", filename));
await uploadFiles(page, fixture("document", "formats", filename));
await assertNonPdfDocumentPreview(page);
await assertNoBrokenImages(page);
assertClean(issues, ext);
@@ -498,7 +498,7 @@ test.describe("Document non-PDF input preview (markdown-to-pdf)", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "markdown-to-pdf");
await uploadFiles(page, fixture("documents", filename));
await uploadFiles(page, fixture("document", "formats", filename));
await assertNonPdfDocumentPreview(page);
await assertNoBrokenImages(page);
assertClean(issues, ext);
@@ -512,7 +512,7 @@ test.describe("Document non-PDF input preview (epub-convert)", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "epub-convert");
await uploadFiles(page, fixture("documents", "tiny.epub"));
await uploadFiles(page, fixture("document", "formats", "tiny.epub"));
// epub-convert uses no-comparison mode, not document viewer.
// Just verify file accepted and no crash.
await page.waitForTimeout(3_000);
@@ -538,7 +538,7 @@ test.describe("File/data input preview", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "csv-json");
await uploadFiles(page, fixture("data", filename));
await uploadFiles(page, fixture("data", "valid", filename));
await page.waitForTimeout(2_000);
await assertNoBrokenImages(page);
assertClean(issues, ext);
@@ -550,7 +550,7 @@ test.describe("File/data input preview", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "json-xml");
await uploadFiles(page, fixture("data", "tiny.xml"));
await uploadFiles(page, fixture("data", "valid", "tiny.xml"));
await page.waitForTimeout(2_000);
await assertNoBrokenImages(page);
assertClean(issues, "xml");
@@ -567,7 +567,7 @@ test.describe("File/data input preview", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "yaml-json");
await uploadFiles(page, fixture("data", filename));
await uploadFiles(page, fixture("data", "valid", filename));
await page.waitForTimeout(2_000);
await assertNoBrokenImages(page);
assertClean(issues, ext);
@@ -579,7 +579,7 @@ test.describe("File/data input preview", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "extract-zip");
await uploadFiles(page, fixture("data", "tiny.zip"));
await uploadFiles(page, fixture("data", "valid", "tiny.zip"));
await page.waitForTimeout(2_000);
await assertNoBrokenImages(page);
assertClean(issues, "zip");
@@ -624,7 +624,7 @@ test.describe("Custom custom-results tools input preview", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "image-to-base64");
await uploadFiles(page, fixture("formats", "sample.png"));
await uploadFiles(page, fixture("image", "formats", "sample.png"));
await assertImageRendered(page, "sample.png", "image-to-base64 input");
await assertNoBrokenImages(page);
assertClean(issues, "image-to-base64");
@@ -636,7 +636,7 @@ test.describe("Custom custom-results tools input preview", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "find-duplicates");
await uploadFiles(page, fixture("formats", "sample.png"));
await uploadFiles(page, fixture("image", "formats", "sample.png"));
await assertCustomResultsPreview(page, "find-duplicates");
await assertNoBrokenImages(page);
assertClean(issues, "find-duplicates");
@@ -648,7 +648,7 @@ test.describe("Custom custom-results tools input preview", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "pdf-to-image");
await uploadFiles(page, fixture("documents", "tiny.pdf"));
await uploadFiles(page, fixture("document", "formats", "tiny.pdf"));
// Try document preview first; fall back to custom-results oracle
try {
await assertDocumentPreview(page);
@@ -665,7 +665,21 @@ test.describe("Custom custom-results tools input preview", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "passport-photo");
await uploadFiles(page, fixture("formats", "sample.png"));
// Guard against route rot: a wrong/404 route must fail loudly, not silently skip.
await expect(page.getByRole("heading", { name: "404" })).toHaveCount(0);
// The country dropdown is only present once the tool's real UI has loaded;
// on a container without the background-removal/face-detection bundles
// installed, this page shows an install prompt instead (no dropzone), which
// would otherwise fail uploadFiles() with an unclear timeout.
const ready = await page
.getByText("Country")
.waitFor({ state: "visible", timeout: 15_000 })
.then(() => true)
.catch(() => false);
if (!ready) {
test.skip(true, "background-removal or face-detection feature bundle not installed");
}
await uploadFiles(page, fixture("image", "formats", "sample.png"));
await assertCustomResultsPreview(page, "passport-photo");
await assertNoBrokenImages(page);
assertClean(issues, "passport-photo");
@@ -682,7 +696,7 @@ test.describe("Custom interactive tools input preview", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "crop");
await uploadFiles(page, fixture("formats", "sample.png"));
await uploadFiles(page, fixture("image", "formats", "sample.png"));
await assertInteractivePreview(page, "crop");
await assertNoBrokenImages(page);
assertClean(issues, "crop");
@@ -692,7 +706,21 @@ test.describe("Custom interactive tools input preview", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "erase-object");
await uploadFiles(page, fixture("formats", "sample.png"));
// Guard against route rot: a wrong/404 route must fail loudly, not silently skip.
await expect(page.getByRole("heading", { name: "404" })).toHaveCount(0);
// The submit button is only present once the tool's real UI has loaded; on
// a container without the object-eraser-colorize bundle installed, this
// page shows an install prompt instead (no dropzone), which would
// otherwise fail uploadFiles() with an unclear timeout.
const ready = await page
.getByTestId("erase-object-submit")
.waitFor({ state: "visible", timeout: 15_000 })
.then(() => true)
.catch(() => false);
if (!ready) {
test.skip(true, "object-eraser-colorize feature bundle not installed");
}
await uploadFiles(page, fixture("image", "formats", "sample.png"));
await assertInteractivePreview(page, "erase-object");
await assertNoBrokenImages(page);
assertClean(issues, "erase-object");
@@ -702,7 +730,7 @@ test.describe("Custom interactive tools input preview", () => {
test.setTimeout(60_000);
const issues = instrument(page);
await gotoTool(page, "split");
await uploadFiles(page, fixture("formats", "sample.png"));
await uploadFiles(page, fixture("image", "formats", "sample.png"));
await assertInteractivePreview(page, "split");
await assertNoBrokenImages(page);
assertClean(issues, "split");
+73 -45
View File
@@ -177,8 +177,8 @@ test.describe("A) Multi-input tools", () => {
const issues = instrument(page);
await gotoTool(page, "merge-pdf");
await uploadFiles(page, [
fixture("documents", "tiny.pdf"),
fixture("content", "alt-2page.pdf"),
fixture("document", "formats", "tiny.pdf"),
fixture("document", "valid", "alt-2page.pdf"),
]);
await page.waitForTimeout(1_000);
const res = await processTool(page, "merge-pdf", "fast");
@@ -202,7 +202,10 @@ test.describe("A) Multi-input tools", () => {
// endpoint.
const issues = instrument(page);
await gotoTool(page, "merge-audio");
await uploadFiles(page, [fixture("media", "tiny.mp3"), fixture("media", "tiny.wav")]);
await uploadFiles(page, [
fixture("audio", "formats", "tiny.mp3"),
fixture("audio", "formats", "tiny.wav"),
]);
await page.waitForTimeout(1_000);
// Click submit and watch what happens
@@ -255,7 +258,10 @@ test.describe("A) Multi-input tools", () => {
test("merge-videos: mp4 + mov -> duration ~= sum", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "merge-videos");
await uploadFiles(page, [fixture("media", "tiny.mp4"), fixture("media", "tiny.mov")]);
await uploadFiles(page, [
fixture("video", "formats", "tiny.mp4"),
fixture("video", "formats", "tiny.mov"),
]);
await page.waitForTimeout(1_000);
// merge-videos has executionHint "long" => SSE/async
@@ -278,7 +284,10 @@ test.describe("A) Multi-input tools", () => {
// files are present.
const issues = instrument(page);
await gotoTool(page, "merge-csvs");
await uploadFiles(page, [fixture("data", "tiny-a.csv"), fixture("data", "tiny-b.csv")]);
await uploadFiles(page, [
fixture("data", "valid", "tiny-a.csv"),
fixture("data", "valid", "tiny-b.csv"),
]);
await page.waitForTimeout(1_000);
await clickSubmit(page, "merge-csvs");
@@ -329,7 +338,10 @@ test.describe("A) Multi-input tools", () => {
test("stitch: 2 images -> stitched output with combined dimensions", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "stitch");
await uploadFiles(page, [fixture("formats", "sample.png"), fixture("formats", "sample.jpg")]);
await uploadFiles(page, [
fixture("image", "formats", "sample.png"),
fixture("image", "formats", "sample.jpg"),
]);
await page.waitForTimeout(1_000);
await clickSubmit(page, "stitch");
@@ -340,8 +352,8 @@ test.describe("A) Multi-input tools", () => {
expect(dl.size).toBeGreaterThan(0);
const info = imageInfo(dl.path);
const src1 = imageInfo(fixture("formats", "sample.png"));
const src2 = imageInfo(fixture("formats", "sample.jpg"));
const src1 = imageInfo(fixture("image", "formats", "sample.png"));
const src2 = imageInfo(fixture("image", "formats", "sample.jpg"));
// Default direction is horizontal => width should be roughly sum of inputs
expect(
info.width,
@@ -355,7 +367,10 @@ test.describe("A) Multi-input tools", () => {
const issues = instrument(page);
await gotoTool(page, "collage");
await uploadFiles(page, [fixture("formats", "sample.png"), fixture("formats", "sample.jpg")]);
await uploadFiles(page, [
fixture("image", "formats", "sample.png"),
fixture("image", "formats", "sample.jpg"),
]);
await page.waitForTimeout(1_500);
await clickSubmit(page, "collage");
@@ -373,10 +388,14 @@ test.describe("A) Multi-input tools", () => {
test("compose: base + overlay -> composited image", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "compose");
await uploadFiles(page, fixture("formats", "sample.png"));
await uploadFiles(page, fixture("image", "formats", "sample.png"));
await page.waitForTimeout(500);
await setSecondaryInput(page, "#compose-overlay-image", fixture("formats", "sample.jpg"));
await setSecondaryInput(
page,
"#compose-overlay-image",
fixture("image", "formats", "sample.jpg"),
);
await page.waitForTimeout(500);
await clickSubmit(page, "compose");
@@ -394,13 +413,13 @@ test.describe("A) Multi-input tools", () => {
test("compare: two distinct images -> similarity score + diff image", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "compare");
await uploadFiles(page, fixture("formats", "sample.png"));
await uploadFiles(page, fixture("image", "formats", "sample.png"));
await page.waitForTimeout(500);
await setSecondaryInput(
page,
"#compare-second-image",
fixture("content", "portrait-color.jpg"),
fixture("image", "valid", "portrait-color.jpg"),
);
await page.waitForTimeout(500);
@@ -423,9 +442,9 @@ test.describe("A) Multi-input tools", () => {
const issues = instrument(page);
await gotoTool(page, "find-duplicates");
await uploadFiles(page, [
fixture("content", "portrait-color.jpg"),
fixture("content", "portrait-color-dup.jpg"),
fixture("formats", "sample.png"),
fixture("image", "valid", "portrait-color.jpg"),
fixture("image", "valid", "portrait-color-dup.jpg"),
fixture("image", "formats", "sample.png"),
]);
await page.waitForTimeout(1_500);
@@ -451,9 +470,9 @@ test.describe("A) Multi-input tools", () => {
const issues = instrument(page);
await gotoTool(page, "images-to-video");
await uploadFiles(page, [
fixture("formats", "sample.png"),
fixture("formats", "sample.jpg"),
fixture("formats", "sample.webp"),
fixture("image", "formats", "sample.png"),
fixture("image", "formats", "sample.jpg"),
fixture("image", "formats", "sample.webp"),
]);
await page.waitForTimeout(1_000);
@@ -487,9 +506,9 @@ test.describe("A) Multi-input tools", () => {
await gotoTool(page, "create-zip");
// Use a mix of file types since create-zip should accept all types
const inputFiles = [
fixture("documents", "tiny.pdf"),
fixture("data", "tiny-a.csv"),
fixture("documents", "tiny.txt"),
fixture("document", "formats", "tiny.pdf"),
fixture("data", "valid", "tiny-a.csv"),
fixture("document", "formats", "tiny.txt"),
];
await uploadFiles(page, inputFiles);
await page.waitForTimeout(2_000);
@@ -561,7 +580,10 @@ test.describe("A) Multi-input tools", () => {
test("replace-audio: video + audio -> output video has audio track", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "replace-audio");
await uploadFiles(page, [fixture("media", "tiny.mp4"), fixture("media", "tiny.mp3")]);
await uploadFiles(page, [
fixture("video", "formats", "tiny.mp4"),
fixture("audio", "formats", "tiny.mp3"),
]);
await page.waitForTimeout(1_000);
const res = await processTool(page, "replace-audio", "fast");
@@ -581,7 +603,10 @@ test.describe("A) Multi-input tools", () => {
test("burn-subtitles: video + srt -> output produced", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "burn-subtitles");
await uploadFiles(page, [fixture("media", "tiny.mp4"), fixture("media", "tiny.srt")]);
await uploadFiles(page, [
fixture("video", "formats", "tiny.mp4"),
fixture("video", "formats", "tiny.srt"),
]);
await page.waitForTimeout(1_000);
const res = await processTool(page, "burn-subtitles", "long");
@@ -599,7 +624,10 @@ test.describe("A) Multi-input tools", () => {
test("embed-subtitles: video + srt -> output has subtitle stream", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "embed-subtitles");
await uploadFiles(page, [fixture("media", "tiny.mp4"), fixture("media", "tiny.srt")]);
await uploadFiles(page, [
fixture("video", "formats", "tiny.mp4"),
fixture("video", "formats", "tiny.srt"),
]);
await page.waitForTimeout(1_000);
const res = await processTool(page, "embed-subtitles", "fast");
@@ -626,11 +654,11 @@ test.describe("B) Batch processing", () => {
const issues = instrument(page);
await gotoTool(page, "resize");
const batchFiles = [
fixture("formats", "sample.png"),
fixture("formats", "sample.jpg"),
fixture("formats", "sample.webp"),
fixture("formats", "sample.bmp"),
fixture("formats", "sample.gif"),
fixture("image", "formats", "sample.png"),
fixture("image", "formats", "sample.jpg"),
fixture("image", "formats", "sample.webp"),
fixture("image", "formats", "sample.bmp"),
fixture("image", "formats", "sample.gif"),
];
await uploadFiles(page, batchFiles);
await page.waitForTimeout(1_500);
@@ -657,11 +685,11 @@ test.describe("B) Batch processing", () => {
const issues = instrument(page);
await gotoTool(page, "convert");
const batchFiles = [
fixture("formats", "sample.png"),
fixture("formats", "sample.jpg"),
fixture("formats", "sample.bmp"),
fixture("formats", "sample.gif"),
fixture("formats", "sample.tiff"),
fixture("image", "formats", "sample.png"),
fixture("image", "formats", "sample.jpg"),
fixture("image", "formats", "sample.bmp"),
fixture("image", "formats", "sample.gif"),
fixture("image", "formats", "sample.tiff"),
];
await uploadFiles(page, batchFiles);
await page.waitForTimeout(1_500);
@@ -685,11 +713,11 @@ test.describe("B) Batch processing", () => {
const issues = instrument(page);
await gotoTool(page, "compress");
const batchFiles = [
fixture("formats", "sample.png"),
fixture("formats", "sample.jpg"),
fixture("formats", "sample.webp"),
fixture("formats", "sample.gif"),
fixture("formats", "sample.tiff"),
fixture("image", "formats", "sample.png"),
fixture("image", "formats", "sample.jpg"),
fixture("image", "formats", "sample.webp"),
fixture("image", "formats", "sample.gif"),
fixture("image", "formats", "sample.tiff"),
];
await uploadFiles(page, batchFiles);
await page.waitForTimeout(1_500);
@@ -727,7 +755,7 @@ test.describe("C) Edge cases", () => {
// Upload 11 copies of the same video to exceed the 10-file limit.
const manyFiles: string[] = [];
for (let i = 0; i < 11; i++) {
manyFiles.push(fixture("media", "tiny.mp4"));
manyFiles.push(fixture("video", "formats", "tiny.mp4"));
}
await uploadFiles(page, manyFiles);
await page.waitForTimeout(2_000);
@@ -790,7 +818,7 @@ test.describe("C) Edge cases", () => {
await gotoTool(page, "collage");
// Upload a text file into collage (expects images)
await uploadFiles(page, fixture("documents", "tiny.txt"));
await uploadFiles(page, fixture("document", "formats", "tiny.txt"));
await page.waitForTimeout(1_500);
// Try to submit -- the button may be disabled (no valid images) or server rejects
@@ -825,9 +853,9 @@ test.describe("C) Edge cases", () => {
// Upload the same file twice (same filename) plus a different one.
const dupeFiles = [
fixture("formats", "sample.png"),
fixture("formats", "sample.png"),
fixture("formats", "sample.jpg"),
fixture("image", "formats", "sample.png"),
fixture("image", "formats", "sample.png"),
fixture("image", "formats", "sample.jpg"),
];
await uploadFiles(page, dupeFiles);
await page.waitForTimeout(1_500);
+11 -11
View File
@@ -33,16 +33,16 @@ import {
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PNG = fixture("formats", "sample.png");
const JPG = fixture("formats", "sample.jpg");
const SVG = fixture("formats", "sample.svg");
const GIF = fixture("formats", "sample.gif");
const PDF = fixture("documents", "tiny.pdf");
const HTML = fixture("documents", "tiny.html");
const CSV = fixture("data", "tiny.csv");
const MP4 = fixture("media", "tiny.mp4");
const MP3 = fixture("media", "tiny.mp3");
const WAV = fixture("media", "tiny.wav");
const PNG = fixture("image", "formats", "sample.png");
const JPG = fixture("image", "formats", "sample.jpg");
const SVG = fixture("image", "formats", "sample.svg");
const GIF = fixture("image", "formats", "sample.gif");
const PDF = fixture("document", "formats", "tiny.pdf");
const HTML = fixture("document", "formats", "tiny.html");
const CSV = fixture("data", "valid", "tiny.csv");
const MP4 = fixture("video", "formats", "tiny.mp4");
const MP3 = fixture("audio", "formats", "tiny.mp3");
const WAV = fixture("audio", "formats", "tiny.wav");
// ---------------------------------------------------------------------------
// Helpers for tools with non-standard submit/download flows
@@ -745,7 +745,7 @@ test.describe("4: State-lifecycle flows", () => {
await newFileBtn.click();
await page.waitForTimeout(1_000);
}
const WEBM = fixture("media", "tiny.webm");
const WEBM = fixture("video", "formats", "tiny.webm");
await uploadFiles(page, WEBM);
await assertVideoPreview(page);
await page.locator("#cv-format").selectOption("mp4");
+1 -1
View File
@@ -8,7 +8,7 @@
import { expect, type Page, test } from "@playwright/test";
import { fixture, instrument, isClean, issuesSummary } from "./qa-helpers";
const FIXTURE_PNG = fixture("formats", "sample.png");
const FIXTURE_PNG = fixture("image", "formats", "sample.png");
/**
* Helper: click a tool in the left palette by searching for it and clicking
+13 -13
View File
@@ -59,19 +59,19 @@ function warn(f: Omit<Finding, "severity">) {
}
// ── Fixtures ─────────────────────────────────────────────────────────
const IMG = fixture("test-200x150.png");
const IMG_JPG = fixture("sample-photo.jpg");
const VID = fixture("media", "tiny.mp4");
const AUD = fixture("media", "tiny.mp3");
const AUD_WAV = fixture("media", "tone-stereo.wav");
const AUD_GAP = fixture("media", "tone-gap.wav");
const PDF3 = fixture("test-3page.pdf");
const GIF = fixture("animated.gif");
const CSV = fixture("data", "tiny.csv");
const JSON_F = fixture("data", "tiny.json");
const YAML_F = fixture("data", "tiny.yaml");
const XML_F = fixture("data", "tiny.xml");
const ENCRYPTED_PDF = fixture("documents", "encrypted.pdf");
const IMG = fixture("image", "valid", "test-200x150.png");
const IMG_JPG = fixture("image", "valid", "sample-photo.jpg");
const VID = fixture("video", "formats", "tiny.mp4");
const AUD = fixture("audio", "formats", "tiny.mp3");
const AUD_WAV = fixture("audio", "formats", "tone-stereo.wav");
const AUD_GAP = fixture("audio", "formats", "tone-gap.wav");
const PDF3 = fixture("document", "valid", "test-3page.pdf");
const GIF = fixture("image", "valid", "animated.gif");
const CSV = fixture("data", "valid", "tiny.csv");
const JSON_F = fixture("data", "valid", "tiny.json");
const YAML_F = fixture("data", "valid", "tiny.yaml");
const XML_F = fixture("data", "valid", "tiny.xml");
const ENCRYPTED_PDF = fixture("document", "valid", "encrypted.pdf");
// ── Helpers ──────────────────────────────────────────────────────────
const TOOL_TIMEOUT = 180_000;
+6 -6
View File
@@ -55,12 +55,12 @@ function warn(f: Omit<Finding, "severity">) {
}
// ── Fixtures ─────────────────────────────────────────────────────────
const IMG_200x150 = fixture("test-200x150.png"); // 200x150 PNG
const IMG_JPG = fixture("sample-photo.jpg");
const VID_MP4 = fixture("media", "tiny.mp4"); // 64x64, 1s, 8fps
const AUD_MP3 = fixture("media", "tiny.mp3"); // mono, ~1s
const AUD_STEREO = fixture("media", "tone-stereo.wav"); // stereo, 1s
const PDF_3PAGE = fixture("test-3page.pdf"); // 3 pages
const IMG_200x150 = fixture("image", "valid", "test-200x150.png"); // 200x150 PNG
const IMG_JPG = fixture("image", "valid", "sample-photo.jpg");
const VID_MP4 = fixture("video", "formats", "tiny.mp4"); // 64x64, 1s, 8fps
const AUD_MP3 = fixture("audio", "formats", "tiny.mp3"); // mono, ~1s
const AUD_STEREO = fixture("audio", "formats", "tone-stereo.wav"); // stereo, 1s
const PDF_3PAGE = fixture("document", "valid", "test-3page.pdf"); // 3 pages
// ── Helpers ──────────────────────────────────────────────────────────
const TOOL_TIMEOUT = 120_000;
+5 -5
View File
@@ -24,7 +24,7 @@ import {
test("image: resize round-trip, previews, dimension oracle", async ({ page }) => {
const issues = instrument(page);
await gotoTool(page, "resize");
await uploadFiles(page, fixture("formats", "sample.png"));
await uploadFiles(page, fixture("image", "formats", "sample.png"));
await assertNoBrokenImages(page);
await page.locator("#resize-width").fill("64");
const res = await processTool(page, "resize", "fast");
@@ -39,7 +39,7 @@ test("image: resize round-trip, previews, dimension oracle", async ({ page }) =>
test("image: convert png -> webp, magic-byte oracle", async ({ page }) => {
await gotoTool(page, "convert");
await uploadFiles(page, fixture("formats", "sample.png"));
await uploadFiles(page, fixture("image", "formats", "sample.png"));
await assertNoBrokenImages(page);
await page.locator("#convert-target-format").selectOption("webp");
const res = await processTool(page, "convert", "fast");
@@ -50,18 +50,18 @@ test("image: convert png -> webp, magic-byte oracle", async ({ page }) => {
test("video: input preview decodes (tiny.mp4)", async ({ page }) => {
await gotoTool(page, "convert-video");
await uploadFiles(page, fixture("media", "tiny.mp4"));
await uploadFiles(page, fixture("video", "formats", "tiny.mp4"));
await assertVideoPreview(page);
});
test("audio: input waveform ready (tiny.mp3)", async ({ page }) => {
await gotoTool(page, "convert-audio");
await uploadFiles(page, fixture("media", "tiny.mp3"));
await uploadFiles(page, fixture("audio", "formats", "tiny.mp3"));
await assertAudioPreview(page);
});
test("document: pdf input preview renders (tiny.pdf)", async ({ page }) => {
await gotoTool(page, "rotate-pdf");
await uploadFiles(page, fixture("documents", "tiny.pdf"));
await uploadFiles(page, fixture("document", "formats", "tiny.pdf"));
await assertDocumentPreview(page);
});
+673 -1
View File
@@ -811,7 +811,7 @@
},
{
"id": "ocr",
"name": "OCR / Text Extraction",
"name": "Extract Text from Image (OCR)",
"modality": "image",
"acceptedInputs": [
".jpg",
@@ -4079,6 +4079,14 @@
"executionHint": "fast",
"isAI": false
},
{
"id": "sign-pdf",
"name": "Sign PDF",
"modality": "document",
"acceptedInputs": [".pdf"],
"executionHint": "fast",
"isAI": false
},
{
"id": "pdf-to-text",
"name": "PDF to Text",
@@ -4238,5 +4246,669 @@
"acceptedInputs": [".zip"],
"executionHint": "fast",
"isAI": false
},
{
"id": "jpg-to-png",
"name": "JPG to PNG",
"modality": "image",
"acceptedInputs": [".jpg", ".jpeg"],
"executionHint": "fast",
"isAI": false
},
{
"id": "png-to-jpg",
"name": "PNG to JPG",
"modality": "image",
"acceptedInputs": [".png"],
"executionHint": "fast",
"isAI": false
},
{
"id": "jpg-to-webp",
"name": "JPG to WebP",
"modality": "image",
"acceptedInputs": [".jpg", ".jpeg"],
"executionHint": "fast",
"isAI": false
},
{
"id": "png-to-webp",
"name": "PNG to WebP",
"modality": "image",
"acceptedInputs": [".png"],
"executionHint": "fast",
"isAI": false
},
{
"id": "webp-to-jpg",
"name": "WebP to JPG",
"modality": "image",
"acceptedInputs": [".webp"],
"executionHint": "fast",
"isAI": false
},
{
"id": "webp-to-png",
"name": "WebP to PNG",
"modality": "image",
"acceptedInputs": [".webp"],
"executionHint": "fast",
"isAI": false
},
{
"id": "jpg-to-avif",
"name": "JPG to AVIF",
"modality": "image",
"acceptedInputs": [".jpg", ".jpeg"],
"executionHint": "fast",
"isAI": false
},
{
"id": "png-to-avif",
"name": "PNG to AVIF",
"modality": "image",
"acceptedInputs": [".png"],
"executionHint": "fast",
"isAI": false
},
{
"id": "webp-to-avif",
"name": "WebP to AVIF",
"modality": "image",
"acceptedInputs": [".webp"],
"executionHint": "fast",
"isAI": false
},
{
"id": "heic-to-jpg",
"name": "HEIC to JPG",
"modality": "image",
"acceptedInputs": [".heic", ".heif"],
"executionHint": "fast",
"isAI": false
},
{
"id": "heic-to-png",
"name": "HEIC to PNG",
"modality": "image",
"acceptedInputs": [".heic", ".heif"],
"executionHint": "fast",
"isAI": false
},
{
"id": "heic-to-avif",
"name": "HEIC to AVIF",
"modality": "image",
"acceptedInputs": [".heic", ".heif"],
"executionHint": "fast",
"isAI": false
},
{
"id": "jpg-to-gif",
"name": "JPG to GIF",
"modality": "image",
"acceptedInputs": [".jpg", ".jpeg"],
"executionHint": "fast",
"isAI": false
},
{
"id": "png-to-gif",
"name": "PNG to GIF",
"modality": "image",
"acceptedInputs": [".png"],
"executionHint": "fast",
"isAI": false
},
{
"id": "gif-to-jpg",
"name": "GIF to JPG",
"modality": "image",
"acceptedInputs": [".gif"],
"executionHint": "fast",
"isAI": false
},
{
"id": "gif-to-png",
"name": "GIF to PNG",
"modality": "image",
"acceptedInputs": [".gif"],
"executionHint": "fast",
"isAI": false
},
{
"id": "webp-to-gif",
"name": "WebP to GIF",
"modality": "image",
"acceptedInputs": [".webp"],
"executionHint": "fast",
"isAI": false
},
{
"id": "jpg-to-tiff",
"name": "JPG to TIFF",
"modality": "image",
"acceptedInputs": [".jpg", ".jpeg"],
"executionHint": "fast",
"isAI": false
},
{
"id": "png-to-tiff",
"name": "PNG to TIFF",
"modality": "image",
"acceptedInputs": [".png"],
"executionHint": "fast",
"isAI": false
},
{
"id": "tiff-to-jpg",
"name": "TIFF to JPG",
"modality": "image",
"acceptedInputs": [".tiff", ".tif"],
"executionHint": "fast",
"isAI": false
},
{
"id": "tiff-to-png",
"name": "TIFF to PNG",
"modality": "image",
"acceptedInputs": [".tiff", ".tif"],
"executionHint": "fast",
"isAI": false
},
{
"id": "psd-to-jpg",
"name": "PSD to JPG",
"modality": "image",
"acceptedInputs": [".psd"],
"executionHint": "fast",
"isAI": false
},
{
"id": "psd-to-png",
"name": "PSD to PNG",
"modality": "image",
"acceptedInputs": [".psd"],
"executionHint": "fast",
"isAI": false
},
{
"id": "png-to-eps",
"name": "PNG to EPS",
"modality": "image",
"acceptedInputs": [".png"],
"executionHint": "fast",
"isAI": false
},
{
"id": "jpg-to-eps",
"name": "JPG to EPS",
"modality": "image",
"acceptedInputs": [".jpg", ".jpeg"],
"executionHint": "fast",
"isAI": false
},
{
"id": "eps-to-png",
"name": "EPS to PNG",
"modality": "image",
"acceptedInputs": [".eps"],
"executionHint": "fast",
"isAI": false
},
{
"id": "eps-to-jpg",
"name": "EPS to JPG",
"modality": "image",
"acceptedInputs": [".eps"],
"executionHint": "fast",
"isAI": false
},
{
"id": "png-to-svg",
"name": "PNG to SVG",
"modality": "image",
"acceptedInputs": [".png"],
"executionHint": "fast",
"isAI": false
},
{
"id": "jpg-to-svg",
"name": "JPG to SVG",
"modality": "image",
"acceptedInputs": [".jpg", ".jpeg"],
"executionHint": "fast",
"isAI": false
},
{
"id": "tiff-to-svg",
"name": "TIFF to SVG",
"modality": "image",
"acceptedInputs": [".tiff", ".tif"],
"executionHint": "fast",
"isAI": false
},
{
"id": "psd-to-svg",
"name": "PSD to SVG",
"modality": "image",
"acceptedInputs": [".psd"],
"executionHint": "fast",
"isAI": false
},
{
"id": "eps-to-svg",
"name": "EPS to SVG",
"modality": "image",
"acceptedInputs": [".eps"],
"executionHint": "fast",
"isAI": false
},
{
"id": "svg-to-png",
"name": "SVG to PNG",
"modality": "image",
"acceptedInputs": [".svg", ".svgz"],
"executionHint": "fast",
"isAI": false
},
{
"id": "svg-to-jpg",
"name": "SVG to JPG",
"modality": "image",
"acceptedInputs": [".svg", ".svgz"],
"executionHint": "fast",
"isAI": false
},
{
"id": "jpg-to-pdf",
"name": "JPG to PDF",
"modality": "image",
"acceptedInputs": [".jpg", ".jpeg"],
"executionHint": "fast",
"isAI": false
},
{
"id": "png-to-pdf",
"name": "PNG to PDF",
"modality": "image",
"acceptedInputs": [".png"],
"executionHint": "fast",
"isAI": false
},
{
"id": "heic-to-pdf",
"name": "HEIC to PDF",
"modality": "image",
"acceptedInputs": [".heic", ".heif"],
"executionHint": "fast",
"isAI": false
},
{
"id": "tiff-to-pdf",
"name": "TIFF to PDF",
"modality": "image",
"acceptedInputs": [".tiff", ".tif"],
"executionHint": "fast",
"isAI": false
},
{
"id": "webp-to-pdf",
"name": "WebP to PDF",
"modality": "image",
"acceptedInputs": [".webp"],
"executionHint": "fast",
"isAI": false
},
{
"id": "gif-to-pdf",
"name": "GIF to PDF",
"modality": "image",
"acceptedInputs": [".gif"],
"executionHint": "fast",
"isAI": false
},
{
"id": "eps-to-pdf",
"name": "EPS to PDF",
"modality": "image",
"acceptedInputs": [".eps"],
"executionHint": "fast",
"isAI": false
},
{
"id": "pdf-to-jpg",
"name": "PDF to JPG",
"modality": "document",
"acceptedInputs": [".pdf"],
"executionHint": "fast",
"isAI": false
},
{
"id": "pdf-to-png",
"name": "PDF to PNG",
"modality": "document",
"acceptedInputs": [".pdf"],
"executionHint": "fast",
"isAI": false
},
{
"id": "pdf-to-tiff",
"name": "PDF to TIFF",
"modality": "document",
"acceptedInputs": [".pdf"],
"executionHint": "fast",
"isAI": false
},
{
"id": "mov-to-mp4",
"name": "MOV to MP4",
"modality": "video",
"acceptedInputs": [".mov"],
"executionHint": "long",
"isAI": false
},
{
"id": "webm-to-mp4",
"name": "WEBM to MP4",
"modality": "video",
"acceptedInputs": [".webm"],
"executionHint": "long",
"isAI": false
},
{
"id": "mkv-to-mp4",
"name": "MKV to MP4",
"modality": "video",
"acceptedInputs": [".mkv"],
"executionHint": "long",
"isAI": false
},
{
"id": "avi-to-mp4",
"name": "AVI to MP4",
"modality": "video",
"acceptedInputs": [".avi"],
"executionHint": "long",
"isAI": false
},
{
"id": "mp4-to-mov",
"name": "MP4 to MOV",
"modality": "video",
"acceptedInputs": [".mp4"],
"executionHint": "long",
"isAI": false
},
{
"id": "mp4-to-webm",
"name": "MP4 to WEBM",
"modality": "video",
"acceptedInputs": [".mp4"],
"executionHint": "long",
"isAI": false
},
{
"id": "webm-to-mov",
"name": "WEBM to MOV",
"modality": "video",
"acceptedInputs": [".webm"],
"executionHint": "long",
"isAI": false
},
{
"id": "mkv-to-mov",
"name": "MKV to MOV",
"modality": "video",
"acceptedInputs": [".mkv"],
"executionHint": "long",
"isAI": false
},
{
"id": "avi-to-mov",
"name": "AVI to MOV",
"modality": "video",
"acceptedInputs": [".avi"],
"executionHint": "long",
"isAI": false
},
{
"id": "mp4-to-avi",
"name": "MP4 to AVI",
"modality": "video",
"acceptedInputs": [".mp4"],
"executionHint": "long",
"isAI": false
},
{
"id": "mov-to-avi",
"name": "MOV to AVI",
"modality": "video",
"acceptedInputs": [".mov"],
"executionHint": "long",
"isAI": false
},
{
"id": "mkv-to-avi",
"name": "MKV to AVI",
"modality": "video",
"acceptedInputs": [".mkv"],
"executionHint": "long",
"isAI": false
},
{
"id": "avi-to-mkv",
"name": "AVI to MKV",
"modality": "video",
"acceptedInputs": [".avi"],
"executionHint": "long",
"isAI": false
},
{
"id": "mp4-to-gif",
"name": "MP4 to GIF",
"modality": "video",
"acceptedInputs": [".mp4"],
"executionHint": "long",
"isAI": false
},
{
"id": "mov-to-gif",
"name": "MOV to GIF",
"modality": "video",
"acceptedInputs": [".mov"],
"executionHint": "long",
"isAI": false
},
{
"id": "mkv-to-gif",
"name": "MKV to GIF",
"modality": "video",
"acceptedInputs": [".mkv"],
"executionHint": "long",
"isAI": false
},
{
"id": "avi-to-gif",
"name": "AVI to GIF",
"modality": "video",
"acceptedInputs": [".avi"],
"executionHint": "long",
"isAI": false
},
{
"id": "gif-to-mp4",
"name": "GIF to MP4",
"modality": "video",
"acceptedInputs": [".gif"],
"executionHint": "fast",
"isAI": false
},
{
"id": "gif-to-webm",
"name": "GIF to WEBM",
"modality": "video",
"acceptedInputs": [".gif"],
"executionHint": "fast",
"isAI": false
},
{
"id": "gif-to-mov",
"name": "GIF to MOV",
"modality": "video",
"acceptedInputs": [".gif"],
"executionHint": "fast",
"isAI": false
},
{
"id": "mp4-to-mp3",
"name": "MP4 to MP3",
"modality": "video",
"acceptedInputs": [".mp4"],
"executionHint": "fast",
"isAI": false
},
{
"id": "mov-to-mp3",
"name": "MOV to MP3",
"modality": "video",
"acceptedInputs": [".mov"],
"executionHint": "fast",
"isAI": false
},
{
"id": "mkv-to-mp3",
"name": "MKV to MP3",
"modality": "video",
"acceptedInputs": [".mkv"],
"executionHint": "fast",
"isAI": false
},
{
"id": "webm-to-mp3",
"name": "WEBM to MP3",
"modality": "video",
"acceptedInputs": [".webm"],
"executionHint": "fast",
"isAI": false
},
{
"id": "avi-to-mp3",
"name": "AVI to MP3",
"modality": "video",
"acceptedInputs": [".avi"],
"executionHint": "fast",
"isAI": false
},
{
"id": "mp4-to-wav",
"name": "MP4 to WAV",
"modality": "video",
"acceptedInputs": [".mp4"],
"executionHint": "fast",
"isAI": false
},
{
"id": "mov-to-wav",
"name": "MOV to WAV",
"modality": "video",
"acceptedInputs": [".mov"],
"executionHint": "fast",
"isAI": false
},
{
"id": "mp4-to-ogg",
"name": "MP4 to OGG",
"modality": "video",
"acceptedInputs": [".mp4"],
"executionHint": "fast",
"isAI": false
},
{
"id": "m4a-to-mp3",
"name": "M4A to MP3",
"modality": "audio",
"acceptedInputs": [".m4a"],
"executionHint": "fast",
"isAI": false
},
{
"id": "m4a-to-wav",
"name": "M4A to WAV",
"modality": "audio",
"acceptedInputs": [".m4a"],
"executionHint": "fast",
"isAI": false
},
{
"id": "aac-to-mp3",
"name": "AAC to MP3",
"modality": "audio",
"acceptedInputs": [".aac"],
"executionHint": "fast",
"isAI": false
},
{
"id": "aac-to-wav",
"name": "AAC to WAV",
"modality": "audio",
"acceptedInputs": [".aac"],
"executionHint": "fast",
"isAI": false
},
{
"id": "aac-to-flac",
"name": "AAC to FLAC",
"modality": "audio",
"acceptedInputs": [".aac"],
"executionHint": "fast",
"isAI": false
},
{
"id": "ogg-to-mp3",
"name": "OGG to MP3",
"modality": "audio",
"acceptedInputs": [".ogg"],
"executionHint": "fast",
"isAI": false
},
{
"id": "ogg-to-wav",
"name": "OGG to WAV",
"modality": "audio",
"acceptedInputs": [".ogg"],
"executionHint": "fast",
"isAI": false
},
{
"id": "wav-to-mp3",
"name": "WAV to MP3",
"modality": "audio",
"acceptedInputs": [".wav"],
"executionHint": "fast",
"isAI": false
},
{
"id": "mp3-to-wav",
"name": "MP3 to WAV",
"modality": "audio",
"acceptedInputs": [".mp3"],
"executionHint": "fast",
"isAI": false
},
{
"id": "flac-to-mp3",
"name": "FLAC to MP3",
"modality": "audio",
"acceptedInputs": [".flac"],
"executionHint": "fast",
"isAI": false
},
{
"id": "excel-to-csv",
"name": "Excel to CSV",
"modality": "document",
"acceptedInputs": [".xlsx", ".xls"],
"executionHint": "long",
"isAI": false
}
]
+10 -2
View File
@@ -3,7 +3,7 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { apiToolPath } from "@snapotter/shared";
import { apiToolPath } from "../../packages/shared/src/constants.js";
const BASE = "http://localhost:13499";
@@ -113,7 +113,15 @@ if (r.out) {
try {
probe = execFileSync(
"ffprobe",
["-v", "quiet", "-show_entries", "stream=width,height,pix_fmt", "-of", "json", "/tmp/rembg-out.png"],
[
"-v",
"quiet",
"-show_entries",
"stream=width,height,pix_fmt",
"-of",
"json",
"/tmp/rembg-out.png",
],
{ encoding: "utf8" },
).replace(/\s+/g, " ");
} catch (e) {
+63
View File
@@ -1224,6 +1224,69 @@ describe("bridge - initDispatcher", () => {
// spawn should only have been called once
expect(spawn).toHaveBeenCalledTimes(1);
});
it("does not count an intentional shutdown() as a crash", async () => {
// Regression test: shutdown() kills the child with SIGTERM, which Node
// reports via the "close" event as code=null (not 0). The close handler
// must not mistake that for a crash, or repeated legitimate restarts
// (e.g. shutdownDispatcher() called on every AI bundle install in
// routes/features.ts) would eventually trip MAX_CONSECUTIVE_CRASHES and
// permanently disable the dispatcher with nothing having actually crashed.
const mock = createMockProcess();
vi.mocked(spawn).mockReturnValue(mock.process);
const promise = initDispatcher();
mock.stderr.emit("data", Buffer.from('{"ready": true, "gpu": false}\n'));
await promise;
shutdownDispatcher();
// Node reports a signal-killed process as code=null, signal="SIGTERM".
mock.emitEvent("close", null, "SIGTERM");
expect(getDispatcherStatus().consecutiveCrashes).toBe(0);
});
it("does not count a shutdown()-induced stdin EPIPE as a crash", async () => {
// Regression: shutdown() ends stdin then SIGTERMs the child. On a real
// pipe that teardown can surface as an EPIPE/ERR_STREAM_DESTROYED on the
// stdin stream (observed in the container shutdown log as a spurious
// "[bridge] Dispatcher crash #1"). The "close" handler alone is guarded,
// but the stdin error handler must be too -- otherwise shutdownDispatcher()
// on every AI bundle install accrues false crashes toward the disable cap.
const mock = createMockProcess();
vi.mocked(spawn).mockReturnValue(mock.process);
const promise = initDispatcher();
mock.stderr.emit("data", Buffer.from('{"ready": true, "gpu": false}\n'));
await promise;
shutdownDispatcher();
const epipe = new Error("write EPIPE") as NodeJS.ErrnoException;
epipe.code = "EPIPE";
mock.stdin.emit("error", epipe);
expect(getDispatcherStatus().consecutiveCrashes).toBe(0);
expect(getDispatcherStatus().failed).toBe(false);
});
it("does not count a shutdown()-induced process error as a crash", async () => {
// The proc "error" handler (non-ENOENT) also records crashes; a kill during
// shutdown can emit one (e.g. ESRCH), which must not be counted.
const mock = createMockProcess();
vi.mocked(spawn).mockReturnValue(mock.process);
const promise = initDispatcher();
mock.stderr.emit("data", Buffer.from('{"ready": true, "gpu": false}\n'));
await promise;
shutdownDispatcher();
const err = new Error("kill ESRCH") as NodeJS.ErrnoException;
err.code = "ESRCH";
mock.emitEvent("error", err);
expect(getDispatcherStatus().consecutiveCrashes).toBe(0);
expect(getDispatcherStatus().failed).toBe(false);
});
});
// ── Dispatcher stdin JSON-RPC protocol ──────────────────────────────
@@ -329,6 +329,89 @@ describe("applyCorrections pipeline (CLAHE + normalise + gamma)", () => {
expect(Buffer.compare(lowBuf, highBuf)).not.toBe(0);
});
it("skips CLAHE above MAX_CLAHE_PIXELS (regression: 20MP RAW photo hung 40+s on this step alone)", async () => {
const corrections = {
brightness: 20,
contrast: 20,
temperature: 0,
saturation: 0,
sharpness: 0,
denoise: 0,
};
// A real 5504x3672 (20.2MP) photo triggered this; reuse those dimensions
// as the reported imageSize so the pixel-count gate is exercised without
// needing to decode an actual 20MP buffer in a unit test.
const overCap = applyCorrections(
sharp(PNG_200x150),
corrections,
"auto",
50,
{},
{
width: 5504,
height: 3672,
},
);
const overCapBuf = await overCap.toBuffer();
const claheDisabled = applyCorrections(
sharp(PNG_200x150),
corrections,
"auto",
50,
{ contrast: false },
{ width: 5504, height: 3672 },
);
const claheDisabledBuf = await claheDisabled.toBuffer();
// Skipping CLAHE via the size cap must produce byte-identical output to
// skipping it via the explicit toggle -- proof the cap actually took effect.
expect(Buffer.compare(overCapBuf, claheDisabledBuf)).toBe(0);
});
it("still applies CLAHE at or below MAX_CLAHE_PIXELS", async () => {
const corrections = {
brightness: 20,
contrast: 20,
temperature: 0,
saturation: 0,
sharpness: 0,
denoise: 0,
};
// Use the real buffer's actual 200x150 dimensions (30,000 px, well under
// the 16M cap) -- CLAHE's tile size is derived from imageSize, and Sharp
// rejects a tile window larger than the real underlying image, so a fake
// imageSize far bigger than the actual small test buffer isn't valid here
// (that's exactly what the "above the cap" test above uses instead,
// where CLAHE never actually runs so the mismatch never surfaces).
const underCap = applyCorrections(
sharp(PNG_200x150),
corrections,
"auto",
50,
{},
{
width: 200,
height: 150,
},
);
const underCapBuf = await underCap.toBuffer();
const claheDisabled = applyCorrections(
sharp(PNG_200x150),
corrections,
"auto",
50,
{ contrast: false },
{ width: 200, height: 150 },
);
const claheDisabledBuf = await claheDisabled.toBuffer();
// Under the cap, CLAHE should still run -- output must differ from the
// contrast-disabled baseline.
expect(Buffer.compare(underCapBuf, claheDisabledBuf)).not.toBe(0);
});
});
describe("auto-enhance edge cases", () => {