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
+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 ───────────────────────────────────