Files
SnapOtter/tests/qa/verify-ai.mts
T
SnapOtterandGitHub bd1838e40b 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.
2026-07-02 14:21:13 +08:00

150 lines
5.0 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";
import { apiToolPath } from "../../packages/shared/src/constants.js";
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}${apiToolPath(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 IMG = "tests/fixtures/image/valid";
const AUD = "tests/fixtures/audio/valid";
const DOC = "tests/fixtures/document/valid";
const t = await run("transcribe-audio", `${AUD}/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", `${IMG}/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", `${IMG}/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", `${DOC}/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/image/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");