mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
A release-readiness QA pass over the whole product. The commits split into defects a user would hit and gates that were reporting green while measuring nothing. ## Fixes that change behaviour Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so request.ip came from a client-set header and a forged X-Forwarded-For got past the login limiter. The default is now a private-network trust list. A transient Postgres outage stranded in-flight jobs, leaving finished output on disk with no row pointing at it. A reconciler now resolves those rows and adopts the bytes rather than dropping the work. A Redis connection that moved to a new address wedged every read-blocked consumer, so completions stopped signalling while health still answered 200. Socket timeouts plus subscriber pings recover it. Installing more than one AI bundle left the shared venv multi-versioned and silently broke three tools. The installer now reconciles distributions to one version each. Converting an image to JXL at quality 1 through 4 returned a 500, because libjxl 0.7 rejects the distance those values compute. The quality is floored at what the encoder honours. A missing ffmpeg was also reported to the user as a corrupt upload; it now says the engine is unavailable. RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at 0.22.2, and the release scan was split so it can fail on an unfixed critical instead of hiding it behind ignore-unfixed. ## Gates that could not fail Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs build; coverage discarded its whole report on any failing test; the lint gate skipped root tests, scripts, and two workspaces; and several generated matrices counted a host missing ffmpeg as a passing tool. Each now measures what it claims. Full evidence and the outstanding release items are tracked locally and are not part of this branch.
1135 lines
36 KiB
TypeScript
1135 lines
36 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { expect, test } from "@playwright/test";
|
|
import { apiToolPath } from "@snapotter/shared";
|
|
|
|
// ─── AI Tools ───────────────────────────────────────────────────────
|
|
// Tests for: remove-background, upscale, ocr, blur-faces, smart-crop,
|
|
// enhance-faces, colorize, noise-removal, red-eye-removal, restore-photo,
|
|
// passport-photo, erase-object
|
|
//
|
|
// Most of these tools require the AI sidecar (Python bridge). OCR Fast is
|
|
// built in; only its signed Balanced/Best runtime is optional. Each test detects
|
|
// whether the required feature bundle is installed:
|
|
// - 200 or terminal 202 = tool works, verify output
|
|
// - 501 FEATURE_NOT_INSTALLED = skip gracefully (expected when bundle missing)
|
|
// - Other errors = genuine failures
|
|
|
|
const FIXTURES = join(process.cwd(), "tests", "fixtures", "image", "valid");
|
|
const EDGE_FIXTURES = join(process.cwd(), "tests", "fixtures", "image", "edge");
|
|
const CONTENT = FIXTURES;
|
|
|
|
let token: string;
|
|
|
|
test.beforeAll(async ({ request }) => {
|
|
const res = await request.post("/api/auth/login", {
|
|
data: { username: "admin", password: "admin" },
|
|
});
|
|
const body = await res.json();
|
|
token = body.token;
|
|
});
|
|
|
|
function fixture(name: string): Buffer {
|
|
return readFileSync(join(FIXTURES, name));
|
|
}
|
|
|
|
function contentFixture(name: string): Buffer {
|
|
return readFileSync(join(CONTENT, name));
|
|
}
|
|
|
|
function edgeFixture(name: string): Buffer {
|
|
return readFileSync(join(EDGE_FIXTURES, name));
|
|
}
|
|
|
|
const JPG_100x100 = fixture("test-100x100.jpg");
|
|
const BLANK_PNG = edgeFixture("test-blank.png");
|
|
const HEIC_PORTRAIT = fixture("test-portrait.heic");
|
|
|
|
/** Minimal valid 1x1 PNG for quick feature-detection probes. */
|
|
const TINY_PNG = edgeFixture("test-1x1.png");
|
|
|
|
/**
|
|
* Determine whether a given AI tool's feature bundle is installed by
|
|
* querying the features API and checking the status of the bundle that
|
|
* enables the tool.
|
|
*/
|
|
async function isFeatureInstalled(
|
|
request: import("@playwright/test").APIRequestContext,
|
|
toolId: string,
|
|
): Promise<boolean> {
|
|
const res = await request.get("/api/v1/features", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (!res.ok()) return false;
|
|
const data = await res.json();
|
|
|
|
// Map tool IDs to their bundle IDs
|
|
const toolBundleMap: Record<string, string> = {
|
|
"remove-background": "background-removal",
|
|
"passport-photo": "background-removal",
|
|
"blur-faces": "face-detection",
|
|
"red-eye-removal": "face-detection",
|
|
"smart-crop": "face-detection",
|
|
"erase-object": "object-eraser-colorize",
|
|
colorize: "object-eraser-colorize",
|
|
"ai-canvas-expand": "object-eraser-colorize",
|
|
upscale: "upscale-enhance",
|
|
"enhance-faces": "upscale-enhance",
|
|
"noise-removal": "upscale-enhance",
|
|
"restore-photo": "photo-restoration",
|
|
};
|
|
|
|
const bundleId = toolBundleMap[toolId];
|
|
if (!bundleId) return false;
|
|
|
|
const bundle = data.bundles?.find((b: { id: string }) => b.id === bundleId);
|
|
return bundle?.status === "installed";
|
|
}
|
|
|
|
interface OcrFeatureState {
|
|
status: string;
|
|
compatibility?: "compatible" | "incompatible" | "invalid";
|
|
availableQualities?: Array<"fast" | "balanced" | "best">;
|
|
}
|
|
|
|
async function getOcrFeatureState(
|
|
request: import("@playwright/test").APIRequestContext,
|
|
): Promise<OcrFeatureState> {
|
|
const res = await request.get("/api/v1/features", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
const state = data.bundles?.find((bundle: { id: string }) => bundle.id === "ocr");
|
|
expect(state).toBeDefined();
|
|
return state as OcrFeatureState;
|
|
}
|
|
|
|
/**
|
|
* Post a file to an AI tool and return the response.
|
|
* Handles the 501 FEATURE_NOT_INSTALLED case gracefully.
|
|
*/
|
|
async function callAiTool(
|
|
request: import("@playwright/test").APIRequestContext,
|
|
toolId: string,
|
|
imageBuffer: Buffer,
|
|
settings: Record<string, unknown> = {},
|
|
filename = "test.png",
|
|
mimeType = "image/png",
|
|
): Promise<{ installed: boolean; ok: boolean; status: number; body: Record<string, unknown> }> {
|
|
const res = await request.post(apiToolPath(toolId), {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: filename, mimeType, buffer: imageBuffer },
|
|
settings: JSON.stringify(settings),
|
|
},
|
|
});
|
|
|
|
const status = res.status();
|
|
const body = await res.json();
|
|
|
|
if (status === 501 && body.code === "FEATURE_NOT_INSTALLED") {
|
|
return { installed: false, ok: false, status, body };
|
|
}
|
|
|
|
// Image OCR is a long-running API contract and always returns 202 once
|
|
// validation/enqueueing succeeds. Follow its SSE stream so black-box Docker
|
|
// assertions still inspect the authoritative terminal metadata.
|
|
if (toolId === "ocr" && status === 202 && typeof body.jobId === "string") {
|
|
const progress = await request.get(
|
|
`/api/v1/jobs/${encodeURIComponent(body.jobId as string)}/progress`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
timeout: 120_000,
|
|
},
|
|
);
|
|
expect(progress.ok()).toBe(true);
|
|
const frames = (await progress.text())
|
|
.split(/\n\n/u)
|
|
.map((frame) => frame.match(/^data:\s*(.+)$/mu)?.[1])
|
|
.filter((data): data is string => data !== undefined)
|
|
.map((data) => JSON.parse(data) as Record<string, unknown>);
|
|
const terminal = [...frames]
|
|
.reverse()
|
|
.find((frame) => frame.phase === "complete" || frame.phase === "failed");
|
|
expect(terminal).toBeDefined();
|
|
if (terminal?.phase === "failed") {
|
|
return {
|
|
installed: true,
|
|
ok: false,
|
|
status: 422,
|
|
body: { error: terminal.error ?? "OCR failed" },
|
|
};
|
|
}
|
|
expect(terminal?.result).toBeDefined();
|
|
return {
|
|
installed: true,
|
|
ok: true,
|
|
status: 200,
|
|
body: terminal?.result as Record<string, unknown>,
|
|
};
|
|
}
|
|
|
|
return { installed: true, ok: res.ok(), status, body };
|
|
}
|
|
|
|
// ─── Remove Background ──────────────────────────────────────────────
|
|
|
|
test.describe("Remove Background", () => {
|
|
test("remove background returns download URL or 501", async ({ request }) => {
|
|
const result = await callAiTool(request, "remove-background", JPG_100x100, {});
|
|
if (!result.installed) {
|
|
expect(result.body.feature).toBe("background-removal");
|
|
expect(result.body.featureName).toBeTruthy();
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
expect(result.body.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("remove background with specific model", async ({ request }) => {
|
|
const result = await callAiTool(request, "remove-background", JPG_100x100, {
|
|
model: "u2net",
|
|
});
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
|
|
test("remove background on HEIC image", async ({ request }) => {
|
|
const result = await callAiTool(
|
|
request,
|
|
"remove-background",
|
|
HEIC_PORTRAIT,
|
|
{},
|
|
"portrait.heic",
|
|
"image/heic",
|
|
);
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// ─── Upscale ────────────────────────────────────────────────────────
|
|
|
|
test.describe("Upscale", () => {
|
|
test("upscale 2x returns larger image or 501", async ({ request }) => {
|
|
const result = await callAiTool(request, "upscale", JPG_100x100, {
|
|
scale: 2,
|
|
model: "auto",
|
|
});
|
|
if (!result.installed) {
|
|
expect(result.body.feature).toBe("upscale-enhance");
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
expect(result.body.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("upscale 4x", async ({ request }) => {
|
|
const result = await callAiTool(request, "upscale", TINY_PNG, {
|
|
scale: 4,
|
|
model: "auto",
|
|
});
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
// 4x upscale on a tiny image may fail with processing error — accept both
|
|
if (result.ok) {
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
} else {
|
|
expect(result.body.error).toBeDefined();
|
|
}
|
|
});
|
|
|
|
test("upscale with lanczos (CPU fallback)", async ({ request }) => {
|
|
const result = await callAiTool(request, "upscale", JPG_100x100, {
|
|
scale: 2,
|
|
model: "lanczos",
|
|
});
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
|
|
test("upscale with realesrgan model", async ({ request }) => {
|
|
const result = await callAiTool(request, "upscale", TINY_PNG, {
|
|
scale: 2,
|
|
model: "realesrgan",
|
|
});
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
// RealESRGAN may fail on very small images — accept both success and error
|
|
if (result.ok) {
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
} else {
|
|
expect(result.body.error).toBeDefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── OCR ────────────────────────────────────────────────────────────
|
|
|
|
test.describe("OCR", () => {
|
|
test("default OCR works with or without the accurate pack", async ({ request }) => {
|
|
const state = await getOcrFeatureState(request);
|
|
const result = await callAiTool(request, "ocr", JPG_100x100, {});
|
|
expect(result.ok).toBe(true);
|
|
expect(typeof result.body.text).toBe("string");
|
|
|
|
if (state.availableQualities?.includes("best")) {
|
|
expect(result.body).toMatchObject({
|
|
engine: "rapidocr-onnx",
|
|
provider: "CPUExecutionProvider",
|
|
device: "cpu",
|
|
requestedQuality: "best",
|
|
actualQuality: "best",
|
|
degraded: false,
|
|
});
|
|
} else {
|
|
expect(result.body).toMatchObject({
|
|
engine: "tesseract",
|
|
provider: "native",
|
|
device: "cpu",
|
|
requestedQuality: "fast",
|
|
actualQuality: "fast",
|
|
degraded: false,
|
|
});
|
|
}
|
|
});
|
|
|
|
test("Fast OCR is always the built-in native CPU engine", async ({ request }) => {
|
|
const result = await callAiTool(request, "ocr", JPG_100x100, { quality: "fast" });
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body).toMatchObject({
|
|
engine: "tesseract",
|
|
provider: "native",
|
|
device: "cpu",
|
|
requestedQuality: "fast",
|
|
actualQuality: "fast",
|
|
degraded: false,
|
|
});
|
|
});
|
|
|
|
for (const quality of ["balanced", "best"] as const) {
|
|
test(`${quality} OCR requires the signed accurate runtime`, async ({ request }) => {
|
|
const state = await getOcrFeatureState(request);
|
|
const result = await callAiTool(request, "ocr", JPG_100x100, { quality });
|
|
|
|
if (state.availableQualities?.includes(quality)) {
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body).toMatchObject({
|
|
engine: "rapidocr-onnx",
|
|
provider: "CPUExecutionProvider",
|
|
device: "cpu",
|
|
requestedQuality: quality,
|
|
actualQuality: quality,
|
|
degraded: false,
|
|
});
|
|
} else {
|
|
expect(result.status).toBe(501);
|
|
expect(["FEATURE_NOT_INSTALLED", "FEATURE_INCOMPATIBLE"]).toContain(result.body.code);
|
|
expect(result.body).toMatchObject({ feature: "ocr", requestedQuality: quality });
|
|
}
|
|
});
|
|
}
|
|
|
|
test("OCR on Japanese text image", async ({ request }) => {
|
|
const ocrJapanese = contentFixture("ocr-japanese.png");
|
|
const result = await callAiTool(
|
|
request,
|
|
"ocr",
|
|
ocrJapanese,
|
|
{ quality: "fast" },
|
|
"ocr-japanese.png",
|
|
"image/png",
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body).toMatchObject({ engine: "tesseract", actualQuality: "fast" });
|
|
});
|
|
|
|
test("OCR on chat screenshot", async ({ request }) => {
|
|
const ocrChat = contentFixture("ocr-chat.jpeg");
|
|
const result = await callAiTool(
|
|
request,
|
|
"ocr",
|
|
ocrChat,
|
|
{ quality: "fast" },
|
|
"ocr-chat.jpeg",
|
|
"image/jpeg",
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body).toMatchObject({ engine: "tesseract", actualQuality: "fast" });
|
|
// Chat screenshot should contain some readable text
|
|
if (result.body.text) {
|
|
expect((result.body.text as string).length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Blur Faces ─────────────────────────────────────────────────────
|
|
|
|
test.describe("Blur Faces", () => {
|
|
test("blur faces processes image or returns 501", async ({ request }) => {
|
|
const portrait = contentFixture("portrait-color.jpg");
|
|
const result = await callAiTool(
|
|
request,
|
|
"blur-faces",
|
|
portrait,
|
|
{ blurRadius: 30, sensitivity: 0.5 },
|
|
"portrait.jpg",
|
|
"image/jpeg",
|
|
);
|
|
if (!result.installed) {
|
|
expect(result.body.feature).toBe("face-detection");
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
|
|
test("blur faces on HEIC portrait", async ({ request }) => {
|
|
const result = await callAiTool(
|
|
request,
|
|
"blur-faces",
|
|
HEIC_PORTRAIT,
|
|
{ blurRadius: 30 },
|
|
"portrait.heic",
|
|
"image/heic",
|
|
);
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
|
|
test("blur faces on image with no faces returns warning", async ({ request }) => {
|
|
const result = await callAiTool(request, "blur-faces", BLANK_PNG, { blurRadius: 30 });
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
// Should succeed but indicate no faces were found
|
|
expect(result.ok).toBe(true);
|
|
// facesDetected should be 0 or a warning should be present
|
|
if (result.body.facesDetected !== undefined) {
|
|
expect(result.body.facesDetected).toBe(0);
|
|
}
|
|
});
|
|
|
|
test("blur faces on multi-face image", async ({ request }) => {
|
|
const multiFace = contentFixture("multi-face.webp");
|
|
const result = await callAiTool(
|
|
request,
|
|
"blur-faces",
|
|
multiFace,
|
|
{ blurRadius: 20 },
|
|
"multi-face.webp",
|
|
"image/webp",
|
|
);
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// ─── Smart Crop ─────────────────────────────────────────────────────
|
|
|
|
test.describe("Smart Crop", () => {
|
|
test("smart crop to portrait dimensions or returns 501", async ({ request }) => {
|
|
const portrait = contentFixture("portrait-color.jpg");
|
|
const result = await callAiTool(
|
|
request,
|
|
"smart-crop",
|
|
portrait,
|
|
{ width: 400, height: 400 },
|
|
"portrait.jpg",
|
|
"image/jpeg",
|
|
);
|
|
if (!result.installed) {
|
|
expect(result.body.feature).toBe("face-detection");
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
|
|
test("smart crop with landscape aspect ratio", async ({ request }) => {
|
|
const portrait = contentFixture("portrait-color.jpg");
|
|
const result = await callAiTool(
|
|
request,
|
|
"smart-crop",
|
|
portrait,
|
|
{ width: 800, height: 400 },
|
|
"portrait.jpg",
|
|
"image/jpeg",
|
|
);
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// ─── Enhance Faces ──────────────────────────────────────────────────
|
|
|
|
test.describe("Enhance Faces", () => {
|
|
test("enhance faces with auto model or returns 501", async ({ request }) => {
|
|
const portrait = contentFixture("portrait-color.jpg");
|
|
const result = await callAiTool(
|
|
request,
|
|
"enhance-faces",
|
|
portrait,
|
|
{ model: "auto" },
|
|
"portrait.jpg",
|
|
"image/jpeg",
|
|
);
|
|
if (!result.installed) {
|
|
expect(result.body.feature).toBe("upscale-enhance");
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
|
|
test("enhance faces with gfpgan model", async ({ request }) => {
|
|
const portrait = contentFixture("portrait-color.jpg");
|
|
const result = await callAiTool(
|
|
request,
|
|
"enhance-faces",
|
|
portrait,
|
|
{ model: "gfpgan" },
|
|
"portrait.jpg",
|
|
"image/jpeg",
|
|
);
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
|
|
test("enhance faces with codeformer model", async ({ request }) => {
|
|
const portrait = contentFixture("portrait-color.jpg");
|
|
const result = await callAiTool(
|
|
request,
|
|
"enhance-faces",
|
|
portrait,
|
|
{ model: "codeformer" },
|
|
"portrait.jpg",
|
|
"image/jpeg",
|
|
);
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// ─── Colorize ───────────────────────────────────────────────────────
|
|
|
|
test.describe("Colorize", () => {
|
|
test("colorize B&W image or returns 501", async ({ request }) => {
|
|
const bwPortrait = contentFixture("portrait-bw.jpeg");
|
|
const result = await callAiTool(
|
|
request,
|
|
"colorize",
|
|
bwPortrait,
|
|
{ model: "auto" },
|
|
"portrait-bw.jpeg",
|
|
"image/jpeg",
|
|
);
|
|
if (!result.installed) {
|
|
expect(result.body.feature).toBe("object-eraser-colorize");
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
|
|
test("colorize with ddcolor model", async ({ request }) => {
|
|
const bwPortrait = contentFixture("portrait-bw.jpeg");
|
|
const result = await callAiTool(
|
|
request,
|
|
"colorize",
|
|
bwPortrait,
|
|
{ model: "ddcolor" },
|
|
"portrait-bw.jpeg",
|
|
"image/jpeg",
|
|
);
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// ─── Noise Removal ──────────────────────────────────────────────────
|
|
|
|
test.describe("Noise Removal", () => {
|
|
test("noise removal with quick tier or returns 501", async ({ request }) => {
|
|
const result = await callAiTool(request, "noise-removal", JPG_100x100, {
|
|
tier: "quick",
|
|
});
|
|
if (!result.installed) {
|
|
expect(result.body.feature).toBe("upscale-enhance");
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
|
|
test("noise removal with balanced tier", async ({ request }) => {
|
|
const result = await callAiTool(request, "noise-removal", JPG_100x100, {
|
|
tier: "balanced",
|
|
});
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
|
|
test("noise removal with quality tier (SCUNet)", async ({ request }) => {
|
|
const result = await callAiTool(request, "noise-removal", JPG_100x100, {
|
|
tier: "quality",
|
|
});
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
|
|
test("noise removal with maximum tier (NAFNet)", async ({ request }) => {
|
|
const result = await callAiTool(request, "noise-removal", TINY_PNG, {
|
|
tier: "maximum",
|
|
});
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
// NAFNet may fail on very small images — accept both success and error
|
|
if (result.ok) {
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
} else {
|
|
expect(result.body.error).toBeDefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Red-Eye Removal ────────────────────────────────────────────────
|
|
|
|
test.describe("Red-Eye Removal", () => {
|
|
test("red-eye removal processes image or returns 501", async ({ request }) => {
|
|
const redEye = contentFixture("red-eye.jpg");
|
|
const result = await callAiTool(
|
|
request,
|
|
"red-eye-removal",
|
|
redEye,
|
|
{},
|
|
"red-eye.jpg",
|
|
"image/jpeg",
|
|
);
|
|
if (!result.installed) {
|
|
expect(result.body.feature).toBe("face-detection");
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
|
|
test("red-eye removal on image without red eyes succeeds gracefully", async ({ request }) => {
|
|
const portrait = contentFixture("portrait-color.jpg");
|
|
const result = await callAiTool(
|
|
request,
|
|
"red-eye-removal",
|
|
portrait,
|
|
{},
|
|
"portrait.jpg",
|
|
"image/jpeg",
|
|
);
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
// Should succeed even if no red eyes found
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// ─── Restore Photo ──────────────────────────────────────────────────
|
|
|
|
test.describe("Restore Photo", () => {
|
|
test("restore photo processes image or returns 501", async ({ request }) => {
|
|
const bwPortrait = contentFixture("portrait-bw.jpeg");
|
|
const result = await callAiTool(
|
|
request,
|
|
"restore-photo",
|
|
bwPortrait,
|
|
{},
|
|
"old-photo.jpeg",
|
|
"image/jpeg",
|
|
);
|
|
if (!result.installed) {
|
|
expect(result.body.feature).toBe("photo-restoration");
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
expect(result.body.processedSize).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// ─── Passport Photo ─────────────────────────────────────────────────
|
|
|
|
test.describe("Passport Photo", () => {
|
|
test("passport photo processes portrait or returns 501", async ({ request }) => {
|
|
const portrait = contentFixture("portrait-color.jpg");
|
|
const result = await callAiTool(
|
|
request,
|
|
"passport-photo",
|
|
portrait,
|
|
{},
|
|
"portrait.jpg",
|
|
"image/jpeg",
|
|
);
|
|
if (!result.installed) {
|
|
expect(result.body.feature).toBe("background-removal");
|
|
test.skip();
|
|
return;
|
|
}
|
|
// Passport photo may succeed or fail with "no face detected"
|
|
if (result.ok) {
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
} else {
|
|
// Acceptable failure if face detection didn't find a face
|
|
expect(result.body.error).toBeDefined();
|
|
expect(typeof result.body.error).toBe("string");
|
|
expect(result.body.error).not.toContain("[object Object]");
|
|
}
|
|
});
|
|
|
|
test("passport photo error is readable for non-face image", async ({ request }) => {
|
|
const result = await callAiTool(request, "passport-photo", BLANK_PNG, {});
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
// Should fail gracefully with a readable error, not [object Object]
|
|
if (!result.ok) {
|
|
expect(typeof result.body.error).toBe("string");
|
|
expect(result.body.error).not.toContain("[object Object]");
|
|
if (result.body.details) {
|
|
expect(typeof result.body.details).toBe("string");
|
|
}
|
|
}
|
|
});
|
|
|
|
test("passport photo on headshot portrait", async ({ request }) => {
|
|
const headshot = contentFixture("portrait-headshot.heic");
|
|
const result = await callAiTool(
|
|
request,
|
|
"passport-photo",
|
|
headshot,
|
|
{},
|
|
"headshot.heic",
|
|
"image/heic",
|
|
);
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
if (result.ok) {
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Erase Object ───────────────────────────────────────────────────
|
|
|
|
test.describe("Erase Object", () => {
|
|
test("erase object returns 501 when feature not installed", async ({ request }) => {
|
|
const result = await callAiTool(request, "erase-object", JPG_100x100, {});
|
|
if (!result.installed) {
|
|
expect(result.body.feature).toBe("object-eraser-colorize");
|
|
expect(result.body.code).toBe("FEATURE_NOT_INSTALLED");
|
|
// This is the expected state — erase-object needs a mask and LaMa model
|
|
return;
|
|
}
|
|
// If installed, it may fail because no mask was provided
|
|
// That's still a valid test — we're checking the tool doesn't crash
|
|
expect(typeof result.body.error === "string" || result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// ─── AI Canvas Expand ──────────────────────────────────────────────
|
|
|
|
test.describe("AI Canvas Expand", () => {
|
|
test("expand canvas or returns 501", async ({ request }) => {
|
|
const result = await callAiTool(request, "ai-canvas-expand", JPG_100x100, {
|
|
extendTop: 50,
|
|
extendRight: 50,
|
|
extendBottom: 50,
|
|
extendLeft: 50,
|
|
tier: "fast",
|
|
});
|
|
if (!result.installed) {
|
|
expect(result.body.feature).toBe("object-eraser-colorize");
|
|
expect(result.body.code).toBe("FEATURE_NOT_INSTALLED");
|
|
test.skip();
|
|
return;
|
|
}
|
|
// AI canvas expand is async (returns 202 with jobId)
|
|
if (result.status === 202) {
|
|
expect(result.body.jobId).toBeTruthy();
|
|
expect(result.body.async).toBe(true);
|
|
} else {
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
expect(result.body.processedSize).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
test("expand canvas with zero extend returns same-size image or error", async ({ request }) => {
|
|
const result = await callAiTool(request, "ai-canvas-expand", TINY_PNG, {
|
|
extendTop: 0,
|
|
extendRight: 0,
|
|
extendBottom: 0,
|
|
extendLeft: 0,
|
|
tier: "fast",
|
|
});
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
// Zero expansion may succeed with unchanged image or may return validation error
|
|
if (result.ok || result.status === 202) {
|
|
expect(result.body.downloadUrl || result.body.jobId).toBeTruthy();
|
|
} else {
|
|
expect(result.body.error).toBeDefined();
|
|
}
|
|
});
|
|
|
|
test("expand canvas with HEIC input", async ({ request }) => {
|
|
const result = await callAiTool(
|
|
request,
|
|
"ai-canvas-expand",
|
|
HEIC_PORTRAIT,
|
|
{ extendRight: 100, tier: "fast" },
|
|
"portrait.heic",
|
|
"image/heic",
|
|
);
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
if (result.status === 202) {
|
|
expect(result.body.jobId).toBeTruthy();
|
|
} else if (result.ok) {
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
} else {
|
|
// May fail on edge cases -- acceptable
|
|
expect(result.body.error).toBeDefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Feature Bundle Status ──────────────────────────────────────────
|
|
|
|
test.describe("AI Feature Bundle Status", () => {
|
|
test("all AI tools return correct 501 response when uninstalled", async ({ request }) => {
|
|
// Map from tool ID used for the API POST to the lookup key in isFeatureInstalled
|
|
const aiTools = [
|
|
{ tool: "remove-background", featureKey: "remove-background", bundle: "background-removal" },
|
|
{ tool: "upscale", featureKey: "upscale", bundle: "upscale-enhance" },
|
|
{ tool: "blur-faces", featureKey: "blur-faces", bundle: "face-detection" },
|
|
{ tool: "erase-object", featureKey: "erase-object", bundle: "object-eraser-colorize" },
|
|
{ tool: "colorize", featureKey: "colorize", bundle: "object-eraser-colorize" },
|
|
{
|
|
tool: "ai-canvas-expand",
|
|
featureKey: "ai-canvas-expand",
|
|
bundle: "object-eraser-colorize",
|
|
},
|
|
{ tool: "enhance-faces", featureKey: "enhance-faces", bundle: "upscale-enhance" },
|
|
{ tool: "noise-removal", featureKey: "noise-removal", bundle: "upscale-enhance" },
|
|
{ tool: "red-eye-removal", featureKey: "red-eye-removal", bundle: "face-detection" },
|
|
{ tool: "restore-photo", featureKey: "restore-photo", bundle: "photo-restoration" },
|
|
{ tool: "passport-photo", featureKey: "passport-photo", bundle: "background-removal" },
|
|
];
|
|
|
|
let testedCount = 0;
|
|
|
|
for (const { tool, featureKey, bundle } of aiTools) {
|
|
const installed = await isFeatureInstalled(request, featureKey);
|
|
if (installed) continue;
|
|
|
|
testedCount++;
|
|
const res = await request.post(apiToolPath(tool), {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "test.png", mimeType: "image/png", buffer: TINY_PNG },
|
|
settings: JSON.stringify({}),
|
|
},
|
|
});
|
|
expect(res.status(), `${tool} should return 501`).toBe(501);
|
|
const body = await res.json();
|
|
expect(body.code, `${tool} missing code`).toBe("FEATURE_NOT_INSTALLED");
|
|
expect(body.feature, `${tool} wrong bundle`).toBe(bundle);
|
|
expect(body.featureName, `${tool} missing featureName`).toBeTruthy();
|
|
expect(
|
|
typeof body.estimatedSize === "string",
|
|
`${tool} estimatedSize should be a string`,
|
|
).toBe(true);
|
|
}
|
|
|
|
// If all bundles are installed, skip the test gracefully
|
|
if (testedCount === 0) {
|
|
test.skip();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Transparency Fixer ────────────────────────────────────────────
|
|
|
|
test.describe("Transparency Fixer", () => {
|
|
test("transparency fixer returns 202 or 501", async ({ request }) => {
|
|
const portrait = contentFixture("portrait-color.jpg");
|
|
const res = await request.post("/api/v1/tools/image/transparency-fixer", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "portrait.jpg", mimeType: "image/jpeg", buffer: portrait },
|
|
settings: JSON.stringify({}),
|
|
},
|
|
});
|
|
|
|
if (res.status() === 501) {
|
|
const body = await res.json();
|
|
expect(body.code).toBe("FEATURE_NOT_INSTALLED");
|
|
expect(body.feature).toBe("background-removal");
|
|
} else {
|
|
// Async tool returns 202
|
|
expect(res.status()).toBe(202);
|
|
const body = await res.json();
|
|
expect(body.jobId).toBeTruthy();
|
|
expect(body.async).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("transparency fixer with custom defringe", async ({ request }) => {
|
|
const res = await request.post("/api/v1/tools/image/transparency-fixer", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "test.png", mimeType: "image/png", buffer: TINY_PNG },
|
|
settings: JSON.stringify({ defringe: 50, outputFormat: "png" }),
|
|
},
|
|
});
|
|
|
|
if (res.status() === 501) {
|
|
const body = await res.json();
|
|
expect(body.code).toBe("FEATURE_NOT_INSTALLED");
|
|
} else {
|
|
expect(res.status()).toBe(202);
|
|
const body = await res.json();
|
|
expect(body.jobId).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
test("transparency fixer with webp output", async ({ request }) => {
|
|
const res = await request.post("/api/v1/tools/image/transparency-fixer", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 },
|
|
settings: JSON.stringify({ outputFormat: "webp" }),
|
|
},
|
|
});
|
|
|
|
if (res.status() === 501) {
|
|
const body = await res.json();
|
|
expect(body.code).toBe("FEATURE_NOT_INSTALLED");
|
|
} else {
|
|
expect(res.status()).toBe(202);
|
|
const body = await res.json();
|
|
expect(body.jobId).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
test("transparency fixer rejects empty file", async ({ request }) => {
|
|
const res = await request.post("/api/v1/tools/image/transparency-fixer", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
settings: JSON.stringify({}),
|
|
},
|
|
});
|
|
expect(res.ok()).toBe(false);
|
|
const body = await res.json();
|
|
expect(body.error).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ─── Smart Crop — Additional ──────────────────────────────────────
|
|
|
|
test.describe("Smart Crop — additional", () => {
|
|
test("smart crop with square output", async ({ request }) => {
|
|
const portrait = contentFixture("portrait-color.jpg");
|
|
const result = await callAiTool(
|
|
request,
|
|
"smart-crop",
|
|
portrait,
|
|
{ width: 500, height: 500 },
|
|
"portrait.jpg",
|
|
"image/jpeg",
|
|
);
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
|
|
test("smart crop on multi-face image", async ({ request }) => {
|
|
const multiFace = contentFixture("multi-face.webp");
|
|
const result = await callAiTool(
|
|
request,
|
|
"smart-crop",
|
|
multiFace,
|
|
{ width: 300, height: 300 },
|
|
"multi-face.webp",
|
|
"image/webp",
|
|
);
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// ─── Colorize — Additional ───────────────────────────────────────
|
|
|
|
test.describe("Colorize — additional", () => {
|
|
test("colorize with auto model", async ({ request }) => {
|
|
const bwPortrait = contentFixture("portrait-bw.jpeg");
|
|
const result = await callAiTool(
|
|
request,
|
|
"colorize",
|
|
bwPortrait,
|
|
{ model: "auto" },
|
|
"bw.jpeg",
|
|
"image/jpeg",
|
|
);
|
|
if (!result.installed) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
expect(result.body.downloadUrl).toBeTruthy();
|
|
expect(result.body.processedSize).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// ─── Auth Failure ──────────────────────────────────────────────────
|
|
|
|
test.describe("Auth failure", () => {
|
|
test("remove-background without token returns 401", async ({ request }) => {
|
|
const res = await request.post("/api/v1/tools/image/remove-background", {
|
|
multipart: {
|
|
file: { name: "test.png", mimeType: "image/png", buffer: TINY_PNG },
|
|
settings: JSON.stringify({}),
|
|
},
|
|
});
|
|
expect(res.status()).toBe(401);
|
|
});
|
|
|
|
test("upscale without token returns 401", async ({ request }) => {
|
|
const res = await request.post("/api/v1/tools/image/upscale", {
|
|
multipart: {
|
|
file: { name: "test.png", mimeType: "image/png", buffer: TINY_PNG },
|
|
settings: JSON.stringify({ scale: 2, model: "auto" }),
|
|
},
|
|
});
|
|
expect(res.status()).toBe(401);
|
|
});
|
|
|
|
test("ocr without token returns 401", async ({ request }) => {
|
|
const res = await request.post("/api/v1/tools/image/ocr", {
|
|
multipart: {
|
|
file: { name: "test.png", mimeType: "image/png", buffer: TINY_PNG },
|
|
settings: JSON.stringify({}),
|
|
},
|
|
});
|
|
expect(res.status()).toBe(401);
|
|
});
|
|
|
|
test("transparency-fixer without token returns 401", async ({ request }) => {
|
|
const res = await request.post("/api/v1/tools/image/transparency-fixer", {
|
|
multipart: {
|
|
file: { name: "test.png", mimeType: "image/png", buffer: TINY_PNG },
|
|
settings: JSON.stringify({}),
|
|
},
|
|
});
|
|
expect(res.status()).toBe(401);
|
|
});
|
|
|
|
test("blur-faces without token returns 401", async ({ request }) => {
|
|
const res = await request.post("/api/v1/tools/image/blur-faces", {
|
|
multipart: {
|
|
file: { name: "test.png", mimeType: "image/png", buffer: TINY_PNG },
|
|
settings: JSON.stringify({ blurRadius: 30 }),
|
|
},
|
|
});
|
|
expect(res.status()).toBe(401);
|
|
});
|
|
|
|
test("colorize without token returns 401", async ({ request }) => {
|
|
const res = await request.post("/api/v1/tools/image/colorize", {
|
|
multipart: {
|
|
file: { name: "test.png", mimeType: "image/png", buffer: TINY_PNG },
|
|
settings: JSON.stringify({}),
|
|
},
|
|
});
|
|
expect(res.status()).toBe(401);
|
|
});
|
|
});
|