Files
SnapOtter/tests/qa/pipeline-api-test.mts
T
SnapOtter 941ed27912 test: reorganize fixture files into modality-first layout (phase 6b)
Move all fixture files from flat/mixed dirs (content/, media/, documents/,
formats/, hostile/, root loose) into the modality-first hierarchy:
image/{valid,formats,edge,hostile}, video/{valid,formats,hostile},
audio/{valid,formats,hostile}, document/{valid,formats,edge,hostile},
data/valid/, security/. Update index.ts paths, fixtureDir aliases,
all literal refs in 17 e2e/qa/script files, manifest.json, and the
three generator scripts. 163 files moved, 0 dropped, 100 new tests
from expanded document scan.
2026-06-20 05:51:17 +08:00

724 lines
26 KiB
TypeScript

/**
* API-level pipeline test harness.
*
* Runs against the QA container at http://localhost:13499 (auth off).
* Tests pipeline chaining by POSTing to /api/v1/pipeline/execute and
* verifying the final output reflects EVERY step.
*
* Usage:
* cd apps/api && npx tsx ../../tests/qa/pipeline-api-test.mts
*/
import { execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const BASE = process.env.QA_BASE_URL || "http://localhost:13499";
const FIXTURE = join(import.meta.dirname, "..", "fixtures", "image", "formats", "sample.png");
// ── Helpers ─────────────────────────────────────────────────────
interface PipelineResult {
status: number;
body: Record<string, unknown>;
downloadUrl?: string;
}
async function executePipeline(
fixtureFile: string,
steps: Array<{ toolId: string; settings: Record<string, unknown> }>,
): Promise<PipelineResult> {
const fileBuffer = readFileSync(fixtureFile);
const blob = new Blob([fileBuffer], { type: "image/png" });
const form = new FormData();
form.append("file", blob, "sample.png");
form.append("pipeline", JSON.stringify({ steps }));
const res = await fetch(`${BASE}/api/v1/pipeline/execute`, {
method: "POST",
body: form,
});
const body = await res.json() as Record<string, unknown>;
return {
status: res.status,
body,
downloadUrl: typeof body.downloadUrl === "string" ? body.downloadUrl : undefined,
};
}
async function downloadToFile(downloadUrl: string): Promise<string> {
const res = await fetch(`${BASE}${downloadUrl}`);
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
const tmp = mkdtempSync(join(tmpdir(), "pipeline-qa-"));
// Extract filename from URL
const urlFilename = downloadUrl.split("/").pop() || "output";
const outPath = join(tmp, decodeURIComponent(urlFilename));
writeFileSync(outPath, buf);
return outPath;
}
function imageInfo(file: string): { width: number; height: number; codec: string; pixFmt: string } {
const out = execFileSync(
"ffprobe",
["-v", "quiet", "-select_streams", "v:0", "-show_entries", "stream=width,height,codec_name,pix_fmt", "-print_format", "json", file],
{ encoding: "utf8", timeout: 20_000 },
);
const s = (JSON.parse(out).streams ?? [])[0] ?? {};
return { width: s.width ?? 0, height: s.height ?? 0, codec: s.codec_name ?? "", pixFmt: s.pix_fmt ?? "" };
}
function imageSaturation(file: string): number {
const esc = file.replace(/\\/g, "\\\\").replace(/:/g, "\\:").replace(/'/g, "\\'");
const out = execFileSync(
"ffprobe",
["-v", "quiet", "-f", "lavfi", "-i", `movie=${esc},signalstats`, "-show_entries", "frame_tags=lavfi.signalstats.SATAVG", "-read_intervals", "%+#1", "-print_format", "json"],
{ encoding: "utf8", timeout: 20_000 },
);
const tags = (JSON.parse(out).frames ?? [])[0]?.tags ?? {};
return Number(tags["lavfi.signalstats.SATAVG"] ?? 0);
}
function magicIsWebp(buf: Buffer): boolean {
return buf.subarray(0, 4).toString("latin1") === "RIFF" && buf.subarray(8, 12).toString("latin1") === "WEBP";
}
// ── Test results ────────────────────────────────────────────────
interface Finding {
chain: string;
severity: "BUG" | "WARN" | "PASS";
expected: string;
actual: string;
detail?: string;
}
const findings: Finding[] = [];
function report(f: Finding) {
findings.push(f);
const icon = f.severity === "BUG" ? "BUG" : f.severity === "WARN" ? "WARN" : "OK";
console.log(`[${icon}] ${f.chain}: ${f.actual}`);
}
// ── Chain 1: resize(100) -> adjust-colors(grayscale) -> convert(webp) ──
async function testChain1() {
const chain = "resize(width=100) -> adjust-colors(effect=grayscale) -> convert(webp)";
console.log(`\nTesting: ${chain}`);
let result: PipelineResult;
try {
result = await executePipeline(FIXTURE, [
{ toolId: "resize", settings: { width: 100 } },
{ toolId: "adjust-colors", settings: { effect: "grayscale" } },
{ toolId: "convert", settings: { format: "webp" } },
]);
} catch (err) {
report({ chain, severity: "BUG", expected: "200 OK", actual: `Request failed: ${err}` });
return;
}
if (result.status !== 200) {
report({ chain, severity: "BUG", expected: "200 OK", actual: `HTTP ${result.status}: ${JSON.stringify(result.body)}` });
return;
}
if (!result.downloadUrl) {
report({ chain, severity: "BUG", expected: "downloadUrl present", actual: "No downloadUrl in response" });
return;
}
// Verify stepsCompleted
const stepsCompleted = result.body.stepsCompleted;
if (stepsCompleted !== 3) {
report({ chain, severity: "BUG", expected: "stepsCompleted=3", actual: `stepsCompleted=${stepsCompleted}` });
}
let filePath: string;
try {
filePath = await downloadToFile(result.downloadUrl);
} catch (err) {
report({ chain, severity: "BUG", expected: "Download succeeds", actual: `Download failed: ${err}` });
return;
}
// Check 1: Width must be 100
const info = imageInfo(filePath);
if (info.width !== 100) {
report({ chain, severity: "BUG", expected: "width=100 (resize applied)", actual: `width=${info.width}` });
} else {
report({ chain, severity: "PASS", expected: "width=100", actual: `width=${info.width}` });
}
// Check 2: Grayscale -- saturation must be near 0
const sat = imageSaturation(filePath);
if (sat > 5) {
report({ chain, severity: "BUG", expected: "saturation ~0 (grayscale applied)", actual: `saturation=${sat}`, detail: "The adjust-colors grayscale step was likely dropped -- output still has color" });
} else {
report({ chain, severity: "PASS", expected: "saturation ~0", actual: `saturation=${sat}` });
}
// Check 3: WebP magic bytes
const buf = readFileSync(filePath);
if (!magicIsWebp(buf)) {
report({ chain, severity: "BUG", expected: "WebP magic bytes (convert applied)", actual: `First bytes: ${buf.subarray(0, 12).toString("hex")}`, detail: "The convert step was likely dropped -- output is not WebP" });
} else {
report({ chain, severity: "PASS", expected: "WebP format", actual: "WebP magic confirmed" });
}
// Cleanup
try { rmSync(join(filePath, ".."), { recursive: true, force: true }); } catch {}
}
// ── Chain 2: crop -> border -> watermark-text ──────────────────
async function testChain2() {
const chain = "crop(50x50+10+10) -> border(20px) -> watermark-text('TEST')";
console.log(`\nTesting: ${chain}`);
let result: PipelineResult;
try {
result = await executePipeline(FIXTURE, [
{ toolId: "crop", settings: { left: 10, top: 10, width: 200, height: 150 } },
{ toolId: "border", settings: { borderWidth: 20, borderColor: "#FF0000" } },
{ toolId: "watermark-text", settings: { text: "TEST", fontSize: 24, opacity: 80 } },
]);
} catch (err) {
report({ chain, severity: "BUG", expected: "200 OK", actual: `Request failed: ${err}` });
return;
}
if (result.status !== 200) {
report({ chain, severity: "BUG", expected: "200 OK", actual: `HTTP ${result.status}: ${JSON.stringify(result.body)}` });
return;
}
if (!result.downloadUrl) {
report({ chain, severity: "BUG", expected: "downloadUrl present", actual: "No downloadUrl in response" });
return;
}
let filePath: string;
try {
filePath = await downloadToFile(result.downloadUrl);
} catch (err) {
report({ chain, severity: "BUG", expected: "Download succeeds", actual: `Download failed: ${err}` });
return;
}
const info = imageInfo(filePath);
// After crop(200x150), border adds 20px on each side: 200+40=240 wide, 150+40=190 tall
const expectedW = 200 + 40;
const expectedH = 150 + 40;
if (info.width !== expectedW) {
report({
chain,
severity: "BUG",
expected: `width=${expectedW} (crop 200 + border 2*20)`,
actual: `width=${info.width}`,
detail: info.width === 200 ? "Border step was likely dropped" : info.width === 640 ? "Crop step was likely dropped" : "Unexpected dimension",
});
} else {
report({ chain, severity: "PASS", expected: `width=${expectedW}`, actual: `width=${info.width}` });
}
if (info.height !== expectedH) {
report({
chain,
severity: "BUG",
expected: `height=${expectedH} (crop 150 + border 2*20)`,
actual: `height=${info.height}`,
});
} else {
report({ chain, severity: "PASS", expected: `height=${expectedH}`, actual: `height=${info.height}` });
}
// File size must be > 0 (watermark applied -- hard to verify pixel content via ffprobe,
// but at minimum the pipeline should complete all 3 steps)
const stepsCompleted = result.body.stepsCompleted;
if (stepsCompleted !== 3) {
report({ chain, severity: "BUG", expected: "stepsCompleted=3", actual: `stepsCompleted=${stepsCompleted}` });
} else {
report({ chain, severity: "PASS", expected: "3 steps completed", actual: `stepsCompleted=${stepsCompleted}` });
}
try { rmSync(join(filePath, ".."), { recursive: true, force: true }); } catch {}
}
// ── Chain 3: rotate(90) -> resize(width=50) ────────────────────
async function testChain3() {
const chain = "rotate(90) -> resize(width=50)";
console.log(`\nTesting: ${chain}`);
// Original: 640x426. After 90-degree rotation: 426x640. After resize width=50: 50x(50*640/426)=50x75ish
let result: PipelineResult;
try {
result = await executePipeline(FIXTURE, [
{ toolId: "rotate", settings: { angle: 90 } },
{ toolId: "resize", settings: { width: 50 } },
]);
} catch (err) {
report({ chain, severity: "BUG", expected: "200 OK", actual: `Request failed: ${err}` });
return;
}
if (result.status !== 200) {
report({ chain, severity: "BUG", expected: "200 OK", actual: `HTTP ${result.status}: ${JSON.stringify(result.body)}` });
return;
}
if (!result.downloadUrl) {
report({ chain, severity: "BUG", expected: "downloadUrl present", actual: "No downloadUrl in response" });
return;
}
let filePath: string;
try {
filePath = await downloadToFile(result.downloadUrl);
} catch (err) {
report({ chain, severity: "BUG", expected: "Download succeeds", actual: `Download failed: ${err}` });
return;
}
const info = imageInfo(filePath);
// Width must be 50 (resize applied)
if (info.width !== 50) {
report({ chain, severity: "BUG", expected: "width=50 (resize applied)", actual: `width=${info.width}` });
} else {
report({ chain, severity: "PASS", expected: "width=50", actual: `width=${info.width}` });
}
// Height should reflect aspect ratio after 90-degree rotation.
// Original 640x426 rotated 90 = 426x640. Resize width=50 with contain => height = round(50 * 640/426) = 75
// Allow +/-1 for rounding
const expectedH = Math.round(50 * 640 / 426);
if (Math.abs(info.height - expectedH) > 1) {
report({
chain,
severity: "BUG",
expected: `height ~${expectedH} (rotation then resize)`,
actual: `height=${info.height}`,
detail: info.height === Math.round(50 * 426 / 640) ? "Rotation step was likely dropped -- aspect ratio matches un-rotated image" : "Unexpected height",
});
} else {
report({ chain, severity: "PASS", expected: `height ~${expectedH}`, actual: `height=${info.height}` });
}
try { rmSync(join(filePath, ".."), { recursive: true, force: true }); } catch {}
}
// ── Chain 4: Edge case -- incompatible step formats ──
async function testChain4_incompatibleFormat() {
const chain = "convert(gif) -> [video tool] (format incompatibility edge case)";
console.log(`\nTesting: ${chain}`);
// This tests an image tool followed by a tool that expects a different modality.
// The pipeline should either error cleanly or handle it gracefully.
// We'll try convert(format=gif) -> trim-video (a video-only tool).
// This should fail with a validation error, not crash.
let result: PipelineResult;
try {
result = await executePipeline(FIXTURE, [
{ toolId: "convert", settings: { format: "gif" } },
{ toolId: "trim-video", settings: { start: 0, end: 5 } },
]);
} catch (err) {
report({ chain, severity: "BUG", expected: "Error response (not a crash)", actual: `Request crashed: ${err}` });
return;
}
// We expect an error response (400 or 422), not a 200 or a 500
if (result.status >= 500) {
report({
chain,
severity: "BUG",
expected: "Clean error (4xx) for incompatible tool",
actual: `HTTP ${result.status}: server error (crash)`,
detail: JSON.stringify(result.body),
});
} else if (result.status >= 400 && result.status < 500) {
report({
chain,
severity: "PASS",
expected: "Clean error for incompatible tool",
actual: `HTTP ${result.status}: ${JSON.stringify(result.body).slice(0, 200)}`,
});
} else if (result.status === 200) {
// If it somehow succeeds, check whether it silently skipped the video step
report({
chain,
severity: "WARN",
expected: "Error for incompatible tool",
actual: `HTTP 200 -- pipeline somehow completed. Check if video step was silently skipped.`,
detail: JSON.stringify(result.body).slice(0, 300),
});
} else {
report({
chain,
severity: "PASS",
expected: "Non-200 response",
actual: `HTTP ${result.status}`,
});
}
}
// ── Chain 5: Single step pipeline (sanity) ─────────────────
async function testChain5_singleStep() {
const chain = "resize(width=200) [single-step sanity]";
console.log(`\nTesting: ${chain}`);
let result: PipelineResult;
try {
result = await executePipeline(FIXTURE, [
{ toolId: "resize", settings: { width: 200 } },
]);
} catch (err) {
report({ chain, severity: "BUG", expected: "200 OK", actual: `Request failed: ${err}` });
return;
}
if (result.status !== 200) {
report({ chain, severity: "BUG", expected: "200 OK", actual: `HTTP ${result.status}: ${JSON.stringify(result.body)}` });
return;
}
if (!result.downloadUrl) {
report({ chain, severity: "BUG", expected: "downloadUrl present", actual: "No downloadUrl" });
return;
}
let filePath: string;
try {
filePath = await downloadToFile(result.downloadUrl);
} catch (err) {
report({ chain, severity: "BUG", expected: "Download succeeds", actual: `Download failed: ${err}` });
return;
}
const info = imageInfo(filePath);
if (info.width !== 200) {
report({ chain, severity: "BUG", expected: "width=200", actual: `width=${info.width}` });
} else {
report({ chain, severity: "PASS", expected: "width=200", actual: `width=${info.width}` });
}
try { rmSync(join(filePath, ".."), { recursive: true, force: true }); } catch {}
}
// ── Chain 6: Empty pipeline ──────────────────────────────────
async function testChain6_emptyPipeline() {
const chain = "[] (empty pipeline)";
console.log(`\nTesting: ${chain}`);
let result: PipelineResult;
try {
result = await executePipeline(FIXTURE, []);
} catch (err) {
report({ chain, severity: "BUG", expected: "400 Bad Request", actual: `Request crashed: ${err}` });
return;
}
if (result.status === 400) {
report({ chain, severity: "PASS", expected: "400 for empty pipeline", actual: `HTTP ${result.status}` });
} else if (result.status >= 500) {
report({ chain, severity: "BUG", expected: "400 (not 5xx)", actual: `HTTP ${result.status}: ${JSON.stringify(result.body)}` });
} else {
report({ chain, severity: "PASS", expected: "Error for empty pipeline", actual: `HTTP ${result.status}` });
}
}
// ── Chain 7: Invalid tool in pipeline ──────────────────────────
async function testChain7_invalidTool() {
const chain = "resize(100) -> nonexistent-tool -> convert(webp)";
console.log(`\nTesting: ${chain}`);
let result: PipelineResult;
try {
result = await executePipeline(FIXTURE, [
{ toolId: "resize", settings: { width: 100 } },
{ toolId: "nonexistent-tool-12345", settings: {} },
{ toolId: "convert", settings: { format: "webp" } },
]);
} catch (err) {
report({ chain, severity: "BUG", expected: "400 Bad Request", actual: `Request crashed: ${err}` });
return;
}
if (result.status === 400) {
report({ chain, severity: "PASS", expected: "400 for invalid tool", actual: `HTTP ${result.status}: ${JSON.stringify(result.body).slice(0, 200)}` });
} else if (result.status >= 500) {
report({ chain, severity: "BUG", expected: "400 (not 5xx)", actual: `HTTP ${result.status}: ${JSON.stringify(result.body)}` });
} else {
report({ chain, severity: "WARN", expected: "400 for invalid tool", actual: `HTTP ${result.status}` });
}
}
// ── Chain 8: resize -> adjust-colors(saturation=-100) -> convert(webp) ──
// Alternative grayscale via saturation instead of effect
async function testChain8_saturationGrayscale() {
const chain = "resize(width=100) -> adjust-colors(saturation=-100) -> convert(webp)";
console.log(`\nTesting: ${chain}`);
let result: PipelineResult;
try {
result = await executePipeline(FIXTURE, [
{ toolId: "resize", settings: { width: 100 } },
{ toolId: "adjust-colors", settings: { saturation: -100 } },
{ toolId: "convert", settings: { format: "webp" } },
]);
} catch (err) {
report({ chain, severity: "BUG", expected: "200 OK", actual: `Request failed: ${err}` });
return;
}
if (result.status !== 200) {
report({ chain, severity: "BUG", expected: "200 OK", actual: `HTTP ${result.status}: ${JSON.stringify(result.body)}` });
return;
}
if (!result.downloadUrl) {
report({ chain, severity: "BUG", expected: "downloadUrl present", actual: "No downloadUrl" });
return;
}
let filePath: string;
try {
filePath = await downloadToFile(result.downloadUrl);
} catch (err) {
report({ chain, severity: "BUG", expected: "Download succeeds", actual: `Download failed: ${err}` });
return;
}
const info = imageInfo(filePath);
if (info.width !== 100) {
report({ chain, severity: "BUG", expected: "width=100", actual: `width=${info.width}` });
} else {
report({ chain, severity: "PASS", expected: "width=100", actual: `width=${info.width}` });
}
const sat = imageSaturation(filePath);
if (sat > 5) {
report({ chain, severity: "BUG", expected: "saturation ~0 (desaturated)", actual: `saturation=${sat}`, detail: "saturation=-100 step was likely dropped" });
} else {
report({ chain, severity: "PASS", expected: "saturation ~0", actual: `saturation=${sat}` });
}
const buf = readFileSync(filePath);
if (!magicIsWebp(buf)) {
report({ chain, severity: "BUG", expected: "WebP format", actual: `Not WebP: ${buf.subarray(0, 12).toString("hex")}` });
} else {
report({ chain, severity: "PASS", expected: "WebP format", actual: "WebP confirmed" });
}
try { rmSync(join(filePath, ".."), { recursive: true, force: true }); } catch {}
}
// ── Chain 9: Save, list, load, delete pipeline ──────────────────
async function testPipelineCRUD() {
const chain = "Pipeline CRUD (save/list/delete)";
console.log(`\nTesting: ${chain}`);
// Save
const saveRes = await fetch(`${BASE}/api/v1/pipeline/save`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "QA Test Pipeline",
description: "Automated QA test",
steps: [
{ toolId: "resize", settings: { width: 100 } },
{ toolId: "convert", settings: { format: "webp" } },
],
}),
});
if (saveRes.status === 201) {
report({ chain, severity: "PASS", expected: "Save returns 201", actual: `HTTP ${saveRes.status}` });
} else {
report({ chain, severity: "BUG", expected: "Save returns 201", actual: `HTTP ${saveRes.status}: ${await saveRes.text()}` });
return;
}
const saved = await saveRes.json() as { id: string };
// List
const listRes = await fetch(`${BASE}/api/v1/pipeline/list`);
if (listRes.ok) {
const data = await listRes.json() as { pipelines: Array<{ id: string; name: string }> };
const found = data.pipelines.find((p) => p.id === saved.id);
if (found) {
report({ chain, severity: "PASS", expected: "Saved pipeline in list", actual: `Found "${found.name}"` });
} else {
report({ chain, severity: "BUG", expected: "Saved pipeline in list", actual: "Not found in list" });
}
} else {
report({ chain, severity: "BUG", expected: "List returns 200", actual: `HTTP ${listRes.status}` });
}
// Delete
const delRes = await fetch(`${BASE}/api/v1/pipeline/${saved.id}`, { method: "DELETE" });
if (delRes.ok) {
report({ chain, severity: "PASS", expected: "Delete returns 200", actual: `HTTP ${delRes.status}` });
} else {
report({ chain, severity: "BUG", expected: "Delete returns 200", actual: `HTTP ${delRes.status}` });
}
// Verify deletion
const listRes2 = await fetch(`${BASE}/api/v1/pipeline/list`);
if (listRes2.ok) {
const data2 = await listRes2.json() as { pipelines: Array<{ id: string }> };
const still = data2.pipelines.find((p) => p.id === saved.id);
if (!still) {
report({ chain, severity: "PASS", expected: "Pipeline removed after delete", actual: "Confirmed removed" });
} else {
report({ chain, severity: "BUG", expected: "Pipeline removed after delete", actual: "Still present in list" });
}
}
}
// ── Chain 10: Batch pipeline ────────────────────────────────────
async function testBatchPipeline() {
const chain = "Batch pipeline (2 files, resize+convert)";
console.log(`\nTesting: ${chain}`);
const fileBuffer = readFileSync(FIXTURE);
const blob1 = new Blob([fileBuffer], { type: "image/png" });
const blob2 = new Blob([fileBuffer], { type: "image/png" });
const form = new FormData();
form.append("file", blob1, "img1.png");
form.append("file", blob2, "img2.png");
form.append("pipeline", JSON.stringify({
steps: [
{ toolId: "resize", settings: { width: 80 } },
{ toolId: "convert", settings: { format: "webp" } },
],
}));
const res = await fetch(`${BASE}/api/v1/pipeline/batch`, {
method: "POST",
body: form,
});
if (res.status !== 200) {
const text = await res.text();
report({ chain, severity: "BUG", expected: "200 OK (ZIP)", actual: `HTTP ${res.status}: ${text.slice(0, 300)}` });
return;
}
const contentType = res.headers.get("content-type") || "";
if (!contentType.includes("zip")) {
report({ chain, severity: "BUG", expected: "Content-Type: application/zip", actual: `Content-Type: ${contentType}` });
} else {
report({ chain, severity: "PASS", expected: "ZIP response", actual: `Content-Type: ${contentType}` });
}
const zipBuf = Buffer.from(await res.arrayBuffer());
if (zipBuf.length < 100) {
report({ chain, severity: "BUG", expected: "Non-trivial ZIP size", actual: `ZIP size: ${zipBuf.length} bytes` });
} else {
report({ chain, severity: "PASS", expected: "ZIP with content", actual: `ZIP size: ${zipBuf.length} bytes` });
}
// Check X-File-Results header
const xFileResults = res.headers.get("x-file-results");
if (xFileResults) {
try {
const decoded = JSON.parse(decodeURIComponent(xFileResults));
const keys = Object.keys(decoded);
if (keys.length === 2) {
report({ chain, severity: "PASS", expected: "2 results in header", actual: `${keys.length} results: ${JSON.stringify(decoded)}` });
} else {
report({ chain, severity: "BUG", expected: "2 results in X-File-Results", actual: `${keys.length} results` });
}
} catch {
report({ chain, severity: "WARN", expected: "Valid X-File-Results header", actual: `Could not parse: ${xFileResults}` });
}
} else {
report({ chain, severity: "BUG", expected: "X-File-Results header present", actual: "Header missing" });
}
}
// ── Run all tests ──────────────────────────────────────────────
async function main() {
console.log("=== SnapOtter Pipeline API Tests ===");
console.log(`Target: ${BASE}`);
console.log(`Fixture: ${FIXTURE}`);
// Verify connectivity
try {
const health = await fetch(`${BASE}/api/v1/health`);
if (!health.ok) throw new Error(`Health check failed: ${health.status}`);
console.log("Container healthy.\n");
} catch (err) {
console.error(`Cannot reach ${BASE}: ${err}`);
process.exit(1);
}
await testChain5_singleStep();
await testChain1();
await testChain8_saturationGrayscale();
await testChain2();
await testChain3();
await testChain4_incompatibleFormat();
await testChain6_emptyPipeline();
await testChain7_invalidTool();
await testPipelineCRUD();
await testBatchPipeline();
console.log("\n=== Summary ===");
const bugs = findings.filter((f) => f.severity === "BUG");
const warns = findings.filter((f) => f.severity === "WARN");
const passes = findings.filter((f) => f.severity === "PASS");
console.log(`PASS: ${passes.length} | WARN: ${warns.length} | BUG: ${bugs.length}`);
if (bugs.length > 0) {
console.log("\n--- BUGS ---");
for (const b of bugs) {
console.log(` [BUG] ${b.chain}`);
console.log(` Expected: ${b.expected}`);
console.log(` Actual: ${b.actual}`);
if (b.detail) console.log(` Detail: ${b.detail}`);
}
}
if (warns.length > 0) {
console.log("\n--- WARNINGS ---");
for (const w of warns) {
console.log(` [WARN] ${w.chain}`);
console.log(` Expected: ${w.expected}`);
console.log(` Actual: ${w.actual}`);
if (w.detail) console.log(` Detail: ${w.detail}`);
}
}
// Write JSON results
const resultsPath = join(import.meta.dirname, "..", "..", "docs", "qa", "pipeline-api-results.json");
writeFileSync(resultsPath, JSON.stringify(findings, null, 2));
console.log(`\nResults written to: ${resultsPath}`);
// Exit with error if bugs found
if (bugs.length > 0) process.exit(1);
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(2);
});