mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: release QA hardening across processing, media, security, and CI gates (#649)
A release-readiness QA pass over the whole product. The commits split into defects a user would hit and gates that were reporting green while measuring nothing. ## Fixes that change behaviour Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so request.ip came from a client-set header and a forged X-Forwarded-For got past the login limiter. The default is now a private-network trust list. A transient Postgres outage stranded in-flight jobs, leaving finished output on disk with no row pointing at it. A reconciler now resolves those rows and adopts the bytes rather than dropping the work. A Redis connection that moved to a new address wedged every read-blocked consumer, so completions stopped signalling while health still answered 200. Socket timeouts plus subscriber pings recover it. Installing more than one AI bundle left the shared venv multi-versioned and silently broke three tools. The installer now reconciles distributions to one version each. Converting an image to JXL at quality 1 through 4 returned a 500, because libjxl 0.7 rejects the distance those values compute. The quality is floored at what the encoder honours. A missing ffmpeg was also reported to the user as a corrupt upload; it now says the engine is unavailable. RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at 0.22.2, and the release scan was split so it can fail on an unfixed critical instead of hiding it behind ignore-unfixed. ## Gates that could not fail Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs build; coverage discarded its whole report on any failing test; the lint gate skipped root tests, scripts, and two workspaces; and several generated matrices counted a host missing ffmpeg as a passing tool. Each now measures what it claims. Full evidence and the outstanding release items are tracked locally and are not part of this branch.
This commit is contained in:
@@ -127,6 +127,19 @@ describe("removeBackground", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards AbortSignal to Python without serializing it into sidecar options", async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR, {
|
||||
model: "u2net",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const [, args, bridgeOptions] = vi.mocked(runPythonWithProgress).mock.calls[0];
|
||||
expect(JSON.parse(args[2])).toEqual({ model: "u2net" });
|
||||
expect(bridgeOptions).toEqual(expect.objectContaining({ signal: controller.signal }));
|
||||
});
|
||||
|
||||
it("converts input to PNG via sharp before writing to disk", async () => {
|
||||
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { Writable } from "node:stream";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("node:child_process", () => ({ spawn: vi.fn() }));
|
||||
|
||||
interface MockProcess {
|
||||
process: ChildProcess;
|
||||
stdinWrites: string[];
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
emit: (event: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
function createMockProcess(): MockProcess {
|
||||
const stdinWrites: string[] = [];
|
||||
const stdin = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
stdinWrites.push(chunk.toString());
|
||||
callback();
|
||||
},
|
||||
});
|
||||
const stdout = new EventEmitter();
|
||||
const stderr = new EventEmitter();
|
||||
const process = new EventEmitter() as unknown as ChildProcess;
|
||||
Object.assign(process, {
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
pid: 12345,
|
||||
killed: false,
|
||||
kill: vi.fn(() => {
|
||||
(process as { killed: boolean }).killed = true;
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
return {
|
||||
process,
|
||||
stdinWrites,
|
||||
stdout,
|
||||
stderr,
|
||||
emit: (event, ...args) => process.emit(event, ...args),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Python bridge cancellation", () => {
|
||||
let shutdownDispatcher: (() => void) | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mocked(spawn).mockReset();
|
||||
delete process.env.PROCESSING_TIMEOUT_S;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
shutdownDispatcher?.();
|
||||
shutdownDispatcher = undefined;
|
||||
delete process.env.PROCESSING_TIMEOUT_S;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("aborts and terminates a per-request Python process even when timeouts are unlimited", async () => {
|
||||
process.env.PROCESSING_TIMEOUT_S = "0";
|
||||
const dispatcher = createMockProcess();
|
||||
const request = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValueOnce(dispatcher.process).mockReturnValueOnce(request.process);
|
||||
|
||||
const bridge = await import("../../../packages/ai/src/bridge.js");
|
||||
shutdownDispatcher = bridge.shutdownDispatcher;
|
||||
const controller = new AbortController();
|
||||
const result = bridge.runPythonWithProgress("test.py", [], { signal: controller.signal });
|
||||
|
||||
controller.abort("job canceled");
|
||||
request.stdout.emit("data", Buffer.from('{"success":true}\n'));
|
||||
request.emit("close", 0, null);
|
||||
|
||||
await expect(result).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(request.process.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
});
|
||||
|
||||
it("aborts a dispatcher request, kills that dispatcher, and ignores its stale response", async () => {
|
||||
const dispatcher = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(dispatcher.process);
|
||||
|
||||
const bridge = await import("../../../packages/ai/src/bridge.js");
|
||||
shutdownDispatcher = bridge.shutdownDispatcher;
|
||||
const ready = bridge.initDispatcher();
|
||||
dispatcher.stderr.emit("data", Buffer.from('{"ready":true,"gpu":false}\n'));
|
||||
await ready;
|
||||
|
||||
const controller = new AbortController();
|
||||
const result = bridge.runPythonWithProgress("test.py", [], { signal: controller.signal });
|
||||
const request = JSON.parse(dispatcher.stdinWrites.at(-1) ?? "{}") as { id?: string };
|
||||
|
||||
controller.abort("job canceled");
|
||||
dispatcher.stdout.emit(
|
||||
"data",
|
||||
Buffer.from(
|
||||
`${JSON.stringify({ id: request.id, exitCode: 0, stdout: '{"success":true}' })}\n`,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(result).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(dispatcher.process.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
});
|
||||
|
||||
it("terminates only the active ENOENT fallback attempt when cancellation races startup", async () => {
|
||||
const dispatcher = createMockProcess();
|
||||
const missingVenvPython = createMockProcess();
|
||||
const fallbackPython = createMockProcess();
|
||||
vi.mocked(spawn)
|
||||
.mockReturnValueOnce(dispatcher.process)
|
||||
.mockReturnValueOnce(missingVenvPython.process)
|
||||
.mockReturnValueOnce(fallbackPython.process);
|
||||
|
||||
const bridge = await import("../../../packages/ai/src/bridge.js");
|
||||
shutdownDispatcher = bridge.shutdownDispatcher;
|
||||
const controller = new AbortController();
|
||||
const result = bridge.runPythonWithProgress("test.py", [], { signal: controller.signal });
|
||||
|
||||
const enoent = Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" });
|
||||
missingVenvPython.emit("error", enoent);
|
||||
controller.abort("job canceled");
|
||||
fallbackPython.stdout.emit("data", Buffer.from('{"success":true}\n'));
|
||||
fallbackPython.emit("close", 0, null);
|
||||
missingVenvPython.emit("close", null, "SIGTERM");
|
||||
|
||||
await expect(result).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(fallbackPython.process.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
expect(missingVenvPython.process.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildMinimalEnv } from "../../../packages/ai/src/bridge.js";
|
||||
|
||||
const modelDownloadEnvKey = "SNAPOTTER_ALLOW_MODEL_DOWNLOAD";
|
||||
const originalAllowModelDownload = process.env[modelDownloadEnvKey];
|
||||
|
||||
function setModelDownloadPolicy(value?: string): void {
|
||||
if (value === undefined) delete process.env[modelDownloadEnvKey];
|
||||
else process.env[modelDownloadEnvKey] = value;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setModelDownloadPolicy(originalAllowModelDownload);
|
||||
});
|
||||
|
||||
describe("AI sidecar model download policy", () => {
|
||||
it("defaults the runtime sidecar to strict offline mode", () => {
|
||||
setModelDownloadPolicy();
|
||||
|
||||
expect(buildMinimalEnv()).toMatchObject({
|
||||
SNAPOTTER_ALLOW_MODEL_DOWNLOAD: "0",
|
||||
HF_HUB_OFFLINE: "1",
|
||||
TRANSFORMERS_OFFLINE: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows an explicit model download opt-in", () => {
|
||||
setModelDownloadPolicy("1");
|
||||
|
||||
const env = buildMinimalEnv();
|
||||
expect(env.SNAPOTTER_ALLOW_MODEL_DOWNLOAD).toBe("1");
|
||||
expect(env.HF_HUB_OFFLINE).toBeUndefined();
|
||||
expect(env.TRANSFORMERS_OFFLINE).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps unknown policy values fail-closed", () => {
|
||||
setModelDownloadPolicy("yes");
|
||||
|
||||
expect(buildMinimalEnv()).toMatchObject({
|
||||
SNAPOTTER_ALLOW_MODEL_DOWNLOAD: "0",
|
||||
HF_HUB_OFFLINE: "1",
|
||||
TRANSFORMERS_OFFLINE: "1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Writable } from "node:stream";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -455,26 +458,32 @@ describe("bridge - PYTHON_VENV_PATH env handling", () => {
|
||||
delete process.env.PYTHON_VENV_PATH;
|
||||
});
|
||||
|
||||
it("uses custom PYTHON_VENV_PATH when set", async () => {
|
||||
process.env.PYTHON_VENV_PATH = "/custom/venv";
|
||||
it("uses custom PYTHON_VENV_PATH when its interpreter exists", async () => {
|
||||
const venvPath = mkdtempSync(join(tmpdir(), "snapotter-bridge-venv-"));
|
||||
const pythonPath =
|
||||
process.platform === "win32"
|
||||
? join(venvPath, "Scripts", "python.exe")
|
||||
: join(venvPath, "bin", "python3");
|
||||
mkdirSync(join(pythonPath, ".."), { recursive: true });
|
||||
writeFileSync(pythonPath, "");
|
||||
process.env.PYTHON_VENV_PATH = venvPath;
|
||||
|
||||
const mod = await import("../../../packages/ai/src/bridge.js");
|
||||
runPythonWithProgress = mod.runPythonWithProgress;
|
||||
try {
|
||||
const mod = await import("../../../packages/ai/src/bridge.js");
|
||||
runPythonWithProgress = mod.runPythonWithProgress;
|
||||
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
const mock = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mock.process);
|
||||
|
||||
const promise = runPythonWithProgress("test.py", []);
|
||||
mock.stdout.emit("data", Buffer.from('{"ok": true}\n'));
|
||||
mock.emitEvent("close", 0, null);
|
||||
await promise;
|
||||
const promise = runPythonWithProgress("test.py", []);
|
||||
mock.stdout.emit("data", Buffer.from('{"ok": true}\n'));
|
||||
mock.emitEvent("close", 0, null);
|
||||
await promise;
|
||||
|
||||
// At least one spawn call should use the custom venv path
|
||||
const allCalls = vi.mocked(spawn).mock.calls;
|
||||
const usesCustomVenv = allCalls.some(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("/custom/venv"),
|
||||
);
|
||||
expect(usesCustomVenv).toBe(true);
|
||||
expect(vi.mocked(spawn).mock.calls.some((call) => call[0] === pythonPath)).toBe(true);
|
||||
} finally {
|
||||
rmSync(venvPath, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("passes PYTHON_VENV_PATH through to env when set", async () => {
|
||||
@@ -1162,6 +1171,36 @@ describe("bridge - per-request ENOENT retry with non-ENOENT fallback error", ()
|
||||
|
||||
await expect(promise).rejects.toThrow("Permission denied");
|
||||
});
|
||||
|
||||
it("ignores the missing venv child's close after the python3 fallback starts", async () => {
|
||||
const mockDisp = createMockProcess();
|
||||
const mockVenv = createMockProcess();
|
||||
const mockFallback = createMockProcess();
|
||||
let callCount = 0;
|
||||
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return mockDisp.process;
|
||||
if (callCount === 2) return mockVenv.process;
|
||||
return mockFallback.process;
|
||||
});
|
||||
|
||||
const promise = runPythonWithProgress("test.py", []);
|
||||
|
||||
const dispatcherError = new Error("ENOENT") as NodeJS.ErrnoException;
|
||||
dispatcherError.code = "ENOENT";
|
||||
mockDisp.emitEvent("error", dispatcherError);
|
||||
|
||||
const venvError = new Error("ENOENT") as NodeJS.ErrnoException;
|
||||
venvError.code = "ENOENT";
|
||||
mockVenv.emitEvent("error", venvError);
|
||||
mockVenv.emitEvent("close", -2, null);
|
||||
|
||||
mockFallback.stdout.emit("data", Buffer.from('{"ok":true}\n'));
|
||||
mockFallback.emitEvent("close", 0, null);
|
||||
|
||||
await expect(promise).resolves.toEqual({ stdout: '{"ok":true}', stderr: "" });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Dispatcher error event rejects pending with extractPythonError ──
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { spawn } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { existsSync } from "node:fs";
|
||||
import { Writable } from "node:stream";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const ORIGINAL_PLATFORM = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const ORIGINAL_VENV_PATH = process.env.PYTHON_VENV_PATH;
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: platform });
|
||||
}
|
||||
|
||||
function createMockProcess(): {
|
||||
process: ChildProcess;
|
||||
stderr: EventEmitter;
|
||||
} {
|
||||
const stdin = new Writable({
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback();
|
||||
},
|
||||
});
|
||||
const stdout = new EventEmitter();
|
||||
const stderr = new EventEmitter();
|
||||
const process = new EventEmitter() as unknown as ChildProcess;
|
||||
Object.assign(process, {
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
pid: 12345,
|
||||
killed: false,
|
||||
kill: vi.fn(() => true),
|
||||
});
|
||||
return { process, stderr };
|
||||
}
|
||||
|
||||
async function dispatcherExecutable(): Promise<string> {
|
||||
const child = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(child.process);
|
||||
|
||||
const { PythonDispatcher } = await import("../../../packages/ai/src/bridge.js");
|
||||
const dispatcher = new PythonDispatcher({ profile: "docs" });
|
||||
const initialized = dispatcher.init(500);
|
||||
child.stderr.emit("data", Buffer.from('{"ready":true,"gpu":false}\n'));
|
||||
await expect(initialized).resolves.toEqual({ ready: true, gpu: false });
|
||||
|
||||
const executable = vi.mocked(spawn).mock.calls[0]?.[0];
|
||||
dispatcher.shutdown();
|
||||
expect(typeof executable).toBe("string");
|
||||
return executable as string;
|
||||
}
|
||||
|
||||
describe("bridge Python interpreter selection", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mocked(spawn).mockReset();
|
||||
vi.mocked(existsSync).mockReset();
|
||||
delete process.env.PYTHON_VENV_PATH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_PLATFORM) Object.defineProperty(process, "platform", ORIGINAL_PLATFORM);
|
||||
if (ORIGINAL_VENV_PATH === undefined) delete process.env.PYTHON_VENV_PATH;
|
||||
else process.env.PYTHON_VENV_PATH = ORIGINAL_VENV_PATH;
|
||||
});
|
||||
|
||||
it("selects an existing Unix venv interpreter", async () => {
|
||||
setPlatform("linux");
|
||||
process.env.PYTHON_VENV_PATH = "/custom/venv";
|
||||
vi.mocked(existsSync).mockImplementation((path) => path === "/custom/venv/bin/python3");
|
||||
|
||||
await expect(dispatcherExecutable()).resolves.toBe("/custom/venv/bin/python3");
|
||||
});
|
||||
|
||||
it("selects python3 directly when the Unix venv interpreter is missing", async () => {
|
||||
setPlatform("linux");
|
||||
process.env.PYTHON_VENV_PATH = "/missing/venv";
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
|
||||
await expect(dispatcherExecutable()).resolves.toBe("python3");
|
||||
});
|
||||
|
||||
it("selects an existing Windows venv interpreter", async () => {
|
||||
setPlatform("win32");
|
||||
process.env.PYTHON_VENV_PATH = "C:\\custom\\venv";
|
||||
vi.mocked(existsSync).mockImplementation(
|
||||
(path) => path === "C:\\custom\\venv\\Scripts\\python.exe",
|
||||
);
|
||||
|
||||
await expect(dispatcherExecutable()).resolves.toBe("C:\\custom\\venv\\Scripts\\python.exe");
|
||||
});
|
||||
|
||||
it("selects python directly when the Windows venv interpreter is missing", async () => {
|
||||
setPlatform("win32");
|
||||
process.env.PYTHON_VENV_PATH = "C:\\missing\\venv";
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
|
||||
await expect(dispatcherExecutable()).resolves.toBe("python");
|
||||
});
|
||||
});
|
||||
@@ -139,40 +139,40 @@ describe("extractPdfText tier routing", () => {
|
||||
expect(runOcrRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"balanced",
|
||||
"best",
|
||||
] as const)("keeps explicit Korean PDF OCR on the %s accurate tier", async (quality) => {
|
||||
vi.mocked(runOcrRuntime).mockResolvedValueOnce(
|
||||
runtimeResponse({
|
||||
success: true,
|
||||
text: "정확한 PDF 텍스트",
|
||||
pages: 2,
|
||||
engine: "rapidocr-onnx",
|
||||
requestedQuality: quality,
|
||||
actualQuality: quality,
|
||||
device: "cpu",
|
||||
provider: "CPUExecutionProvider",
|
||||
degraded: false,
|
||||
warnings: [],
|
||||
}),
|
||||
);
|
||||
it.each(["balanced", "best"] as const)(
|
||||
"keeps explicit Korean PDF OCR on the %s accurate tier",
|
||||
async (quality) => {
|
||||
vi.mocked(runOcrRuntime).mockResolvedValueOnce(
|
||||
runtimeResponse({
|
||||
success: true,
|
||||
text: "정확한 PDF 텍스트",
|
||||
pages: 2,
|
||||
engine: "rapidocr-onnx",
|
||||
requestedQuality: quality,
|
||||
actualQuality: quality,
|
||||
device: "cpu",
|
||||
provider: "CPUExecutionProvider",
|
||||
degraded: false,
|
||||
warnings: [],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await extractPdfText("/tmp/job/document.pdf", {
|
||||
quality,
|
||||
language: "ko",
|
||||
});
|
||||
const result = await extractPdfText("/tmp/job/document.pdf", {
|
||||
quality,
|
||||
language: "ko",
|
||||
});
|
||||
|
||||
expect(result.requestedQuality).toBe(quality);
|
||||
expect(result.actualQuality).toBe(quality);
|
||||
expect(runTesseractPdf).not.toHaveBeenCalled();
|
||||
expect(preparePdfOcrPages).toHaveBeenCalledTimes(1);
|
||||
expect(runOcrRuntime).toHaveBeenCalledTimes(1);
|
||||
const runtimeOptions = JSON.parse(
|
||||
vi.mocked(runOcrRuntime).mock.calls[0]?.[1][1] ?? "null",
|
||||
) as Record<string, unknown>;
|
||||
expect(runtimeOptions).toMatchObject({ language: "ko", quality });
|
||||
});
|
||||
expect(result.requestedQuality).toBe(quality);
|
||||
expect(result.actualQuality).toBe(quality);
|
||||
expect(runTesseractPdf).not.toHaveBeenCalled();
|
||||
expect(preparePdfOcrPages).toHaveBeenCalledTimes(1);
|
||||
expect(runOcrRuntime).toHaveBeenCalledTimes(1);
|
||||
const runtimeOptions = JSON.parse(
|
||||
vi.mocked(runOcrRuntime).mock.calls[0]?.[1][1] ?? "null",
|
||||
) as Record<string, unknown>;
|
||||
expect(runtimeOptions).toMatchObject({ language: "ko", quality });
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps Fast PDF OCR jobs alive while native processing is quiet", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
+27
-27
@@ -108,35 +108,35 @@ describe("extractText error and progress behavior", () => {
|
||||
expect(runOcrRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"balanced",
|
||||
"best",
|
||||
] as const)("keeps explicit Korean on the %s accurate tier without silent rerouting", async (quality) => {
|
||||
vi.mocked(runOcrRuntime).mockResolvedValueOnce(
|
||||
runtimeResponse({
|
||||
success: true,
|
||||
text: "한글",
|
||||
engine: "rapidocr-onnx",
|
||||
requestedQuality: quality,
|
||||
actualQuality: quality,
|
||||
device: "cpu",
|
||||
provider: "CPUExecutionProvider",
|
||||
degraded: false,
|
||||
warnings: [],
|
||||
}),
|
||||
);
|
||||
it.each(["balanced", "best"] as const)(
|
||||
"keeps explicit Korean on the %s accurate tier without silent rerouting",
|
||||
async (quality) => {
|
||||
vi.mocked(runOcrRuntime).mockResolvedValueOnce(
|
||||
runtimeResponse({
|
||||
success: true,
|
||||
text: "한글",
|
||||
engine: "rapidocr-onnx",
|
||||
requestedQuality: quality,
|
||||
actualQuality: quality,
|
||||
device: "cpu",
|
||||
provider: "CPUExecutionProvider",
|
||||
degraded: false,
|
||||
warnings: [],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await extractText(INPUT, "/tmp/ocr", { quality, language: "ko" });
|
||||
const result = await extractText(INPUT, "/tmp/ocr", { quality, language: "ko" });
|
||||
|
||||
expect(result.requestedQuality).toBe(quality);
|
||||
expect(result.actualQuality).toBe(quality);
|
||||
expect(runAdaptiveTesseract).not.toHaveBeenCalled();
|
||||
expect(runOcrRuntime).toHaveBeenCalledTimes(1);
|
||||
const runtimeOptions = JSON.parse(
|
||||
vi.mocked(runOcrRuntime).mock.calls[0]?.[1][1] ?? "null",
|
||||
) as Record<string, unknown>;
|
||||
expect(runtimeOptions).toMatchObject({ language: "ko", quality });
|
||||
});
|
||||
expect(result.requestedQuality).toBe(quality);
|
||||
expect(result.actualQuality).toBe(quality);
|
||||
expect(runAdaptiveTesseract).not.toHaveBeenCalled();
|
||||
expect(runOcrRuntime).toHaveBeenCalledTimes(1);
|
||||
const runtimeOptions = JSON.parse(
|
||||
vi.mocked(runOcrRuntime).mock.calls[0]?.[1][1] ?? "null",
|
||||
) as Record<string, unknown>;
|
||||
expect(runtimeOptions).toMatchObject({ language: "ko", quality });
|
||||
},
|
||||
);
|
||||
|
||||
it("applies requested local-contrast preprocessing for Fast OCR", async () => {
|
||||
const recognitionPipeline = {
|
||||
|
||||
@@ -279,27 +279,30 @@ describe("OCR runtime memory compatibility", () => {
|
||||
"31 29 0:26 / /sys/fs/cgroup rw,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw",
|
||||
],
|
||||
],
|
||||
])("ignores a hidden old child when its replacement has the same mountpoint (%s)", (_label, mounts) => {
|
||||
const files = new Map([
|
||||
["/proc/self/cgroup", "0::/child/job\n"],
|
||||
["/proc/self/mountinfo", mounts.join("\n")],
|
||||
["/sys/fs/cgroup/child/job/memory.max", String(5 * GiB)],
|
||||
["/sys/fs/cgroup/child/memory.max", String(6 * GiB)],
|
||||
["/sys/fs/cgroup/memory.max", String(7 * GiB)],
|
||||
]);
|
||||
])(
|
||||
"ignores a hidden old child when its replacement has the same mountpoint (%s)",
|
||||
(_label, mounts) => {
|
||||
const files = new Map([
|
||||
["/proc/self/cgroup", "0::/child/job\n"],
|
||||
["/proc/self/mountinfo", mounts.join("\n")],
|
||||
["/sys/fs/cgroup/child/job/memory.max", String(5 * GiB)],
|
||||
["/sys/fs/cgroup/child/memory.max", String(6 * GiB)],
|
||||
["/sys/fs/cgroup/memory.max", String(7 * GiB)],
|
||||
]);
|
||||
|
||||
expect(
|
||||
getOcrRuntimeEffectiveMemoryBytes({
|
||||
hostPlatform: "linux",
|
||||
physicalMemoryBytes: 8 * GiB,
|
||||
readTextFile: (path) => {
|
||||
const value = files.get(path);
|
||||
if (value === undefined) throw Object.assign(new Error("missing"), { code: "ENOENT" });
|
||||
return value;
|
||||
},
|
||||
}),
|
||||
).toBe(5 * GiB);
|
||||
});
|
||||
expect(
|
||||
getOcrRuntimeEffectiveMemoryBytes({
|
||||
hostPlatform: "linux",
|
||||
physicalMemoryBytes: 8 * GiB,
|
||||
readTextFile: (path) => {
|
||||
const value = files.get(path);
|
||||
if (value === undefined) throw Object.assign(new Error("missing"), { code: "ENOENT" });
|
||||
return value;
|
||||
},
|
||||
}),
|
||||
).toBe(5 * GiB);
|
||||
},
|
||||
);
|
||||
|
||||
it("fails closed when two reachable mounts claim the same mountpoint", () => {
|
||||
const files = new Map([
|
||||
@@ -610,30 +613,30 @@ describe("OCR runtime memory compatibility", () => {
|
||||
).toBe(5 * GiB);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"ENOENT",
|
||||
"EACCES",
|
||||
])("fails closed when an absent v2 limit cannot be verified against its cgroup directory (%s)", (coreErrorCode) => {
|
||||
const files = new Map([
|
||||
["/proc/self/cgroup", "0::/deleted\n"],
|
||||
[
|
||||
"/proc/self/mountinfo",
|
||||
"29 23 0:26 / /sys/fs/cgroup rw,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw\n",
|
||||
],
|
||||
]);
|
||||
it.each(["ENOENT", "EACCES"])(
|
||||
"fails closed when an absent v2 limit cannot be verified against its cgroup directory (%s)",
|
||||
(coreErrorCode) => {
|
||||
const files = new Map([
|
||||
["/proc/self/cgroup", "0::/deleted\n"],
|
||||
[
|
||||
"/proc/self/mountinfo",
|
||||
"29 23 0:26 / /sys/fs/cgroup rw,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw\n",
|
||||
],
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
getOcrRuntimeEffectiveMemoryBytes({
|
||||
physicalMemoryBytes: 8 * GiB,
|
||||
readTextFile: (path) => {
|
||||
const value = files.get(path);
|
||||
if (value !== undefined) return value;
|
||||
const code = path.endsWith("/cgroup.controllers") ? coreErrorCode : "ENOENT";
|
||||
throw Object.assign(new Error("unavailable"), { code });
|
||||
},
|
||||
}),
|
||||
).toThrow("cgroup memory capacity");
|
||||
});
|
||||
expect(() =>
|
||||
getOcrRuntimeEffectiveMemoryBytes({
|
||||
physicalMemoryBytes: 8 * GiB,
|
||||
readTextFile: (path) => {
|
||||
const value = files.get(path);
|
||||
if (value !== undefined) return value;
|
||||
const code = path.endsWith("/cgroup.controllers") ? coreErrorCode : "ENOENT";
|
||||
throw Object.assign(new Error("unavailable"), { code });
|
||||
},
|
||||
}),
|
||||
).toThrow("cgroup memory capacity");
|
||||
},
|
||||
);
|
||||
|
||||
it("fails closed when a cgroup v2 ancestor limit is unreadable", () => {
|
||||
const files = new Map([
|
||||
@@ -869,33 +872,32 @@ describe("OCR runtime memory compatibility", () => {
|
||||
).toThrow("cgroup memory capacity");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"x::/job\n",
|
||||
"1::/job\n",
|
||||
"0:memory:/job\n",
|
||||
])("rejects invalid Linux cgroup hierarchy metadata: %s", (membership) => {
|
||||
const files = new Map([
|
||||
["/proc/self/cgroup", membership],
|
||||
[
|
||||
"/proc/self/mountinfo",
|
||||
"29 23 0:26 / /sys/fs/cgroup/memory rw,nosuid,nodev,noexec,relatime - cgroup cgroup rw,memory\n",
|
||||
],
|
||||
["/sys/fs/cgroup/memory/job/memory.limit_in_bytes", String(5 * GiB)],
|
||||
["/sys/fs/cgroup/memory/memory.limit_in_bytes", String(8 * GiB)],
|
||||
]);
|
||||
it.each(["x::/job\n", "1::/job\n", "0:memory:/job\n"])(
|
||||
"rejects invalid Linux cgroup hierarchy metadata: %s",
|
||||
(membership) => {
|
||||
const files = new Map([
|
||||
["/proc/self/cgroup", membership],
|
||||
[
|
||||
"/proc/self/mountinfo",
|
||||
"29 23 0:26 / /sys/fs/cgroup/memory rw,nosuid,nodev,noexec,relatime - cgroup cgroup rw,memory\n",
|
||||
],
|
||||
["/sys/fs/cgroup/memory/job/memory.limit_in_bytes", String(5 * GiB)],
|
||||
["/sys/fs/cgroup/memory/memory.limit_in_bytes", String(8 * GiB)],
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
getOcrRuntimeEffectiveMemoryBytes({
|
||||
hostPlatform: "linux",
|
||||
physicalMemoryBytes: 8 * GiB,
|
||||
readTextFile: (path) => {
|
||||
const value = files.get(path);
|
||||
if (value === undefined) throw Object.assign(new Error("missing"), { code: "ENOENT" });
|
||||
return value;
|
||||
},
|
||||
}),
|
||||
).toThrow("cgroup memory capacity");
|
||||
});
|
||||
expect(() =>
|
||||
getOcrRuntimeEffectiveMemoryBytes({
|
||||
hostPlatform: "linux",
|
||||
physicalMemoryBytes: 8 * GiB,
|
||||
readTextFile: (path) => {
|
||||
const value = files.get(path);
|
||||
if (value === undefined) throw Object.assign(new Error("missing"), { code: "ENOENT" });
|
||||
return value;
|
||||
},
|
||||
}),
|
||||
).toThrow("cgroup memory capacity");
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts a legitimate v1 named hierarchy without treating it as cgroup v2", () => {
|
||||
let membershipReads = 0;
|
||||
|
||||
@@ -111,18 +111,12 @@ describe("parsePdfPageSpec", () => {
|
||||
expect(parsePdfPageSpec("5, 1-3, 2, 7-8", 10)).toEqual([1, 2, 3, 5, 7, 8]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
"1,,2",
|
||||
"0",
|
||||
"-1",
|
||||
"3-1",
|
||||
"1-a",
|
||||
"1-2-3",
|
||||
"2,",
|
||||
])("rejects invalid page selection %j", (spec) => {
|
||||
expect(() => parsePdfPageSpec(spec, 10)).toThrow(/Invalid|No pages/);
|
||||
});
|
||||
it.each(["", "1,,2", "0", "-1", "3-1", "1-a", "1-2-3", "2,"])(
|
||||
"rejects invalid page selection %j",
|
||||
(spec) => {
|
||||
expect(() => parsePdfPageSpec(spec, 10)).toThrow(/Invalid|No pages/);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects pages outside the document", () => {
|
||||
expect(() => parsePdfPageSpec("1,11", 10)).toThrow("document has 10 pages");
|
||||
|
||||
@@ -130,22 +130,22 @@ describe("runTesseract", () => {
|
||||
await resultPromise;
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Hangul",
|
||||
"kor",
|
||||
])("ignores legacy installed Korean model %s when resolving Fast auto languages", async (koreanModel) => {
|
||||
const child = createMockChild();
|
||||
const inventory = new Set(["eng", koreanModel, "osd"]);
|
||||
mockGetCachedTesseractLanguages.mockReturnValueOnce(inventory);
|
||||
mockSpawn.mockReturnValue(child);
|
||||
it.each(["Hangul", "kor"])(
|
||||
"ignores legacy installed Korean model %s when resolving Fast auto languages",
|
||||
async (koreanModel) => {
|
||||
const child = createMockChild();
|
||||
const inventory = new Set(["eng", koreanModel, "osd"]);
|
||||
mockGetCachedTesseractLanguages.mockReturnValueOnce(inventory);
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const resultPromise = runTesseract("/tmp/mixed.png", { language: "auto" });
|
||||
const language = mockSpawn.mock.calls[0]?.[1]?.[3];
|
||||
child.emit("close", 0, null);
|
||||
const resultPromise = runTesseract("/tmp/mixed.png", { language: "auto" });
|
||||
const language = mockSpawn.mock.calls[0]?.[1]?.[3];
|
||||
child.emit("close", 0, null);
|
||||
|
||||
await resultPromise;
|
||||
expect(language).toBe("eng");
|
||||
});
|
||||
await resultPromise;
|
||||
expect(language).toBe("eng");
|
||||
},
|
||||
);
|
||||
|
||||
it("uses the installed supported subset for auto on a partial native host", async () => {
|
||||
const child = createMockChild();
|
||||
@@ -188,19 +188,16 @@ describe("runTesseract", () => {
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Hangul",
|
||||
"kor",
|
||||
"jpn+Hangul",
|
||||
"Hangul/../../eng",
|
||||
"Hangul+kor",
|
||||
])("rejects unsafe internal language set %s before spawning", async (tesseractLanguages) => {
|
||||
mockGetCachedTesseractLanguages.mockReturnValueOnce(new Set(["eng", "osd"]));
|
||||
await expect(
|
||||
runTesseract("/tmp/input.png", { language: "auto", tesseractLanguages }),
|
||||
).rejects.toThrow("Unsupported internal Tesseract language set");
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["Hangul", "kor", "jpn+Hangul", "Hangul/../../eng", "Hangul+kor"])(
|
||||
"rejects unsafe internal language set %s before spawning",
|
||||
async (tesseractLanguages) => {
|
||||
mockGetCachedTesseractLanguages.mockReturnValueOnce(new Set(["eng", "osd"]));
|
||||
await expect(
|
||||
runTesseract("/tmp/input.png", { language: "auto", tesseractLanguages }),
|
||||
).rejects.toThrow("Unsupported internal Tesseract language set");
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("fails auto clearly when no supported traineddata is installed", async () => {
|
||||
mockGetCachedTesseractLanguages.mockReturnValueOnce(new Set(["osd"]));
|
||||
|
||||
@@ -261,5 +261,17 @@ describe("transcribeAudio", () => {
|
||||
const options = vi.mocked(runPythonWithProgress).mock.calls[0][2];
|
||||
expect(options.onProgress).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards the caller AbortSignal to the Python bridge", async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
await transcribeAudio(FAKE_AUDIO, { language: "auto", signal: controller.signal });
|
||||
|
||||
expect(runPythonWithProgress).toHaveBeenCalledWith(
|
||||
"transcribe.py",
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ signal: controller.signal }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user