mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Exhaustive QA sweep of all 157 tools. Fixes: CSP blob media, csv-excel ExcelJS interop, ocr-pdf segfault, chart-maker upload, non-PDF doc preview, RAW decode, merge-tool multi-file path, html-to-image chromium, ogv/wma/amr/ac3 preview fallbacks, meme/gif/stabilize codecs, nav+home a11y. Plus orphan-format and test-debt cleanup, the AI bundle build script, and a reusable Playwright QA harness under tests/qa/.
139 lines
4.8 KiB
TypeScript
139 lines
4.8 KiB
TypeScript
// Verifies the installed AI models produce CORRECT output (not just run), using
|
|
// real-content fixtures. Run: ./apps/api/node_modules/.bin/tsx tests/qa/verify-ai.mts
|
|
import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const BASE = "http://localhost:13499";
|
|
|
|
async function pollSSE(jobId: string, timeoutMs = 240_000): Promise<Record<string, unknown>> {
|
|
const res = await fetch(`${BASE}/api/v1/jobs/${jobId}/progress`);
|
|
if (!res.body) return { error: "no body" };
|
|
const reader = res.body.getReader();
|
|
const dec = new TextDecoder();
|
|
let buf = "";
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
buf += dec.decode(value, { stream: true });
|
|
let idx: number;
|
|
// biome-ignore lint/suspicious/noAssignInExpressions: stream parse
|
|
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
const line = buf.slice(0, idx).trim();
|
|
buf = buf.slice(idx + 1);
|
|
if (!line.startsWith("data:")) continue;
|
|
try {
|
|
const d = JSON.parse(line.slice(5).trim());
|
|
if (d.phase === "complete" || d.status === "completed") {
|
|
await reader.cancel();
|
|
return d;
|
|
}
|
|
if (d.phase === "failed" || d.status === "failed") {
|
|
await reader.cancel();
|
|
return { failed: true, ...d };
|
|
}
|
|
} catch {
|
|
// partial line
|
|
}
|
|
}
|
|
}
|
|
await reader.cancel();
|
|
return { timeout: true };
|
|
}
|
|
|
|
async function fetchOutput(jobId: string): Promise<Buffer | null> {
|
|
try {
|
|
const meta = await fetch(`${BASE}/api/v1/download/${jobId}/output-meta.json`);
|
|
if (meta.ok) {
|
|
const m = (await meta.json()) as { filename?: string };
|
|
if (m.filename) {
|
|
const dl = await fetch(`${BASE}/api/v1/download/${jobId}/${m.filename}`);
|
|
if (dl.ok) return Buffer.from(await dl.arrayBuffer());
|
|
}
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function run(toolId: string, file: string, settings: Record<string, unknown>) {
|
|
const buf = fs.readFileSync(file);
|
|
const fd = new FormData();
|
|
fd.append("file", new Blob([buf]), path.basename(file));
|
|
fd.append("settings", JSON.stringify(settings));
|
|
const res = await fetch(`${BASE}/api/v1/tools/${toolId}`, { method: "POST", body: fd });
|
|
if (res.status === 200) {
|
|
const j = (await res.json()) as { downloadUrl?: string };
|
|
if (j.downloadUrl) {
|
|
const dl = await fetch(`${BASE}${j.downloadUrl}`);
|
|
return { mode: "sync", out: Buffer.from(await dl.arrayBuffer()) };
|
|
}
|
|
return { mode: "sync", j };
|
|
}
|
|
if (res.status === 202) {
|
|
const j = (await res.json()) as { jobId: string };
|
|
const done = await pollSSE(j.jobId);
|
|
if (done.failed || done.timeout) return { mode: "async", failed: done };
|
|
const dlUrl =
|
|
(done.downloadUrl as string | undefined) ??
|
|
(done.result as { downloadUrl?: string } | undefined)?.downloadUrl;
|
|
if (dlUrl) {
|
|
const dl = await fetch(`${BASE}${dlUrl}`);
|
|
if (dl.ok) return { mode: "async", out: Buffer.from(await dl.arrayBuffer()), done };
|
|
}
|
|
const out = await fetchOutput(j.jobId);
|
|
return { mode: "async", out, done };
|
|
}
|
|
return { status: res.status, body: (await res.text()).slice(0, 200) };
|
|
}
|
|
|
|
const C = "tests/fixtures/content";
|
|
|
|
const t = await run("transcribe-audio", `${C}/speech-10s.wav`, { outputFormat: "txt" });
|
|
console.log(
|
|
"TRANSCRIBE:",
|
|
t.out ? `text="${t.out.toString("utf8").slice(0, 200)}"` : JSON.stringify(t).slice(0, 300),
|
|
);
|
|
|
|
const o = await run("ocr", `${C}/ocr-clean.png`, {});
|
|
console.log(
|
|
"OCR:",
|
|
o.out ? `text="${o.out.toString("utf8").slice(0, 200)}"` : JSON.stringify(o).slice(0, 300),
|
|
);
|
|
|
|
const r = await run("remove-background", `${C}/portrait-color.jpg`, { outputFormat: "png" });
|
|
if (r.out) {
|
|
fs.writeFileSync("/tmp/rembg-out.png", r.out);
|
|
let probe = "";
|
|
try {
|
|
probe = execFileSync(
|
|
"ffprobe",
|
|
["-v", "quiet", "-show_entries", "stream=width,height,pix_fmt", "-of", "json", "/tmp/rembg-out.png"],
|
|
{ encoding: "utf8" },
|
|
).replace(/\s+/g, " ");
|
|
} catch (e) {
|
|
probe = `ffprobe-failed: ${String(e).slice(0, 80)}`;
|
|
}
|
|
console.log(`REMOVE-BG: png bytes=${r.out.length} ${probe}`);
|
|
} else {
|
|
console.log("REMOVE-BG:", JSON.stringify(r).slice(0, 300));
|
|
}
|
|
|
|
// F9: ocr-pdf must not segfault and should return text
|
|
const op = await run("ocr-pdf", `${C}/ocr-scanned.pdf`, {});
|
|
console.log(
|
|
"OCR-PDF:",
|
|
op.out ? `out="${op.out.toString("utf8").slice(0, 160)}"` : JSON.stringify(op).slice(0, 300),
|
|
);
|
|
|
|
// F11: RAW processing must work (resize a .cr2 from the formats fixtures)
|
|
const raw = await run("resize", "tests/fixtures/formats/sample.cr2", { width: 80 });
|
|
console.log(
|
|
"RAW-RESIZE(cr2):",
|
|
raw.out ? `bytes=${raw.out.length}` : JSON.stringify(raw).slice(0, 300),
|
|
);
|
|
|
|
console.log("DONE");
|