mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
* fix: make OCR portable and reliable * fix: harden OCR installation portability * fix: pin OCR partials across downloads * fix: make OCR execution reliably asynchronous * fix: harden OCR portability and docs routes * fix: preserve decoder and docs safeguards
1130 lines
36 KiB
TypeScript
1130 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");
|
|
const CONTENT = join(FIXTURES, "content");
|
|
|
|
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));
|
|
}
|
|
|
|
const JPG_100x100 = fixture("test-100x100.jpg");
|
|
const BLANK_PNG = fixture("test-blank.png");
|
|
const HEIC_PORTRAIT = fixture("test-portrait.heic");
|
|
|
|
/** Minimal valid 1x1 PNG for quick feature-detection probes. */
|
|
const TINY_PNG = fixture("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);
|
|
});
|
|
});
|