mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
test: comprehensive test coverage expansion (+965 tests)
Add 42 new test files covering all untested tool routes, image engine internals, AI sidecar bridge, Zustand stores, and cross-format compatibility. Expand e2e-docker suite with 7 spec files covering all 48 tools against a real Docker container. Unit tests: - Image engine: format detection, MIME mapping, metadata parsing, pipeline - AI bridge: sidecar lifecycle, all 11 tool functions (mocked) - Web stores: 14 Zustand stores (collage, settings, features, analytics, etc.) - API helpers: format decoders, page range, file validation Integration tests: - 25 tool routes that had zero dedicated tests - Cross-format matrix: 17 input formats x 3 tools - Edge cases: zero-byte files, corrupted headers, path traversal, XSS, SQL injection - Concurrent request handling and pipeline edge cases E2E-Docker (Playwright against real container): - 7 spec files: essential, adjustment, conversion, creative, utility, AI, pipeline - Custom buildMultipart helper for multi-file tool uploads - AI tools gracefully skip when sidecar not installed Fixtures: - Organized test media: formats/ (18 formats) + content/ (17 content types) - Reduced from 3.1 GB unorganized samples to 33 MB structured fixtures Bug fix: - color-adjustments: gamma exposure used invalid single-param gamma() for positive values; fixed to use two-param gamma(gammaIn, gammaOut) form
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock child_process.spawn before importing the bridge module
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock sharp (required transitively by tool modules)
|
||||
vi.mock("sharp", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
// Helper to create a fake ChildProcess with controllable streams
|
||||
function createMockProcess(): {
|
||||
process: ChildProcess;
|
||||
stdin: Writable;
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
emitEvent: (event: string, ...args: unknown[]) => void;
|
||||
} {
|
||||
const stdin = new Writable({
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback();
|
||||
},
|
||||
});
|
||||
const stdout = new EventEmitter();
|
||||
const stderr = new EventEmitter();
|
||||
|
||||
const proc = new EventEmitter() as unknown as ChildProcess;
|
||||
Object.assign(proc, {
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
pid: 12345,
|
||||
killed: false,
|
||||
kill: vi.fn(() => {
|
||||
(proc as { killed: boolean }).killed = true;
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
process: proc,
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
emitEvent: (event: string, ...args: unknown[]) => proc.emit(event, ...args),
|
||||
};
|
||||
}
|
||||
|
||||
describe("bridge - parseStdoutJson", () => {
|
||||
// parseStdoutJson is a pure function, safe to test without mocking spawn
|
||||
let parseStdoutJson: (stdout: string) => unknown;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Dynamic import to get a fresh module each time
|
||||
const mod = await import("../../../packages/ai/src/bridge.js");
|
||||
parseStdoutJson = mod.parseStdoutJson;
|
||||
});
|
||||
|
||||
it("extracts JSON object from clean stdout", () => {
|
||||
const result = parseStdoutJson('{"success": true, "text": "hello"}');
|
||||
expect(result).toEqual({ success: true, text: "hello" });
|
||||
});
|
||||
|
||||
it("extracts JSON from stdout with leading progress lines", () => {
|
||||
const stdout = [
|
||||
"Loading model...",
|
||||
"Processing: 50%",
|
||||
"Processing: 100%",
|
||||
'{"success": true, "width": 800, "height": 600}',
|
||||
].join("\n");
|
||||
|
||||
const result = parseStdoutJson(stdout);
|
||||
expect(result).toEqual({ success: true, width: 800, height: 600 });
|
||||
});
|
||||
|
||||
it("matches greedily from first brace to last brace", () => {
|
||||
// The regex /\{[\s\S]*\}$/ is greedy: when multiple JSON objects appear
|
||||
// on separate lines it captures from the FIRST '{' to the LAST '}'.
|
||||
// This only works when the earlier lines don't contain braces.
|
||||
const stdout = "some log line\n" + '{"success": true, "result": "final"}';
|
||||
|
||||
const result = parseStdoutJson(stdout);
|
||||
expect(result).toEqual({ success: true, result: "final" });
|
||||
});
|
||||
|
||||
it("throws when multiple JSON objects produce invalid merged JSON", () => {
|
||||
// The greedy regex merges two separate JSON lines into one invalid string
|
||||
const stdout = [
|
||||
'{"progress": 50}',
|
||||
"some log line",
|
||||
'{"success": true, "result": "final"}',
|
||||
].join("\n");
|
||||
|
||||
// This demonstrates the greedy regex limitation
|
||||
expect(() => parseStdoutJson(stdout)).toThrow();
|
||||
});
|
||||
|
||||
it("throws when stdout contains no JSON", () => {
|
||||
expect(() => parseStdoutJson("just some text output")).toThrow(
|
||||
"No JSON response from Python script",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on empty stdout", () => {
|
||||
expect(() => parseStdoutJson("")).toThrow("No JSON response from Python script");
|
||||
});
|
||||
|
||||
it("throws when JSON is malformed", () => {
|
||||
expect(() => parseStdoutJson("{not valid json}")).toThrow();
|
||||
});
|
||||
|
||||
it("handles multiline JSON object", () => {
|
||||
const stdout = `some progress line
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"nested": "value"
|
||||
}
|
||||
}`;
|
||||
const result = parseStdoutJson(stdout);
|
||||
expect(result).toEqual({ success: true, data: { nested: "value" } });
|
||||
});
|
||||
|
||||
it("extracts JSON with special characters in string values", () => {
|
||||
const result = parseStdoutJson('{"text": "hello\\nworld", "path": "/tmp/foo bar.png"}');
|
||||
expect(result).toEqual({ text: "hello\nworld", path: "/tmp/foo bar.png" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("bridge - isGpuAvailable", () => {
|
||||
let isGpuAvailable: () => boolean;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const mod = await import("../../../packages/ai/src/bridge.js");
|
||||
isGpuAvailable = mod.isGpuAvailable;
|
||||
});
|
||||
|
||||
it("returns false by default (no dispatcher started)", () => {
|
||||
// Without starting a dispatcher, GPU should default to false
|
||||
expect(isGpuAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bridge - shutdownDispatcher", () => {
|
||||
let shutdownDispatcher: () => void;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const mod = await import("../../../packages/ai/src/bridge.js");
|
||||
shutdownDispatcher = mod.shutdownDispatcher;
|
||||
});
|
||||
|
||||
it("does not throw when no dispatcher is running", () => {
|
||||
expect(() => shutdownDispatcher()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bridge - runPythonWithProgress (per-request fallback)", () => {
|
||||
let runPythonWithProgress: typeof import("../../../packages/ai/src/bridge.js").runPythonWithProgress;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.mocked(spawn).mockReset();
|
||||
|
||||
const mod = await import("../../../packages/ai/src/bridge.js");
|
||||
runPythonWithProgress = mod.runPythonWithProgress;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("resolves with stdout/stderr on successful exit (code 0)", async () => {
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
|
||||
const promise = runPythonWithProgress("test_script.py", ["arg1", "arg2"]);
|
||||
|
||||
// Simulate Python output then exit
|
||||
mock.stdout.emit("data", Buffer.from('{"success": true}\n'));
|
||||
mock.emitEvent("close", 0, null);
|
||||
|
||||
const result = await promise;
|
||||
expect(result.stdout).toBe('{"success": true}');
|
||||
});
|
||||
|
||||
it("rejects with error message on non-zero exit code", async () => {
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
|
||||
const promise = runPythonWithProgress("test_script.py", []);
|
||||
|
||||
mock.stderr.emit("data", Buffer.from("RuntimeError: model not found\n"));
|
||||
mock.emitEvent("close", 1, null);
|
||||
|
||||
await expect(promise).rejects.toThrow("RuntimeError: model not found");
|
||||
});
|
||||
|
||||
it("rejects with OOM message on exit code 137 (SIGKILL)", async () => {
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
|
||||
const promise = runPythonWithProgress("test_script.py", []);
|
||||
|
||||
mock.emitEvent("close", 137, "SIGKILL");
|
||||
|
||||
await expect(promise).rejects.toThrow("Process killed (out of memory)");
|
||||
});
|
||||
|
||||
it("rejects with segfault message on exit code 139 (SIGSEGV)", async () => {
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
|
||||
const promise = runPythonWithProgress("test_script.py", []);
|
||||
|
||||
mock.emitEvent("close", 139, "SIGSEGV");
|
||||
|
||||
await expect(promise).rejects.toThrow("Process crashed (segmentation fault)");
|
||||
});
|
||||
|
||||
it("rejects with timeout error when process exceeds timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
|
||||
const promise = runPythonWithProgress("test_script.py", [], {
|
||||
timeout: 1000,
|
||||
});
|
||||
|
||||
// Advance past the timeout
|
||||
vi.advanceTimersByTime(1500);
|
||||
|
||||
// The timeout kills the process, then close event fires
|
||||
mock.emitEvent("close", null, "SIGTERM");
|
||||
|
||||
await expect(promise).rejects.toThrow("Python script timed out");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("invokes onProgress callback for JSON progress lines on stderr", async () => {
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
const progressUpdates: Array<{ percent: number; stage: string }> = [];
|
||||
|
||||
const promise = runPythonWithProgress("test_script.py", [], {
|
||||
onProgress: (percent, stage) => {
|
||||
progressUpdates.push({ percent, stage });
|
||||
},
|
||||
});
|
||||
|
||||
// Emit progress lines on stderr (Python convention)
|
||||
mock.stderr.emit("data", Buffer.from('{"progress": 25, "stage": "Loading model"}\n'));
|
||||
mock.stderr.emit("data", Buffer.from('{"progress": 75, "stage": "Processing"}\n'));
|
||||
|
||||
// Emit result and close
|
||||
mock.stdout.emit("data", Buffer.from('{"success": true}\n'));
|
||||
mock.emitEvent("close", 0, null);
|
||||
|
||||
await promise;
|
||||
expect(progressUpdates).toEqual([
|
||||
{ percent: 25, stage: "Loading model" },
|
||||
{ percent: 75, stage: "Processing" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects when spawn emits ENOENT error and fallback also fails", async () => {
|
||||
// runPythonWithProgress does 3 spawn calls in the ENOENT path:
|
||||
// 1. dispatcher spawn (startDispatcher)
|
||||
// 2. per-request venv python spawn
|
||||
// 3. per-request fallback python3 spawn
|
||||
const mockDispatcher = createMockProcess();
|
||||
const mockVenv = createMockProcess();
|
||||
const mockFallback = createMockProcess();
|
||||
let callCount = 0;
|
||||
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return mockDispatcher.process;
|
||||
if (callCount === 2) return mockVenv.process;
|
||||
return mockFallback.process;
|
||||
});
|
||||
|
||||
const promise = runPythonWithProgress("test_script.py", []);
|
||||
|
||||
// Dispatcher spawn fails with ENOENT (marks dispatcherFailed = true)
|
||||
const dispatcherError = new Error("spawn ENOENT") as NodeJS.ErrnoException;
|
||||
dispatcherError.code = "ENOENT";
|
||||
mockDispatcher.emitEvent("error", dispatcherError);
|
||||
|
||||
// Allow microtask queue to process the dispatcher failure and start per-request
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Per-request venv python fails with ENOENT
|
||||
const venvError = new Error("spawn ENOENT") as NodeJS.ErrnoException;
|
||||
venvError.code = "ENOENT";
|
||||
mockVenv.emitEvent("error", venvError);
|
||||
|
||||
// Allow microtask for fallback spawn
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Fallback python3 also fails
|
||||
const fallbackError = new Error("spawn ENOENT") as NodeJS.ErrnoException;
|
||||
fallbackError.code = "ENOENT";
|
||||
mockFallback.emitEvent("error", fallbackError);
|
||||
|
||||
await expect(promise).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("extracts error from JSON stderr when Python writes structured errors", async () => {
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
|
||||
const promise = runPythonWithProgress("test_script.py", []);
|
||||
|
||||
// Python writes a structured error to stdout
|
||||
mock.stdout.emit("data", Buffer.from('{"error": "CUDA out of memory"}\n'));
|
||||
mock.emitEvent("close", 1, null);
|
||||
|
||||
await expect(promise).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("handles stderr output that is not JSON (regular log lines)", async () => {
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
|
||||
const promise = runPythonWithProgress("test_script.py", []);
|
||||
|
||||
// Regular log line, not JSON
|
||||
mock.stderr.emit("data", Buffer.from("Warning: deprecated API\n"));
|
||||
mock.stdout.emit("data", Buffer.from('{"success": true}\n'));
|
||||
mock.emitEvent("close", 0, null);
|
||||
|
||||
const result = await promise;
|
||||
// Stderr contains the warning line
|
||||
expect(result.stderr).toContain("Warning: deprecated API");
|
||||
});
|
||||
|
||||
it("handles chunked stdout data arriving in multiple events", async () => {
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
|
||||
const promise = runPythonWithProgress("test_script.py", []);
|
||||
|
||||
// JSON arrives in two chunks
|
||||
mock.stdout.emit("data", Buffer.from('{"success":'));
|
||||
mock.stdout.emit("data", Buffer.from(" true}\n"));
|
||||
mock.emitEvent("close", 0, null);
|
||||
|
||||
const result = await promise;
|
||||
expect(result.stdout).toBe('{"success": true}');
|
||||
});
|
||||
|
||||
it("extracts last line from Python traceback on non-zero exit", async () => {
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
|
||||
const promise = runPythonWithProgress("test_script.py", []);
|
||||
|
||||
const traceback = [
|
||||
"Traceback (most recent call last):",
|
||||
' File "script.py", line 10, in <module>',
|
||||
' raise ValueError("bad input")',
|
||||
"ValueError: bad input",
|
||||
].join("\n");
|
||||
|
||||
mock.stderr.emit("data", Buffer.from(traceback + "\n"));
|
||||
mock.emitEvent("close", 1, null);
|
||||
|
||||
await expect(promise).rejects.toThrow("ValueError: bad input");
|
||||
});
|
||||
|
||||
it("passes script path and args to spawn correctly", async () => {
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
|
||||
const promise = runPythonWithProgress("remove_bg.py", ["/tmp/in.png", "/tmp/out.png"]);
|
||||
|
||||
mock.stdout.emit("data", Buffer.from('{"success": true}\n'));
|
||||
mock.emitEvent("close", 0, null);
|
||||
|
||||
await promise;
|
||||
|
||||
// spawn is called at least twice: once for dispatcher, once for per-request.
|
||||
// The per-request call (last or second) includes the script path + user args.
|
||||
expect(spawn).toHaveBeenCalled();
|
||||
const allCalls = vi.mocked(spawn).mock.calls;
|
||||
// Find the per-request call that includes our user args
|
||||
const perRequestCall = allCalls.find(
|
||||
(call) =>
|
||||
Array.isArray(call[1]) && call[1].some((arg: string) => arg.includes("/tmp/in.png")),
|
||||
);
|
||||
expect(perRequestCall).toBeDefined();
|
||||
expect(perRequestCall![1]).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("remove_bg.py"),
|
||||
"/tmp/in.png",
|
||||
"/tmp/out.png",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,900 @@
|
||||
import { readFile, rm, unlink, writeFile } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock sharp before any imports that use it
|
||||
vi.mock("sharp", () => {
|
||||
const mockSharp = vi.fn(() => ({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||
}));
|
||||
return { default: mockSharp };
|
||||
});
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
readFile: vi.fn().mockResolvedValue(Buffer.from("mock-output-data")),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
rm: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock the bridge module
|
||||
vi.mock("../../../packages/ai/src/bridge.js", () => ({
|
||||
runPythonWithProgress: vi.fn(),
|
||||
parseStdoutJson: vi.fn(),
|
||||
isGpuAvailable: vi.fn(() => false),
|
||||
shutdownDispatcher: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import tool functions
|
||||
import { removeBackground } from "../../../packages/ai/src/background-removal.js";
|
||||
// Import the mocked bridge functions
|
||||
import { parseStdoutJson, runPythonWithProgress } from "../../../packages/ai/src/bridge.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";
|
||||
|
||||
const FAKE_INPUT = Buffer.from("fake-image-data");
|
||||
const FAKE_OUTPUT_DIR = "/tmp/test-output";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Re-establish fs mock defaults after clearAllMocks wipes them
|
||||
vi.mocked(readFile).mockResolvedValue(Buffer.from("mock-output-data"));
|
||||
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(unlink).mockResolvedValue(undefined);
|
||||
vi.mocked(rm).mockResolvedValue(undefined);
|
||||
|
||||
// Default: runPythonWithProgress resolves with stdout containing success JSON
|
||||
vi.mocked(runPythonWithProgress).mockResolvedValue({
|
||||
stdout: '{"success": true}',
|
||||
stderr: "",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── removeBackground ──────────────────────────────────────────────────
|
||||
|
||||
describe("removeBackground", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({ success: true });
|
||||
});
|
||||
|
||||
it("calls runPythonWithProgress with remove_bg.py", async () => {
|
||||
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(runPythonWithProgress).toHaveBeenCalledWith(
|
||||
"remove_bg.py",
|
||||
expect.arrayContaining([expect.stringContaining("rembg_in_")]),
|
||||
expect.objectContaining({ timeout: expect.any(Number) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes options as JSON string argument", async () => {
|
||||
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR, { model: "u2net" });
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
const optionsArg = args[2];
|
||||
expect(JSON.parse(optionsArg)).toEqual({ model: "u2net" });
|
||||
});
|
||||
|
||||
it("returns the output buffer on success", async () => {
|
||||
const outputBuf = Buffer.from("result-image");
|
||||
vi.mocked(readFile).mockResolvedValueOnce(outputBuf);
|
||||
|
||||
const result = await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
expect(result).toBe(outputBuf);
|
||||
});
|
||||
|
||||
it("throws when Python returns success: false", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: false,
|
||||
error: "Model not loaded",
|
||||
});
|
||||
|
||||
await expect(removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("Model not loaded");
|
||||
});
|
||||
|
||||
it("cleans up temp files on success", async () => {
|
||||
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
// unlink is called for both input and output temp files
|
||||
expect(unlink).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("cleans up temp files on failure", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({ success: false, error: "fail" });
|
||||
|
||||
await expect(removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow();
|
||||
expect(unlink).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("passes onProgress callback to runPythonWithProgress", async () => {
|
||||
const onProgress = vi.fn();
|
||||
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR, {}, onProgress);
|
||||
|
||||
expect(runPythonWithProgress).toHaveBeenCalledWith(
|
||||
"remove_bg.py",
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ onProgress }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses higher timeout for birefnet models", async () => {
|
||||
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR, {
|
||||
model: "birefnet-general",
|
||||
});
|
||||
|
||||
const options = vi.mocked(runPythonWithProgress).mock.calls[0][2];
|
||||
// birefnet base timeout is 600000 vs 300000 for others
|
||||
expect(options.timeout).toBeGreaterThanOrEqual(600000);
|
||||
});
|
||||
});
|
||||
|
||||
// ── upscale ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("upscale", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
width: 1600,
|
||||
height: 1200,
|
||||
method: "realesrgan",
|
||||
format: "png",
|
||||
});
|
||||
});
|
||||
|
||||
it("calls runPythonWithProgress with upscale.py", async () => {
|
||||
await upscale(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(runPythonWithProgress).toHaveBeenCalledWith(
|
||||
"upscale.py",
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("input_upscale.png"),
|
||||
expect.stringContaining("output_upscale.png"),
|
||||
]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns UpscaleResult with dimensions and method", async () => {
|
||||
const result = await upscale(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(result).toEqual({
|
||||
buffer: expect.any(Buffer),
|
||||
width: 1600,
|
||||
height: 1200,
|
||||
method: "realesrgan",
|
||||
format: "png",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes scale and model options as JSON", async () => {
|
||||
await upscale(FAKE_INPUT, FAKE_OUTPUT_DIR, { scale: 4, model: "realesrgan-x4plus" });
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
const optionsArg = args[2];
|
||||
expect(JSON.parse(optionsArg)).toEqual({ scale: 4, model: "realesrgan-x4plus" });
|
||||
});
|
||||
|
||||
it("throws when Python returns success: false", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: false,
|
||||
error: "CUDA error",
|
||||
});
|
||||
|
||||
await expect(upscale(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("CUDA error");
|
||||
});
|
||||
|
||||
it("reads from output_path when Python provides alternate path", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
width: 1600,
|
||||
height: 1200,
|
||||
output_path: "/tmp/test-output/output_upscale.webp",
|
||||
});
|
||||
|
||||
await upscale(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(readFile).toHaveBeenCalledWith("/tmp/test-output/output_upscale.webp");
|
||||
});
|
||||
|
||||
it("defaults method and format when not provided by Python", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
|
||||
const result = await upscale(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
expect(result.method).toBe("unknown");
|
||||
expect(result.format).toBe("png");
|
||||
});
|
||||
});
|
||||
|
||||
// ── extractText (OCR) ─────────────────────────────────────────────────
|
||||
|
||||
describe("extractText (OCR)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
text: "Hello World",
|
||||
engine: "paddleocr",
|
||||
});
|
||||
});
|
||||
|
||||
it("calls runPythonWithProgress with ocr.py", async () => {
|
||||
await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(runPythonWithProgress).toHaveBeenCalledWith(
|
||||
"ocr.py",
|
||||
expect.arrayContaining([expect.stringContaining("input_ocr.png")]),
|
||||
expect.objectContaining({ timeout: expect.any(Number) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns OcrResult with text and engine", async () => {
|
||||
const result = await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(result).toEqual({
|
||||
text: "Hello World",
|
||||
engine: "paddleocr",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes quality and language options", async () => {
|
||||
await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR, {
|
||||
quality: "best",
|
||||
language: "en",
|
||||
});
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
const optionsArg = args[1];
|
||||
expect(JSON.parse(optionsArg)).toEqual({ quality: "best", language: "en" });
|
||||
});
|
||||
|
||||
it("throws when OCR fails", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: false,
|
||||
error: "No text detected",
|
||||
});
|
||||
|
||||
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("No text detected");
|
||||
});
|
||||
|
||||
it("uses fallback error message when no error string provided", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({ success: false });
|
||||
|
||||
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("OCR failed");
|
||||
});
|
||||
});
|
||||
|
||||
// ── colorize ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("colorize", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
width: 800,
|
||||
height: 600,
|
||||
method: "deoldify",
|
||||
});
|
||||
});
|
||||
|
||||
it("calls runPythonWithProgress with colorize.py", async () => {
|
||||
await colorize(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(runPythonWithProgress).toHaveBeenCalledWith(
|
||||
"colorize.py",
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("input_colorize.png"),
|
||||
expect.stringContaining("output_colorize.png"),
|
||||
]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns ColorizeResult with buffer and metadata", async () => {
|
||||
const result = await colorize(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(result).toEqual({
|
||||
buffer: expect.any(Buffer),
|
||||
width: 800,
|
||||
height: 600,
|
||||
method: "deoldify",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes intensity option", async () => {
|
||||
await colorize(FAKE_INPUT, FAKE_OUTPUT_DIR, { intensity: 0.8 });
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
expect(JSON.parse(args[2])).toEqual({ intensity: 0.8 });
|
||||
});
|
||||
|
||||
it("throws when colorization fails", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({ success: false });
|
||||
|
||||
await expect(colorize(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("Colorization failed");
|
||||
});
|
||||
|
||||
it("reads from alternate output_path when provided", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
width: 800,
|
||||
height: 600,
|
||||
output_path: "/tmp/alt-output.png",
|
||||
});
|
||||
|
||||
await colorize(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
expect(readFile).toHaveBeenCalledWith("/tmp/alt-output.png");
|
||||
});
|
||||
});
|
||||
|
||||
// ── blurFaces ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("blurFaces", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
facesDetected: 2,
|
||||
faces: [
|
||||
{ x: 10, y: 20, w: 50, h: 60 },
|
||||
{ x: 100, y: 120, w: 55, h: 65 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("calls runPythonWithProgress with detect_faces.py", async () => {
|
||||
await blurFaces(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(runPythonWithProgress).toHaveBeenCalledWith(
|
||||
"detect_faces.py",
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("input_faces.png"),
|
||||
expect.stringContaining("output_faces.png"),
|
||||
]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns BlurFacesResult with face regions", async () => {
|
||||
const result = await blurFaces(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(result.facesDetected).toBe(2);
|
||||
expect(result.faces).toHaveLength(2);
|
||||
expect(result.faces[0]).toEqual({ x: 10, y: 20, w: 50, h: 60 });
|
||||
expect(result.buffer).toBeInstanceOf(Buffer);
|
||||
});
|
||||
|
||||
it("passes blur options", async () => {
|
||||
await blurFaces(FAKE_INPUT, FAKE_OUTPUT_DIR, {
|
||||
blurRadius: 20,
|
||||
sensitivity: 0.5,
|
||||
});
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
expect(JSON.parse(args[2])).toEqual({ blurRadius: 20, sensitivity: 0.5 });
|
||||
});
|
||||
|
||||
it("returns empty faces array when none detected", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
facesDetected: 0,
|
||||
});
|
||||
|
||||
const result = await blurFaces(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
expect(result.facesDetected).toBe(0);
|
||||
expect(result.faces).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws when detection fails", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({ success: false });
|
||||
|
||||
await expect(blurFaces(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("Face detection failed");
|
||||
});
|
||||
});
|
||||
|
||||
// ── detectFaces ───────────────────────────────────────────────────────
|
||||
|
||||
describe("detectFaces", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
facesDetected: 1,
|
||||
faces: [{ x: 50, y: 50, w: 100, h: 100 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("calls runPythonWithProgress with detectOnly option", async () => {
|
||||
await detectFaces(FAKE_INPUT);
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
const optionsArg = JSON.parse(args[2]);
|
||||
expect(optionsArg.detectOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("returns DetectFacesResult without buffer", async () => {
|
||||
const result = await detectFaces(FAKE_INPUT);
|
||||
|
||||
expect(result).toEqual({
|
||||
facesDetected: 1,
|
||||
faces: [{ x: 50, y: 50, w: 100, h: 100 }],
|
||||
});
|
||||
// detectFaces does not return a buffer (unlike blurFaces)
|
||||
expect(result).not.toHaveProperty("buffer");
|
||||
});
|
||||
|
||||
it("cleans up temp input file after success", async () => {
|
||||
await detectFaces(FAKE_INPUT);
|
||||
expect(unlink).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans up temp input file after failure", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({ success: false });
|
||||
|
||||
await expect(detectFaces(FAKE_INPUT)).rejects.toThrow();
|
||||
expect(unlink).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── enhanceFaces ──────────────────────────────────────────────────────
|
||||
|
||||
describe("enhanceFaces", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
facesDetected: 1,
|
||||
faces: [{ x: 10, y: 20, w: 80, h: 90 }],
|
||||
model: "gfpgan",
|
||||
});
|
||||
});
|
||||
|
||||
it("calls runPythonWithProgress with enhance_faces.py", async () => {
|
||||
await enhanceFaces(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(runPythonWithProgress).toHaveBeenCalledWith(
|
||||
"enhance_faces.py",
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("input_enhance_faces.png"),
|
||||
expect.stringContaining("output_enhance_faces.png"),
|
||||
]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns EnhanceFacesResult with model info", async () => {
|
||||
const result = await enhanceFaces(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(result).toEqual({
|
||||
buffer: expect.any(Buffer),
|
||||
facesDetected: 1,
|
||||
faces: [{ x: 10, y: 20, w: 80, h: 90 }],
|
||||
model: "gfpgan",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes enhancement options", async () => {
|
||||
await enhanceFaces(FAKE_INPUT, FAKE_OUTPUT_DIR, {
|
||||
model: "codeformer",
|
||||
strength: 0.7,
|
||||
onlyCenterFace: true,
|
||||
});
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
expect(JSON.parse(args[2])).toEqual({
|
||||
model: "codeformer",
|
||||
strength: 0.7,
|
||||
onlyCenterFace: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when enhancement fails", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: false,
|
||||
error: "No faces found",
|
||||
});
|
||||
|
||||
await expect(enhanceFaces(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("No faces found");
|
||||
});
|
||||
|
||||
it("defaults model to unknown when not provided by Python", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
facesDetected: 0,
|
||||
});
|
||||
|
||||
const result = await enhanceFaces(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
expect(result.model).toBe("unknown");
|
||||
expect(result.faces).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── detectFaceLandmarks ───────────────────────────────────────────────
|
||||
|
||||
describe("detectFaceLandmarks", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
faceDetected: true,
|
||||
landmarks: {
|
||||
leftEye: { x: 100, y: 150 },
|
||||
rightEye: { x: 200, y: 150 },
|
||||
eyeCenter: { x: 150, y: 150 },
|
||||
chin: { x: 150, y: 300 },
|
||||
forehead: { x: 150, y: 80 },
|
||||
crown: { x: 150, y: 50 },
|
||||
nose: { x: 150, y: 200 },
|
||||
faceCenterX: 150,
|
||||
},
|
||||
imageWidth: 800,
|
||||
imageHeight: 600,
|
||||
});
|
||||
});
|
||||
|
||||
it("calls runPythonWithProgress with face_landmarks.py", async () => {
|
||||
await detectFaceLandmarks(FAKE_INPUT);
|
||||
|
||||
expect(runPythonWithProgress).toHaveBeenCalledWith(
|
||||
"face_landmarks.py",
|
||||
expect.arrayContaining([expect.stringContaining("face_landmarks_"), "unused", "{}"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns FaceLandmarksResult with all landmark points", async () => {
|
||||
const result = await detectFaceLandmarks(FAKE_INPUT);
|
||||
|
||||
expect(result.faceDetected).toBe(true);
|
||||
expect(result.landmarks).toBeDefined();
|
||||
expect(result.landmarks!.leftEye).toEqual({ x: 100, y: 150 });
|
||||
expect(result.landmarks!.rightEye).toEqual({ x: 200, y: 150 });
|
||||
expect(result.imageWidth).toBe(800);
|
||||
expect(result.imageHeight).toBe(600);
|
||||
});
|
||||
|
||||
it("returns null landmarks when no face detected", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
faceDetected: false,
|
||||
imageWidth: 800,
|
||||
imageHeight: 600,
|
||||
});
|
||||
|
||||
const result = await detectFaceLandmarks(FAKE_INPUT);
|
||||
expect(result.faceDetected).toBe(false);
|
||||
expect(result.landmarks).toBeNull();
|
||||
});
|
||||
|
||||
it("does not use sharp to convert to PNG (writes buffer directly)", async () => {
|
||||
await detectFaceLandmarks(FAKE_INPUT);
|
||||
|
||||
// face-landmarks writes inputBuffer directly, no sharp conversion
|
||||
expect(writeFile).toHaveBeenCalledWith(expect.stringContaining("face_landmarks_"), FAKE_INPUT);
|
||||
});
|
||||
|
||||
it("cleans up temp file in finally block", async () => {
|
||||
await detectFaceLandmarks(FAKE_INPUT);
|
||||
expect(unlink).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans up temp file even on failure", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({ success: false });
|
||||
|
||||
await expect(detectFaceLandmarks(FAKE_INPUT)).rejects.toThrow();
|
||||
expect(unlink).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── inpaint ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("inpaint", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({ success: true });
|
||||
});
|
||||
|
||||
it("calls runPythonWithProgress with inpaint.py", async () => {
|
||||
const maskBuffer = Buffer.from("fake-mask");
|
||||
await inpaint(FAKE_INPUT, maskBuffer, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(runPythonWithProgress).toHaveBeenCalledWith(
|
||||
"inpaint.py",
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("input_inpaint.png"),
|
||||
expect.stringContaining("mask_inpaint.png"),
|
||||
expect.stringContaining("output_inpaint.png"),
|
||||
]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("writes both input and mask files", async () => {
|
||||
const maskBuffer = Buffer.from("fake-mask");
|
||||
await inpaint(FAKE_INPUT, maskBuffer, FAKE_OUTPUT_DIR);
|
||||
|
||||
// writeFile called for input and mask
|
||||
expect(writeFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns the output buffer", async () => {
|
||||
const maskBuffer = Buffer.from("fake-mask");
|
||||
const outputBuf = Buffer.from("inpainted-result");
|
||||
vi.mocked(readFile).mockResolvedValueOnce(outputBuf);
|
||||
|
||||
const result = await inpaint(FAKE_INPUT, maskBuffer, FAKE_OUTPUT_DIR);
|
||||
expect(result).toBe(outputBuf);
|
||||
});
|
||||
|
||||
it("throws when inpainting fails", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: false,
|
||||
error: "Mask is empty",
|
||||
});
|
||||
|
||||
const maskBuffer = Buffer.from("fake-mask");
|
||||
await expect(inpaint(FAKE_INPUT, maskBuffer, FAKE_OUTPUT_DIR)).rejects.toThrow("Mask is empty");
|
||||
});
|
||||
|
||||
it("uses fallback error message when no error string", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({ success: false });
|
||||
|
||||
const maskBuffer = Buffer.from("fake-mask");
|
||||
await expect(inpaint(FAKE_INPUT, maskBuffer, FAKE_OUTPUT_DIR)).rejects.toThrow(
|
||||
"Inpainting failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── noiseRemoval ──────────────────────────────────────────────────────
|
||||
|
||||
describe("noiseRemoval", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
width: 800,
|
||||
height: 600,
|
||||
format: "png",
|
||||
tier: "balanced",
|
||||
});
|
||||
});
|
||||
|
||||
it("calls runPythonWithProgress with noise_removal.py", async () => {
|
||||
await noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(runPythonWithProgress).toHaveBeenCalledWith(
|
||||
"noise_removal.py",
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("input_denoise.png"),
|
||||
expect.stringContaining("output_denoise.png"),
|
||||
]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns NoiseRemovalResult with all fields", async () => {
|
||||
const result = await noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(result).toEqual({
|
||||
buffer: expect.any(Buffer),
|
||||
width: 800,
|
||||
height: 600,
|
||||
format: "png",
|
||||
tier: "balanced",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes strength and tier options", async () => {
|
||||
await noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR, {
|
||||
tier: "aggressive",
|
||||
strength: 0.9,
|
||||
});
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
expect(JSON.parse(args[2])).toEqual({ tier: "aggressive", strength: 0.9 });
|
||||
});
|
||||
|
||||
it("defaults tier from options when Python omits it", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
|
||||
const result = await noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR, { tier: "aggressive" });
|
||||
expect(result.tier).toBe("aggressive");
|
||||
expect(result.format).toBe("png");
|
||||
});
|
||||
|
||||
it("throws on failure", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({ success: false });
|
||||
|
||||
await expect(noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("Noise removal failed");
|
||||
});
|
||||
});
|
||||
|
||||
// ── removeRedEye ──────────────────────────────────────────────────────
|
||||
|
||||
describe("removeRedEye", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
facesDetected: 1,
|
||||
eyesCorrected: 2,
|
||||
width: 800,
|
||||
height: 600,
|
||||
format: "png",
|
||||
});
|
||||
});
|
||||
|
||||
it("calls runPythonWithProgress with red_eye_removal.py", async () => {
|
||||
await removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(runPythonWithProgress).toHaveBeenCalledWith(
|
||||
"red_eye_removal.py",
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("input_redeye.png"),
|
||||
expect.stringContaining("output_redeye.png"),
|
||||
]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns RedEyeRemovalResult with correction counts", async () => {
|
||||
const result = await removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(result).toEqual({
|
||||
buffer: expect.any(Buffer),
|
||||
facesDetected: 1,
|
||||
eyesCorrected: 2,
|
||||
width: 800,
|
||||
height: 600,
|
||||
format: "png",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults facesDetected and eyesCorrected to 0", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
|
||||
const result = await removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
expect(result.facesDetected).toBe(0);
|
||||
expect(result.eyesCorrected).toBe(0);
|
||||
});
|
||||
|
||||
it("passes sensitivity and strength options", async () => {
|
||||
await removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR, {
|
||||
sensitivity: 0.8,
|
||||
strength: 0.6,
|
||||
});
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
expect(JSON.parse(args[2])).toEqual({ sensitivity: 0.8, strength: 0.6 });
|
||||
});
|
||||
|
||||
it("throws on failure", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({ success: false });
|
||||
|
||||
await expect(removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow(
|
||||
"Red eye removal failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── restorePhoto ──────────────────────────────────────────────────────
|
||||
|
||||
describe("restorePhoto", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
width: 800,
|
||||
height: 600,
|
||||
steps: ["denoise", "face_enhance"],
|
||||
scratchCoverage: 0.15,
|
||||
facesEnhanced: 2,
|
||||
isGrayscale: true,
|
||||
colorized: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("calls runPythonWithProgress with restore.py", async () => {
|
||||
await restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(runPythonWithProgress).toHaveBeenCalledWith(
|
||||
"restore.py",
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("input_restore.png"),
|
||||
expect.stringContaining("output_restore.png"),
|
||||
]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns RestorePhotoResult with all metadata", async () => {
|
||||
const result = await restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(result).toEqual({
|
||||
buffer: expect.any(Buffer),
|
||||
width: 800,
|
||||
height: 600,
|
||||
steps: ["denoise", "face_enhance"],
|
||||
scratchCoverage: 0.15,
|
||||
facesEnhanced: 2,
|
||||
isGrayscale: true,
|
||||
colorized: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes restoration options including scratch and colorize", async () => {
|
||||
await restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR, {
|
||||
scratchRemoval: true,
|
||||
faceEnhancement: true,
|
||||
colorize: true,
|
||||
});
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
expect(JSON.parse(args[2])).toEqual({
|
||||
scratchRemoval: true,
|
||||
faceEnhancement: true,
|
||||
colorize: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults optional fields when Python omits them", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
width: 400,
|
||||
height: 300,
|
||||
});
|
||||
|
||||
const result = await restorePhoto(FAKE_INPUT, FAKE_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 () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({ success: false });
|
||||
|
||||
await expect(restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow(
|
||||
"Photo restoration failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── error propagation from runPythonWithProgress ──────────────────────
|
||||
|
||||
describe("error propagation from bridge", () => {
|
||||
it("propagates bridge rejection through tool functions", async () => {
|
||||
vi.mocked(runPythonWithProgress).mockRejectedValue(new Error("Python script timed out"));
|
||||
|
||||
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow(
|
||||
"Python script timed out",
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates OOM errors through tool functions", async () => {
|
||||
vi.mocked(runPythonWithProgress).mockRejectedValue(
|
||||
new Error("Process killed (out of memory) -- try a lighter model or smaller image"),
|
||||
);
|
||||
|
||||
await expect(colorize(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("out of memory");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decodeToSharpCompat, needsCliDecode } from "../../../apps/api/src/lib/format-decoders.js";
|
||||
|
||||
// ==========================================================================
|
||||
// needsCliDecode
|
||||
// ==========================================================================
|
||||
|
||||
describe("needsCliDecode", () => {
|
||||
it("returns true for raw format", () => {
|
||||
expect(needsCliDecode("raw")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for ico format", () => {
|
||||
expect(needsCliDecode("ico")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for tga format", () => {
|
||||
expect(needsCliDecode("tga")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for psd format", () => {
|
||||
expect(needsCliDecode("psd")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for exr format", () => {
|
||||
expect(needsCliDecode("exr")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for hdr format", () => {
|
||||
expect(needsCliDecode("hdr")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for jpeg format", () => {
|
||||
expect(needsCliDecode("jpeg")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for png format", () => {
|
||||
expect(needsCliDecode("png")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for webp format", () => {
|
||||
expect(needsCliDecode("webp")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for gif format", () => {
|
||||
expect(needsCliDecode("gif")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for avif format", () => {
|
||||
expect(needsCliDecode("avif")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for svg format", () => {
|
||||
expect(needsCliDecode("svg")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty string", () => {
|
||||
expect(needsCliDecode("")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for unknown format", () => {
|
||||
expect(needsCliDecode("bmp")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// decodeToSharpCompat — routing logic (default passthrough)
|
||||
// ==========================================================================
|
||||
|
||||
describe("decodeToSharpCompat", () => {
|
||||
it("returns buffer unchanged for unknown/native formats", async () => {
|
||||
const buf = Buffer.from("test data");
|
||||
const result = await decodeToSharpCompat(buf, "jpeg");
|
||||
expect(result).toBe(buf);
|
||||
});
|
||||
|
||||
it("returns buffer unchanged for png format", async () => {
|
||||
const buf = Buffer.from("png data");
|
||||
const result = await decodeToSharpCompat(buf, "png");
|
||||
expect(result).toBe(buf);
|
||||
});
|
||||
|
||||
it("returns buffer unchanged for empty format string", async () => {
|
||||
const buf = Buffer.from("some bytes");
|
||||
const result = await decodeToSharpCompat(buf, "");
|
||||
expect(result).toBe(buf);
|
||||
});
|
||||
|
||||
it("returns buffer unchanged for webp format", async () => {
|
||||
const buf = Buffer.from("webp");
|
||||
const result = await decodeToSharpCompat(buf, "webp");
|
||||
expect(result).toBe(buf);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parsePageRange } from "../../../apps/api/src/routes/tools/pdf-to-image.js";
|
||||
|
||||
describe("parsePageRange", () => {
|
||||
// -- "all" / empty → full range -------------------------------------------
|
||||
|
||||
it("returns all pages for empty string", () => {
|
||||
expect(parsePageRange("", 5)).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it("returns all pages for 'all'", () => {
|
||||
expect(parsePageRange("all", 3)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("returns all pages for 'ALL' (case-insensitive)", () => {
|
||||
expect(parsePageRange("ALL", 4)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("returns all pages for ' all ' (trimmed)", () => {
|
||||
expect(parsePageRange(" all ", 2)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
// -- Single pages ---------------------------------------------------------
|
||||
|
||||
it("parses a single page number", () => {
|
||||
expect(parsePageRange("3", 10)).toEqual([3]);
|
||||
});
|
||||
|
||||
it("parses multiple single pages", () => {
|
||||
expect(parsePageRange("1, 3, 5", 10)).toEqual([1, 3, 5]);
|
||||
});
|
||||
|
||||
it("deduplicates repeated pages", () => {
|
||||
expect(parsePageRange("2, 2, 3, 3", 5)).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
it("sorts pages in ascending order", () => {
|
||||
expect(parsePageRange("5, 1, 3", 10)).toEqual([1, 3, 5]);
|
||||
});
|
||||
|
||||
// -- Ranges ---------------------------------------------------------------
|
||||
|
||||
it("parses a simple range", () => {
|
||||
expect(parsePageRange("2-4", 10)).toEqual([2, 3, 4]);
|
||||
});
|
||||
|
||||
it("parses multiple ranges", () => {
|
||||
expect(parsePageRange("1-3, 7-9", 10)).toEqual([1, 2, 3, 7, 8, 9]);
|
||||
});
|
||||
|
||||
it("parses mixed single pages and ranges", () => {
|
||||
expect(parsePageRange("1, 3-5, 8", 10)).toEqual([1, 3, 4, 5, 8]);
|
||||
});
|
||||
|
||||
it("handles ranges with spaces", () => {
|
||||
expect(parsePageRange(" 2 - 4 , 6 ", 10)).toEqual([2, 3, 4, 6]);
|
||||
});
|
||||
|
||||
it("deduplicates overlapping ranges", () => {
|
||||
expect(parsePageRange("1-3, 2-4", 5)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
// -- Edge cases -----------------------------------------------------------
|
||||
|
||||
it("handles single page equal to totalPages", () => {
|
||||
expect(parsePageRange("5", 5)).toEqual([5]);
|
||||
});
|
||||
|
||||
it("handles range ending at totalPages", () => {
|
||||
expect(parsePageRange("3-5", 5)).toEqual([3, 4, 5]);
|
||||
});
|
||||
|
||||
it("handles a range of one page (start equals end)", () => {
|
||||
expect(parsePageRange("3-3", 5)).toEqual([3]);
|
||||
});
|
||||
|
||||
// -- Error cases ----------------------------------------------------------
|
||||
|
||||
it("throws for page exceeding totalPages", () => {
|
||||
expect(() => parsePageRange("6", 5)).toThrow("out of range");
|
||||
});
|
||||
|
||||
it("throws for range exceeding totalPages", () => {
|
||||
expect(() => parsePageRange("3-10", 5)).toThrow("out of range");
|
||||
});
|
||||
|
||||
it("throws for page 0 (pages start at 1)", () => {
|
||||
expect(() => parsePageRange("0", 5)).toThrow("positive");
|
||||
});
|
||||
|
||||
it("throws for negative page number", () => {
|
||||
expect(() => parsePageRange("-1", 5)).toThrow();
|
||||
});
|
||||
|
||||
it("throws for reversed range (start > end)", () => {
|
||||
expect(() => parsePageRange("5-3", 10)).toThrow("start exceeds end");
|
||||
});
|
||||
|
||||
it("throws for non-integer page number", () => {
|
||||
expect(() => parsePageRange("1.5", 5)).toThrow();
|
||||
});
|
||||
|
||||
it("throws for non-numeric input", () => {
|
||||
expect(() => parsePageRange("abc", 5)).toThrow();
|
||||
});
|
||||
|
||||
it("throws for empty segment (trailing comma)", () => {
|
||||
expect(() => parsePageRange("1,", 5)).toThrow("Invalid page range format");
|
||||
});
|
||||
|
||||
it("throws for empty segment (leading comma)", () => {
|
||||
expect(() => parsePageRange(",1", 5)).toThrow("Invalid page range format");
|
||||
});
|
||||
});
|
||||
@@ -354,6 +354,277 @@ describe("validateImageBuffer", () => {
|
||||
const result = await validateImageBuffer(exe);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
// -- Null-byte buffers ----------------------------------------------------
|
||||
|
||||
it("rejects a buffer that is entirely null bytes (small)", async () => {
|
||||
const nullBuf = Buffer.alloc(32); // all zeros
|
||||
const result = await validateImageBuffer(nullBuf);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.reason).toBe("File contains no image data");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a large buffer that is entirely null bytes", async () => {
|
||||
const nullBuf = Buffer.alloc(1024); // all zeros, > 64 bytes
|
||||
const result = await validateImageBuffer(nullBuf);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.reason).toBe("File contains no image data");
|
||||
}
|
||||
});
|
||||
|
||||
// -- SVG detection --------------------------------------------------------
|
||||
|
||||
it("accepts an SVG buffer with valid XML", async () => {
|
||||
const svg = Buffer.from(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="red"/></svg>',
|
||||
);
|
||||
const result = await validateImageBuffer(svg);
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.format).toBe("svg");
|
||||
}
|
||||
});
|
||||
|
||||
// -- HDR text detection ---------------------------------------------------
|
||||
|
||||
it("accepts an HDR buffer with #?RADIANCE header", async () => {
|
||||
// Build a minimal buffer that starts with the HDR magic text
|
||||
const hdrHeader = Buffer.from("#?RADIANCE\n");
|
||||
const padding = Buffer.alloc(100);
|
||||
const hdrBuf = Buffer.concat([hdrHeader, padding]);
|
||||
const result = await validateImageBuffer(hdrBuf);
|
||||
// HDR is a CLI_DECODED_FORMAT, so it bypasses sharp metadata
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.format).toBe("hdr");
|
||||
expect(result.width).toBe(0);
|
||||
expect(result.height).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts an HDR buffer with #?RGBE header", async () => {
|
||||
const hdrHeader = Buffer.from("#?RGBE\n");
|
||||
const padding = Buffer.alloc(100);
|
||||
const hdrBuf = Buffer.concat([hdrHeader, padding]);
|
||||
const result = await validateImageBuffer(hdrBuf);
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.format).toBe("hdr");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not detect HDR for a buffer shorter than 10 bytes", async () => {
|
||||
const shortBuf = Buffer.from("#?RADIAN"); // 8 bytes, too short
|
||||
const result = await validateImageBuffer(shortBuf);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
// -- RAW extension differentiation ----------------------------------------
|
||||
|
||||
it("detects RAW format when TIFF magic bytes + RAW extension (DNG)", async () => {
|
||||
const tiffLE = Buffer.from([0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00]);
|
||||
const result = await validateImageBuffer(tiffLE, "photo.dng");
|
||||
// RAW is a CLI_DECODED_FORMAT, so width/height are 0
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.format).toBe("raw");
|
||||
expect(result.width).toBe(0);
|
||||
expect(result.height).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("detects RAW format with CR2 extension", async () => {
|
||||
const tiffLE = Buffer.from([0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00]);
|
||||
const result = await validateImageBuffer(tiffLE, "photo.cr2");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.format).toBe("raw");
|
||||
}
|
||||
});
|
||||
|
||||
it("detects RAW format with NEF extension", async () => {
|
||||
const tiffLE = Buffer.from([0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00]);
|
||||
const result = await validateImageBuffer(tiffLE, "photo.nef");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.format).toBe("raw");
|
||||
}
|
||||
});
|
||||
|
||||
// -- TGA extension-only detection -----------------------------------------
|
||||
|
||||
it("detects TGA format by extension (no magic bytes)", async () => {
|
||||
// TGA has no magic bytes, so detection is extension-only
|
||||
const randomBytes = Buffer.from([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
|
||||
const result = await validateImageBuffer(randomBytes, "image.tga");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.format).toBe("tga");
|
||||
expect(result.width).toBe(0);
|
||||
expect(result.height).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
// -- CLI decoded format bypass (PSD, EXR) ---------------------------------
|
||||
|
||||
it("detects PSD format and bypasses sharp dimension check", async () => {
|
||||
// PSD magic: "8BPS"
|
||||
const psdBuf = Buffer.alloc(64);
|
||||
psdBuf[0] = 0x38;
|
||||
psdBuf[1] = 0x42;
|
||||
psdBuf[2] = 0x50;
|
||||
psdBuf[3] = 0x53;
|
||||
const result = await validateImageBuffer(psdBuf);
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.format).toBe("psd");
|
||||
expect(result.width).toBe(0);
|
||||
expect(result.height).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("detects EXR format and bypasses sharp dimension check", async () => {
|
||||
// OpenEXR magic bytes
|
||||
const exrBuf = Buffer.alloc(64);
|
||||
exrBuf[0] = 0x76;
|
||||
exrBuf[1] = 0x2f;
|
||||
exrBuf[2] = 0x31;
|
||||
exrBuf[3] = 0x01;
|
||||
const result = await validateImageBuffer(exrBuf);
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.format).toBe("exr");
|
||||
expect(result.width).toBe(0);
|
||||
expect(result.height).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
// -- ICO detection --------------------------------------------------------
|
||||
|
||||
it("detects ICO format via magic bytes", async () => {
|
||||
const icoBuf = Buffer.alloc(64);
|
||||
icoBuf[0] = 0x00;
|
||||
icoBuf[1] = 0x00;
|
||||
icoBuf[2] = 0x01;
|
||||
icoBuf[3] = 0x00;
|
||||
// Need non-zero bytes past first 64 positions to avoid null-byte rejection
|
||||
icoBuf[4] = 0x01;
|
||||
const result = await validateImageBuffer(icoBuf);
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.format).toBe("ico");
|
||||
expect(result.width).toBe(0);
|
||||
expect(result.height).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
// -- AVIF/HEIF ftyp brand verification -----------------------------------
|
||||
|
||||
it("rejects ftyp box with unrecognized brand (not avif/heic)", async () => {
|
||||
// Build a buffer with ftyp at offset 4 but brand "mp41" (not avif or heif)
|
||||
const buf = Buffer.alloc(16);
|
||||
buf.write("ftyp", 4, "ascii");
|
||||
buf.write("mp41", 8, "ascii");
|
||||
const result = await validateImageBuffer(buf);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
// -- JXL detection --------------------------------------------------------
|
||||
|
||||
it("detects JXL ISOBMFF container format", async () => {
|
||||
const jxlBuf = Buffer.alloc(64);
|
||||
// JXL ISOBMFF magic: 00 00 00 0C 4A 58 4C 20
|
||||
jxlBuf[0] = 0x00;
|
||||
jxlBuf[1] = 0x00;
|
||||
jxlBuf[2] = 0x00;
|
||||
jxlBuf[3] = 0x0c;
|
||||
jxlBuf[4] = 0x4a;
|
||||
jxlBuf[5] = 0x58;
|
||||
jxlBuf[6] = 0x4c;
|
||||
jxlBuf[7] = 0x20;
|
||||
// Need non-null bytes to avoid null-byte rejection
|
||||
jxlBuf[8] = 0x01;
|
||||
const result = await validateImageBuffer(jxlBuf);
|
||||
// JXL is not in CLI_DECODED_FORMATS, so sharp metadata may fail
|
||||
expect(result).toBeDefined();
|
||||
if (result.valid) {
|
||||
expect(result.format).toBe("jxl");
|
||||
}
|
||||
});
|
||||
|
||||
it("detects JXL raw codestream format", async () => {
|
||||
const jxlRaw = Buffer.alloc(64);
|
||||
jxlRaw[0] = 0xff;
|
||||
jxlRaw[1] = 0x0a;
|
||||
// Need more non-zero data
|
||||
jxlRaw[2] = 0x01;
|
||||
const result = await validateImageBuffer(jxlRaw);
|
||||
expect(result).toBeDefined();
|
||||
if (result.valid) {
|
||||
expect(result.format).toBe("jxl");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1b. isRawExtension
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("isRawExtension", () => {
|
||||
let isRawExtension: typeof import("../../../apps/api/src/lib/file-validation.js").isRawExtension;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mod = await import("../../../apps/api/src/lib/file-validation.js");
|
||||
isRawExtension = mod.isRawExtension;
|
||||
});
|
||||
|
||||
it("returns true for DNG extension", () => {
|
||||
expect(isRawExtension("dng")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for CR2 extension", () => {
|
||||
expect(isRawExtension("cr2")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for NEF extension", () => {
|
||||
expect(isRawExtension("nef")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for ARW extension", () => {
|
||||
expect(isRawExtension("arw")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for ORF extension", () => {
|
||||
expect(isRawExtension("orf")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for RW2 extension", () => {
|
||||
expect(isRawExtension("rw2")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for uppercase extensions", () => {
|
||||
expect(isRawExtension("DNG")).toBe(true);
|
||||
expect(isRawExtension("CR2")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true with leading dot", () => {
|
||||
expect(isRawExtension(".dng")).toBe(true);
|
||||
expect(isRawExtension(".CR2")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-RAW extensions", () => {
|
||||
expect(isRawExtension("png")).toBe(false);
|
||||
expect(isRawExtension("jpg")).toBe(false);
|
||||
expect(isRawExtension("tiff")).toBe(false);
|
||||
expect(isRawExtension("webp")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty string", () => {
|
||||
expect(isRawExtension("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { detectFormat } from "@ashim/image-engine";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const FORMATS_DIR = path.resolve(__dirname, "../../fixtures/formats");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Format detection via Sharp metadata + magic byte fallback
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("detectFormat", () => {
|
||||
// Formats that Sharp can natively detect via metadata
|
||||
const sharpNativeFormats: Array<{ file: string; expected: string }> = [
|
||||
{ file: "sample.jpg", expected: "jpeg" },
|
||||
{ file: "sample.png", expected: "png" },
|
||||
{ file: "sample.webp", expected: "webp" },
|
||||
{ file: "sample.gif", expected: "gif" },
|
||||
{ file: "sample.avif", expected: "heif" }, // Sharp reports AVIF as "heif"
|
||||
{ file: "sample.tiff", expected: "tiff" },
|
||||
{ file: "sample.svg", expected: "svg" },
|
||||
];
|
||||
|
||||
for (const { file, expected } of sharpNativeFormats) {
|
||||
it(`detects ${file} as "${expected}" via Sharp metadata`, async () => {
|
||||
const buffer = readFileSync(path.join(FORMATS_DIR, file));
|
||||
const format = await detectFormat(buffer);
|
||||
expect(format).toBe(expected);
|
||||
});
|
||||
}
|
||||
|
||||
// Formats that require magic byte fallback because Sharp cannot parse them
|
||||
const magicByteFormats: Array<{ file: string; expected: string }> = [
|
||||
{ file: "sample.bmp", expected: "bmp" },
|
||||
{ file: "sample.ico", expected: "ico" },
|
||||
{ file: "sample.psd", expected: "psd" },
|
||||
{ file: "sample.exr", expected: "exr" },
|
||||
];
|
||||
|
||||
for (const { file, expected } of magicByteFormats) {
|
||||
it(`detects ${file} as "${expected}" via magic bytes`, async () => {
|
||||
const buffer = readFileSync(path.join(FORMATS_DIR, file));
|
||||
const format = await detectFormat(buffer);
|
||||
expect(format).toBe(expected);
|
||||
});
|
||||
}
|
||||
|
||||
// HEIC/HEIF - Sharp may or may not handle these depending on libheif
|
||||
it("detects sample.heic via Sharp or magic bytes", async () => {
|
||||
const buffer = readFileSync(path.join(FORMATS_DIR, "sample.heic"));
|
||||
const format = await detectFormat(buffer);
|
||||
// Sharp may report "heif" or magic bytes may detect "avif" (ftyp box)
|
||||
expect(["heif", "avif"]).toContain(format);
|
||||
});
|
||||
|
||||
it("detects sample.heif via Sharp or magic bytes", async () => {
|
||||
const buffer = readFileSync(path.join(FORMATS_DIR, "sample.heif"));
|
||||
const format = await detectFormat(buffer);
|
||||
expect(["heif", "avif"]).toContain(format);
|
||||
});
|
||||
|
||||
// JXL detection
|
||||
it("detects sample.jxl", async () => {
|
||||
const buffer = readFileSync(path.join(FORMATS_DIR, "sample.jxl"));
|
||||
const format = await detectFormat(buffer);
|
||||
// Sharp may detect "jxl" natively or magic bytes catch it
|
||||
expect(["jxl", "unknown"]).toContain(format);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Synthetic magic byte buffers
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("magic byte detection with synthetic buffers", () => {
|
||||
it("detects PNG magic bytes", async () => {
|
||||
const buf = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
|
||||
const format = await detectFormat(buf);
|
||||
expect(format).toBe("png");
|
||||
});
|
||||
|
||||
it("detects JPEG magic bytes", async () => {
|
||||
const buf = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0]);
|
||||
const format = await detectFormat(buf);
|
||||
expect(format).toBe("jpeg");
|
||||
});
|
||||
|
||||
it("detects GIF magic bytes", async () => {
|
||||
const buf = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0, 0, 0, 0, 0, 0, 0]);
|
||||
const format = await detectFormat(buf);
|
||||
expect(format).toBe("gif");
|
||||
});
|
||||
|
||||
it("detects WEBP magic bytes (RIFF + WEBP)", async () => {
|
||||
// RIFF....WEBP
|
||||
const buf = Buffer.alloc(16);
|
||||
buf[0] = 0x52;
|
||||
buf[1] = 0x49;
|
||||
buf[2] = 0x46;
|
||||
buf[3] = 0x46; // RIFF
|
||||
buf[8] = 0x57;
|
||||
buf[9] = 0x45;
|
||||
buf[10] = 0x42;
|
||||
buf[11] = 0x50; // WEBP
|
||||
const format = await detectFormat(buf);
|
||||
expect(format).toBe("webp");
|
||||
});
|
||||
|
||||
it("rejects RIFF without WEBP signature", async () => {
|
||||
const buf = Buffer.alloc(16);
|
||||
buf[0] = 0x52;
|
||||
buf[1] = 0x49;
|
||||
buf[2] = 0x46;
|
||||
buf[3] = 0x46; // RIFF
|
||||
buf[8] = 0x41;
|
||||
buf[9] = 0x56;
|
||||
buf[10] = 0x49;
|
||||
buf[11] = 0x20; // AVI
|
||||
const format = await detectFormat(buf);
|
||||
// Should not detect as webp; might detect as tiff or unknown
|
||||
expect(format).not.toBe("webp");
|
||||
});
|
||||
|
||||
it("detects little-endian TIFF magic bytes", async () => {
|
||||
const buf = Buffer.from([0x49, 0x49, 0x2a, 0x00, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
const format = await detectFormat(buf);
|
||||
expect(format).toBe("tiff");
|
||||
});
|
||||
|
||||
it("detects big-endian TIFF magic bytes", async () => {
|
||||
const buf = Buffer.from([0x4d, 0x4d, 0x00, 0x2a, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
const format = await detectFormat(buf);
|
||||
expect(format).toBe("tiff");
|
||||
});
|
||||
|
||||
it("detects BMP magic bytes", async () => {
|
||||
const buf = Buffer.from([0x42, 0x4d, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
const format = await detectFormat(buf);
|
||||
expect(format).toBe("bmp");
|
||||
});
|
||||
|
||||
it("detects ICO magic bytes", async () => {
|
||||
const buf = Buffer.from([0x00, 0x00, 0x01, 0x00, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
const format = await detectFormat(buf);
|
||||
expect(format).toBe("ico");
|
||||
});
|
||||
|
||||
it("detects PSD magic bytes", async () => {
|
||||
const buf = Buffer.from([0x38, 0x42, 0x50, 0x53, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
const format = await detectFormat(buf);
|
||||
expect(format).toBe("psd");
|
||||
});
|
||||
|
||||
it("detects OpenEXR magic bytes", async () => {
|
||||
const buf = Buffer.from([0x76, 0x2f, 0x31, 0x01, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
const format = await detectFormat(buf);
|
||||
expect(format).toBe("exr");
|
||||
});
|
||||
|
||||
it("detects AVIF magic bytes (ftyp at offset 4 with avif brand)", async () => {
|
||||
const buf = Buffer.alloc(16);
|
||||
// ftyp at offset 4
|
||||
buf[4] = 0x66;
|
||||
buf[5] = 0x74;
|
||||
buf[6] = 0x79;
|
||||
buf[7] = 0x70;
|
||||
// brand "avif" at offset 8
|
||||
buf[8] = 0x61;
|
||||
buf[9] = 0x76;
|
||||
buf[10] = 0x69;
|
||||
buf[11] = 0x66;
|
||||
const format = await detectFormat(buf);
|
||||
expect(format).toBe("avif");
|
||||
});
|
||||
|
||||
it("detects AVIF magic bytes with avis brand", async () => {
|
||||
const buf = Buffer.alloc(16);
|
||||
buf[4] = 0x66;
|
||||
buf[5] = 0x74;
|
||||
buf[6] = 0x79;
|
||||
buf[7] = 0x70;
|
||||
buf[8] = 0x61;
|
||||
buf[9] = 0x76;
|
||||
buf[10] = 0x69;
|
||||
buf[11] = 0x73; // "avis"
|
||||
const format = await detectFormat(buf);
|
||||
expect(format).toBe("avif");
|
||||
});
|
||||
|
||||
it("rejects ftyp box with non-AVIF brand", async () => {
|
||||
const buf = Buffer.alloc(16);
|
||||
buf[4] = 0x66;
|
||||
buf[5] = 0x74;
|
||||
buf[6] = 0x79;
|
||||
buf[7] = 0x70;
|
||||
buf[8] = 0x69;
|
||||
buf[9] = 0x73;
|
||||
buf[10] = 0x6f;
|
||||
buf[11] = 0x6d; // "isom"
|
||||
const format = await detectFormat(buf);
|
||||
expect(format).not.toBe("avif");
|
||||
});
|
||||
|
||||
it("detects JXL ISOBMFF container magic bytes", async () => {
|
||||
const buf = Buffer.from([0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20, 0, 0, 0, 0]);
|
||||
const format = await detectFormat(buf);
|
||||
expect(format).toBe("jxl");
|
||||
});
|
||||
|
||||
it("detects JXL raw codestream magic bytes", async () => {
|
||||
const buf = Buffer.from([0xff, 0x0a, 0, 0, 0, 0, 0, 0]);
|
||||
const format = await detectFormat(buf);
|
||||
// Note: 0xFF 0x0A starts with 0xFF which also matches JPEG prefix,
|
||||
// but JPEG needs 0xFF 0xD8 0xFF, so the JPEG check fails and JXL wins
|
||||
expect(format).toBe("jxl");
|
||||
});
|
||||
|
||||
it("returns 'unknown' for unrecognized bytes", async () => {
|
||||
const buf = Buffer.from([0xde, 0xad, 0xbe, 0xef, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
const format = await detectFormat(buf);
|
||||
expect(format).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns 'unknown' for empty buffer", async () => {
|
||||
const format = await detectFormat(Buffer.alloc(0));
|
||||
expect(format).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns 'unknown' for very short buffer", async () => {
|
||||
const format = await detectFormat(Buffer.from([0x89]));
|
||||
expect(format).toBe("unknown");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Engine-level tests for processImage.
|
||||
*
|
||||
* The existing operations.test.ts covers individual operations and basic
|
||||
* pipeline chaining extensively. This file focuses on:
|
||||
* - Format conversion via the outputFormat parameter
|
||||
* - Edge cases around operation ordering
|
||||
* - The Operation type contract
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(
|
||||
path.resolve(__dirname, "../../../packages/image-engine/src/index.ts"),
|
||||
);
|
||||
const sharp = require("sharp") as typeof import("sharp").default;
|
||||
|
||||
import { processImage } from "@ashim/image-engine";
|
||||
|
||||
const FIXTURES_DIR = path.resolve(__dirname, "../../fixtures");
|
||||
const FORMATS_DIR = path.resolve(__dirname, "../../fixtures/formats");
|
||||
|
||||
let png200x150: Buffer;
|
||||
let jpg100x100: Buffer;
|
||||
let webp50x50: Buffer;
|
||||
|
||||
beforeAll(() => {
|
||||
png200x150 = readFileSync(path.join(FIXTURES_DIR, "test-200x150.png"));
|
||||
jpg100x100 = readFileSync(path.join(FIXTURES_DIR, "test-100x100.jpg"));
|
||||
webp50x50 = readFileSync(path.join(FIXTURES_DIR, "test-50x50.webp"));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output format conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("processImage output format", () => {
|
||||
it("converts PNG input to JPEG output", async () => {
|
||||
const result = await processImage(png200x150, [], "jpg");
|
||||
expect(result.info.format).toBe("jpeg");
|
||||
expect(result.buffer.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("converts PNG input to WebP output", async () => {
|
||||
const result = await processImage(png200x150, [], "webp");
|
||||
expect(result.info.format).toBe("webp");
|
||||
});
|
||||
|
||||
it("converts PNG input to AVIF output", async () => {
|
||||
const result = await processImage(png200x150, [], "avif");
|
||||
// Sharp reports AVIF as "heif"
|
||||
expect(result.info.format).toBe("heif");
|
||||
});
|
||||
|
||||
it("converts PNG input to TIFF output", async () => {
|
||||
const result = await processImage(png200x150, [], "tiff");
|
||||
expect(result.info.format).toBe("tiff");
|
||||
});
|
||||
|
||||
it("converts PNG input to GIF output", async () => {
|
||||
const result = await processImage(png200x150, [], "gif");
|
||||
expect(result.info.format).toBe("gif");
|
||||
});
|
||||
|
||||
it("converts JPEG input to PNG output", async () => {
|
||||
const result = await processImage(jpg100x100, [], "png");
|
||||
expect(result.info.format).toBe("png");
|
||||
});
|
||||
|
||||
it("converts WebP input to PNG output", async () => {
|
||||
const result = await processImage(webp50x50, [], "png");
|
||||
expect(result.info.format).toBe("png");
|
||||
});
|
||||
|
||||
it("preserves input format when no outputFormat specified", async () => {
|
||||
const result = await processImage(png200x150, []);
|
||||
expect(result.info.format).toBe("png");
|
||||
});
|
||||
|
||||
it("preserves JPEG format when no outputFormat specified", async () => {
|
||||
const result = await processImage(jpg100x100, []);
|
||||
expect(result.info.format).toBe("jpeg");
|
||||
});
|
||||
|
||||
it("throws on unsupported output format", async () => {
|
||||
await expect(processImage(png200x150, [], "bmp" as any)).rejects.toThrow(
|
||||
"Unsupported output format: bmp",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operations + output format combined
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("processImage operations + format conversion", () => {
|
||||
it("resize then convert to JPEG", async () => {
|
||||
const result = await processImage(
|
||||
png200x150,
|
||||
[{ type: "resize", options: { width: 80, height: 60 } }],
|
||||
"jpg",
|
||||
);
|
||||
expect(result.info.width).toBe(80);
|
||||
expect(result.info.height).toBe(60);
|
||||
expect(result.info.format).toBe("jpeg");
|
||||
});
|
||||
|
||||
it("rotate then convert to WebP", async () => {
|
||||
const result = await processImage(
|
||||
png200x150,
|
||||
[{ type: "rotate", options: { angle: 90 } }],
|
||||
"webp",
|
||||
);
|
||||
expect(result.info.width).toBe(150);
|
||||
expect(result.info.height).toBe(200);
|
||||
expect(result.info.format).toBe("webp");
|
||||
});
|
||||
|
||||
it("chain resize + rotate + grayscale + format conversion", async () => {
|
||||
const result = await processImage(
|
||||
png200x150,
|
||||
[
|
||||
{ type: "resize", options: { width: 100, height: 100 } },
|
||||
{ type: "rotate", options: { angle: 180 } },
|
||||
{ type: "grayscale", options: {} },
|
||||
],
|
||||
"tiff",
|
||||
);
|
||||
expect(result.info.width).toBe(100);
|
||||
expect(result.info.height).toBe(100);
|
||||
expect(result.info.format).toBe("tiff");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error propagation
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("processImage error handling", () => {
|
||||
it("throws on unknown operation type", async () => {
|
||||
await expect(processImage(png200x150, [{ type: "fake-op", options: {} }])).rejects.toThrow(
|
||||
"Unknown operation: fake-op",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on invalid image buffer", async () => {
|
||||
await expect(
|
||||
processImage(Buffer.from("garbage data"), [{ type: "grayscale", options: {} }]),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("throws on empty buffer", async () => {
|
||||
await expect(
|
||||
processImage(Buffer.alloc(0), [{ type: "invert", options: {} }]),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("propagates validation errors from operations", async () => {
|
||||
await expect(
|
||||
processImage(png200x150, [{ type: "resize", options: { width: -10 } }]),
|
||||
).rejects.toThrow("Resize width must be greater than 0");
|
||||
});
|
||||
|
||||
it("error in later operation aborts entire pipeline", async () => {
|
||||
await expect(
|
||||
processImage(png200x150, [
|
||||
{ type: "resize", options: { width: 50, height: 50 } },
|
||||
{ type: "brightness", options: { value: 999 } },
|
||||
]),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result shape
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("processImage result", () => {
|
||||
it("returns buffer and info with correct shape", async () => {
|
||||
const result = await processImage(png200x150, [
|
||||
{ type: "resize", options: { width: 60, height: 40 } },
|
||||
]);
|
||||
expect(result.buffer).toBeInstanceOf(Buffer);
|
||||
expect(result.buffer.length).toBeGreaterThan(0);
|
||||
expect(result.info.width).toBe(60);
|
||||
expect(result.info.height).toBe(40);
|
||||
expect(typeof result.info.format).toBe("string");
|
||||
expect(typeof result.info.channels).toBe("number");
|
||||
expect(typeof result.info.size).toBe("number");
|
||||
expect(typeof result.info.hasAlpha).toBe("boolean");
|
||||
expect(result.info.size).toBe(result.buffer.length);
|
||||
});
|
||||
|
||||
it("info.metadata contains expected keys", async () => {
|
||||
const result = await processImage(png200x150, []);
|
||||
expect(result.info.metadata).toBeDefined();
|
||||
expect(typeof result.info.metadata).toBe("object");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-format input processing
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("processImage with different input formats", () => {
|
||||
it("processes JPEG input", async () => {
|
||||
const result = await processImage(jpg100x100, [{ type: "resize", options: { width: 50 } }]);
|
||||
expect(result.info.width).toBe(50);
|
||||
expect(result.buffer.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("processes WebP input", async () => {
|
||||
const result = await processImage(webp50x50, [{ type: "resize", options: { width: 25 } }]);
|
||||
expect(result.info.width).toBe(25);
|
||||
expect(result.buffer.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("processes AVIF input from fixture", async () => {
|
||||
const avif = readFileSync(path.join(FORMATS_DIR, "sample.avif"));
|
||||
const result = await processImage(avif, [{ type: "resize", options: { width: 40 } }]);
|
||||
expect(result.info.width).toBe(40);
|
||||
expect(result.buffer.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("processes GIF input from fixture", async () => {
|
||||
const gif = readFileSync(path.join(FORMATS_DIR, "sample.gif"));
|
||||
const result = await processImage(gif, [{ type: "resize", options: { width: 30 } }]);
|
||||
expect(result.info.width).toBe(30);
|
||||
expect(result.buffer.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("processes TIFF input from fixture", async () => {
|
||||
const tiff = readFileSync(path.join(FORMATS_DIR, "sample.tiff"));
|
||||
const result = await processImage(tiff, [{ type: "resize", options: { width: 30 } }]);
|
||||
expect(result.info.width).toBe(30);
|
||||
expect(result.buffer.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
// sharp is only installed in the image-engine package, so resolve it from there
|
||||
const require = createRequire(
|
||||
path.resolve(__dirname, "../../../packages/image-engine/src/index.ts"),
|
||||
);
|
||||
const sharp = require("sharp") as typeof import("sharp").default;
|
||||
|
||||
import { getImageInfo, parseExif, parseGps, parseXmp, sanitizeValue } from "@ashim/image-engine";
|
||||
|
||||
const FIXTURES_DIR = path.resolve(__dirname, "../../fixtures");
|
||||
|
||||
let png200x150: Buffer;
|
||||
let jpg100x100: Buffer;
|
||||
let webp50x50: Buffer;
|
||||
let jpgWithExif: Buffer;
|
||||
|
||||
beforeAll(() => {
|
||||
png200x150 = readFileSync(path.join(FIXTURES_DIR, "test-200x150.png"));
|
||||
jpg100x100 = readFileSync(path.join(FIXTURES_DIR, "test-100x100.jpg"));
|
||||
webp50x50 = readFileSync(path.join(FIXTURES_DIR, "test-50x50.webp"));
|
||||
jpgWithExif = readFileSync(path.join(FIXTURES_DIR, "test-with-exif.jpg"));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getImageInfo
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("getImageInfo", () => {
|
||||
it("returns correct dimensions for PNG", async () => {
|
||||
const info = await getImageInfo(png200x150);
|
||||
expect(info.width).toBe(200);
|
||||
expect(info.height).toBe(150);
|
||||
});
|
||||
|
||||
it("returns correct format for PNG", async () => {
|
||||
const info = await getImageInfo(png200x150);
|
||||
expect(info.format).toBe("png");
|
||||
});
|
||||
|
||||
it("returns correct format for JPEG", async () => {
|
||||
const info = await getImageInfo(jpg100x100);
|
||||
expect(info.format).toBe("jpeg");
|
||||
});
|
||||
|
||||
it("returns correct dimensions for JPEG", async () => {
|
||||
const info = await getImageInfo(jpg100x100);
|
||||
expect(info.width).toBe(100);
|
||||
expect(info.height).toBe(100);
|
||||
});
|
||||
|
||||
it("returns correct format for WebP", async () => {
|
||||
const info = await getImageInfo(webp50x50);
|
||||
expect(info.format).toBe("webp");
|
||||
});
|
||||
|
||||
it("returns correct dimensions for WebP", async () => {
|
||||
const info = await getImageInfo(webp50x50);
|
||||
expect(info.width).toBe(50);
|
||||
expect(info.height).toBe(50);
|
||||
});
|
||||
|
||||
it("returns correct size matching buffer length", async () => {
|
||||
const info = await getImageInfo(png200x150);
|
||||
expect(info.size).toBe(png200x150.length);
|
||||
});
|
||||
|
||||
it("returns channel count", async () => {
|
||||
const info = await getImageInfo(png200x150);
|
||||
expect(info.channels).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("returns hasAlpha boolean", async () => {
|
||||
const info = await getImageInfo(png200x150);
|
||||
expect(typeof info.hasAlpha).toBe("boolean");
|
||||
});
|
||||
|
||||
it("returns metadata sub-object with expected keys", async () => {
|
||||
const info = await getImageInfo(png200x150);
|
||||
expect(info.metadata).toBeDefined();
|
||||
expect("space" in info.metadata).toBe(true);
|
||||
expect("density" in info.metadata).toBe(true);
|
||||
expect("exif" in info.metadata).toBe(true);
|
||||
expect("icc" in info.metadata).toBe(true);
|
||||
expect("xmp" in info.metadata).toBe(true);
|
||||
});
|
||||
|
||||
it("detects EXIF presence in JPEG with EXIF", async () => {
|
||||
const info = await getImageInfo(jpgWithExif);
|
||||
expect(info.metadata.exif).toBe(true);
|
||||
});
|
||||
|
||||
it("reports no EXIF for plain PNG", async () => {
|
||||
const info = await getImageInfo(png200x150);
|
||||
expect(info.metadata.exif).toBe(false);
|
||||
});
|
||||
|
||||
it("throws for invalid buffer", async () => {
|
||||
await expect(getImageInfo(Buffer.from("not an image"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("returns info for a dynamically created image", async () => {
|
||||
const buf = await sharp({
|
||||
create: { width: 30, height: 20, channels: 4, background: "#ff000080" },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const info = await getImageInfo(buf);
|
||||
expect(info.width).toBe(30);
|
||||
expect(info.height).toBe(20);
|
||||
expect(info.channels).toBe(4);
|
||||
expect(info.hasAlpha).toBe(true);
|
||||
expect(info.format).toBe("png");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sanitizeValue — these tests complement the ones in operations.test.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("sanitizeValue", () => {
|
||||
it("converts Date to ISO string", () => {
|
||||
const d = new Date("2025-06-15T12:00:00Z");
|
||||
expect(sanitizeValue(d)).toBe("2025-06-15T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("converts small Buffer to number array", () => {
|
||||
const buf = Buffer.from([10, 20, 30]);
|
||||
expect(sanitizeValue(buf)).toEqual([10, 20, 30]);
|
||||
});
|
||||
|
||||
it("converts large Buffer (>256 bytes) to placeholder", () => {
|
||||
const buf = Buffer.alloc(512, 0xab);
|
||||
expect(sanitizeValue(buf)).toBe("<binary 512 bytes>");
|
||||
});
|
||||
|
||||
it("handles Buffer at exactly 256 bytes boundary", () => {
|
||||
const buf = Buffer.alloc(256, 0xcd);
|
||||
// 256 is not > 256, so it should convert to array
|
||||
expect(Array.isArray(sanitizeValue(buf))).toBe(true);
|
||||
});
|
||||
|
||||
it("handles Buffer at 257 bytes (just over boundary)", () => {
|
||||
const buf = Buffer.alloc(257, 0xef);
|
||||
expect(sanitizeValue(buf)).toBe("<binary 257 bytes>");
|
||||
});
|
||||
|
||||
it("recursively sanitizes arrays", () => {
|
||||
const d = new Date("2025-01-01T00:00:00Z");
|
||||
expect(sanitizeValue([d, 42, "text"])).toEqual(["2025-01-01T00:00:00.000Z", 42, "text"]);
|
||||
});
|
||||
|
||||
it("recursively sanitizes nested objects", () => {
|
||||
const result = sanitizeValue({
|
||||
a: new Date("2025-03-01T00:00:00Z"),
|
||||
b: { c: Buffer.from([1, 2]) },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
a: "2025-03-01T00:00:00.000Z",
|
||||
b: { c: [1, 2] },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes through null", () => {
|
||||
expect(sanitizeValue(null)).toBe(null);
|
||||
});
|
||||
|
||||
it("passes through undefined", () => {
|
||||
expect(sanitizeValue(undefined)).toBe(undefined);
|
||||
});
|
||||
|
||||
it("passes through booleans", () => {
|
||||
expect(sanitizeValue(true)).toBe(true);
|
||||
expect(sanitizeValue(false)).toBe(false);
|
||||
});
|
||||
|
||||
it("passes through numbers", () => {
|
||||
expect(sanitizeValue(0)).toBe(0);
|
||||
expect(sanitizeValue(3.14)).toBe(3.14);
|
||||
expect(sanitizeValue(-99)).toBe(-99);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseExif
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("parseExif", () => {
|
||||
it("parses EXIF from test fixture with metadata fields", async () => {
|
||||
const metadata = await sharp(jpgWithExif).metadata();
|
||||
expect(metadata.exif).toBeTruthy();
|
||||
const result = parseExif(metadata.exif!);
|
||||
expect(result.image).toBeDefined();
|
||||
expect(result.photo).toBeDefined();
|
||||
expect(result.iop).toBeDefined();
|
||||
expect(result.gps).toBeDefined();
|
||||
// Test fixture has known fields
|
||||
expect(result.image.Artist).toBe("Test Artist");
|
||||
expect(result.image.Copyright).toBe("2026 Test Copyright");
|
||||
});
|
||||
|
||||
it("returns empty sections for empty buffer", () => {
|
||||
const result = parseExif(Buffer.alloc(0));
|
||||
expect(result.image).toEqual({});
|
||||
expect(result.photo).toEqual({});
|
||||
expect(result.iop).toEqual({});
|
||||
expect(result.gps).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty sections for null-like buffer", () => {
|
||||
// Passing a minimal buffer that won't parse as valid EXIF
|
||||
const result = parseExif(Buffer.from([0x00, 0x00]));
|
||||
expect(result.image).toEqual({});
|
||||
expect(result.photo).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseGps
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("parseGps", () => {
|
||||
it("parses north-east coordinates", () => {
|
||||
const result = parseGps({
|
||||
GPSLatitude: [40, 44, 54],
|
||||
GPSLatitudeRef: "N",
|
||||
GPSLongitude: [73, 59, 8.4],
|
||||
GPSLongitudeRef: "W",
|
||||
});
|
||||
expect(result.latitude).toBeCloseTo(40.7483, 3);
|
||||
expect(result.longitude).toBeCloseTo(-73.9857, 3);
|
||||
expect(result.altitude).toBeNull();
|
||||
});
|
||||
|
||||
it("parses southern hemisphere with altitude", () => {
|
||||
const result = parseGps({
|
||||
GPSLatitude: [33, 51, 54],
|
||||
GPSLatitudeRef: "S",
|
||||
GPSLongitude: [151, 12, 36],
|
||||
GPSLongitudeRef: "E",
|
||||
GPSAltitude: 25,
|
||||
GPSAltitudeRef: 0,
|
||||
});
|
||||
expect(result.latitude).toBeCloseTo(-33.865, 2);
|
||||
expect(result.longitude).toBeCloseTo(151.21, 2);
|
||||
expect(result.altitude).toBe(25);
|
||||
});
|
||||
|
||||
it("returns negative altitude for below sea level", () => {
|
||||
const result = parseGps({
|
||||
GPSLatitude: [31, 30, 0],
|
||||
GPSLatitudeRef: "N",
|
||||
GPSLongitude: [35, 28, 0],
|
||||
GPSLongitudeRef: "E",
|
||||
GPSAltitude: 400,
|
||||
GPSAltitudeRef: 1, // below sea level
|
||||
});
|
||||
expect(result.altitude).toBe(-400);
|
||||
});
|
||||
|
||||
it("returns nulls for empty GPS data", () => {
|
||||
const result = parseGps({});
|
||||
expect(result.latitude).toBeNull();
|
||||
expect(result.longitude).toBeNull();
|
||||
expect(result.altitude).toBeNull();
|
||||
});
|
||||
|
||||
it("handles missing longitude", () => {
|
||||
const result = parseGps({
|
||||
GPSLatitude: [51, 30, 0],
|
||||
GPSLatitudeRef: "N",
|
||||
});
|
||||
expect(result.latitude).toBeCloseTo(51.5, 1);
|
||||
expect(result.longitude).toBeNull();
|
||||
});
|
||||
|
||||
it("handles missing latitude", () => {
|
||||
const result = parseGps({
|
||||
GPSLongitude: [0, 7, 39.6],
|
||||
GPSLongitudeRef: "W",
|
||||
});
|
||||
expect(result.latitude).toBeNull();
|
||||
expect(result.longitude).toBeCloseTo(-0.1277, 3);
|
||||
});
|
||||
|
||||
it("ignores invalid (NaN) GPS values", () => {
|
||||
const result = parseGps({
|
||||
GPSLatitude: [NaN, 30, 0],
|
||||
GPSLatitudeRef: "N",
|
||||
GPSLongitude: [0, NaN, 39.6],
|
||||
GPSLongitudeRef: "W",
|
||||
});
|
||||
expect(result.latitude).toBeNull();
|
||||
expect(result.longitude).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores wrong-length GPS arrays", () => {
|
||||
const result = parseGps({
|
||||
GPSLatitude: [51, 30],
|
||||
GPSLatitudeRef: "N",
|
||||
});
|
||||
expect(result.latitude).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseXmp
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("parseXmp", () => {
|
||||
it("extracts key-value pairs from XMP XML", () => {
|
||||
const xml = Buffer.from(
|
||||
'<x:xmpmeta xmlns:x="adobe:ns:meta/">' +
|
||||
'<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">' +
|
||||
'<rdf:Description dc:creator="Bob" dc:title="Sunset" />' +
|
||||
"</rdf:RDF></x:xmpmeta>",
|
||||
);
|
||||
const result = parseXmp(xml);
|
||||
expect(result["dc:creator"]).toBe("Bob");
|
||||
expect(result["dc:title"]).toBe("Sunset");
|
||||
});
|
||||
|
||||
it("filters out xmlns: namespace declarations", () => {
|
||||
const xml = Buffer.from(
|
||||
'<x:xmpmeta xmlns:x="adobe:ns:meta/" xmlns:dc="http://purl.org/dc/elements/1.1/">' +
|
||||
'<rdf:Description dc:format="image/png" />' +
|
||||
"</x:xmpmeta>",
|
||||
);
|
||||
const result = parseXmp(xml);
|
||||
expect(result["xmlns:x"]).toBeUndefined();
|
||||
expect(result["xmlns:dc"]).toBeUndefined();
|
||||
expect(result["dc:format"]).toBe("image/png");
|
||||
});
|
||||
|
||||
it("filters out rdf: prefixed attributes", () => {
|
||||
const xml = Buffer.from('<rdf:Description rdf:about="" dc:subject="test" />');
|
||||
const result = parseXmp(xml);
|
||||
expect(result["rdf:about"]).toBeUndefined();
|
||||
expect(result["dc:subject"]).toBe("test");
|
||||
});
|
||||
|
||||
it("returns empty object for empty buffer", () => {
|
||||
expect(parseXmp(Buffer.alloc(0))).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for non-XML content", () => {
|
||||
expect(parseXmp(Buffer.from("hello world"))).toEqual({});
|
||||
});
|
||||
|
||||
it("extracts multiple namespaced attributes", () => {
|
||||
const xml = Buffer.from(
|
||||
"<rdf:Description " +
|
||||
'xmp:CreateDate="2025-01-01" ' +
|
||||
'xmp:ModifyDate="2025-06-01" ' +
|
||||
'photoshop:ColorMode="3" />',
|
||||
);
|
||||
const result = parseXmp(xml);
|
||||
expect(result["xmp:CreateDate"]).toBe("2025-01-01");
|
||||
expect(result["xmp:ModifyDate"]).toBe("2025-06-01");
|
||||
expect(result["photoshop:ColorMode"]).toBe("3");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { extToMime, formatToExt, formatToMime, mimeToExt } from "@ashim/image-engine";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extToMime — file extension to MIME type
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("extToMime", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["jpg", "image/jpeg"],
|
||||
["jpeg", "image/jpeg"],
|
||||
["png", "image/png"],
|
||||
["webp", "image/webp"],
|
||||
["avif", "image/avif"],
|
||||
["tiff", "image/tiff"],
|
||||
["tif", "image/tiff"],
|
||||
["gif", "image/gif"],
|
||||
["bmp", "image/bmp"],
|
||||
["svg", "image/svg+xml"],
|
||||
["ico", "image/x-icon"],
|
||||
["heif", "image/heif"],
|
||||
["heic", "image/heic"],
|
||||
["jxl", "image/jxl"],
|
||||
["dng", "image/x-adobe-dng"],
|
||||
["cr2", "image/x-canon-cr2"],
|
||||
["nef", "image/x-nikon-nef"],
|
||||
["arw", "image/x-sony-arw"],
|
||||
["orf", "image/x-olympus-orf"],
|
||||
["rw2", "image/x-panasonic-rw2"],
|
||||
["tga", "image/x-tga"],
|
||||
["psd", "image/vnd.adobe.photoshop"],
|
||||
["exr", "image/x-exr"],
|
||||
["hdr", "image/vnd.radiance"],
|
||||
];
|
||||
|
||||
for (const [ext, mime] of cases) {
|
||||
it(`maps "${ext}" to "${mime}"`, () => {
|
||||
expect(extToMime(ext)).toBe(mime);
|
||||
});
|
||||
}
|
||||
|
||||
it("normalizes uppercase extensions", () => {
|
||||
expect(extToMime("PNG")).toBe("image/png");
|
||||
expect(extToMime("JPG")).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("strips leading dot from extension", () => {
|
||||
expect(extToMime(".png")).toBe("image/png");
|
||||
expect(extToMime(".jpg")).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("returns application/octet-stream for unknown extension", () => {
|
||||
expect(extToMime("xyz")).toBe("application/octet-stream");
|
||||
expect(extToMime("")).toBe("application/octet-stream");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mimeToExt — MIME type to file extension
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("mimeToExt", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["image/jpeg", "jpg"],
|
||||
["image/png", "png"],
|
||||
["image/webp", "webp"],
|
||||
["image/avif", "avif"],
|
||||
["image/tiff", "tiff"],
|
||||
["image/gif", "gif"],
|
||||
["image/bmp", "bmp"],
|
||||
["image/svg+xml", "svg"],
|
||||
["image/x-icon", "ico"],
|
||||
["image/heif", "heif"],
|
||||
["image/heic", "heic"],
|
||||
["image/jxl", "jxl"],
|
||||
["image/x-adobe-dng", "dng"],
|
||||
["image/x-canon-cr2", "cr2"],
|
||||
["image/x-nikon-nef", "nef"],
|
||||
["image/x-sony-arw", "arw"],
|
||||
["image/x-olympus-orf", "orf"],
|
||||
["image/x-panasonic-rw2", "rw2"],
|
||||
["image/x-tga", "tga"],
|
||||
["image/vnd.adobe.photoshop", "psd"],
|
||||
["image/x-exr", "exr"],
|
||||
["image/vnd.radiance", "hdr"],
|
||||
];
|
||||
|
||||
for (const [mime, ext] of cases) {
|
||||
it(`maps "${mime}" to "${ext}"`, () => {
|
||||
expect(mimeToExt(mime)).toBe(ext);
|
||||
});
|
||||
}
|
||||
|
||||
it("normalizes uppercase MIME types", () => {
|
||||
expect(mimeToExt("IMAGE/JPEG")).toBe("jpg");
|
||||
expect(mimeToExt("Image/PNG")).toBe("png");
|
||||
});
|
||||
|
||||
it("returns 'bin' for unknown MIME type", () => {
|
||||
expect(mimeToExt("application/pdf")).toBe("bin");
|
||||
expect(mimeToExt("text/plain")).toBe("bin");
|
||||
expect(mimeToExt("")).toBe("bin");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatToMime — Sharp format string to MIME type
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("formatToMime", () => {
|
||||
it("maps 'jpeg' (Sharp format) to 'image/jpeg'", () => {
|
||||
expect(formatToMime("jpeg")).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("maps 'png' to 'image/png'", () => {
|
||||
expect(formatToMime("png")).toBe("image/png");
|
||||
});
|
||||
|
||||
it("maps 'webp' to 'image/webp'", () => {
|
||||
expect(formatToMime("webp")).toBe("image/webp");
|
||||
});
|
||||
|
||||
it("maps 'gif' to 'image/gif'", () => {
|
||||
expect(formatToMime("gif")).toBe("image/gif");
|
||||
});
|
||||
|
||||
it("maps 'tiff' to 'image/tiff'", () => {
|
||||
expect(formatToMime("tiff")).toBe("image/tiff");
|
||||
});
|
||||
|
||||
it("maps 'avif' to 'image/avif'", () => {
|
||||
expect(formatToMime("avif")).toBe("image/avif");
|
||||
});
|
||||
|
||||
it("maps 'svg' to 'image/svg+xml'", () => {
|
||||
expect(formatToMime("svg")).toBe("image/svg+xml");
|
||||
});
|
||||
|
||||
it("returns application/octet-stream for unknown format", () => {
|
||||
expect(formatToMime("unknown")).toBe("application/octet-stream");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatToExt — Sharp format string to file extension
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("formatToExt", () => {
|
||||
it("maps 'jpeg' to 'jpg'", () => {
|
||||
expect(formatToExt("jpeg")).toBe("jpg");
|
||||
});
|
||||
|
||||
it("maps 'png' to 'png' (identity)", () => {
|
||||
expect(formatToExt("png")).toBe("png");
|
||||
});
|
||||
|
||||
it("maps 'webp' to 'webp' (identity)", () => {
|
||||
expect(formatToExt("webp")).toBe("webp");
|
||||
});
|
||||
|
||||
it("maps 'gif' to 'gif' (identity)", () => {
|
||||
expect(formatToExt("gif")).toBe("gif");
|
||||
});
|
||||
|
||||
it("maps 'tiff' to 'tiff' (identity)", () => {
|
||||
expect(formatToExt("tiff")).toBe("tiff");
|
||||
});
|
||||
|
||||
it("normalizes case", () => {
|
||||
expect(formatToExt("JPEG")).toBe("jpg");
|
||||
expect(formatToExt("PNG")).toBe("png");
|
||||
});
|
||||
|
||||
it("returns the format itself for unknown formats", () => {
|
||||
expect(formatToExt("bmp")).toBe("bmp");
|
||||
expect(formatToExt("xyz")).toBe("xyz");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
get length() {
|
||||
return 0;
|
||||
},
|
||||
key: vi.fn(() => null),
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// formatFileSize (download.ts)
|
||||
// ==========================================================================
|
||||
|
||||
import { formatFileSize } from "@/lib/download";
|
||||
|
||||
describe("formatFileSize", () => {
|
||||
it("formats bytes under 1 MB as KB", () => {
|
||||
expect(formatFileSize(512)).toBe("1 KB");
|
||||
expect(formatFileSize(1024)).toBe("1 KB");
|
||||
expect(formatFileSize(10240)).toBe("10 KB");
|
||||
expect(formatFileSize(512000)).toBe("500 KB");
|
||||
});
|
||||
|
||||
it("formats bytes at or above 1 MB as MB", () => {
|
||||
expect(formatFileSize(1048576)).toBe("1.0 MB");
|
||||
expect(formatFileSize(1572864)).toBe("1.5 MB");
|
||||
expect(formatFileSize(10485760)).toBe("10.0 MB");
|
||||
});
|
||||
|
||||
it("handles zero bytes", () => {
|
||||
expect(formatFileSize(0)).toBe("0 KB");
|
||||
});
|
||||
|
||||
it("formats values just below 1 MB threshold", () => {
|
||||
expect(formatFileSize(1048575)).toBe("1024 KB");
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// formatExifValue / exifStr (metadata-utils.ts)
|
||||
// ==========================================================================
|
||||
|
||||
import { exifStr, formatExifValue, SKIP_KEYS } from "@/lib/metadata-utils";
|
||||
|
||||
describe("formatExifValue", () => {
|
||||
it("returns 'N/A' for null", () => {
|
||||
expect(formatExifValue("any", null)).toBe("N/A");
|
||||
});
|
||||
|
||||
it("returns 'N/A' for undefined", () => {
|
||||
expect(formatExifValue("any", undefined)).toBe("N/A");
|
||||
});
|
||||
|
||||
it("returns string values as-is", () => {
|
||||
expect(formatExifValue("Make", "Canon")).toBe("Canon");
|
||||
});
|
||||
|
||||
it("formats ExposureTime as fraction", () => {
|
||||
expect(formatExifValue("ExposureTime", 0.004)).toBe("1/250s");
|
||||
expect(formatExifValue("ExposureTime", 0.0125)).toBe("1/80s");
|
||||
});
|
||||
|
||||
it("formats ExposureTime >= 1 as plain number", () => {
|
||||
expect(formatExifValue("ExposureTime", 2)).toBe("2");
|
||||
});
|
||||
|
||||
it("formats FNumber with f/ prefix", () => {
|
||||
expect(formatExifValue("FNumber", 2.8)).toBe("f/2.8");
|
||||
});
|
||||
|
||||
it("formats FocalLength with mm suffix", () => {
|
||||
expect(formatExifValue("FocalLength", 50)).toBe("50mm");
|
||||
expect(formatExifValue("FocalLengthIn35mmFormat", 85)).toBe("85mm");
|
||||
});
|
||||
|
||||
it("returns plain number string for other numeric keys", () => {
|
||||
expect(formatExifValue("ISO", 400)).toBe("400");
|
||||
});
|
||||
|
||||
it("joins short arrays with commas", () => {
|
||||
expect(formatExifValue("Keywords", ["nature", "sunset"])).toBe("nature, sunset");
|
||||
});
|
||||
|
||||
it("summarizes long arrays", () => {
|
||||
const arr = [1, 2, 3, 4, 5, 6, 7];
|
||||
expect(formatExifValue("SomeField", arr)).toBe("[7 values]");
|
||||
});
|
||||
|
||||
it("handles arrays of exactly 6 items", () => {
|
||||
const arr = [1, 2, 3, 4, 5, 6];
|
||||
expect(formatExifValue("SomeField", arr)).toBe("1, 2, 3, 4, 5, 6");
|
||||
});
|
||||
|
||||
it("stringifies other types", () => {
|
||||
expect(formatExifValue("unknown", true)).toBe("true");
|
||||
expect(formatExifValue("unknown", { nested: 1 })).toBe("[object Object]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exifStr", () => {
|
||||
it("returns string value from exif object", () => {
|
||||
expect(exifStr({ Make: "Nikon" }, "Make")).toBe("Nikon");
|
||||
});
|
||||
|
||||
it("returns stringified number from exif object", () => {
|
||||
expect(exifStr({ ISO: 800 }, "ISO")).toBe("800");
|
||||
});
|
||||
|
||||
it("returns empty string for missing key", () => {
|
||||
expect(exifStr({ Make: "Canon" }, "Model")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for null exif", () => {
|
||||
expect(exifStr(null, "Make")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for undefined exif", () => {
|
||||
expect(exifStr(undefined, "Make")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for non-string/number values", () => {
|
||||
expect(exifStr({ Keywords: ["a", "b"] }, "Keywords")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SKIP_KEYS", () => {
|
||||
it("contains expected internal keys", () => {
|
||||
expect(SKIP_KEYS.has("ExifToolVersion")).toBe(true);
|
||||
expect(SKIP_KEYS.has("FileName")).toBe(true);
|
||||
expect(SKIP_KEYS.has("ThumbnailImage")).toBe(true);
|
||||
expect(SKIP_KEYS.has("MakerNote")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not contain display keys", () => {
|
||||
expect(SKIP_KEYS.has("Make")).toBe(false);
|
||||
expect(SKIP_KEYS.has("Model")).toBe(false);
|
||||
expect(SKIP_KEYS.has("ISO")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// getSuggestedTools (suggested-tools.ts)
|
||||
// ==========================================================================
|
||||
|
||||
import { getSuggestedTools } from "@/lib/suggested-tools";
|
||||
|
||||
describe("getSuggestedTools", () => {
|
||||
it("returns suggestions for a known tool", () => {
|
||||
const suggestions = getSuggestedTools("resize");
|
||||
expect(suggestions).toContain("compress");
|
||||
expect(suggestions).toContain("convert");
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns suggestions for compress", () => {
|
||||
const suggestions = getSuggestedTools("compress");
|
||||
expect(suggestions).toContain("convert");
|
||||
});
|
||||
|
||||
it("returns default fallback for an unknown tool", () => {
|
||||
const suggestions = getSuggestedTools("nonexistent-tool");
|
||||
expect(suggestions).toEqual(["resize", "compress", "convert"]);
|
||||
});
|
||||
|
||||
it("returns suggestions for remove-background", () => {
|
||||
const suggestions = getSuggestedTools("remove-background");
|
||||
expect(suggestions).toContain("resize");
|
||||
expect(suggestions).toContain("compress");
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// needsServerPreview (image-preview.ts)
|
||||
// ==========================================================================
|
||||
|
||||
import { needsServerPreview } from "@/lib/image-preview";
|
||||
|
||||
describe("needsServerPreview", () => {
|
||||
it("returns true for HEIC files", () => {
|
||||
const file = new File([], "photo.heic", { type: "image/heic" });
|
||||
expect(needsServerPreview(file)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for HEIF files", () => {
|
||||
const file = new File([], "photo.heif", { type: "image/heif" });
|
||||
expect(needsServerPreview(file)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for JPEG XL files", () => {
|
||||
const file = new File([], "photo.jxl", { type: "image/jxl" });
|
||||
expect(needsServerPreview(file)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for camera RAW formats", () => {
|
||||
for (const ext of ["dng", "cr2", "nef", "arw", "orf", "rw2"]) {
|
||||
const file = new File([], `photo.${ext}`);
|
||||
expect(needsServerPreview(file)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns true for PSD files", () => {
|
||||
const file = new File([], "design.psd");
|
||||
expect(needsServerPreview(file)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for EXR files", () => {
|
||||
const file = new File([], "render.exr");
|
||||
expect(needsServerPreview(file)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for HDR files", () => {
|
||||
const file = new File([], "panorama.hdr");
|
||||
expect(needsServerPreview(file)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for TGA files", () => {
|
||||
const file = new File([], "texture.tga");
|
||||
expect(needsServerPreview(file)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for ICO files", () => {
|
||||
const file = new File([], "favicon.ico");
|
||||
expect(needsServerPreview(file)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for standard browser-supported formats", () => {
|
||||
for (const ext of ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"]) {
|
||||
const file = new File([], `image.${ext}`);
|
||||
expect(needsServerPreview(file)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false for files with no extension", () => {
|
||||
const file = new File([], "noext");
|
||||
expect(needsServerPreview(file)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Collage template helpers (collage-templates.ts)
|
||||
// ==========================================================================
|
||||
|
||||
import {
|
||||
COLLAGE_TEMPLATES,
|
||||
getDefaultTemplate,
|
||||
getTemplateById,
|
||||
getTemplatesForCount,
|
||||
} from "@/lib/collage-templates";
|
||||
|
||||
describe("getTemplatesForCount", () => {
|
||||
it("returns templates matching a given image count", () => {
|
||||
const twoImage = getTemplatesForCount(2);
|
||||
expect(twoImage.length).toBeGreaterThan(0);
|
||||
expect(twoImage.every((t) => t.imageCount === 2)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns multiple templates for counts with variants", () => {
|
||||
const threeImage = getTemplatesForCount(3);
|
||||
expect(threeImage.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("returns empty array for unsupported count", () => {
|
||||
expect(getTemplatesForCount(100)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultTemplate", () => {
|
||||
it("returns first exact match for known count", () => {
|
||||
const template = getDefaultTemplate(2);
|
||||
expect(template.imageCount).toBe(2);
|
||||
expect(template.id).toBe("2-h-equal");
|
||||
});
|
||||
|
||||
it("returns nearest template for unknown count", () => {
|
||||
const template = getDefaultTemplate(10);
|
||||
expect(template).toBeDefined();
|
||||
expect(template.cells.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns a template for count 1", () => {
|
||||
const template = getDefaultTemplate(1);
|
||||
expect(template).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTemplateById", () => {
|
||||
it("finds a template by ID", () => {
|
||||
const template = getTemplateById("4-grid");
|
||||
expect(template).toBeDefined();
|
||||
expect(template?.imageCount).toBe(4);
|
||||
expect(template?.cells).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("returns undefined for unknown ID", () => {
|
||||
expect(getTemplateById("nonexistent")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("COLLAGE_TEMPLATES", () => {
|
||||
it("has templates for counts 2 through 9", () => {
|
||||
for (let count = 2; count <= 9; count++) {
|
||||
const templates = getTemplatesForCount(count);
|
||||
expect(templates.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("every template has cells matching or exceeding imageCount", () => {
|
||||
for (const t of COLLAGE_TEMPLATES) {
|
||||
expect(t.cells.length).toBeGreaterThanOrEqual(t.imageCount);
|
||||
}
|
||||
});
|
||||
|
||||
it("every template has a non-empty label", () => {
|
||||
for (const t of COLLAGE_TEMPLATES) {
|
||||
expect(t.label.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user