mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
test: expand coverage across all layers -- 1,268 new tests, fix replace-color div-by-zero
14-agent parallel test expansion covering integration, unit, E2E, E2E-Docker, cross-format matrix, adversarial, GUI navigation/tools/settings/visual/a11y/perf. - Integration: expand 23 tool test files with HEIC, stress, batch, edge cases - Unit: close coverage gaps in image-engine, stores, lib (metadata, auto-enhance, connection-store, lazy-with-retry, collage/file-store HEIC preview) - AI bridge: 141 new tests for dispatcher buffering, crash recovery, OOM/segfault - Cross-format: 794 parameterized tests (16 formats x 12 tools + no-crash matrix) - Adversarial: memory stress (50x large file), zero-byte, corrupted headers, unicode - E2E-Docker: expand 8 spec files with dimension verification, pipeline chains - GUI E2E: tool UI settings/interactions for all 47 tools, remove all test.skip, RBAC per-role verification, visual screenshot naming, cross-browser smoke tests, a11y ARIA/focus/contrast, performance budgets, 15-tool stability test - Fix: replace-color.ts tolerance=0 caused division-by-zero producing NaN pixels Total: 8,958 tests passing across 202 files. Zero failures, zero skips.
This commit is contained in:
@@ -484,4 +484,57 @@ describe("removeBackground", () => {
|
||||
expect(JSON.parse(fallbackArgs[2]).model).toBe("u2net");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("propagates segfault from bridge", async () => {
|
||||
vi.mocked(runPythonWithProgress).mockRejectedValue(
|
||||
new Error("Process crashed (segmentation fault)"),
|
||||
);
|
||||
|
||||
await expect(removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow(
|
||||
"segmentation fault",
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates writeFile error", async () => {
|
||||
vi.mocked(writeFile).mockRejectedValueOnce(new Error("ENOSPC: disk full"));
|
||||
|
||||
await expect(removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("disk full");
|
||||
});
|
||||
|
||||
it("propagates readFile error after successful Python run", async () => {
|
||||
vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT: output missing"));
|
||||
|
||||
await expect(removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("output missing");
|
||||
});
|
||||
|
||||
it("handles missing width and height from metadata", async () => {
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: undefined, height: undefined }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
// Should not throw: origW and origH default to 0
|
||||
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
expect(runPythonWithProgress).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes backgroundColor option in args JSON", async () => {
|
||||
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR, {
|
||||
model: "u2net",
|
||||
backgroundColor: "#00FF00",
|
||||
});
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
expect(JSON.parse(args[2])).toEqual({
|
||||
model: "u2net",
|
||||
backgroundColor: "#00FF00",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2113,3 +2113,966 @@ describe("bridge - extractPythonError via dispatcher responses", () => {
|
||||
await expect(promise).rejects.toThrow("exited with code 42");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Dispatcher partial stderr buffering ──────────────────────────────
|
||||
|
||||
describe("bridge - dispatcher stderr partial buffering", () => {
|
||||
let runPythonWithProgress: typeof import("../../../packages/ai/src/bridge.js").runPythonWithProgress;
|
||||
let initDispatcher: typeof import("../../../packages/ai/src/bridge.js").initDispatcher;
|
||||
let shutdownDispatcher: typeof import("../../../packages/ai/src/bridge.js").shutdownDispatcher;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.mocked(spawn).mockReset();
|
||||
|
||||
const mod = await import("../../../packages/ai/src/bridge.js");
|
||||
runPythonWithProgress = mod.runPythonWithProgress;
|
||||
initDispatcher = mod.initDispatcher;
|
||||
shutdownDispatcher = mod.shutdownDispatcher;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
shutdownDispatcher();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function setupReadyDispatcher() {
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
const initPromise = initDispatcher();
|
||||
mock.stderr.emit("data", Buffer.from('{"ready": true, "gpu": false}\n'));
|
||||
await initPromise;
|
||||
return mock;
|
||||
}
|
||||
|
||||
it("buffers partial JSON progress lines across stderr chunks", async () => {
|
||||
const mock = await setupReadyDispatcher();
|
||||
const progressUpdates: Array<{ percent: number; stage: string }> = [];
|
||||
|
||||
const promise = runPythonWithProgress("test.py", [], {
|
||||
onProgress: (p, s) => progressUpdates.push({ percent: p, stage: s }),
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Send a progress line split across two stderr chunks
|
||||
mock.stderr.emit("data", Buffer.from('{"progress": 30, "sta'));
|
||||
mock.stderr.emit("data", Buffer.from('ge": "Loading"}\n'));
|
||||
|
||||
const line = mock.stdinWrites.join("").split("\n").filter(Boolean)[0];
|
||||
const id = JSON.parse(line).id;
|
||||
|
||||
mock.stdout.emit(
|
||||
"data",
|
||||
Buffer.from(`${JSON.stringify({ id, exitCode: 0, stdout: '{"ok": true}' })}\n`),
|
||||
);
|
||||
|
||||
await promise;
|
||||
expect(progressUpdates).toEqual([{ percent: 30, stage: "Loading" }]);
|
||||
});
|
||||
|
||||
it("handles readiness signal split across stderr chunks", async () => {
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
|
||||
const initPromise = initDispatcher();
|
||||
|
||||
// Split the ready signal across two chunks
|
||||
mock.stderr.emit("data", Buffer.from('{"ready": tr'));
|
||||
mock.stderr.emit("data", Buffer.from('ue, "gpu": false}\n'));
|
||||
|
||||
const result = await initPromise;
|
||||
expect(result).toEqual({ ready: true, gpu: false });
|
||||
});
|
||||
|
||||
it("handles multiple complete lines in a single stderr chunk", async () => {
|
||||
const mock = await setupReadyDispatcher();
|
||||
const progressUpdates: Array<{ percent: number; stage: string }> = [];
|
||||
|
||||
const promise = runPythonWithProgress("test.py", [], {
|
||||
onProgress: (p, s) => progressUpdates.push({ percent: p, stage: s }),
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Two progress lines in one chunk
|
||||
mock.stderr.emit(
|
||||
"data",
|
||||
Buffer.from('{"progress": 25, "stage": "Step 1"}\n{"progress": 75, "stage": "Step 2"}\n'),
|
||||
);
|
||||
|
||||
const line = mock.stdinWrites.join("").split("\n").filter(Boolean)[0];
|
||||
const id = JSON.parse(line).id;
|
||||
|
||||
mock.stdout.emit("data", Buffer.from(`${JSON.stringify({ id, exitCode: 0, stdout: "{}" })}\n`));
|
||||
|
||||
await promise;
|
||||
expect(progressUpdates).toEqual([
|
||||
{ percent: 25, stage: "Step 1" },
|
||||
{ percent: 75, stage: "Step 2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not fire progress for partial JSON that is not yet complete", async () => {
|
||||
const mock = await setupReadyDispatcher();
|
||||
const progressUpdates: Array<{ percent: number; stage: string }> = [];
|
||||
|
||||
const promise = runPythonWithProgress("test.py", [], {
|
||||
onProgress: (p, s) => progressUpdates.push({ percent: p, stage: s }),
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Send incomplete JSON with no trailing newline
|
||||
mock.stderr.emit("data", Buffer.from('{"progress": 50, "stage": "Half'));
|
||||
|
||||
// No progress should have fired yet
|
||||
expect(progressUpdates).toEqual([]);
|
||||
|
||||
// Complete the line
|
||||
mock.stderr.emit("data", Buffer.from('way"}\n'));
|
||||
expect(progressUpdates).toEqual([{ percent: 50, stage: "Halfway" }]);
|
||||
|
||||
// Finish
|
||||
const line = mock.stdinWrites.join("").split("\n").filter(Boolean)[0];
|
||||
const id = JSON.parse(line).id;
|
||||
mock.stdout.emit("data", Buffer.from(`${JSON.stringify({ id, exitCode: 0, stdout: "{}" })}\n`));
|
||||
await promise;
|
||||
});
|
||||
});
|
||||
|
||||
// ── Dispatcher shutdown behavior ─────────────────────────────────────
|
||||
|
||||
describe("bridge - dispatcher shutdown behavior", () => {
|
||||
let initDispatcher: typeof import("../../../packages/ai/src/bridge.js").initDispatcher;
|
||||
let shutdownDispatcher: typeof import("../../../packages/ai/src/bridge.js").shutdownDispatcher;
|
||||
let getDispatcherStatus: typeof import("../../../packages/ai/src/bridge.js").getDispatcherStatus;
|
||||
let isGpuAvailable: typeof import("../../../packages/ai/src/bridge.js").isGpuAvailable;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.mocked(spawn).mockReset();
|
||||
|
||||
const mod = await import("../../../packages/ai/src/bridge.js");
|
||||
initDispatcher = mod.initDispatcher;
|
||||
shutdownDispatcher = mod.shutdownDispatcher;
|
||||
getDispatcherStatus = mod.getDispatcherStatus;
|
||||
isGpuAvailable = mod.isGpuAvailable;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("calls kill(SIGTERM) on the dispatcher process", async () => {
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
|
||||
const initPromise = initDispatcher();
|
||||
mock.stderr.emit("data", Buffer.from('{"ready": true, "gpu": false}\n'));
|
||||
await initPromise;
|
||||
|
||||
shutdownDispatcher();
|
||||
|
||||
expect(mock.process.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
});
|
||||
|
||||
it("calls stdin.end() before killing the process", async () => {
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
|
||||
const stdinEndSpy = vi.spyOn(mock.stdin, "end");
|
||||
|
||||
const initPromise = initDispatcher();
|
||||
mock.stderr.emit("data", Buffer.from('{"ready": true, "gpu": false}\n'));
|
||||
await initPromise;
|
||||
|
||||
shutdownDispatcher();
|
||||
|
||||
expect(stdinEndSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets status to not running and not ready after shutdown", async () => {
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
|
||||
const initPromise = initDispatcher();
|
||||
mock.stderr.emit("data", Buffer.from('{"ready": true, "gpu": true}\n'));
|
||||
await initPromise;
|
||||
|
||||
expect(getDispatcherStatus().running).toBe(true);
|
||||
expect(getDispatcherStatus().ready).toBe(true);
|
||||
|
||||
shutdownDispatcher();
|
||||
|
||||
expect(getDispatcherStatus().running).toBe(false);
|
||||
expect(getDispatcherStatus().ready).toBe(false);
|
||||
});
|
||||
|
||||
it("does not change GPU status after shutdown (isGpuAvailable returns module-level state)", async () => {
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
|
||||
const initPromise = initDispatcher();
|
||||
mock.stderr.emit("data", Buffer.from('{"ready": true, "gpu": true}\n'));
|
||||
await initPromise;
|
||||
|
||||
expect(isGpuAvailable()).toBe(true);
|
||||
|
||||
shutdownDispatcher();
|
||||
|
||||
// GPU status persists at module level even after shutdown
|
||||
expect(isGpuAvailable()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── initDispatcher edge cases ────────────────────────────────────────
|
||||
|
||||
describe("bridge - initDispatcher edge cases", () => {
|
||||
let initDispatcher: typeof import("../../../packages/ai/src/bridge.js").initDispatcher;
|
||||
let runPythonWithProgress: typeof import("../../../packages/ai/src/bridge.js").runPythonWithProgress;
|
||||
let shutdownDispatcher: typeof import("../../../packages/ai/src/bridge.js").shutdownDispatcher;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.mocked(spawn).mockReset();
|
||||
|
||||
const mod = await import("../../../packages/ai/src/bridge.js");
|
||||
initDispatcher = mod.initDispatcher;
|
||||
runPythonWithProgress = mod.runPythonWithProgress;
|
||||
shutdownDispatcher = mod.shutdownDispatcher;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
shutdownDispatcher();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns ready=false immediately when dispatcher already permanently failed", async () => {
|
||||
// Force dispatcherFailed by triggering ENOENT error
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
|
||||
const initPromise1 = initDispatcher();
|
||||
|
||||
const enoent = new Error("spawn ENOENT") as NodeJS.ErrnoException;
|
||||
enoent.code = "ENOENT";
|
||||
mock.emitEvent("error", enoent);
|
||||
|
||||
const result1 = await initPromise1;
|
||||
expect(result1).toEqual({ ready: false, gpu: false });
|
||||
|
||||
// Second call should return immediately without spawning
|
||||
const result2 = await initDispatcher();
|
||||
expect(result2).toEqual({ ready: false, gpu: false });
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns ready=false when spawn throws synchronously", async () => {
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
throw new Error("Cannot spawn");
|
||||
});
|
||||
|
||||
const result = await initDispatcher();
|
||||
expect(result).toEqual({ ready: false, gpu: false });
|
||||
});
|
||||
|
||||
it("resolves with ready=false when dispatcher fails during init timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
|
||||
const promise = initDispatcher(500);
|
||||
|
||||
// Dispatcher crashes before timeout
|
||||
const err = new Error("spawn error") as NodeJS.ErrnoException;
|
||||
err.code = "ENOENT";
|
||||
mock.emitEvent("error", err);
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toEqual({ ready: false, gpu: false });
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Per-request fallback edge cases ──────────────────────────────────
|
||||
|
||||
describe("bridge - per-request fallback edge cases", () => {
|
||||
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("handles stderr that mixes progress JSON and regular text", async () => {
|
||||
const mockDisp = createMockProcess();
|
||||
const mockPerReq = createMockProcess();
|
||||
let callCount = 0;
|
||||
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return mockDisp.process;
|
||||
return mockPerReq.process;
|
||||
});
|
||||
|
||||
const progressUpdates: Array<{ percent: number; stage: string }> = [];
|
||||
const promise = runPythonWithProgress("test.py", [], {
|
||||
onProgress: (p, s) => progressUpdates.push({ percent: p, stage: s }),
|
||||
});
|
||||
|
||||
// Kill dispatcher
|
||||
const enoent = new Error("ENOENT") as NodeJS.ErrnoException;
|
||||
enoent.code = "ENOENT";
|
||||
mockDisp.emitEvent("error", enoent);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Mix of progress JSON and regular log lines on per-request stderr
|
||||
mockPerReq.stderr.emit(
|
||||
"data",
|
||||
Buffer.from(
|
||||
'WARNING: GPU not found\n{"progress": 40, "stage": "Loading model"}\nINFO: Using CPU\n',
|
||||
),
|
||||
);
|
||||
|
||||
mockPerReq.stdout.emit("data", Buffer.from('{"success": true}\n'));
|
||||
mockPerReq.emitEvent("close", 0, null);
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(progressUpdates).toEqual([{ percent: 40, stage: "Loading model" }]);
|
||||
expect(result.stderr).toContain("WARNING: GPU not found");
|
||||
expect(result.stderr).toContain("INFO: Using CPU");
|
||||
});
|
||||
|
||||
it("per-request stderr JSON with only progress is not collected as error output", async () => {
|
||||
const mockDisp = createMockProcess();
|
||||
const mockPerReq = createMockProcess();
|
||||
let callCount = 0;
|
||||
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return mockDisp.process;
|
||||
return mockPerReq.process;
|
||||
});
|
||||
|
||||
const promise = runPythonWithProgress("test.py", [], {
|
||||
onProgress: vi.fn(),
|
||||
});
|
||||
|
||||
// Kill dispatcher
|
||||
const enoent = new Error("ENOENT") as NodeJS.ErrnoException;
|
||||
enoent.code = "ENOENT";
|
||||
mockDisp.emitEvent("error", enoent);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Only progress JSON on stderr
|
||||
mockPerReq.stderr.emit("data", Buffer.from('{"progress": 100, "stage": "Done"}\n'));
|
||||
|
||||
mockPerReq.stdout.emit("data", Buffer.from('{"success": true}\n'));
|
||||
mockPerReq.emitEvent("close", 0, null);
|
||||
|
||||
const result = await promise;
|
||||
// Stderr should not contain the progress JSON since it was parsed as progress
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
|
||||
it("handles empty stdout with successful exit", async () => {
|
||||
const mockDisp = createMockProcess();
|
||||
const mockPerReq = createMockProcess();
|
||||
let callCount = 0;
|
||||
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return mockDisp.process;
|
||||
return mockPerReq.process;
|
||||
});
|
||||
|
||||
const promise = runPythonWithProgress("test.py", []);
|
||||
|
||||
const enoent = new Error("ENOENT") as NodeJS.ErrnoException;
|
||||
enoent.code = "ENOENT";
|
||||
mockDisp.emitEvent("error", enoent);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Empty stdout, exit code 0
|
||||
mockPerReq.emitEvent("close", 0, null);
|
||||
|
||||
const result = await promise;
|
||||
expect(result.stdout).toBe("");
|
||||
});
|
||||
|
||||
it("handles large stdout that arrives in many small chunks", async () => {
|
||||
const mockDisp = createMockProcess();
|
||||
const mockPerReq = createMockProcess();
|
||||
let callCount = 0;
|
||||
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return mockDisp.process;
|
||||
return mockPerReq.process;
|
||||
});
|
||||
|
||||
const promise = runPythonWithProgress("test.py", []);
|
||||
|
||||
const enoent = new Error("ENOENT") as NodeJS.ErrnoException;
|
||||
enoent.code = "ENOENT";
|
||||
mockDisp.emitEvent("error", enoent);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Send large JSON result in many small pieces
|
||||
const fullJson = JSON.stringify({ success: true, data: "x".repeat(1000) });
|
||||
for (let i = 0; i < fullJson.length; i += 50) {
|
||||
mockPerReq.stdout.emit("data", Buffer.from(fullJson.slice(i, i + 50)));
|
||||
}
|
||||
mockPerReq.stdout.emit("data", Buffer.from("\n"));
|
||||
mockPerReq.emitEvent("close", 0, null);
|
||||
|
||||
const result = await promise;
|
||||
expect(result.stdout).toBe(fullJson);
|
||||
});
|
||||
|
||||
it("does not invoke onProgress for JSON stderr that lacks stage field", async () => {
|
||||
const mockDisp = createMockProcess();
|
||||
const mockPerReq = createMockProcess();
|
||||
let callCount = 0;
|
||||
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return mockDisp.process;
|
||||
return mockPerReq.process;
|
||||
});
|
||||
|
||||
const onProgress = vi.fn();
|
||||
const promise = runPythonWithProgress("test.py", [], { onProgress });
|
||||
|
||||
const enoent = new Error("ENOENT") as NodeJS.ErrnoException;
|
||||
enoent.code = "ENOENT";
|
||||
mockDisp.emitEvent("error", enoent);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// JSON on stderr but missing "stage" field
|
||||
mockPerReq.stderr.emit("data", Buffer.from('{"progress": 50}\n'));
|
||||
|
||||
mockPerReq.stdout.emit("data", Buffer.from('{"success": true}\n'));
|
||||
mockPerReq.emitEvent("close", 0, null);
|
||||
|
||||
await promise;
|
||||
expect(onProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not invoke onProgress for JSON stderr that lacks progress field", async () => {
|
||||
const mockDisp = createMockProcess();
|
||||
const mockPerReq = createMockProcess();
|
||||
let callCount = 0;
|
||||
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return mockDisp.process;
|
||||
return mockPerReq.process;
|
||||
});
|
||||
|
||||
const onProgress = vi.fn();
|
||||
const promise = runPythonWithProgress("test.py", [], { onProgress });
|
||||
|
||||
const enoent = new Error("ENOENT") as NodeJS.ErrnoException;
|
||||
enoent.code = "ENOENT";
|
||||
mockDisp.emitEvent("error", enoent);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// JSON on stderr but missing "progress" field
|
||||
mockPerReq.stderr.emit("data", Buffer.from('{"stage": "Loading"}\n'));
|
||||
|
||||
mockPerReq.stdout.emit("data", Buffer.from('{"success": true}\n'));
|
||||
mockPerReq.emitEvent("close", 0, null);
|
||||
|
||||
await promise;
|
||||
expect(onProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Dispatcher error event handling ──────────────────────────────────
|
||||
|
||||
describe("bridge - dispatcher error event handling", () => {
|
||||
let runPythonWithProgress: typeof import("../../../packages/ai/src/bridge.js").runPythonWithProgress;
|
||||
let getDispatcherStatus: typeof import("../../../packages/ai/src/bridge.js").getDispatcherStatus;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.mocked(spawn).mockReset();
|
||||
|
||||
const mod = await import("../../../packages/ai/src/bridge.js");
|
||||
runPythonWithProgress = mod.runPythonWithProgress;
|
||||
getDispatcherStatus = mod.getDispatcherStatus;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("ENOENT error permanently disables the dispatcher", async () => {
|
||||
const mock = createMockProcess();
|
||||
const mockPerReq = createMockProcess();
|
||||
let callCount = 0;
|
||||
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return mock.process;
|
||||
return mockPerReq.process;
|
||||
});
|
||||
|
||||
const promise = runPythonWithProgress("test.py", []);
|
||||
|
||||
const enoent = new Error("ENOENT") as NodeJS.ErrnoException;
|
||||
enoent.code = "ENOENT";
|
||||
mock.emitEvent("error", enoent);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
mockPerReq.stdout.emit("data", Buffer.from('{"ok": true}\n'));
|
||||
mockPerReq.emitEvent("close", 0, null);
|
||||
await promise;
|
||||
|
||||
expect(getDispatcherStatus().failed).toBe(true);
|
||||
});
|
||||
|
||||
it("non-ENOENT error records a crash but does not permanently disable", async () => {
|
||||
const mock = createMockProcess();
|
||||
const mockPerReq = createMockProcess();
|
||||
let callCount = 0;
|
||||
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return mock.process;
|
||||
return mockPerReq.process;
|
||||
});
|
||||
|
||||
const promise = runPythonWithProgress("test.py", []);
|
||||
|
||||
const eacces = new Error("EACCES") as NodeJS.ErrnoException;
|
||||
eacces.code = "EACCES";
|
||||
mock.emitEvent("error", eacces);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
mockPerReq.stdout.emit("data", Buffer.from('{"ok": true}\n'));
|
||||
mockPerReq.emitEvent("close", 0, null);
|
||||
await promise;
|
||||
|
||||
const status = getDispatcherStatus();
|
||||
expect(status.failed).toBe(false);
|
||||
expect(status.consecutiveCrashes).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("non-ENOENT dispatcher error increments crash counter and clears dispatcher state", async () => {
|
||||
const mock = createMockProcess();
|
||||
const mockPerReq = createMockProcess();
|
||||
let callCount = 0;
|
||||
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return mock.process;
|
||||
return mockPerReq.process;
|
||||
});
|
||||
|
||||
const promise = runPythonWithProgress("test.py", []);
|
||||
|
||||
// Non-ENOENT error: records a crash, does NOT permanently disable
|
||||
const eacces = new Error("permission denied") as NodeJS.ErrnoException;
|
||||
eacces.code = "EACCES";
|
||||
mock.emitEvent("error", eacces);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Per-request fallback picks up
|
||||
mockPerReq.stdout.emit("data", Buffer.from('{"ok": true}\n'));
|
||||
mockPerReq.emitEvent("close", 0, null);
|
||||
|
||||
await promise;
|
||||
|
||||
const status = getDispatcherStatus();
|
||||
expect(status.consecutiveCrashes).toBeGreaterThanOrEqual(1);
|
||||
expect(status.failed).toBe(false);
|
||||
expect(status.running).toBe(false);
|
||||
expect(status.ready).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Dispatcher stdout partial line buffering ─────────────────────────
|
||||
|
||||
describe("bridge - dispatcher stdout partial line buffering", () => {
|
||||
let runPythonWithProgress: typeof import("../../../packages/ai/src/bridge.js").runPythonWithProgress;
|
||||
let initDispatcher: typeof import("../../../packages/ai/src/bridge.js").initDispatcher;
|
||||
let shutdownDispatcher: typeof import("../../../packages/ai/src/bridge.js").shutdownDispatcher;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.mocked(spawn).mockReset();
|
||||
|
||||
const mod = await import("../../../packages/ai/src/bridge.js");
|
||||
runPythonWithProgress = mod.runPythonWithProgress;
|
||||
initDispatcher = mod.initDispatcher;
|
||||
shutdownDispatcher = mod.shutdownDispatcher;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
shutdownDispatcher();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function setupReadyDispatcher() {
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
const initPromise = initDispatcher();
|
||||
mock.stderr.emit("data", Buffer.from('{"ready": true, "gpu": false}\n'));
|
||||
await initPromise;
|
||||
return mock;
|
||||
}
|
||||
|
||||
it("handles dispatcher response split across 3 or more stdout chunks", async () => {
|
||||
const mock = await setupReadyDispatcher();
|
||||
|
||||
const promise = runPythonWithProgress("test.py", []);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const line = mock.stdinWrites.join("").split("\n").filter(Boolean)[0];
|
||||
const id = JSON.parse(line).id;
|
||||
|
||||
const fullResponse = JSON.stringify({ id, exitCode: 0, stdout: '{"ok":true}' });
|
||||
|
||||
// Split into 3 chunks
|
||||
const third = Math.floor(fullResponse.length / 3);
|
||||
mock.stdout.emit("data", Buffer.from(fullResponse.slice(0, third)));
|
||||
mock.stdout.emit("data", Buffer.from(fullResponse.slice(third, third * 2)));
|
||||
mock.stdout.emit("data", Buffer.from(`${fullResponse.slice(third * 2)}\n`));
|
||||
|
||||
const result = await promise;
|
||||
expect(result.stdout).toBe('{"ok":true}');
|
||||
});
|
||||
|
||||
it("handles two complete responses in a single stdout data event", async () => {
|
||||
const mock = await setupReadyDispatcher();
|
||||
|
||||
const p1 = runPythonWithProgress("a.py", []);
|
||||
const p2 = runPythonWithProgress("b.py", []);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const lines = mock.stdinWrites.join("").split("\n").filter(Boolean);
|
||||
const id1 = JSON.parse(lines[0]).id;
|
||||
const id2 = JSON.parse(lines[1]).id;
|
||||
|
||||
// Both responses in a single chunk
|
||||
const response1 = JSON.stringify({ id: id1, exitCode: 0, stdout: '{"r":"one"}' });
|
||||
const response2 = JSON.stringify({ id: id2, exitCode: 0, stdout: '{"r":"two"}' });
|
||||
mock.stdout.emit("data", Buffer.from(`${response1}\n${response2}\n`));
|
||||
|
||||
const [r1, r2] = await Promise.all([p1, p2]);
|
||||
expect(r1.stdout).toBe('{"r":"one"}');
|
||||
expect(r2.stdout).toBe('{"r":"two"}');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Crash recovery resets on success ─────────────────────────────────
|
||||
|
||||
describe("bridge - crash recovery resets on successful readiness", () => {
|
||||
let runPythonWithProgress: typeof import("../../../packages/ai/src/bridge.js").runPythonWithProgress;
|
||||
let initDispatcher: typeof import("../../../packages/ai/src/bridge.js").initDispatcher;
|
||||
let getDispatcherStatus: typeof import("../../../packages/ai/src/bridge.js").getDispatcherStatus;
|
||||
let shutdownDispatcher: typeof import("../../../packages/ai/src/bridge.js").shutdownDispatcher;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.mocked(spawn).mockReset();
|
||||
|
||||
const mod = await import("../../../packages/ai/src/bridge.js");
|
||||
runPythonWithProgress = mod.runPythonWithProgress;
|
||||
initDispatcher = mod.initDispatcher;
|
||||
getDispatcherStatus = mod.getDispatcherStatus;
|
||||
shutdownDispatcher = mod.shutdownDispatcher;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
shutdownDispatcher();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("resets consecutiveCrashes to 0 when dispatcher becomes ready", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// First: cause a crash to increment the counter
|
||||
const mock1 = createMockProcess();
|
||||
const mockPR = createMockProcess();
|
||||
let callCount = 0;
|
||||
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return mock1.process;
|
||||
if (callCount === 2) return mockPR.process;
|
||||
// Third call will be a new dispatcher
|
||||
return createMockProcess().process;
|
||||
});
|
||||
|
||||
const promise = runPythonWithProgress("test.py", []);
|
||||
mock1.emitEvent("close", 1, null);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
|
||||
mockPR.stdout.emit("data", Buffer.from('{"ok": true}\n'));
|
||||
mockPR.emitEvent("close", 0, null);
|
||||
await promise;
|
||||
|
||||
expect(getDispatcherStatus().consecutiveCrashes).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Now: successfully initialize dispatcher after backoff
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
const mock2 = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock2.process);
|
||||
|
||||
const initPromise = initDispatcher();
|
||||
mock2.stderr.emit("data", Buffer.from('{"ready": true, "gpu": false}\n'));
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
await initPromise;
|
||||
|
||||
expect(getDispatcherStatus().consecutiveCrashes).toBe(0);
|
||||
expect(getDispatcherStatus().ready).toBe(true);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Backoff prevents restarts during cooldown ────────────────────────
|
||||
|
||||
describe("bridge - backoff prevents restart during cooldown", () => {
|
||||
let runPythonWithProgress: typeof import("../../../packages/ai/src/bridge.js").runPythonWithProgress;
|
||||
let getDispatcherStatus: typeof import("../../../packages/ai/src/bridge.js").getDispatcherStatus;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.mocked(spawn).mockReset();
|
||||
|
||||
const mod = await import("../../../packages/ai/src/bridge.js");
|
||||
runPythonWithProgress = mod.runPythonWithProgress;
|
||||
getDispatcherStatus = mod.getDispatcherStatus;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("second crash has 2x the backoff delay of the first crash", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mocks: ReturnType<typeof createMockProcess>[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
mocks.push(createMockProcess());
|
||||
}
|
||||
|
||||
let callCount = 0;
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
const m = mocks[callCount % mocks.length];
|
||||
callCount++;
|
||||
return m.process;
|
||||
});
|
||||
|
||||
// First crash: backoff = 1000ms
|
||||
const p1 = runPythonWithProgress("test.py", []);
|
||||
mocks[0].emitEvent("close", 1, null);
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
mocks[1].stdout.emit("data", Buffer.from('{"ok": true}\n'));
|
||||
mocks[1].emitEvent("close", 0, null);
|
||||
await p1;
|
||||
|
||||
expect(getDispatcherStatus().consecutiveCrashes).toBe(1);
|
||||
|
||||
// Still in first backoff: advance only 500ms (< 1000ms)
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
// Second request during backoff: should skip dispatcher, go to per-request only
|
||||
const spawnCountBefore = callCount;
|
||||
const p2 = runPythonWithProgress("test2.py", []);
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
|
||||
mocks[callCount - 1].stdout.emit("data", Buffer.from('{"ok": true}\n'));
|
||||
mocks[callCount - 1].emitEvent("close", 0, null);
|
||||
await p2;
|
||||
|
||||
// Only 1 additional spawn (per-request), not 2 (dispatcher + per-request)
|
||||
expect(callCount - spawnCountBefore).toBe(1);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// ── extractPythonError comprehensive coverage ────────────────────────
|
||||
|
||||
describe("bridge - extractPythonError coverage via per-request path", () => {
|
||||
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("extracts error from JSON on stdout when non-zero exit", async () => {
|
||||
const mockDisp = createMockProcess();
|
||||
const mockPerReq = createMockProcess();
|
||||
let callCount = 0;
|
||||
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return mockDisp.process;
|
||||
return mockPerReq.process;
|
||||
});
|
||||
|
||||
const promise = runPythonWithProgress("test.py", []);
|
||||
|
||||
const enoent = new Error("ENOENT") as NodeJS.ErrnoException;
|
||||
enoent.code = "ENOENT";
|
||||
mockDisp.emitEvent("error", enoent);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// stdout contains JSON error, stderr is empty
|
||||
mockPerReq.stdout.emit("data", Buffer.from('{"error": "Model failed to load"}\n'));
|
||||
mockPerReq.emitEvent("close", 1, null);
|
||||
|
||||
await expect(promise).rejects.toThrow("Model failed to load");
|
||||
});
|
||||
|
||||
it("extracts last meaningful line from traceback when error is only in stderr", async () => {
|
||||
const mockDisp = createMockProcess();
|
||||
const mockPerReq = createMockProcess();
|
||||
let callCount = 0;
|
||||
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return mockDisp.process;
|
||||
return mockPerReq.process;
|
||||
});
|
||||
|
||||
const promise = runPythonWithProgress("test.py", []);
|
||||
|
||||
const enoent = new Error("ENOENT") as NodeJS.ErrnoException;
|
||||
enoent.code = "ENOENT";
|
||||
mockDisp.emitEvent("error", enoent);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Full Python traceback on stderr
|
||||
mockPerReq.stderr.emit(
|
||||
"data",
|
||||
Buffer.from(
|
||||
"Traceback (most recent call last):\n" +
|
||||
' File "model.py", line 42, in load\n' +
|
||||
" weights = torch.load(path)\n" +
|
||||
"FileNotFoundError: [Errno 2] No such file or directory: 'model.pth'\n",
|
||||
),
|
||||
);
|
||||
mockPerReq.emitEvent("close", 1, null);
|
||||
|
||||
await expect(promise).rejects.toThrow("FileNotFoundError");
|
||||
});
|
||||
|
||||
it("returns generic exit code message when stderr has only the traceback header", async () => {
|
||||
const mockDisp = createMockProcess();
|
||||
const mockPerReq = createMockProcess();
|
||||
let callCount = 0;
|
||||
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return mockDisp.process;
|
||||
return mockPerReq.process;
|
||||
});
|
||||
|
||||
const promise = runPythonWithProgress("test.py", []);
|
||||
|
||||
const enoent = new Error("ENOENT") as NodeJS.ErrnoException;
|
||||
enoent.code = "ENOENT";
|
||||
mockDisp.emitEvent("error", enoent);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Only traceback header, no actual error line
|
||||
mockPerReq.stderr.emit("data", Buffer.from("Traceback (most recent call last):\n"));
|
||||
mockPerReq.emitEvent("close", 3, null);
|
||||
|
||||
await expect(promise).rejects.toThrow("exited with code 3");
|
||||
});
|
||||
});
|
||||
|
||||
// ── parseStdoutJson boundary conditions ──────────────────────────────
|
||||
|
||||
describe("bridge - parseStdoutJson boundary conditions", () => {
|
||||
let parseStdoutJson: (stdout: string) => unknown;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const mod = await import("../../../packages/ai/src/bridge.js");
|
||||
parseStdoutJson = mod.parseStdoutJson;
|
||||
});
|
||||
|
||||
it("handles JSON with escaped quotes in string values", () => {
|
||||
const result = parseStdoutJson('{"text": "he said \\"hello\\""}');
|
||||
expect(result).toEqual({ text: 'he said "hello"' });
|
||||
});
|
||||
|
||||
it("handles JSON with empty object", () => {
|
||||
const result = parseStdoutJson("{}");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("handles JSON with empty arrays and nested empty objects", () => {
|
||||
const result = parseStdoutJson('{"faces": [], "meta": {}}');
|
||||
expect(result).toEqual({ faces: [], meta: {} });
|
||||
});
|
||||
|
||||
it("handles JSON with very long string values", () => {
|
||||
const longText = "a".repeat(10000);
|
||||
const result = parseStdoutJson(`{"text": "${longText}"}`);
|
||||
expect((result as { text: string }).text).toBe(longText);
|
||||
});
|
||||
|
||||
it("handles JSON preceded by ANSI escape codes in stdout", () => {
|
||||
// Some Python programs write ANSI codes before output
|
||||
const stdout = '\x1b[32mProcessing complete\x1b[0m\n{"success": true}';
|
||||
// The regex matches from the first { to the last }
|
||||
const result = parseStdoutJson(stdout);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("handles JSON with negative numbers", () => {
|
||||
const result = parseStdoutJson('{"x": -10, "y": -0.5}');
|
||||
expect(result).toEqual({ x: -10, y: -0.5 });
|
||||
});
|
||||
|
||||
it("throws on truncated JSON", () => {
|
||||
expect(() => parseStdoutJson('{"success": true, "dat')).toThrow();
|
||||
});
|
||||
|
||||
it("handles JSON with only whitespace before the object", () => {
|
||||
const result = parseStdoutJson(' \n \t {"success": true}');
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,5 +198,48 @@ describe("colorize", () => {
|
||||
expect.objectContaining({ onProgress }),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits onProgress when not provided", async () => {
|
||||
await colorize(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
const options = vi.mocked(runPythonWithProgress).mock.calls[0][2];
|
||||
expect(options.onProgress).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sharp conversion errors", () => {
|
||||
it("propagates sharp toBuffer error", async () => {
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockRejectedValue(new Error("Corrupt image data")),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
await expect(colorize(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("Corrupt image data");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("passes empty options as empty JSON object", async () => {
|
||||
await colorize(FAKE_INPUT, FAKE_OUTPUT_DIR, {});
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
expect(JSON.parse(args[2])).toEqual({});
|
||||
});
|
||||
|
||||
it("handles zero width and height in response", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
width: 0,
|
||||
height: 0,
|
||||
method: "deoldify",
|
||||
});
|
||||
|
||||
const result = await colorize(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
expect(result.width).toBe(0);
|
||||
expect(result.height).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -311,5 +311,38 @@ describe("detectFaces", () => {
|
||||
expect.objectContaining({ onProgress }),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits onProgress when not provided", async () => {
|
||||
await detectFaces(FAKE_INPUT);
|
||||
|
||||
const options = vi.mocked(runPythonWithProgress).mock.calls[0][2];
|
||||
expect(options.onProgress).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("converts input to PNG before writing to tmpdir", async () => {
|
||||
await detectFaces(FAKE_INPUT);
|
||||
|
||||
expect(sharp).toHaveBeenCalledWith(FAKE_INPUT);
|
||||
});
|
||||
|
||||
it("propagates sharp conversion errors", async () => {
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockRejectedValue(new Error("Corrupt image")),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
await expect(detectFaces(FAKE_INPUT)).rejects.toThrow("Corrupt image");
|
||||
});
|
||||
|
||||
it("propagates writeFile errors", async () => {
|
||||
vi.mocked(writeFile).mockRejectedValueOnce(new Error("Permission denied"));
|
||||
|
||||
await expect(detectFaces(FAKE_INPUT)).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -225,5 +225,50 @@ describe("enhanceFaces", () => {
|
||||
expect.objectContaining({ onProgress }),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits onProgress when not provided", async () => {
|
||||
await enhanceFaces(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
const options = vi.mocked(runPythonWithProgress).mock.calls[0][2];
|
||||
expect(options.onProgress).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sharp conversion errors", () => {
|
||||
it("propagates sharp toBuffer error", async () => {
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockRejectedValue(new Error("Invalid JPEG")),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
await expect(enhanceFaces(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("Invalid JPEG");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles response with zero facesDetected and non-empty faces array", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
facesDetected: 0,
|
||||
faces: [{ x: 10, y: 20, w: 30, h: 40 }],
|
||||
model: "gfpgan",
|
||||
});
|
||||
|
||||
const result = await enhanceFaces(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
// The function trusts what Python returns
|
||||
expect(result.facesDetected).toBe(0);
|
||||
expect(result.faces).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("propagates segfault errors from bridge", async () => {
|
||||
vi.mocked(runPythonWithProgress).mockRejectedValue(
|
||||
new Error("Process crashed (segmentation fault)"),
|
||||
);
|
||||
|
||||
await expect(enhanceFaces(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("segmentation fault");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -263,4 +263,41 @@ describe("detectFaceLandmarks", () => {
|
||||
expect(options.onProgress).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("propagates writeFile error", async () => {
|
||||
vi.mocked(writeFile).mockRejectedValueOnce(new Error("Permission denied"));
|
||||
|
||||
await expect(detectFaceLandmarks(FAKE_INPUT)).rejects.toThrow("Permission denied");
|
||||
});
|
||||
|
||||
it("propagates segfault from bridge", async () => {
|
||||
vi.mocked(runPythonWithProgress).mockRejectedValue(
|
||||
new Error("Process crashed (segmentation fault)"),
|
||||
);
|
||||
|
||||
await expect(detectFaceLandmarks(FAKE_INPUT)).rejects.toThrow("segmentation fault");
|
||||
});
|
||||
|
||||
it("uses timestamps in temp file names", async () => {
|
||||
await detectFaceLandmarks(FAKE_INPUT);
|
||||
|
||||
const writePath = vi.mocked(writeFile).mock.calls[0][0] as string;
|
||||
// File path includes a numeric timestamp
|
||||
expect(writePath).toMatch(/face_landmarks_\d+\.png$/);
|
||||
});
|
||||
|
||||
it("handles faceDetected true with missing landmarks", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
faceDetected: true,
|
||||
imageWidth: 1920,
|
||||
imageHeight: 1080,
|
||||
});
|
||||
|
||||
const result = await detectFaceLandmarks(FAKE_INPUT);
|
||||
expect(result.faceDetected).toBe(true);
|
||||
expect(result.landmarks).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,4 +199,51 @@ describe("inpaint", () => {
|
||||
expect(options.onProgress).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sharp conversion errors", () => {
|
||||
it("propagates sharp error on mask conversion", async () => {
|
||||
let callCount = 0;
|
||||
vi.mocked(sharp).mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return {
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")),
|
||||
} as unknown as ReturnType<typeof sharp>;
|
||||
}
|
||||
return {
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockRejectedValue(new Error("Corrupt mask")),
|
||||
} as unknown as ReturnType<typeof sharp>;
|
||||
});
|
||||
|
||||
await expect(inpaint(FAKE_INPUT, FAKE_MASK, FAKE_OUTPUT_DIR)).rejects.toThrow("Corrupt mask");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("propagates segfault from bridge", async () => {
|
||||
vi.mocked(runPythonWithProgress).mockRejectedValue(
|
||||
new Error("Process crashed (segmentation fault)"),
|
||||
);
|
||||
|
||||
await expect(inpaint(FAKE_INPUT, FAKE_MASK, FAKE_OUTPUT_DIR)).rejects.toThrow(
|
||||
"segmentation fault",
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates readFile error after successful Python run", async () => {
|
||||
vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT: output missing"));
|
||||
|
||||
await expect(inpaint(FAKE_INPUT, FAKE_MASK, FAKE_OUTPUT_DIR)).rejects.toThrow(
|
||||
"output missing",
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates writeFile error when writing input", async () => {
|
||||
vi.mocked(writeFile).mockRejectedValueOnce(new Error("ENOSPC: disk full"));
|
||||
|
||||
await expect(inpaint(FAKE_INPUT, FAKE_MASK, FAKE_OUTPUT_DIR)).rejects.toThrow("disk full");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -329,5 +329,59 @@ describe("noiseRemoval", () => {
|
||||
Buffer.from("mock-png-data"),
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates sharp toBuffer error", async () => {
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockRejectedValue(new Error("Image decode failed")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
await expect(noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow(
|
||||
"Image decode failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles zero dimensions from metadata", async () => {
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: undefined, height: undefined }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
await noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
// Should not throw; dimensions default to 0
|
||||
const options = vi.mocked(runPythonWithProgress).mock.calls[0][2];
|
||||
expect(options.timeout).toBe(300000); // min timeout since megapixels = 0
|
||||
});
|
||||
|
||||
it("propagates segfault from bridge", async () => {
|
||||
vi.mocked(runPythonWithProgress).mockRejectedValue(
|
||||
new Error("Process crashed (segmentation fault)"),
|
||||
);
|
||||
|
||||
await expect(noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("segmentation fault");
|
||||
});
|
||||
|
||||
it("propagates readFile error after successful Python run", async () => {
|
||||
vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT: output missing"));
|
||||
|
||||
await expect(noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("output missing");
|
||||
});
|
||||
|
||||
it("propagates writeFile error", async () => {
|
||||
vi.mocked(writeFile).mockRejectedValueOnce(new Error("ENOSPC: disk full"));
|
||||
|
||||
await expect(noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("disk full");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -339,4 +339,45 @@ describe("extractText", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("propagates writeFile error", async () => {
|
||||
vi.mocked(writeFile).mockRejectedValueOnce(new Error("Permission denied"));
|
||||
|
||||
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("Permission denied");
|
||||
});
|
||||
|
||||
it("handles zero dimensions from metadata for timeout calculation", async () => {
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: undefined, height: undefined }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
const options = vi.mocked(runPythonWithProgress).mock.calls[0][2];
|
||||
// 0 MP: timeout = max(600_000, 0) = 600_000
|
||||
expect(options.timeout).toBe(600_000);
|
||||
});
|
||||
|
||||
it("propagates segfault from bridge", async () => {
|
||||
vi.mocked(runPythonWithProgress).mockRejectedValue(
|
||||
new Error("Process crashed (segmentation fault)"),
|
||||
);
|
||||
|
||||
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("segmentation fault");
|
||||
});
|
||||
|
||||
it("passes empty options as empty JSON", async () => {
|
||||
await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
expect(JSON.parse(args[1])).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -258,5 +258,47 @@ describe("removeRedEye", () => {
|
||||
expect.objectContaining({ onProgress }),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits onProgress when not provided", async () => {
|
||||
await removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
const options = vi.mocked(runPythonWithProgress).mock.calls[0][2];
|
||||
expect(options.onProgress).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sharp conversion errors", () => {
|
||||
it("propagates sharp toBuffer error", async () => {
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockRejectedValue(new Error("Invalid BMP data")),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
await expect(removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("Invalid BMP data");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("propagates writeFile error", async () => {
|
||||
vi.mocked(writeFile).mockRejectedValueOnce(new Error("ENOSPC: disk full"));
|
||||
|
||||
await expect(removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("disk full");
|
||||
});
|
||||
|
||||
it("propagates readFile error after successful Python run", async () => {
|
||||
vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT: output missing"));
|
||||
|
||||
await expect(removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("output missing");
|
||||
});
|
||||
|
||||
it("passes empty options as empty JSON object", async () => {
|
||||
await removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
expect(JSON.parse(args[2])).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -288,5 +288,78 @@ describe("restorePhoto", () => {
|
||||
expect.objectContaining({ onProgress }),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits onProgress when not provided", async () => {
|
||||
await restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
const options = vi.mocked(runPythonWithProgress).mock.calls[0][2];
|
||||
expect(options.onProgress).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sharp conversion errors", () => {
|
||||
it("propagates sharp toBuffer error", async () => {
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockRejectedValue(new Error("Damaged TIFF")),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
await expect(restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("Damaged TIFF");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("propagates writeFile error", async () => {
|
||||
vi.mocked(writeFile).mockRejectedValueOnce(new Error("ENOSPC: disk full"));
|
||||
|
||||
await expect(restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("disk full");
|
||||
});
|
||||
|
||||
it("propagates readFile error after successful Python run", async () => {
|
||||
vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT: output missing"));
|
||||
|
||||
await expect(restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("output missing");
|
||||
});
|
||||
|
||||
it("passes empty options as empty JSON object", async () => {
|
||||
await restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
expect(JSON.parse(args[2])).toEqual({});
|
||||
});
|
||||
|
||||
it("handles single-step restoration pipeline", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
width: 400,
|
||||
height: 300,
|
||||
steps: ["denoise"],
|
||||
scratchCoverage: 0,
|
||||
facesEnhanced: 0,
|
||||
isGrayscale: false,
|
||||
colorized: false,
|
||||
});
|
||||
|
||||
const result = await restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
expect(result.steps).toEqual(["denoise"]);
|
||||
});
|
||||
|
||||
it("propagates segfault from bridge", async () => {
|
||||
vi.mocked(runPythonWithProgress).mockRejectedValue(
|
||||
new Error("Process crashed (segmentation fault)"),
|
||||
);
|
||||
|
||||
await expect(restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("segmentation fault");
|
||||
});
|
||||
|
||||
it("passes mode option", async () => {
|
||||
await restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR, { mode: "light" });
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
expect(JSON.parse(args[2])).toEqual({ mode: "light" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -453,4 +453,92 @@ describe("seamCarve", () => {
|
||||
// shortest side is 400
|
||||
expect(caireCall?.[1]).toContain("400");
|
||||
});
|
||||
|
||||
it("propagates sharp metadata error", async () => {
|
||||
const { seamCarve } = await importFresh();
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||
metadata: vi.fn().mockRejectedValue(new Error("Corrupt file header")),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
await expect(seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("Corrupt file header");
|
||||
});
|
||||
|
||||
it("propagates sharp jpeg conversion error", async () => {
|
||||
const { seamCarve } = await importFresh();
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockRejectedValue(new Error("JPEG encode failed")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
await expect(seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("JPEG encode failed");
|
||||
});
|
||||
|
||||
it("propagates readFile error when reading caire output", async () => {
|
||||
const { seamCarve } = await importFresh();
|
||||
vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT: caire output missing"));
|
||||
|
||||
await expect(seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("caire output missing");
|
||||
});
|
||||
|
||||
it("propagates writeFile error when writing input", async () => {
|
||||
const { seamCarve } = await importFresh();
|
||||
vi.mocked(writeFile).mockRejectedValueOnce(new Error("ENOSPC: disk full"));
|
||||
|
||||
await expect(seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("disk full");
|
||||
});
|
||||
|
||||
it("uses default width and height when no options are specified", async () => {
|
||||
const { seamCarve } = await importFresh();
|
||||
// With 800x600, no width/height options, targetW = 800, targetH = 600
|
||||
// wRatio = 1, hRatio = 1, so no 75% check triggers
|
||||
await seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
const calls = mockExecFileAsync.mock.calls;
|
||||
const caireCall = calls.find(
|
||||
(c: unknown[]) => Array.isArray(c[1]) && c[1].includes("-preview=false"),
|
||||
);
|
||||
expect(caireCall).toBeDefined();
|
||||
// No -width or -height when defaults match original
|
||||
expect(caireCall?.[1]).not.toContain("-width");
|
||||
expect(caireCall?.[1]).not.toContain("-height");
|
||||
});
|
||||
|
||||
it("accepts 75% reduction exactly (ratio = 0.25)", async () => {
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
const { seamCarve } = await importFresh();
|
||||
// 200/800 = 0.25, exactly at boundary -- should NOT throw
|
||||
await expect(seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR, { width: 200 })).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("uses unique UUID in temp file names to prevent collisions", async () => {
|
||||
const { seamCarve } = await importFresh();
|
||||
|
||||
await seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
await seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
const writeCalls = vi.mocked(writeFile).mock.calls;
|
||||
const paths = writeCalls.map((c) => c[0] as string);
|
||||
// Each call generates a different UUID in the filename
|
||||
expect(paths[0]).not.toBe(paths[1]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1664,4 +1664,337 @@ describe("parseStdoutJson throws in tool pipeline", () => {
|
||||
"No JSON response from Python script",
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates parse error through colorize", async () => {
|
||||
vi.mocked(parseStdoutJson).mockImplementation(() => {
|
||||
throw new Error("No JSON response from Python script");
|
||||
});
|
||||
|
||||
await expect(colorize(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow(
|
||||
"No JSON response from Python script",
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates parse error through blurFaces", async () => {
|
||||
vi.mocked(parseStdoutJson).mockImplementation(() => {
|
||||
throw new Error("No JSON response from Python script");
|
||||
});
|
||||
|
||||
await expect(blurFaces(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow(
|
||||
"No JSON response from Python script",
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates parse error through detectFaces", async () => {
|
||||
vi.mocked(parseStdoutJson).mockImplementation(() => {
|
||||
throw new Error("No JSON response from Python script");
|
||||
});
|
||||
|
||||
await expect(detectFaces(FAKE_INPUT)).rejects.toThrow("No JSON response from Python script");
|
||||
});
|
||||
|
||||
it("propagates parse error through enhanceFaces", async () => {
|
||||
vi.mocked(parseStdoutJson).mockImplementation(() => {
|
||||
throw new Error("No JSON response from Python script");
|
||||
});
|
||||
|
||||
await expect(enhanceFaces(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow(
|
||||
"No JSON response from Python script",
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates parse error through noiseRemoval", async () => {
|
||||
vi.mocked(parseStdoutJson).mockImplementation(() => {
|
||||
throw new Error("No JSON response from Python script");
|
||||
});
|
||||
|
||||
await expect(noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow(
|
||||
"No JSON response from Python script",
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates parse error through removeRedEye", async () => {
|
||||
vi.mocked(parseStdoutJson).mockImplementation(() => {
|
||||
throw new Error("No JSON response from Python script");
|
||||
});
|
||||
|
||||
await expect(removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow(
|
||||
"No JSON response from Python script",
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates parse error through restorePhoto", async () => {
|
||||
vi.mocked(parseStdoutJson).mockImplementation(() => {
|
||||
throw new Error("No JSON response from Python script");
|
||||
});
|
||||
|
||||
await expect(restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow(
|
||||
"No JSON response from Python script",
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates parse error through inpaint", async () => {
|
||||
vi.mocked(parseStdoutJson).mockImplementation(() => {
|
||||
throw new Error("No JSON response from Python script");
|
||||
});
|
||||
|
||||
const maskBuffer = Buffer.from("fake-mask");
|
||||
await expect(inpaint(FAKE_INPUT, maskBuffer, FAKE_OUTPUT_DIR)).rejects.toThrow(
|
||||
"No JSON response from Python script",
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates parse error through detectFaceLandmarks", async () => {
|
||||
vi.mocked(parseStdoutJson).mockImplementation(() => {
|
||||
throw new Error("No JSON response from Python script");
|
||||
});
|
||||
|
||||
await expect(detectFaceLandmarks(FAKE_INPUT)).rejects.toThrow(
|
||||
"No JSON response from Python script",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── readFile errors after successful Python run ────────────────────────
|
||||
|
||||
describe("readFile errors after successful Python run", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({ success: true, width: 800, height: 600 });
|
||||
});
|
||||
|
||||
it("colorize propagates readFile error", async () => {
|
||||
vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT: output file missing"));
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
width: 800,
|
||||
height: 600,
|
||||
method: "deoldify",
|
||||
});
|
||||
|
||||
await expect(colorize(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("output file missing");
|
||||
});
|
||||
|
||||
it("blurFaces propagates readFile error", async () => {
|
||||
vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT: output file missing"));
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
facesDetected: 1,
|
||||
faces: [{ x: 0, y: 0, w: 50, h: 50 }],
|
||||
});
|
||||
|
||||
await expect(blurFaces(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("output file missing");
|
||||
});
|
||||
|
||||
it("enhanceFaces propagates readFile error", async () => {
|
||||
vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT: output file missing"));
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
facesDetected: 1,
|
||||
faces: [],
|
||||
model: "gfpgan",
|
||||
});
|
||||
|
||||
await expect(enhanceFaces(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("output file missing");
|
||||
});
|
||||
|
||||
it("noiseRemoval propagates readFile error", async () => {
|
||||
vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT: output file missing"));
|
||||
|
||||
await expect(noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("output file missing");
|
||||
});
|
||||
|
||||
it("removeRedEye propagates readFile error", async () => {
|
||||
vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT: output file missing"));
|
||||
|
||||
await expect(removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("output file missing");
|
||||
});
|
||||
|
||||
it("restorePhoto propagates readFile error", async () => {
|
||||
vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT: output file missing"));
|
||||
|
||||
await expect(restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("output file missing");
|
||||
});
|
||||
|
||||
it("upscale propagates readFile error", async () => {
|
||||
vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT: output file missing"));
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
width: 1600,
|
||||
height: 1200,
|
||||
});
|
||||
|
||||
await expect(upscale(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("output file missing");
|
||||
});
|
||||
|
||||
it("inpaint propagates readFile error", async () => {
|
||||
vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT: output file missing"));
|
||||
|
||||
const mask = Buffer.from("mask");
|
||||
await expect(inpaint(FAKE_INPUT, mask, FAKE_OUTPUT_DIR)).rejects.toThrow("output file missing");
|
||||
});
|
||||
});
|
||||
|
||||
// ── writeFile errors before Python run ─────────────────────────────────
|
||||
|
||||
describe("writeFile errors before Python run", () => {
|
||||
it("colorize propagates writeFile error", async () => {
|
||||
vi.mocked(writeFile).mockRejectedValueOnce(new Error("ENOSPC: no space left"));
|
||||
|
||||
await expect(colorize(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("no space left");
|
||||
});
|
||||
|
||||
it("blurFaces propagates writeFile error", async () => {
|
||||
vi.mocked(writeFile).mockRejectedValueOnce(new Error("ENOSPC: no space left"));
|
||||
|
||||
await expect(blurFaces(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("no space left");
|
||||
});
|
||||
|
||||
it("enhanceFaces propagates writeFile error", async () => {
|
||||
vi.mocked(writeFile).mockRejectedValueOnce(new Error("ENOSPC: no space left"));
|
||||
|
||||
await expect(enhanceFaces(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("no space left");
|
||||
});
|
||||
|
||||
it("noiseRemoval propagates writeFile error", async () => {
|
||||
vi.mocked(writeFile).mockRejectedValueOnce(new Error("ENOSPC: no space left"));
|
||||
|
||||
await expect(noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("no space left");
|
||||
});
|
||||
|
||||
it("removeRedEye propagates writeFile error", async () => {
|
||||
vi.mocked(writeFile).mockRejectedValueOnce(new Error("ENOSPC: no space left"));
|
||||
|
||||
await expect(removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("no space left");
|
||||
});
|
||||
|
||||
it("restorePhoto propagates writeFile error", async () => {
|
||||
vi.mocked(writeFile).mockRejectedValueOnce(new Error("ENOSPC: no space left"));
|
||||
|
||||
await expect(restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("no space left");
|
||||
});
|
||||
|
||||
it("upscale propagates writeFile error", async () => {
|
||||
vi.mocked(writeFile).mockRejectedValueOnce(new Error("ENOSPC: no space left"));
|
||||
|
||||
await expect(upscale(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("no space left");
|
||||
});
|
||||
|
||||
it("inpaint propagates writeFile error on input", async () => {
|
||||
vi.mocked(writeFile).mockRejectedValueOnce(new Error("ENOSPC: no space left"));
|
||||
|
||||
const mask = Buffer.from("mask");
|
||||
await expect(inpaint(FAKE_INPUT, mask, FAKE_OUTPUT_DIR)).rejects.toThrow("no space left");
|
||||
});
|
||||
|
||||
it("detectFaceLandmarks propagates writeFile error", async () => {
|
||||
vi.mocked(writeFile).mockRejectedValueOnce(new Error("ENOSPC: no space left"));
|
||||
|
||||
await expect(detectFaceLandmarks(FAKE_INPUT)).rejects.toThrow("no space left");
|
||||
});
|
||||
|
||||
it("detectFaces propagates writeFile error", async () => {
|
||||
vi.mocked(writeFile).mockRejectedValueOnce(new Error("ENOSPC: no space left"));
|
||||
|
||||
await expect(detectFaces(FAKE_INPUT)).rejects.toThrow("no space left");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Segfault propagation through all tools ─────────────────────────────
|
||||
|
||||
describe("segfault propagation through all tools", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(runPythonWithProgress).mockRejectedValue(
|
||||
new Error("Process crashed (segmentation fault)"),
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates segfault through colorize", async () => {
|
||||
await expect(colorize(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("segmentation fault");
|
||||
});
|
||||
|
||||
it("propagates segfault through blurFaces", async () => {
|
||||
await expect(blurFaces(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("segmentation fault");
|
||||
});
|
||||
|
||||
it("propagates segfault through enhanceFaces", async () => {
|
||||
await expect(enhanceFaces(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("segmentation fault");
|
||||
});
|
||||
|
||||
it("propagates segfault through detectFaceLandmarks", async () => {
|
||||
await expect(detectFaceLandmarks(FAKE_INPUT)).rejects.toThrow("segmentation fault");
|
||||
});
|
||||
|
||||
it("propagates segfault through inpaint", async () => {
|
||||
const mask = Buffer.from("mask");
|
||||
await expect(inpaint(FAKE_INPUT, mask, FAKE_OUTPUT_DIR)).rejects.toThrow("segmentation fault");
|
||||
});
|
||||
|
||||
it("propagates segfault through noiseRemoval", async () => {
|
||||
await expect(noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("segmentation fault");
|
||||
});
|
||||
|
||||
it("propagates segfault through removeRedEye", async () => {
|
||||
await expect(removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("segmentation fault");
|
||||
});
|
||||
|
||||
it("propagates segfault through restorePhoto", async () => {
|
||||
await expect(restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("segmentation fault");
|
||||
});
|
||||
|
||||
it("propagates segfault through removeBackground", async () => {
|
||||
await expect(removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow(
|
||||
"segmentation fault",
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates segfault through upscale", async () => {
|
||||
await expect(upscale(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("segmentation fault");
|
||||
});
|
||||
});
|
||||
|
||||
// ── OOM propagation through all tools ──────────────────────────────────
|
||||
|
||||
describe("OOM propagation through all tools", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(runPythonWithProgress).mockRejectedValue(
|
||||
new Error("Process killed (out of memory) -- try a lighter model or smaller image"),
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates OOM through colorize", async () => {
|
||||
await expect(colorize(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("out of memory");
|
||||
});
|
||||
|
||||
it("propagates OOM through blurFaces", async () => {
|
||||
await expect(blurFaces(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("out of memory");
|
||||
});
|
||||
|
||||
it("propagates OOM through enhanceFaces", async () => {
|
||||
await expect(enhanceFaces(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("out of memory");
|
||||
});
|
||||
|
||||
it("propagates OOM through detectFaceLandmarks", async () => {
|
||||
await expect(detectFaceLandmarks(FAKE_INPUT)).rejects.toThrow("out of memory");
|
||||
});
|
||||
|
||||
it("propagates OOM through inpaint", async () => {
|
||||
const mask = Buffer.from("mask");
|
||||
await expect(inpaint(FAKE_INPUT, mask, FAKE_OUTPUT_DIR)).rejects.toThrow("out of memory");
|
||||
});
|
||||
|
||||
it("propagates OOM through noiseRemoval", async () => {
|
||||
await expect(noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("out of memory");
|
||||
});
|
||||
|
||||
it("propagates OOM through removeRedEye", async () => {
|
||||
await expect(removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("out of memory");
|
||||
});
|
||||
|
||||
it("propagates OOM through restorePhoto", async () => {
|
||||
await expect(restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("out of memory");
|
||||
});
|
||||
|
||||
it("propagates OOM through detectFaces", async () => {
|
||||
await expect(detectFaces(FAKE_INPUT)).rejects.toThrow("out of memory");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -389,5 +389,65 @@ describe("upscale", () => {
|
||||
// 4MP * 4 (2^2) * 180_000 = 2_880_000ms
|
||||
expect(options.timeout).toBe(2_880_000);
|
||||
});
|
||||
|
||||
it("handles zero dimensions from metadata gracefully", async () => {
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: undefined, height: undefined }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
await upscale(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
const options = vi.mocked(runPythonWithProgress).mock.calls[0][2];
|
||||
// 0 MP: timeout = max(600_000, 0) = 600_000
|
||||
expect(options.timeout).toBe(600_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sharp conversion errors", () => {
|
||||
it("propagates sharp toBuffer error", async () => {
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockRejectedValue(new Error("Input buffer is empty")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
await expect(upscale(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("Input buffer is empty");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("propagates writeFile error", async () => {
|
||||
vi.mocked(writeFile).mockRejectedValueOnce(new Error("ENOSPC: disk full"));
|
||||
|
||||
await expect(upscale(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("disk full");
|
||||
});
|
||||
|
||||
it("propagates readFile error after successful Python run", async () => {
|
||||
vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT: output missing"));
|
||||
|
||||
await expect(upscale(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("output missing");
|
||||
});
|
||||
|
||||
it("passes empty options as empty JSON object", async () => {
|
||||
await upscale(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
expect(JSON.parse(args[2])).toEqual({});
|
||||
});
|
||||
|
||||
it("propagates segfault from bridge", async () => {
|
||||
vi.mocked(runPythonWithProgress).mockRejectedValue(
|
||||
new Error("Process crashed (segmentation fault)"),
|
||||
);
|
||||
|
||||
await expect(upscale(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("segmentation fault");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -332,3 +332,95 @@ describe("applyCorrections pipeline (CLAHE + normalise + gamma)", () => {
|
||||
expect(Buffer.compare(lowBuf, highBuf)).not.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auto-enhance edge cases", () => {
|
||||
it("produces negative saturation correction for over-saturated image (saturation > 60)", async () => {
|
||||
// Create a highly saturated image (pure bright colors with extreme channel spread)
|
||||
const saturatedBuf = await sharp({
|
||||
create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const result = await analyzeImage(saturatedBuf);
|
||||
// A pure red image has extreme channel spread, so saturation score should be > 60
|
||||
expect(result.scores.saturation).toBeGreaterThan(60);
|
||||
// The correction should be negative (desaturate)
|
||||
expect(result.corrections.saturation).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("applies denoise with kernel 5 when denoise adjustment is >= 4", async () => {
|
||||
// To get adj >= 4, we need corrections.denoise * presets.denoise * scale >= 4
|
||||
// With low-light preset (denoise: 2.0), intensity 100 (scale = 2.0):
|
||||
// adj = denoise * 2.0 * 2.0 = denoise * 4.0
|
||||
// For denoise = 5: adj = 20 >= 4, so kernel = 5
|
||||
const corrections = {
|
||||
brightness: 0,
|
||||
contrast: 0,
|
||||
temperature: 0,
|
||||
saturation: 0,
|
||||
sharpness: 0,
|
||||
denoise: 5,
|
||||
};
|
||||
const image = sharp(PNG_200x150);
|
||||
const enhanced = applyCorrections(image, corrections, "low-light", 100, {});
|
||||
const buf = await enhanced.toBuffer();
|
||||
expect(buf.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("applies denoise with kernel 3 when denoise adjustment is >= 2 but < 4", async () => {
|
||||
// With auto preset (denoise: 1.0), intensity 50 (scale = 1.0):
|
||||
// adj = denoise * 1.0 * 1.0 = denoise
|
||||
// For denoise = 3: adj = 3 (>= 2 but < 4), so kernel = 3
|
||||
const corrections = {
|
||||
brightness: 0,
|
||||
contrast: 0,
|
||||
temperature: 0,
|
||||
saturation: 0,
|
||||
sharpness: 0,
|
||||
denoise: 3,
|
||||
};
|
||||
const image = sharp(PNG_200x150);
|
||||
const enhanced = applyCorrections(image, corrections, "auto", 50, {});
|
||||
const buf = await enhanced.toBuffer();
|
||||
expect(buf.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("suggests document mode for high-contrast low-saturation image", async () => {
|
||||
// Create a high-contrast black & white image
|
||||
const bwBuf = await sharp(
|
||||
Buffer.from(
|
||||
Array.from({ length: 100 * 100 * 3 }, (_, i) => {
|
||||
const row = Math.floor(i / 300);
|
||||
return row % 2 === 0 ? 255 : 0;
|
||||
}),
|
||||
),
|
||||
{ raw: { width: 100, height: 100, channels: 3 } },
|
||||
)
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const result = await analyzeImage(bwBuf);
|
||||
// High contrast + low saturation should suggest document mode
|
||||
if (result.scores.contrast > 60 && result.scores.saturation < 30) {
|
||||
expect(result.suggestedMode).toBe("document");
|
||||
}
|
||||
});
|
||||
|
||||
it("scaleCorrections with landscape mode applies correct multipliers", () => {
|
||||
const base = {
|
||||
brightness: 10,
|
||||
contrast: 10,
|
||||
temperature: 10,
|
||||
saturation: 10,
|
||||
sharpness: 10,
|
||||
denoise: 10,
|
||||
};
|
||||
const scaled = scaleCorrections(base, "landscape", 50);
|
||||
// Landscape preset: brightness=1.0, contrast=1.3, saturation=1.4, sharpness=1.5
|
||||
expect(scaled.brightness).toBe(10); // 10 * 1.0 * 1.0 = 10
|
||||
expect(scaled.contrast).toBe(13); // 10 * 1.3 * 1.0 = 13
|
||||
expect(scaled.saturation).toBe(14); // 10 * 1.4 * 1.0 = 14
|
||||
expect(scaled.sharpness).toBe(15); // 10 * 1.5 * 1.0 = 15
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,3 +233,42 @@ describe("processImage with different input formats", () => {
|
||||
expect(result.buffer.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OPERATION_MAP coverage -- operations invoked through processImage
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("processImage OPERATION_MAP coverage", () => {
|
||||
it("color-blindness operation through processImage", async () => {
|
||||
const result = await processImage(png200x150, [
|
||||
{ type: "color-blindness", options: { type: "protanopia" } },
|
||||
]);
|
||||
expect(result.buffer.length).toBeGreaterThan(0);
|
||||
expect(result.info.width).toBe(200);
|
||||
expect(result.info.height).toBe(150);
|
||||
});
|
||||
|
||||
it("edit-metadata operation through processImage", async () => {
|
||||
const result = await processImage(jpg100x100, [
|
||||
{ type: "edit-metadata", options: { artist: "Engine Test" } },
|
||||
]);
|
||||
expect(result.buffer.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("sharpen-advanced operation through processImage", async () => {
|
||||
const result = await processImage(png200x150, [
|
||||
{ type: "sharpen-advanced", options: { method: "adaptive" } },
|
||||
]);
|
||||
expect(result.buffer.length).toBeGreaterThan(0);
|
||||
expect(result.info.width).toBe(200);
|
||||
});
|
||||
|
||||
it("sharpen operation through processImage", async () => {
|
||||
const result = await processImage(png200x150, [{ type: "sharpen", options: { value: 30 } }]);
|
||||
expect(result.buffer.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("heif output format maps to avif", async () => {
|
||||
const result = await processImage(png200x150, [], "heif");
|
||||
expect(result.info.format).toBe("heif");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,6 +239,33 @@ describe("parseExif", () => {
|
||||
expect(Object.keys(result.gps).length).toBeGreaterThan(0);
|
||||
expect(result.gps.GPSLatitudeRef).toBe("N");
|
||||
});
|
||||
|
||||
it("populates Iop section when exif-reader returns Iop data", async () => {
|
||||
// Create a JPEG that includes the InteropOffset (0xA005) tag in its
|
||||
// EXIF sub-IFD. Sharp >= 0.33 writes IFD2 as the Interoperability IFD.
|
||||
// If Sharp's version doesn't support writing IFD2, we embed the tag
|
||||
// manually by round-tripping through a buffer with a modified EXIF.
|
||||
|
||||
// First, create a JPEG with full EXIF including IFD1 (thumbnail)
|
||||
// which often triggers exif-reader to parse more sections.
|
||||
const base = await sharp({
|
||||
create: { width: 100, height: 100, channels: 3, background: "#808080" },
|
||||
})
|
||||
.withExif({
|
||||
IFD0: { Artist: "Iop Test", Software: "TestSuite" },
|
||||
IFD1: { Compression: "6" },
|
||||
})
|
||||
.jpeg()
|
||||
.toBuffer();
|
||||
|
||||
const metadata = await sharp(base).metadata();
|
||||
expect(metadata.exif).toBeTruthy();
|
||||
const result = parseExif(metadata.exif!);
|
||||
// The Iop section should always be initialized as an object
|
||||
expect(typeof result.iop).toBe("object");
|
||||
// Even if it is empty, the Image section should be populated
|
||||
expect(result.image.Artist).toBe("Iop Test");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -764,6 +764,13 @@ describe("compress", () => {
|
||||
expect(Math.abs(buf.length - targetBytes) / targetBytes).toBeLessThan(0.5);
|
||||
});
|
||||
|
||||
it("compresses with format=avif adds effort option", async () => {
|
||||
const img = sharp(png200x150);
|
||||
const result = await compress(img, { quality: 50, format: "avif" });
|
||||
const meta = await getMeta(result);
|
||||
expect(meta.format).toBe("heif");
|
||||
});
|
||||
|
||||
it("target size falls back to bestQuality=1 when bestBuffer stays null", async () => {
|
||||
// Use a very tiny target that forces all iterations to overshoot,
|
||||
// so bestBuffer never gets assigned and the fallback path runs
|
||||
|
||||
@@ -125,6 +125,17 @@ describe("analytics lib", () => {
|
||||
expect(mockSentryInit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows Sentry init errors and logs a warning", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
mockSentryInit.mockImplementationOnce(() => {
|
||||
throw new Error("Sentry init boom");
|
||||
});
|
||||
setAnalyticsConsent(true);
|
||||
await initAnalytics(enabledConfig);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("[analytics] Sentry init failed:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not double-initialize on repeated calls", async () => {
|
||||
setAnalyticsConsent(true);
|
||||
await initAnalytics(enabledConfig);
|
||||
|
||||
@@ -343,3 +343,71 @@ describe("throwWithMessage error extraction", () => {
|
||||
await expect(apiGet("/v1/test")).rejects.toThrow("API error: 502");
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// apiGetFileDetails
|
||||
// ==========================================================================
|
||||
describe("apiGetFileDetails", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
storageMap.clear();
|
||||
});
|
||||
|
||||
it("returns merged file and versions", async () => {
|
||||
const { apiGetFileDetails } = await import("@/lib/api");
|
||||
fetchMock.mockReturnValueOnce(
|
||||
okJson({
|
||||
file: {
|
||||
id: "f1",
|
||||
originalName: "photo.jpg",
|
||||
size: 2048,
|
||||
mimeType: "image/jpeg",
|
||||
createdAt: "2025-01-01",
|
||||
},
|
||||
versions: [{ version: 1, size: 2048 }],
|
||||
}),
|
||||
);
|
||||
const result = await apiGetFileDetails("f1");
|
||||
expect(result.id).toBe("f1");
|
||||
expect(result.originalName).toBe("photo.jpg");
|
||||
expect(result.versions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// apiDownloadBlob network error coverage
|
||||
// ==========================================================================
|
||||
describe("apiDownloadBlob network errors", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
storageMap.clear();
|
||||
});
|
||||
|
||||
it("triggers disconnected on TypeError from fetch", async () => {
|
||||
const { apiDownloadBlob } = await import("@/lib/api");
|
||||
const { useConnectionStore } = await import("@/stores/connection-store");
|
||||
useConnectionStore.setState({
|
||||
status: "connected",
|
||||
failedSince: null,
|
||||
lastHealthCheck: null,
|
||||
});
|
||||
|
||||
fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch"));
|
||||
await expect(apiDownloadBlob("job-x", "file.png")).rejects.toThrow("Failed to fetch");
|
||||
expect(useConnectionStore.getState().status).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("does NOT trigger disconnected on non-TypeError", async () => {
|
||||
const { apiDownloadBlob } = await import("@/lib/api");
|
||||
const { useConnectionStore } = await import("@/stores/connection-store");
|
||||
useConnectionStore.setState({
|
||||
status: "connected",
|
||||
failedSince: null,
|
||||
lastHealthCheck: null,
|
||||
});
|
||||
|
||||
fetchMock.mockRejectedValueOnce(new Error("Some other error"));
|
||||
await expect(apiDownloadBlob("job-x", "file.png")).rejects.toThrow("Some other error");
|
||||
expect(useConnectionStore.getState().status).toBe("connected");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,4 +127,34 @@ describe("connection-store", () => {
|
||||
vi.advanceTimersByTime(6000);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("setOnline is no-op when not offline", () => {
|
||||
useConnectionStore.setState({ status: "connected", failedSince: null, lastHealthCheck: null });
|
||||
useConnectionStore.getState().setOnline();
|
||||
expect(useConnectionStore.getState().status).toBe("connected");
|
||||
});
|
||||
|
||||
it("refreshStaleData calls settings fetch and features refresh", async () => {
|
||||
vi.useRealTimers();
|
||||
// Mock the dynamically imported stores
|
||||
const mockFetch = vi.fn().mockResolvedValue(undefined);
|
||||
const mockRefresh = vi.fn().mockResolvedValue(undefined);
|
||||
vi.doMock("@/stores/settings-store", () => ({
|
||||
useSettingsStore: {
|
||||
setState: vi.fn(),
|
||||
getState: () => ({ fetch: mockFetch }),
|
||||
},
|
||||
}));
|
||||
vi.doMock("@/stores/features-store", () => ({
|
||||
useFeaturesStore: {
|
||||
getState: () => ({ refresh: mockRefresh }),
|
||||
},
|
||||
}));
|
||||
|
||||
await useConnectionStore.getState().refreshStaleData();
|
||||
// The function should complete without throwing
|
||||
expect(true).toBe(true);
|
||||
vi.doUnmock("@/stores/settings-store");
|
||||
vi.doUnmock("@/stores/features-store");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,80 @@ vi.stubGlobal("localStorage", {
|
||||
// fetchDecodedPreview & revokePreviewUrl (image-preview.ts)
|
||||
// ==========================================================================
|
||||
|
||||
import { fetchDecodedPreview, revokePreviewUrl } from "@/lib/image-preview";
|
||||
import { fetchDecodedPreview, needsServerPreview, revokePreviewUrl } from "@/lib/image-preview";
|
||||
|
||||
describe("needsServerPreview", () => {
|
||||
it("returns true for HEIC files", () => {
|
||||
const file = new File(["data"], "photo.heic", { type: "image/heic" });
|
||||
expect(needsServerPreview(file)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for HEIF files", () => {
|
||||
const file = new File(["data"], "photo.heif", { type: "image/heif" });
|
||||
expect(needsServerPreview(file)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for HIF files", () => {
|
||||
const file = new File(["data"], "photo.hif", { type: "image/heif" });
|
||||
expect(needsServerPreview(file)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for JXL files", () => {
|
||||
const file = new File(["data"], "photo.jxl", { type: "image/jxl" });
|
||||
expect(needsServerPreview(file)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for RAW formats (dng, cr2, nef, arw, orf, rw2)", () => {
|
||||
for (const ext of ["dng", "cr2", "nef", "arw", "orf", "rw2"]) {
|
||||
const file = new File(["data"], `photo.${ext}`, { type: "image/raw" });
|
||||
expect(needsServerPreview(file)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns true for ICO files", () => {
|
||||
const file = new File(["data"], "icon.ico", { type: "image/x-icon" });
|
||||
expect(needsServerPreview(file)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for TGA files", () => {
|
||||
const file = new File(["data"], "texture.tga", { type: "image/tga" });
|
||||
expect(needsServerPreview(file)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for PSD files", () => {
|
||||
const file = new File(["data"], "design.psd", { type: "image/vnd.adobe.photoshop" });
|
||||
expect(needsServerPreview(file)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for EXR files", () => {
|
||||
const file = new File(["data"], "render.exr", { type: "image/x-exr" });
|
||||
expect(needsServerPreview(file)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for HDR files", () => {
|
||||
const file = new File(["data"], "panorama.hdr", { type: "image/vnd.radiance" });
|
||||
expect(needsServerPreview(file)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for common browser-decodable formats", () => {
|
||||
expect(needsServerPreview(new File(["data"], "photo.png", { type: "image/png" }))).toBe(false);
|
||||
expect(needsServerPreview(new File(["data"], "photo.jpg", { type: "image/jpeg" }))).toBe(false);
|
||||
expect(needsServerPreview(new File(["data"], "photo.webp", { type: "image/webp" }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(needsServerPreview(new File(["data"], "photo.gif", { type: "image/gif" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for files without extension", () => {
|
||||
const file = new File(["data"], "noextension", { type: "application/octet-stream" });
|
||||
expect(needsServerPreview(file)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles uppercase extensions (case insensitive)", () => {
|
||||
const file = new File(["data"], "photo.HEIC", { type: "image/heic" });
|
||||
expect(needsServerPreview(file)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchDecodedPreview", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -5,7 +5,7 @@ vi.mock("@/stores/connection-store", () => ({
|
||||
useConnectionStore: { getState: () => ({ setDisconnected: vi.fn() }) },
|
||||
}));
|
||||
|
||||
import { retryDynamicImport } from "@/lib/lazy-with-retry";
|
||||
import { isChunkError, lazyWithRetry, retryDynamicImport } from "@/lib/lazy-with-retry";
|
||||
|
||||
describe("retryDynamicImport", () => {
|
||||
beforeEach(() => {
|
||||
@@ -70,3 +70,50 @@ describe("retryDynamicImport", () => {
|
||||
expect(importFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isChunkError", () => {
|
||||
it("returns false for non-Error values", () => {
|
||||
expect(isChunkError("string")).toBe(false);
|
||||
expect(isChunkError(null)).toBe(false);
|
||||
expect(isChunkError(undefined)).toBe(false);
|
||||
expect(isChunkError(42)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for dynamically imported module error", () => {
|
||||
expect(isChunkError(new Error("Failed to fetch dynamically imported module /foo.js"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true for loading chunk error", () => {
|
||||
expect(isChunkError(new Error("Loading chunk 123 failed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for loading CSS chunk error", () => {
|
||||
expect(isChunkError(new Error("Loading CSS chunk abc-def failed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for failed to fetch error", () => {
|
||||
expect(isChunkError(new TypeError("Failed to fetch"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for unable to preload error", () => {
|
||||
expect(isChunkError(new Error("Unable to preload CSS for /assets/foo.css"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unrelated errors", () => {
|
||||
expect(isChunkError(new Error("Something else went wrong"))).toBe(false);
|
||||
expect(isChunkError(new Error("Network timeout"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lazyWithRetry", () => {
|
||||
it("returns a React lazy component", () => {
|
||||
const Comp = () => null;
|
||||
const importFn = vi.fn().mockResolvedValue({ default: Comp });
|
||||
const LazyComp = lazyWithRetry(importFn as any);
|
||||
// React.lazy returns an object with $$typeof symbol
|
||||
expect(LazyComp).toBeDefined();
|
||||
expect(typeof LazyComp).toBe("object");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -443,6 +443,49 @@ describe("FileStore", () => {
|
||||
expect(allDone).toBe(false);
|
||||
});
|
||||
|
||||
// -- HEIC async preview in setFiles -----------------------------------------
|
||||
|
||||
it("setFiles triggers async preview for HEIC files", async () => {
|
||||
const previewBlob = new Blob(["decoded"], { type: "image/png" });
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
blob: () => Promise.resolve(previewBlob),
|
||||
});
|
||||
|
||||
const heicFile = makeFile("photo.heic", 500, "image/heic");
|
||||
useFileStore.getState().setFiles([heicFile]);
|
||||
|
||||
// Entry should have previewLoading=true initially
|
||||
expect(useFileStore.getState().entries[0].previewLoading).toBe(true);
|
||||
|
||||
// Wait for async preview to resolve
|
||||
await vi.waitFor(() => {
|
||||
expect(useFileStore.getState().entries[0].previewLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("addFiles triggers async preview for HEIC files", async () => {
|
||||
// First add a normal file
|
||||
useFileStore.getState().setFiles([makeFile("normal.png", 100)]);
|
||||
|
||||
const previewBlob = new Blob(["decoded"], { type: "image/png" });
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
blob: () => Promise.resolve(previewBlob),
|
||||
});
|
||||
|
||||
const heicFile = makeFile("photo2.heic", 500, "image/heic");
|
||||
useFileStore.getState().addFiles([heicFile]);
|
||||
|
||||
// Second entry should have previewLoading=true initially
|
||||
expect(useFileStore.getState().entries[1].previewLoading).toBe(true);
|
||||
|
||||
// Wait for async preview to resolve
|
||||
await vi.waitFor(() => {
|
||||
expect(useFileStore.getState().entries[1].previewLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// -- setProcessedUrl (backward compat, updates current entry) -------------
|
||||
|
||||
it("setProcessedUrl updates current entry processedUrl and status", () => {
|
||||
|
||||
@@ -937,6 +937,23 @@ describe("useThemeStore", () => {
|
||||
useThemeStore.getState().applyServerDefault("dark");
|
||||
expect(localStorage.getItem("snapotter-theme-user-set")).toBeNull();
|
||||
});
|
||||
|
||||
it("system theme resolves to dark when matchMedia prefers dark", () => {
|
||||
const originalMatchMedia = globalThis.matchMedia;
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn().mockReturnValue({
|
||||
matches: true,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}),
|
||||
);
|
||||
useThemeStore.getState().setTheme("system");
|
||||
const s = useThemeStore.getState();
|
||||
expect(s.theme).toBe("system");
|
||||
expect(s.resolvedTheme).toBe("dark");
|
||||
vi.stubGlobal("matchMedia", originalMatchMedia);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
@@ -1512,6 +1529,79 @@ describe("useCollageStore", () => {
|
||||
expect(s.phase).toBe("upload");
|
||||
});
|
||||
|
||||
it("addImages triggers async preview loading for HEIC files", async () => {
|
||||
// Override the mocked needsServerPreview to return true for this test
|
||||
const imagePreview = await import("@/lib/image-preview");
|
||||
const needsMock = vi.mocked(imagePreview.needsServerPreview);
|
||||
const fetchPreviewMock = vi.mocked(imagePreview.fetchDecodedPreview);
|
||||
needsMock.mockReturnValueOnce(true);
|
||||
fetchPreviewMock.mockResolvedValueOnce("blob:preview-decoded");
|
||||
|
||||
const heicFile = new File(["heic-data"], "photo.heic", { type: "image/heic" });
|
||||
useCollageStore.getState().addImages([heicFile]);
|
||||
|
||||
// The image should initially have previewLoading = true
|
||||
expect(useCollageStore.getState().images[0].previewLoading).toBe(true);
|
||||
|
||||
// Wait for the async fetchDecodedPreview to complete
|
||||
await vi.waitFor(() => {
|
||||
const img = useCollageStore.getState().images[0];
|
||||
expect(img.previewLoading).toBe(false);
|
||||
});
|
||||
|
||||
// The preview blob URL should be set
|
||||
expect(useCollageStore.getState().images[0].previewBlobUrl).toBe("blob:preview-decoded");
|
||||
});
|
||||
|
||||
it("addImages preview callback handles image removed before resolve", async () => {
|
||||
const imagePreview = await import("@/lib/image-preview");
|
||||
const needsMock = vi.mocked(imagePreview.needsServerPreview);
|
||||
const fetchPreviewMock = vi.mocked(imagePreview.fetchDecodedPreview);
|
||||
needsMock.mockReturnValueOnce(true);
|
||||
|
||||
let resolvePreview!: (v: string | null) => void;
|
||||
fetchPreviewMock.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolvePreview = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const heicFile = new File(["data"], "gone.heic", { type: "image/heic" });
|
||||
useCollageStore.getState().addImages([heicFile]);
|
||||
|
||||
// Remove the image before the preview resolves
|
||||
useCollageStore.getState().removeImage(0);
|
||||
|
||||
// Now resolve the preview -- should not crash (idx === -1 path)
|
||||
resolvePreview("blob:late-preview");
|
||||
|
||||
// Give the promise chain time to settle
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Store should still be in reset state
|
||||
expect(useCollageStore.getState().images).toEqual([]);
|
||||
});
|
||||
|
||||
it("addImages preview callback handles null URL from fetchDecodedPreview", async () => {
|
||||
const imagePreview = await import("@/lib/image-preview");
|
||||
const needsMock = vi.mocked(imagePreview.needsServerPreview);
|
||||
const fetchPreviewMock = vi.mocked(imagePreview.fetchDecodedPreview);
|
||||
needsMock.mockReturnValueOnce(true);
|
||||
fetchPreviewMock.mockResolvedValueOnce(null);
|
||||
|
||||
const heicFile = new File(["data"], "fail.heic", { type: "image/heic" });
|
||||
useCollageStore.getState().addImages([heicFile]);
|
||||
|
||||
// Wait for the async preview to resolve with null
|
||||
await vi.waitFor(() => {
|
||||
const img = useCollageStore.getState().images[0];
|
||||
expect(img.previewLoading).toBe(false);
|
||||
});
|
||||
|
||||
// previewBlobUrl should NOT be set when URL is null
|
||||
expect(useCollageStore.getState().images[0].previewBlobUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearImages revokes all blob URLs and resets to upload phase", () => {
|
||||
const file1 = new File(["a"], "a.png", { type: "image/png" });
|
||||
const file2 = new File(["b"], "b.png", { type: "image/png" });
|
||||
|
||||
Reference in New Issue
Block a user