import { beforeEach, describe, expect, it, vi } from "vitest"; // --------------------------------------------------------------------------- // Mock all dependencies BEFORE importing tool modules. // // vi.mock factories are hoisted to the top of the file, so they CANNOT // reference variables declared at module scope. Every mock must be fully // self-contained inside the factory function. We use vi.hoisted() to // create shared mock references that are safe to use in both the factories // and the test bodies. // --------------------------------------------------------------------------- const { mockRunPythonWithProgress, mockParseStdoutJson, mockIsGpuAvailable, mockSharp, mockWriteFile, mockReadFile, mockUnlink, mockRm, mockExecFile, } = vi.hoisted(() => { const mockRunPythonWithProgress = vi.fn(); const mockParseStdoutJson = vi.fn(); const mockIsGpuAvailable = vi.fn().mockReturnValue(false); function createSharpChain(meta?: Record) { const chain: Record> = {}; chain.png = vi.fn().mockReturnValue(chain); chain.jpeg = vi.fn().mockReturnValue(chain); chain.resize = vi.fn().mockReturnValue(chain); chain.toBuffer = vi.fn().mockResolvedValue(Buffer.from("mock-png")); chain.metadata = vi.fn().mockResolvedValue({ width: 800, height: 600, format: "png", ...meta, }); return chain; } const mockSharp = Object.assign(vi.fn().mockReturnValue(createSharpChain()), { _createChain: createSharpChain, }); return { mockRunPythonWithProgress, mockParseStdoutJson, mockIsGpuAvailable, mockSharp, mockWriteFile: vi.fn().mockResolvedValue(undefined), mockReadFile: vi.fn().mockResolvedValue(Buffer.from("output-buffer")), mockUnlink: vi.fn().mockResolvedValue(undefined), mockRm: vi.fn().mockResolvedValue(undefined), mockExecFile: vi.fn(), }; }); vi.mock("../../../packages/ai/src/bridge.js", () => ({ runPythonWithProgress: mockRunPythonWithProgress, parseStdoutJson: mockParseStdoutJson, isGpuAvailable: mockIsGpuAvailable, })); vi.mock("sharp", () => ({ default: mockSharp })); vi.mock("node:fs/promises", () => ({ writeFile: mockWriteFile, readFile: mockReadFile, unlink: mockUnlink, rm: mockRm, })); vi.mock("node:child_process", () => ({ execFile: mockExecFile, spawn: vi.fn(), })); vi.mock("node:util", () => ({ promisify: () => mockExecFile, })); // --------------------------------------------------------------------------- // Import tool modules (after mocks are in place) // --------------------------------------------------------------------------- import { removeBackground } from "../../../packages/ai/src/background-removal.js"; import { colorize } from "../../../packages/ai/src/colorization.js"; import { blurFaces, detectFaces } from "../../../packages/ai/src/face-detection.js"; import { enhanceFaces } from "../../../packages/ai/src/face-enhancement.js"; import { detectFaceLandmarks } from "../../../packages/ai/src/face-landmarks.js"; import { inpaint } from "../../../packages/ai/src/inpainting.js"; import { noiseRemoval } from "../../../packages/ai/src/noise-removal.js"; import { extractText } from "../../../packages/ai/src/ocr.js"; import { removeRedEye } from "../../../packages/ai/src/red-eye-removal.js"; import { restorePhoto } from "../../../packages/ai/src/restoration.js"; import { upscale } from "../../../packages/ai/src/upscaling.js"; // --------------------------------------------------------------------------- // Helper // --------------------------------------------------------------------------- function createSharpChain(meta?: Record) { return mockSharp._createChain(meta); } // --------------------------------------------------------------------------- // Shared setup // --------------------------------------------------------------------------- const INPUT_BUFFER = Buffer.from("test-input"); const OUTPUT_DIR = "/tmp/test-output"; beforeEach(() => { vi.clearAllMocks(); mockSharp.mockReturnValue(createSharpChain()); mockReadFile.mockResolvedValue(Buffer.from("output-buffer")); mockWriteFile.mockResolvedValue(undefined); mockRunPythonWithProgress.mockResolvedValue({ stdout: "", stderr: "" }); mockParseStdoutJson.mockReturnValue({ success: true }); mockIsGpuAvailable.mockReturnValue(false); }); // ═══════════════════════════════════════════════════════════════════════════ // removeBackground // ═══════════════════════════════════════════════════════════════════════════ describe("removeBackground", () => { it("calls runPythonWithProgress with remove_bg.py", async () => { mockParseStdoutJson.mockReturnValue({ success: true }); await removeBackground(INPUT_BUFFER, OUTPUT_DIR); expect(mockRunPythonWithProgress).toHaveBeenCalledTimes(1); const [script] = mockRunPythonWithProgress.mock.calls[0]; expect(script).toBe("remove_bg.py"); }); it("passes options as JSON in args", async () => { mockParseStdoutJson.mockReturnValue({ success: true }); await removeBackground(INPUT_BUFFER, OUTPUT_DIR, { model: "birefnet" }); const [, args] = mockRunPythonWithProgress.mock.calls[0]; const optsArg = JSON.parse(args[2]); expect(optsArg.model).toBe("birefnet"); }); it("writes input as PNG before processing", async () => { mockParseStdoutJson.mockReturnValue({ success: true }); await removeBackground(INPUT_BUFFER, OUTPUT_DIR); expect(mockWriteFile).toHaveBeenCalled(); const writtenBuffer = mockWriteFile.mock.calls[0][1]; expect(Buffer.isBuffer(writtenBuffer)).toBe(true); }); it("returns the output file buffer", async () => { const expected = Buffer.from("mask-output"); mockReadFile.mockResolvedValue(expected); mockParseStdoutJson.mockReturnValue({ success: true }); const result = await removeBackground(INPUT_BUFFER, OUTPUT_DIR); expect(result).toBe(expected); }); it("throws when Python reports failure", async () => { mockParseStdoutJson.mockReturnValue({ success: false, error: "No model available", }); await expect(removeBackground(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("No model available"); }); it("provides fallback error message when error field is empty", async () => { mockParseStdoutJson.mockReturnValue({ success: false }); await expect(removeBackground(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow( "Background removal failed", ); }); it("passes onProgress callback through to bridge", async () => { mockParseStdoutJson.mockReturnValue({ success: true }); const onProgress = vi.fn(); await removeBackground(INPUT_BUFFER, OUTPUT_DIR, {}, onProgress); const [, , opts] = mockRunPythonWithProgress.mock.calls[0]; expect(opts.onProgress).toBe(onProgress); }); it("cleans up temp files in finally block", async () => { mockParseStdoutJson.mockReturnValue({ success: true }); await removeBackground(INPUT_BUFFER, OUTPUT_DIR); // unlink called for input and output paths expect(mockUnlink).toHaveBeenCalledTimes(2); }); it("cleans up temp files even on failure", async () => { mockRunPythonWithProgress.mockRejectedValue(new Error("crash")); await expect(removeBackground(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("crash"); expect(mockUnlink).toHaveBeenCalledTimes(2); }); it("retries with u2net fallback on OOM error", async () => { // First call fails with OOM, second succeeds mockRunPythonWithProgress .mockRejectedValueOnce(new Error("Process killed (out of memory)")) .mockResolvedValueOnce({ stdout: "", stderr: "" }); mockParseStdoutJson.mockReturnValue({ success: true }); await removeBackground(INPUT_BUFFER, OUTPUT_DIR, { model: "birefnet" }); expect(mockRunPythonWithProgress).toHaveBeenCalledTimes(2); // Second call should use u2net const secondArgs = mockRunPythonWithProgress.mock.calls[1][1]; const secondOpts = JSON.parse(secondArgs[2]); expect(secondOpts.model).toBe("u2net"); }); it("does not retry OOM if already using u2net", async () => { mockRunPythonWithProgress.mockRejectedValue(new Error("Process killed (out of memory)")); await expect(removeBackground(INPUT_BUFFER, OUTPUT_DIR, { model: "u2net" })).rejects.toThrow( "out of memory", ); expect(mockRunPythonWithProgress).toHaveBeenCalledTimes(1); }); it("calculates timeout based on megapixels", async () => { mockParseStdoutJson.mockReturnValue({ success: true }); await removeBackground(INPUT_BUFFER, OUTPUT_DIR); const [, , opts] = mockRunPythonWithProgress.mock.calls[0]; expect(opts.timeout).toBeGreaterThan(0); }); it("uses longer base timeout for birefnet model", async () => { mockParseStdoutJson.mockReturnValue({ success: true }); await removeBackground(INPUT_BUFFER, OUTPUT_DIR, { model: "birefnet-large" }); const [, , opts] = mockRunPythonWithProgress.mock.calls[0]; // birefnet gets 600000 base timeout expect(opts.timeout).toBeGreaterThanOrEqual(600000); }); it("downscales large images and upscales mask back", async () => { // Simulate a 4000x3000 image (larger than MAX_REMBG_PX=2048) const largeChain = createSharpChain({ width: 4000, height: 3000 }); mockSharp.mockReturnValue(largeChain); mockParseStdoutJson.mockReturnValue({ success: true }); await removeBackground(INPUT_BUFFER, OUTPUT_DIR); // resize should have been called for downscaling expect(largeChain.resize).toHaveBeenCalled(); }); }); // ═══════════════════════════════════════════════════════════════════════════ // colorize // ═══════════════════════════════════════════════════════════════════════════ describe("colorize", () => { it("calls runPythonWithProgress with colorize.py", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 800, height: 600 }); await colorize(INPUT_BUFFER, OUTPUT_DIR); const [script] = mockRunPythonWithProgress.mock.calls[0]; expect(script).toBe("colorize.py"); }); it("passes options as JSON in args", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 800, height: 600 }); await colorize(INPUT_BUFFER, OUTPUT_DIR, { intensity: 0.8, model: "eccv16" }); const [, args] = mockRunPythonWithProgress.mock.calls[0]; const optsArg = JSON.parse(args[2]); expect(optsArg.intensity).toBe(0.8); expect(optsArg.model).toBe("eccv16"); }); it("returns structured result with buffer, dimensions, and method", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 800, height: 600, method: "eccv16", }); const result = await colorize(INPUT_BUFFER, OUTPUT_DIR); expect(result.width).toBe(800); expect(result.height).toBe(600); expect(result.method).toBe("eccv16"); expect(Buffer.isBuffer(result.buffer)).toBe(true); }); it("defaults method to 'unknown' when not provided", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 100, height: 100 }); const result = await colorize(INPUT_BUFFER, OUTPUT_DIR); expect(result.method).toBe("unknown"); }); it("throws on failure", async () => { mockParseStdoutJson.mockReturnValue({ success: false, error: "Model missing" }); await expect(colorize(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("Model missing"); }); it("uses output_path from result when available", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 100, height: 100, output_path: "/custom/path.png", }); await colorize(INPUT_BUFFER, OUTPUT_DIR); expect(mockReadFile).toHaveBeenCalledWith("/custom/path.png"); }); it("forwards onProgress callback", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 100, height: 100 }); const onProgress = vi.fn(); await colorize(INPUT_BUFFER, OUTPUT_DIR, {}, onProgress); const [, , opts] = mockRunPythonWithProgress.mock.calls[0]; expect(opts.onProgress).toBe(onProgress); }); }); // ═══════════════════════════════════════════════════════════════════════════ // blurFaces // ═══════════════════════════════════════════════════════════════════════════ describe("blurFaces", () => { it("calls runPythonWithProgress with detect_faces.py", async () => { mockParseStdoutJson.mockReturnValue({ success: true, facesDetected: 2, faces: [ { x: 10, y: 20, w: 50, h: 50 }, { x: 100, y: 200, w: 60, h: 60 }, ], }); await blurFaces(INPUT_BUFFER, OUTPUT_DIR); const [script] = mockRunPythonWithProgress.mock.calls[0]; expect(script).toBe("detect_faces.py"); }); it("passes blur options in args", async () => { mockParseStdoutJson.mockReturnValue({ success: true, facesDetected: 0, faces: [] }); await blurFaces(INPUT_BUFFER, OUTPUT_DIR, { blurRadius: 30, sensitivity: 0.5 }); const [, args] = mockRunPythonWithProgress.mock.calls[0]; const optsArg = JSON.parse(args[2]); expect(optsArg.blurRadius).toBe(30); expect(optsArg.sensitivity).toBe(0.5); }); it("returns buffer, facesDetected, and faces array", async () => { const faces = [{ x: 10, y: 20, w: 50, h: 50 }]; mockParseStdoutJson.mockReturnValue({ success: true, facesDetected: 1, faces }); const result = await blurFaces(INPUT_BUFFER, OUTPUT_DIR); expect(result.facesDetected).toBe(1); expect(result.faces).toEqual(faces); expect(Buffer.isBuffer(result.buffer)).toBe(true); }); it("defaults faces to empty array when absent", async () => { mockParseStdoutJson.mockReturnValue({ success: true, facesDetected: 0 }); const result = await blurFaces(INPUT_BUFFER, OUTPUT_DIR); expect(result.faces).toEqual([]); }); it("throws on failure", async () => { mockParseStdoutJson.mockReturnValue({ success: false, error: "No face detector" }); await expect(blurFaces(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("No face detector"); }); }); // ═══════════════════════════════════════════════════════════════════════════ // detectFaces (detect-only mode) // ═══════════════════════════════════════════════════════════════════════════ describe("detectFaces", () => { it("passes detectOnly: true in options", async () => { mockParseStdoutJson.mockReturnValue({ success: true, facesDetected: 0, faces: [] }); await detectFaces(INPUT_BUFFER); const [, args] = mockRunPythonWithProgress.mock.calls[0]; const optsArg = JSON.parse(args[2]); expect(optsArg.detectOnly).toBe(true); }); it("passes 'unused' as outputPath arg", async () => { mockParseStdoutJson.mockReturnValue({ success: true, facesDetected: 0, faces: [] }); await detectFaces(INPUT_BUFFER); const [, args] = mockRunPythonWithProgress.mock.calls[0]; expect(args[1]).toBe("unused"); }); it("returns facesDetected and faces without a buffer", async () => { const faces = [{ x: 5, y: 10, w: 30, h: 30 }]; mockParseStdoutJson.mockReturnValue({ success: true, facesDetected: 1, faces }); const result = await detectFaces(INPUT_BUFFER); expect(result.facesDetected).toBe(1); expect(result.faces).toEqual(faces); expect((result as Record).buffer).toBeUndefined(); }); it("cleans up temp input file", async () => { mockParseStdoutJson.mockReturnValue({ success: true, facesDetected: 0, faces: [] }); await detectFaces(INPUT_BUFFER); expect(mockUnlink).toHaveBeenCalledTimes(1); }); it("cleans up temp file even on error", async () => { mockRunPythonWithProgress.mockRejectedValue(new Error("fail")); await expect(detectFaces(INPUT_BUFFER)).rejects.toThrow("fail"); expect(mockUnlink).toHaveBeenCalledTimes(1); }); it("merges sensitivity option", async () => { mockParseStdoutJson.mockReturnValue({ success: true, facesDetected: 0, faces: [] }); await detectFaces(INPUT_BUFFER, { sensitivity: 0.3 }); const [, args] = mockRunPythonWithProgress.mock.calls[0]; const optsArg = JSON.parse(args[2]); expect(optsArg.sensitivity).toBe(0.3); expect(optsArg.detectOnly).toBe(true); }); }); // ═══════════════════════════════════════════════════════════════════════════ // enhanceFaces // ═══════════════════════════════════════════════════════════════════════════ describe("enhanceFaces", () => { it("calls enhance_faces.py", async () => { mockParseStdoutJson.mockReturnValue({ success: true, facesDetected: 1, faces: [], model: "gfpgan", }); await enhanceFaces(INPUT_BUFFER, OUTPUT_DIR); const [script] = mockRunPythonWithProgress.mock.calls[0]; expect(script).toBe("enhance_faces.py"); }); it("passes all options through", async () => { mockParseStdoutJson.mockReturnValue({ success: true, facesDetected: 0, faces: [] }); await enhanceFaces(INPUT_BUFFER, OUTPUT_DIR, { model: "codeformer", strength: 0.7, onlyCenterFace: true, sensitivity: 0.4, }); const [, args] = mockRunPythonWithProgress.mock.calls[0]; const optsArg = JSON.parse(args[2]); expect(optsArg.model).toBe("codeformer"); expect(optsArg.strength).toBe(0.7); expect(optsArg.onlyCenterFace).toBe(true); }); it("returns result with buffer, facesDetected, faces, and model", async () => { mockParseStdoutJson.mockReturnValue({ success: true, facesDetected: 2, faces: [{ x: 1, y: 2, w: 3, h: 4 }], model: "codeformer", }); const result = await enhanceFaces(INPUT_BUFFER, OUTPUT_DIR); expect(result.facesDetected).toBe(2); expect(result.model).toBe("codeformer"); expect(result.faces).toHaveLength(1); }); it("defaults model to 'unknown'", async () => { mockParseStdoutJson.mockReturnValue({ success: true, facesDetected: 0 }); const result = await enhanceFaces(INPUT_BUFFER, OUTPUT_DIR); expect(result.model).toBe("unknown"); }); it("throws on failure with specific error", async () => { mockParseStdoutJson.mockReturnValue({ success: false, error: "GFPGAN not installed" }); await expect(enhanceFaces(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("GFPGAN not installed"); }); it("provides fallback error message", async () => { mockParseStdoutJson.mockReturnValue({ success: false }); await expect(enhanceFaces(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("Face enhancement failed"); }); }); // ═══════════════════════════════════════════════════════════════════════════ // detectFaceLandmarks // ═══════════════════════════════════════════════════════════════════════════ describe("detectFaceLandmarks", () => { it("calls face_landmarks.py", async () => { mockParseStdoutJson.mockReturnValue({ success: true, faceDetected: true, landmarks: null, }); await detectFaceLandmarks(INPUT_BUFFER); const [script] = mockRunPythonWithProgress.mock.calls[0]; expect(script).toBe("face_landmarks.py"); }); it("passes 'unused' as output path and empty JSON options", async () => { mockParseStdoutJson.mockReturnValue({ success: true, faceDetected: false }); await detectFaceLandmarks(INPUT_BUFFER); const [, args] = mockRunPythonWithProgress.mock.calls[0]; expect(args[1]).toBe("unused"); expect(args[2]).toBe("{}"); }); it("returns landmarks result", async () => { const landmarks = { leftEye: { x: 100, y: 100 }, rightEye: { x: 200, y: 100 }, eyeCenter: { x: 150, y: 100 }, chin: { x: 150, y: 250 }, forehead: { x: 150, y: 50 }, crown: { x: 150, y: 30 }, nose: { x: 150, y: 150 }, faceCenterX: 150, }; mockParseStdoutJson.mockReturnValue({ success: true, faceDetected: true, landmarks, imageWidth: 800, imageHeight: 600, }); const result = await detectFaceLandmarks(INPUT_BUFFER); expect(result.faceDetected).toBe(true); expect(result.landmarks).toEqual(landmarks); expect(result.imageWidth).toBe(800); expect(result.imageHeight).toBe(600); }); it("returns null landmarks when no face found", async () => { mockParseStdoutJson.mockReturnValue({ success: true, faceDetected: false, }); const result = await detectFaceLandmarks(INPUT_BUFFER); expect(result.faceDetected).toBe(false); expect(result.landmarks).toBeNull(); }); it("defaults dimensions to 0 when absent", async () => { mockParseStdoutJson.mockReturnValue({ success: true, faceDetected: false }); const result = await detectFaceLandmarks(INPUT_BUFFER); expect(result.imageWidth).toBe(0); expect(result.imageHeight).toBe(0); }); it("cleans up temp file", async () => { mockParseStdoutJson.mockReturnValue({ success: true, faceDetected: false }); await detectFaceLandmarks(INPUT_BUFFER); expect(mockUnlink).toHaveBeenCalledTimes(1); }); it("throws on failure", async () => { mockParseStdoutJson.mockReturnValue({ success: false, error: "MediaPipe not found" }); await expect(detectFaceLandmarks(INPUT_BUFFER)).rejects.toThrow("MediaPipe not found"); }); it("writes raw input buffer (no sharp conversion)", async () => { mockParseStdoutJson.mockReturnValue({ success: true, faceDetected: false }); await detectFaceLandmarks(INPUT_BUFFER); // face-landmarks writes inputBuffer directly, no sharp pipeline expect(mockWriteFile).toHaveBeenCalledWith(expect.any(String), INPUT_BUFFER); }); }); // ═══════════════════════════════════════════════════════════════════════════ // inpaint // ═══════════════════════════════════════════════════════════════════════════ describe("inpaint", () => { const MASK_BUFFER = Buffer.from("mask-data"); it("calls inpaint.py with input, mask, and output paths", async () => { mockParseStdoutJson.mockReturnValue({ success: true }); await inpaint(INPUT_BUFFER, MASK_BUFFER, OUTPUT_DIR); const [script, args] = mockRunPythonWithProgress.mock.calls[0]; expect(script).toBe("inpaint.py"); expect(args).toHaveLength(3); expect(args[0]).toContain("input_inpaint.png"); expect(args[1]).toContain("mask_inpaint.png"); expect(args[2]).toContain("output_inpaint.png"); }); it("converts both input and mask to PNG", async () => { mockParseStdoutJson.mockReturnValue({ success: true }); await inpaint(INPUT_BUFFER, MASK_BUFFER, OUTPUT_DIR); // sharp is called for both input and mask expect(mockSharp).toHaveBeenCalledTimes(2); }); it("writes both input and mask files", async () => { mockParseStdoutJson.mockReturnValue({ success: true }); await inpaint(INPUT_BUFFER, MASK_BUFFER, OUTPUT_DIR); expect(mockWriteFile).toHaveBeenCalledTimes(2); }); it("returns the output buffer", async () => { const outputBuf = Buffer.from("inpainted"); mockReadFile.mockResolvedValue(outputBuf); mockParseStdoutJson.mockReturnValue({ success: true }); const result = await inpaint(INPUT_BUFFER, MASK_BUFFER, OUTPUT_DIR); expect(result).toBe(outputBuf); }); it("throws on failure", async () => { mockParseStdoutJson.mockReturnValue({ success: false, error: "LaMa model not found" }); await expect(inpaint(INPUT_BUFFER, MASK_BUFFER, OUTPUT_DIR)).rejects.toThrow( "LaMa model not found", ); }); it("provides fallback error message", async () => { mockParseStdoutJson.mockReturnValue({ success: false }); await expect(inpaint(INPUT_BUFFER, MASK_BUFFER, OUTPUT_DIR)).rejects.toThrow( "Inpainting failed", ); }); it("forwards onProgress", async () => { mockParseStdoutJson.mockReturnValue({ success: true }); const onProgress = vi.fn(); await inpaint(INPUT_BUFFER, MASK_BUFFER, OUTPUT_DIR, onProgress); const [, , opts] = mockRunPythonWithProgress.mock.calls[0]; expect(opts.onProgress).toBe(onProgress); }); }); // ═══════════════════════════════════════════════════════════════════════════ // noiseRemoval // ═══════════════════════════════════════════════════════════════════════════ describe("noiseRemoval", () => { it("calls noise_removal.py", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 800, height: 600 }); await noiseRemoval(INPUT_BUFFER, OUTPUT_DIR); const [script] = mockRunPythonWithProgress.mock.calls[0]; expect(script).toBe("noise_removal.py"); }); it("passes options as JSON", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 800, height: 600 }); await noiseRemoval(INPUT_BUFFER, OUTPUT_DIR, { tier: "quality", strength: 0.8, detailPreservation: 0.5, }); const [, args] = mockRunPythonWithProgress.mock.calls[0]; const optsArg = JSON.parse(args[2]); expect(optsArg.tier).toBe("quality"); expect(optsArg.strength).toBe(0.8); expect(optsArg.detailPreservation).toBe(0.5); }); it("returns structured result", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 1920, height: 1080, format: "png", tier: "quality", }); const result = await noiseRemoval(INPUT_BUFFER, OUTPUT_DIR); expect(result.width).toBe(1920); expect(result.height).toBe(1080); expect(result.format).toBe("png"); expect(result.tier).toBe("quality"); }); it("defaults format and tier", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 100, height: 100 }); const result = await noiseRemoval(INPUT_BUFFER, OUTPUT_DIR); expect(result.format).toBe("png"); expect(result.tier).toBe("balanced"); }); it("prefers tier from result over options", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 100, height: 100, tier: "fast", }); const result = await noiseRemoval(INPUT_BUFFER, OUTPUT_DIR, { tier: "quality" }); expect(result.tier).toBe("fast"); }); it("calculates timeout based on megapixels", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 100, height: 100 }); await noiseRemoval(INPUT_BUFFER, OUTPUT_DIR); const [, , opts] = mockRunPythonWithProgress.mock.calls[0]; expect(opts.timeout).toBeGreaterThanOrEqual(300_000); }); it("throws on failure", async () => { mockParseStdoutJson.mockReturnValue({ success: false, error: "Denoiser unavailable" }); await expect(noiseRemoval(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("Denoiser unavailable"); }); it("uses output_path from result when available", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 100, height: 100, output_path: "/custom/denoise.png", }); await noiseRemoval(INPUT_BUFFER, OUTPUT_DIR); expect(mockReadFile).toHaveBeenCalledWith("/custom/denoise.png"); }); }); // ═══════════════════════════════════════════════════════════════════════════ // extractText (OCR) // ═══════════════════════════════════════════════════════════════════════════ describe("extractText (OCR)", () => { it("calls ocr.py", async () => { mockParseStdoutJson.mockReturnValue({ success: true, text: "hello" }); await extractText(INPUT_BUFFER, OUTPUT_DIR); const [script] = mockRunPythonWithProgress.mock.calls[0]; expect(script).toBe("ocr.py"); }); it("passes options as JSON", async () => { mockParseStdoutJson.mockReturnValue({ success: true, text: "" }); await extractText(INPUT_BUFFER, OUTPUT_DIR, { quality: "best", language: "en" }); const [, args] = mockRunPythonWithProgress.mock.calls[0]; const optsArg = JSON.parse(args[1]); expect(optsArg.quality).toBe("best"); expect(optsArg.language).toBe("en"); }); it("returns text and engine", async () => { mockParseStdoutJson.mockReturnValue({ success: true, text: "Sample OCR text", engine: "paddleocr", }); const result = await extractText(INPUT_BUFFER, OUTPUT_DIR); expect(result.text).toBe("Sample OCR text"); expect(result.engine).toBe("paddleocr"); }); it("resizes image to max 2048px", async () => { mockParseStdoutJson.mockReturnValue({ success: true, text: "" }); const chain = createSharpChain(); mockSharp.mockReturnValue(chain); await extractText(INPUT_BUFFER, OUTPUT_DIR); expect(chain.resize).toHaveBeenCalledWith({ width: 2048, height: 2048, fit: "inside", withoutEnlargement: true, }); }); it("throws on failure", async () => { mockParseStdoutJson.mockReturnValue({ success: false, error: "PaddleOCR init failed" }); await expect(extractText(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("PaddleOCR init failed"); }); it("provides fallback error message", async () => { mockParseStdoutJson.mockReturnValue({ success: false }); await expect(extractText(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("OCR failed"); }); it("calculates timeout based on megapixels", async () => { mockParseStdoutJson.mockReturnValue({ success: true, text: "" }); await extractText(INPUT_BUFFER, OUTPUT_DIR); const [, , opts] = mockRunPythonWithProgress.mock.calls[0]; expect(opts.timeout).toBeGreaterThanOrEqual(600_000); }); }); // ═══════════════════════════════════════════════════════════════════════════ // removeRedEye // ═══════════════════════════════════════════════════════════════════════════ describe("removeRedEye", () => { it("calls red_eye_removal.py", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 800, height: 600 }); await removeRedEye(INPUT_BUFFER, OUTPUT_DIR); const [script] = mockRunPythonWithProgress.mock.calls[0]; expect(script).toBe("red_eye_removal.py"); }); it("passes options as JSON", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 800, height: 600 }); await removeRedEye(INPUT_BUFFER, OUTPUT_DIR, { sensitivity: 0.6, strength: 0.9 }); const [, args] = mockRunPythonWithProgress.mock.calls[0]; const optsArg = JSON.parse(args[2]); expect(optsArg.sensitivity).toBe(0.6); expect(optsArg.strength).toBe(0.9); }); it("returns structured result", async () => { mockParseStdoutJson.mockReturnValue({ success: true, facesDetected: 2, eyesCorrected: 3, width: 1920, height: 1080, format: "png", }); const result = await removeRedEye(INPUT_BUFFER, OUTPUT_DIR); expect(result.facesDetected).toBe(2); expect(result.eyesCorrected).toBe(3); expect(result.width).toBe(1920); expect(result.height).toBe(1080); expect(result.format).toBe("png"); }); it("defaults optional fields", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 100, height: 100 }); const result = await removeRedEye(INPUT_BUFFER, OUTPUT_DIR); expect(result.facesDetected).toBe(0); expect(result.eyesCorrected).toBe(0); expect(result.format).toBe("png"); }); it("throws on failure", async () => { mockParseStdoutJson.mockReturnValue({ success: false, error: "Eye detector failed" }); await expect(removeRedEye(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("Eye detector failed"); }); it("provides fallback error message", async () => { mockParseStdoutJson.mockReturnValue({ success: false }); await expect(removeRedEye(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("Red eye removal failed"); }); it("uses output_path from result when available", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 100, height: 100, output_path: "/alt/redeye.png", }); await removeRedEye(INPUT_BUFFER, OUTPUT_DIR); expect(mockReadFile).toHaveBeenCalledWith("/alt/redeye.png"); }); }); // ═══════════════════════════════════════════════════════════════════════════ // restorePhoto // ═══════════════════════════════════════════════════════════════════════════ describe("restorePhoto", () => { it("calls restore.py", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 800, height: 600 }); await restorePhoto(INPUT_BUFFER, OUTPUT_DIR); const [script] = mockRunPythonWithProgress.mock.calls[0]; expect(script).toBe("restore.py"); }); it("passes options as JSON", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 800, height: 600 }); await restorePhoto(INPUT_BUFFER, OUTPUT_DIR, { mode: "heavy", scratchRemoval: true, faceEnhancement: true, fidelity: 0.5, denoise: true, denoiseStrength: 0.3, colorize: true, }); const [, args] = mockRunPythonWithProgress.mock.calls[0]; const optsArg = JSON.parse(args[2]); expect(optsArg.mode).toBe("heavy"); expect(optsArg.scratchRemoval).toBe(true); expect(optsArg.colorize).toBe(true); }); it("returns full restoration result", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 2000, height: 1500, steps: ["denoise", "scratch_removal", "colorize"], scratchCoverage: 15.5, facesEnhanced: 2, isGrayscale: true, colorized: true, }); const result = await restorePhoto(INPUT_BUFFER, OUTPUT_DIR); expect(result.width).toBe(2000); expect(result.height).toBe(1500); expect(result.steps).toEqual(["denoise", "scratch_removal", "colorize"]); expect(result.scratchCoverage).toBe(15.5); expect(result.facesEnhanced).toBe(2); expect(result.isGrayscale).toBe(true); expect(result.colorized).toBe(true); }); it("defaults optional result fields", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 100, height: 100 }); const result = await restorePhoto(INPUT_BUFFER, OUTPUT_DIR); expect(result.steps).toEqual([]); expect(result.scratchCoverage).toBe(0); expect(result.facesEnhanced).toBe(0); expect(result.isGrayscale).toBe(false); expect(result.colorized).toBe(false); }); it("throws on failure", async () => { mockParseStdoutJson.mockReturnValue({ success: false, error: "Restoration model missing" }); await expect(restorePhoto(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow( "Restoration model missing", ); }); it("provides fallback error message", async () => { mockParseStdoutJson.mockReturnValue({ success: false }); await expect(restorePhoto(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow( "Photo restoration failed", ); }); it("uses output_path from result when available", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 100, height: 100, output_path: "/restored/out.png", }); await restorePhoto(INPUT_BUFFER, OUTPUT_DIR); expect(mockReadFile).toHaveBeenCalledWith("/restored/out.png"); }); }); // ═══════════════════════════════════════════════════════════════════════════ // upscale // ═══════════════════════════════════════════════════════════════════════════ describe("upscale", () => { it("calls upscale.py", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 1600, height: 1200, method: "realesrgan", }); await upscale(INPUT_BUFFER, OUTPUT_DIR); const [script] = mockRunPythonWithProgress.mock.calls[0]; expect(script).toBe("upscale.py"); }); it("passes options as JSON", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 3200, height: 2400 }); await upscale(INPUT_BUFFER, OUTPUT_DIR, { scale: 4, model: "realesrgan-x4plus", faceEnhance: true, denoise: 0.5, }); const [, args] = mockRunPythonWithProgress.mock.calls[0]; const optsArg = JSON.parse(args[2]); expect(optsArg.scale).toBe(4); expect(optsArg.model).toBe("realesrgan-x4plus"); expect(optsArg.faceEnhance).toBe(true); expect(optsArg.denoise).toBe(0.5); }); it("returns structured result", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 3200, height: 2400, method: "realesrgan", format: "png", }); const result = await upscale(INPUT_BUFFER, OUTPUT_DIR); expect(result.width).toBe(3200); expect(result.height).toBe(2400); expect(result.method).toBe("realesrgan"); expect(result.format).toBe("png"); }); it("defaults method and format", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 100, height: 100 }); const result = await upscale(INPUT_BUFFER, OUTPUT_DIR); expect(result.method).toBe("unknown"); expect(result.format).toBe("png"); }); it("calculates timeout with GPU rate when GPU available", async () => { mockIsGpuAvailable.mockReturnValue(true); mockParseStdoutJson.mockReturnValue({ success: true, width: 100, height: 100 }); await upscale(INPUT_BUFFER, OUTPUT_DIR, { scale: 2 }); const [, , opts] = mockRunPythonWithProgress.mock.calls[0]; // GPU rate is 30_000 per MP, CPU rate is 180_000 // With GPU, timeout should be lower than CPU expect(opts.timeout).toBeGreaterThanOrEqual(600_000); }); it("calculates higher timeout for CPU mode", async () => { mockIsGpuAvailable.mockReturnValue(false); mockParseStdoutJson.mockReturnValue({ success: true, width: 100, height: 100 }); await upscale(INPUT_BUFFER, OUTPUT_DIR, { scale: 4 }); const [, , opts] = mockRunPythonWithProgress.mock.calls[0]; expect(opts.timeout).toBeGreaterThanOrEqual(600_000); }); it("throws on failure", async () => { mockParseStdoutJson.mockReturnValue({ success: false, error: "RealESRGAN OOM" }); await expect(upscale(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("RealESRGAN OOM"); }); it("provides fallback error message", async () => { mockParseStdoutJson.mockReturnValue({ success: false }); await expect(upscale(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("Upscaling failed"); }); it("uses output_path from result when available", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 100, height: 100, output_path: "/custom/upscaled.webp", }); await upscale(INPUT_BUFFER, OUTPUT_DIR); expect(mockReadFile).toHaveBeenCalledWith("/custom/upscaled.webp"); }); it("defaults scale to 2 for timeout calculation", async () => { mockIsGpuAvailable.mockReturnValue(false); mockParseStdoutJson.mockReturnValue({ success: true, width: 100, height: 100 }); await upscale(INPUT_BUFFER, OUTPUT_DIR); // no scale option const [, , opts] = mockRunPythonWithProgress.mock.calls[0]; // scale defaults to 2, effectiveMp = mp * 4 expect(opts.timeout).toBeGreaterThan(0); }); }); // ═══════════════════════════════════════════════════════════════════════════ // seamCarve (uses caire binary, not Python bridge) // ═══════════════════════════════════════════════════════════════════════════ describe("seamCarve", () => { beforeEach(() => { // Mock execFile for findCaire -- the -help call and the actual carve call mockExecFile.mockResolvedValue({ stdout: "", stderr: "" }); mockReadFile.mockResolvedValue(Buffer.from("carved-output")); }); it("writes input as JPEG", async () => { const chain = createSharpChain(); mockSharp.mockReturnValue(chain); await seamCarve(INPUT_BUFFER, OUTPUT_DIR, { width: 600 }); expect(chain.jpeg).toHaveBeenCalledWith({ quality: 95 }); }); it("passes -width and -height flags", async () => { await seamCarve(INPUT_BUFFER, OUTPUT_DIR, { width: 600, height: 400 }); // The actual carve call (second call -- first is -help for findCaire) const carveCall = mockExecFile.mock.calls.find( (c) => Array.isArray(c[1]) && c[1].includes("-width"), ); expect(carveCall).toBeDefined(); const args = carveCall?.[1] as string[]; expect(args).toContain("-width"); expect(args).toContain("600"); expect(args).toContain("-height"); expect(args).toContain("400"); }); it("passes -face flag when protectFaces is true", async () => { await seamCarve(INPUT_BUFFER, OUTPUT_DIR, { width: 600, protectFaces: true }); const carveCall = mockExecFile.mock.calls.find( (c) => Array.isArray(c[1]) && c[1].includes("-face"), ); expect(carveCall).toBeDefined(); }); it("passes -square flag with shortest dimension", async () => { await seamCarve(INPUT_BUFFER, OUTPUT_DIR, { square: true }); const carveCall = mockExecFile.mock.calls.find( (c) => Array.isArray(c[1]) && c[1].includes("-square"), ); expect(carveCall).toBeDefined(); const args = carveCall?.[1] as string[]; // For 800x600 image, shortest = 600 expect(args).toContain("-width"); expect(args).toContain("600"); }); it("passes blur and sobel options", async () => { await seamCarve(INPUT_BUFFER, OUTPUT_DIR, { width: 600, blurRadius: 3, sobelThreshold: 5, }); const carveCall = mockExecFile.mock.calls.find( (c) => Array.isArray(c[1]) && c[1].includes("-blur"), ); expect(carveCall).toBeDefined(); const args = carveCall?.[1] as string[]; expect(args).toContain("-blur"); expect(args).toContain("3"); expect(args).toContain("-sobel"); expect(args).toContain("5"); }); it("returns buffer with dimensions", async () => { const outChain = createSharpChain({ width: 600, height: 600 }); // First call for input, second for output metadata let callIdx = 0; mockSharp.mockImplementation(() => { callIdx++; if (callIdx >= 3) return outChain; return createSharpChain(); }); const result = await seamCarve(INPUT_BUFFER, OUTPUT_DIR, { width: 600 }); expect(Buffer.isBuffer(result.buffer)).toBe(true); expect(typeof result.width).toBe("number"); expect(typeof result.height).toBe("number"); }); it("cleans up temp files in finally block", async () => { await seamCarve(INPUT_BUFFER, OUTPUT_DIR, { width: 600 }); expect(mockRm).toHaveBeenCalledTimes(2); }); it("cleans up temp files even on error", async () => { // findCaire caches the path after the first successful call, so only // the actual carve invocation needs to be mocked here. mockExecFile.mockRejectedValueOnce(new Error("caire crashed")); await expect(seamCarve(INPUT_BUFFER, OUTPUT_DIR, { width: 600 })).rejects.toThrow( "caire crashed", ); expect(mockRm).toHaveBeenCalledTimes(2); }); it("rejects images larger than 25 MP", async () => { // 6000 x 5000 = 30 MP mockSharp.mockReturnValue(createSharpChain({ width: 6000, height: 5000 })); await expect(seamCarve(INPUT_BUFFER, OUTPUT_DIR, { width: 5000 })).rejects.toThrow( "too large for content-aware resize", ); }); it("rejects reductions larger than 75%", async () => { // 800x600, requesting width: 100 => ratio 0.125 < 0.25 await expect(seamCarve(INPUT_BUFFER, OUTPUT_DIR, { width: 100 })).rejects.toThrow( "cannot reduce dimensions by more than 75%", ); }); it("uses original dimensions when width/height not specified", async () => { await seamCarve(INPUT_BUFFER, OUTPUT_DIR); // Should not throw -- target equals original (800x600) const carveCall = mockExecFile.mock.calls.find( (c) => Array.isArray(c[1]) && c[1].includes("-in"), ); expect(carveCall).toBeDefined(); }); it("calculates timeout based on megapixels", async () => { await seamCarve(INPUT_BUFFER, OUTPUT_DIR, { width: 600 }); const carveCall = mockExecFile.mock.calls.find( (c) => Array.isArray(c[1]) && c[1].includes("-in"), ); expect(carveCall).toBeDefined(); const opts = carveCall?.[2] as { timeout: number }; expect(opts.timeout).toBeGreaterThanOrEqual(120_000); }); it("passes -preview=false", async () => { await seamCarve(INPUT_BUFFER, OUTPUT_DIR, { width: 600 }); const carveCall = mockExecFile.mock.calls.find( (c) => Array.isArray(c[1]) && c[1].includes("-preview=false"), ); expect(carveCall).toBeDefined(); }); }); // ═══════════════════════════════════════════════════════════════════════════ // Cross-cutting: all Python-based tools share common patterns // ═══════════════════════════════════════════════════════════════════════════ describe("cross-cutting tool patterns", () => { it("all Python tools call parseStdoutJson on the result", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 100, height: 100, text: "", facesDetected: 0, faces: [], faceDetected: false, }); // Run each tool await colorize(INPUT_BUFFER, OUTPUT_DIR); await blurFaces(INPUT_BUFFER, OUTPUT_DIR); await enhanceFaces(INPUT_BUFFER, OUTPUT_DIR); await noiseRemoval(INPUT_BUFFER, OUTPUT_DIR); await removeRedEye(INPUT_BUFFER, OUTPUT_DIR); await restorePhoto(INPUT_BUFFER, OUTPUT_DIR); // Each tool calls parseStdoutJson exactly once expect(mockParseStdoutJson).toHaveBeenCalledTimes(6); }); it("all Python tools propagate runPythonWithProgress errors", async () => { mockRunPythonWithProgress.mockRejectedValue(new Error("Python script timed out")); await expect(colorize(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("timed out"); await expect(blurFaces(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("timed out"); await expect(enhanceFaces(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("timed out"); await expect(noiseRemoval(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("timed out"); await expect(removeRedEye(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("timed out"); await expect(restorePhoto(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("timed out"); await expect(upscale(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("timed out"); await expect(extractText(INPUT_BUFFER, OUTPUT_DIR)).rejects.toThrow("timed out"); }); it("all Python tools convert input to PNG via sharp", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 100, height: 100, text: "", facesDetected: 0, faces: [], }); const chain = createSharpChain(); mockSharp.mockReturnValue(chain); await colorize(INPUT_BUFFER, OUTPUT_DIR); expect(chain.png).toHaveBeenCalled(); chain.png.mockClear(); await blurFaces(INPUT_BUFFER, OUTPUT_DIR); expect(chain.png).toHaveBeenCalled(); }); it("all Python tools accept default empty options", async () => { mockParseStdoutJson.mockReturnValue({ success: true, width: 100, height: 100, text: "", facesDetected: 0, faces: [], faceDetected: false, }); // These should not throw due to missing options await colorize(INPUT_BUFFER, OUTPUT_DIR); await blurFaces(INPUT_BUFFER, OUTPUT_DIR); await enhanceFaces(INPUT_BUFFER, OUTPUT_DIR); await noiseRemoval(INPUT_BUFFER, OUTPUT_DIR); await removeRedEye(INPUT_BUFFER, OUTPUT_DIR); await restorePhoto(INPUT_BUFFER, OUTPUT_DIR); await upscale(INPUT_BUFFER, OUTPUT_DIR); await extractText(INPUT_BUFFER, OUTPUT_DIR); await detectFaceLandmarks(INPUT_BUFFER); }); }); // ═══════════════════════════════════════════════════════════════════════════ // seamCarve (imported separately since it uses caire, not Python bridge) // ═══════════════════════════════════════════════════════════════════════════ import { seamCarve } from "../../../packages/ai/src/seam-carving.js";