mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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 ───────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user