mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: make OCR portable and reliable across AMD64 and ARM64 (#519)
* fix: make OCR portable and reliable * fix: harden OCR installation portability * fix: pin OCR partials across downloads * fix: make OCR execution reliably asynchronous * fix: harden OCR portability and docs routes * fix: preserve decoder and docs safeguards
This commit is contained in:
@@ -59,6 +59,15 @@ describe("missingBundleForScript", () => {
|
||||
expect(missingBundleForScript("unknown_script")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not gate built-in OCR through the legacy shared environment", () => {
|
||||
setInstalled([]);
|
||||
|
||||
expect(SCRIPT_BUNDLE_MAP).not.toHaveProperty("ocr");
|
||||
expect(SCRIPT_BUNDLE_MAP).not.toHaveProperty("ocr_pdf");
|
||||
expect(missingBundleForScript("ocr")).toBeNull();
|
||||
expect(missingBundleForScript("ocr_pdf.py")).toBeNull();
|
||||
});
|
||||
|
||||
it("fails closed when installed.json is missing", () => {
|
||||
// No setInstalled(): the file does not exist. Like the dispatcher, an
|
||||
// unreadable installed.json reads as "nothing installed", so a gated
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sharpMocks = vi.hoisted(() => ({
|
||||
resize: vi.fn(),
|
||||
png: vi.fn(),
|
||||
toFile: vi.fn(),
|
||||
metadata: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("sharp", () => ({
|
||||
default: vi.fn(() => ({
|
||||
resize: sharpMocks.resize,
|
||||
png: sharpMocks.png,
|
||||
toFile: sharpMocks.toFile,
|
||||
metadata: sharpMocks.metadata,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../../packages/ai/src/ocr-runtime-dispatcher.js", () => ({
|
||||
runOcrRuntime: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../packages/ai/src/tesseract.js", () => ({
|
||||
runAdaptiveTesseract: vi.fn(),
|
||||
runTesseract: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../packages/ai/src/tesseract-pdf.js", () => ({
|
||||
preparePdfOcrPages: vi.fn(),
|
||||
runTesseractPdf: vi.fn(),
|
||||
}));
|
||||
|
||||
import { extractPdfText, extractText } from "../../../packages/ai/src/ocr.js";
|
||||
import { runOcrRuntime } from "../../../packages/ai/src/ocr-runtime-dispatcher.js";
|
||||
import { runAdaptiveTesseract } from "../../../packages/ai/src/tesseract.js";
|
||||
import { preparePdfOcrPages, runTesseractPdf } from "../../../packages/ai/src/tesseract-pdf.js";
|
||||
|
||||
const INPUT = Buffer.from("full-resolution-image");
|
||||
const PNG = Buffer.from("lossless-png");
|
||||
const PDF_PAGES = [
|
||||
{ page: 1, path: "/tmp/job/ocr-pdf-pages/page-1.png" },
|
||||
{ page: 2, path: "/tmp/job/ocr-pdf-pages/page-2.png" },
|
||||
];
|
||||
|
||||
function runtimeResponse(result: Record<string, unknown>) {
|
||||
return {
|
||||
result,
|
||||
stderr: "",
|
||||
runtime: {
|
||||
generation: "ocr-runtime-1",
|
||||
artifactVersion: "1.0.0",
|
||||
target: "linux-amd64-cpu-py312" as const,
|
||||
providers: ["CPUExecutionProvider"],
|
||||
models: { detection: "sha256:detection" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sharpMocks.png.mockReturnThis();
|
||||
sharpMocks.resize.mockReturnThis();
|
||||
sharpMocks.toFile.mockResolvedValue({ size: PNG.length });
|
||||
sharpMocks.metadata.mockResolvedValue({ width: 4000, height: 3000 });
|
||||
vi.mocked(runAdaptiveTesseract).mockResolvedValue({
|
||||
text: "Fast text",
|
||||
engine: "tesseract",
|
||||
provider: "native",
|
||||
device: "cpu",
|
||||
});
|
||||
vi.mocked(runTesseractPdf).mockResolvedValue({
|
||||
text: "--- Page 1 ---\n\nFast PDF text",
|
||||
pages: 1,
|
||||
pageNumbers: [1],
|
||||
engine: "tesseract",
|
||||
provider: "native",
|
||||
device: "cpu",
|
||||
});
|
||||
vi.mocked(runOcrRuntime).mockResolvedValue(
|
||||
runtimeResponse({
|
||||
success: true,
|
||||
text: "Accurate text",
|
||||
engine: "rapidocr-onnx",
|
||||
requestedQuality: "balanced",
|
||||
actualQuality: "balanced",
|
||||
device: "cpu",
|
||||
provider: "CPUExecutionProvider",
|
||||
degraded: false,
|
||||
warnings: [],
|
||||
runtimeVersion: "ocr-runtime-1",
|
||||
modelVersion: "pp-ocrv6-small",
|
||||
}),
|
||||
);
|
||||
vi.mocked(preparePdfOcrPages).mockResolvedValue({
|
||||
pages: PDF_PAGES,
|
||||
totalPages: 2,
|
||||
remainingTimeoutMs: () => 900_000,
|
||||
cleanup: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractPdfText tier routing", () => {
|
||||
it("uses built-in Ghostscript plus Tesseract by default", async () => {
|
||||
const result = await extractPdfText("/tmp/job/document.pdf", {
|
||||
pages: "1",
|
||||
language: "en",
|
||||
});
|
||||
|
||||
expect(runTesseractPdf).toHaveBeenCalledWith(
|
||||
"/tmp/job/document.pdf",
|
||||
"/tmp/job",
|
||||
expect.objectContaining({ pages: "1", language: "en" }),
|
||||
);
|
||||
expect(runOcrRuntime).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
pages: 1,
|
||||
engine: "tesseract",
|
||||
requestedQuality: "fast",
|
||||
actualQuality: "fast",
|
||||
device: "cpu",
|
||||
provider: "native",
|
||||
degraded: false,
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects explicit Korean Fast PDF OCR before native processing", async () => {
|
||||
await expect(
|
||||
extractPdfText("/tmp/job/document.pdf", {
|
||||
quality: "fast",
|
||||
language: "ko",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Fast OCR does not support Korean. Install the Accurate OCR bundle and choose Balanced or Best.",
|
||||
);
|
||||
|
||||
expect(runTesseractPdf).not.toHaveBeenCalled();
|
||||
expect(preparePdfOcrPages).not.toHaveBeenCalled();
|
||||
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: [],
|
||||
}),
|
||||
);
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
it("keeps Fast PDF OCR jobs alive while native processing is quiet", async () => {
|
||||
vi.useFakeTimers();
|
||||
let finish: ((value: Awaited<ReturnType<typeof runTesseractPdf>>) => void) | undefined;
|
||||
vi.mocked(runTesseractPdf).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const onProgress = vi.fn();
|
||||
|
||||
try {
|
||||
const pending = extractPdfText("/tmp/job/document.pdf", {}, onProgress);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(onProgress).toHaveBeenCalledWith(10, "Running Fast PDF OCR");
|
||||
|
||||
finish?.({
|
||||
text: "Fast PDF text",
|
||||
pages: 1,
|
||||
pageNumbers: [1],
|
||||
engine: "tesseract",
|
||||
provider: "native",
|
||||
device: "cpu",
|
||||
});
|
||||
await pending;
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes Balanced PDF OCR once through the accurate runtime", async () => {
|
||||
const cleanup = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(preparePdfOcrPages).mockResolvedValueOnce({
|
||||
pages: PDF_PAGES,
|
||||
totalPages: 2,
|
||||
remainingTimeoutMs: () => 900_000,
|
||||
cleanup,
|
||||
});
|
||||
vi.mocked(runOcrRuntime).mockResolvedValueOnce(
|
||||
runtimeResponse({
|
||||
success: true,
|
||||
text: "Accurate PDF text",
|
||||
pages: 2,
|
||||
engine: "rapidocr-onnx",
|
||||
requestedQuality: "balanced",
|
||||
actualQuality: "balanced",
|
||||
device: "cpu",
|
||||
provider: "CPUExecutionProvider",
|
||||
degraded: false,
|
||||
warnings: [],
|
||||
runtimeVersion: "ocr-runtime-1",
|
||||
modelVersion: "pp-ocrv6-small",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await extractPdfText("/tmp/job/document.pdf", {
|
||||
quality: "balanced",
|
||||
pages: "1-2",
|
||||
});
|
||||
|
||||
expect(runTesseractPdf).not.toHaveBeenCalled();
|
||||
expect(preparePdfOcrPages).toHaveBeenCalledWith(
|
||||
"/tmp/job/document.pdf",
|
||||
"/tmp/job",
|
||||
expect.objectContaining({ pages: "1-2" }),
|
||||
);
|
||||
expect(runOcrRuntime).toHaveBeenCalledWith(
|
||||
"ocr_pdf",
|
||||
[
|
||||
JSON.stringify(PDF_PAGES),
|
||||
JSON.stringify({ quality: "balanced", language: "auto", enhance: false }),
|
||||
],
|
||||
expect.objectContaining({ timeoutMs: 900_000 }),
|
||||
);
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
expect(result).toMatchObject({
|
||||
pages: 2,
|
||||
requestedQuality: "balanced",
|
||||
actualQuality: "balanced",
|
||||
modelVersion: "pp-ocrv6-small",
|
||||
});
|
||||
});
|
||||
|
||||
it("enables calibrated enhancement for Best PDF OCR unless explicitly disabled", async () => {
|
||||
vi.mocked(runOcrRuntime).mockResolvedValueOnce(
|
||||
runtimeResponse({
|
||||
success: true,
|
||||
text: "Best PDF text",
|
||||
pages: 2,
|
||||
engine: "rapidocr-onnx",
|
||||
requestedQuality: "best",
|
||||
actualQuality: "best",
|
||||
device: "cpu",
|
||||
provider: "CPUExecutionProvider",
|
||||
degraded: false,
|
||||
warnings: [],
|
||||
}),
|
||||
);
|
||||
|
||||
await extractPdfText("/tmp/job/document.pdf", { quality: "best" });
|
||||
|
||||
expect(runOcrRuntime).toHaveBeenCalledWith(
|
||||
"ocr_pdf",
|
||||
[
|
||||
JSON.stringify(PDF_PAGES),
|
||||
JSON.stringify({ quality: "best", language: "auto", enhance: true }),
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps accurate PDF preparation alive while Ghostscript is quiet", async () => {
|
||||
vi.useFakeTimers();
|
||||
let finish: ((value: Awaited<ReturnType<typeof preparePdfOcrPages>>) => void) | undefined;
|
||||
vi.mocked(preparePdfOcrPages).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
vi.mocked(runOcrRuntime).mockResolvedValueOnce(
|
||||
runtimeResponse({
|
||||
success: true,
|
||||
text: "Accurate PDF text",
|
||||
pages: 2,
|
||||
engine: "rapidocr-onnx",
|
||||
requestedQuality: "balanced",
|
||||
actualQuality: "balanced",
|
||||
device: "cpu",
|
||||
provider: "CPUExecutionProvider",
|
||||
degraded: false,
|
||||
warnings: [],
|
||||
}),
|
||||
);
|
||||
const onProgress = vi.fn();
|
||||
|
||||
try {
|
||||
const pending = extractPdfText("/tmp/job/document.pdf", { quality: "balanced" }, onProgress);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(onProgress).toHaveBeenCalledWith(10, "Preparing accurate PDF OCR");
|
||||
|
||||
finish?.({
|
||||
pages: PDF_PAGES,
|
||||
totalPages: 2,
|
||||
remainingTimeoutMs: () => 900_000,
|
||||
cleanup: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
await pending;
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an accurate PDF result that omits prepared pages", async () => {
|
||||
const cleanup = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(preparePdfOcrPages).mockResolvedValueOnce({
|
||||
pages: PDF_PAGES,
|
||||
totalPages: 2,
|
||||
remainingTimeoutMs: () => 900_000,
|
||||
cleanup,
|
||||
});
|
||||
vi.mocked(runOcrRuntime).mockResolvedValueOnce(
|
||||
runtimeResponse({
|
||||
success: true,
|
||||
text: "Only one page",
|
||||
pages: 1,
|
||||
engine: "rapidocr-onnx",
|
||||
requestedQuality: "balanced",
|
||||
actualQuality: "balanced",
|
||||
device: "cpu",
|
||||
provider: "CPUExecutionProvider",
|
||||
degraded: false,
|
||||
warnings: [],
|
||||
runtimeVersion: "ocr-runtime-1",
|
||||
modelVersion: "pp-ocrv6-small",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
extractPdfText("/tmp/job/document.pdf", { quality: "balanced", pages: "1-2" }),
|
||||
).rejects.toThrow("page count");
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractText tier routing", () => {
|
||||
it("uses built-in Tesseract by default with complete truthful metadata", async () => {
|
||||
const result = await extractText(INPUT, "/tmp/ocr");
|
||||
|
||||
expect(runAdaptiveTesseract).toHaveBeenCalledWith(
|
||||
"/tmp/ocr/input_ocr.png",
|
||||
expect.objectContaining({ language: "auto" }),
|
||||
);
|
||||
expect(runOcrRuntime).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
text: "Fast text",
|
||||
engine: "tesseract",
|
||||
requestedQuality: "fast",
|
||||
actualQuality: "fast",
|
||||
device: "cpu",
|
||||
provider: "native",
|
||||
degraded: false,
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Fast image OCR jobs alive while native processing is quiet", async () => {
|
||||
vi.useFakeTimers();
|
||||
let finish: ((value: Awaited<ReturnType<typeof runAdaptiveTesseract>>) => void) | undefined;
|
||||
vi.mocked(runAdaptiveTesseract).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const onProgress = vi.fn();
|
||||
|
||||
try {
|
||||
const pending = extractText(INPUT, "/tmp/ocr", { quality: "fast" }, onProgress);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(onProgress).toHaveBeenCalledWith(10, "Running Fast OCR");
|
||||
|
||||
finish?.({
|
||||
text: "Fast text",
|
||||
engine: "tesseract",
|
||||
provider: "native",
|
||||
device: "cpu",
|
||||
});
|
||||
await pending;
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves source resolution instead of applying the old 2048px cap", async () => {
|
||||
await extractText(INPUT, "/tmp/ocr", { quality: "fast" });
|
||||
|
||||
expect(sharpMocks.resize).not.toHaveBeenCalled();
|
||||
expect(sharpMocks.png).toHaveBeenCalledTimes(1);
|
||||
expect(sharpMocks.toFile).toHaveBeenCalledWith("/tmp/ocr/input_ocr.png");
|
||||
});
|
||||
|
||||
it("rejects unsafe source pixel counts before allocating a full PNG", async () => {
|
||||
sharpMocks.metadata.mockResolvedValueOnce({ width: 10_000, height: 5_000 });
|
||||
|
||||
await expect(extractText(INPUT, "/tmp/ocr", { quality: "best" })).rejects.toThrow(
|
||||
"40,000,000 pixel safety limit",
|
||||
);
|
||||
expect(sharpMocks.toFile).not.toHaveBeenCalled();
|
||||
expect(runOcrRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes Balanced to the accurate runtime and preserves its provenance", async () => {
|
||||
const result = await extractText(INPUT, "/tmp/ocr", {
|
||||
quality: "balanced",
|
||||
language: "ja",
|
||||
enhance: false,
|
||||
});
|
||||
|
||||
expect(runAdaptiveTesseract).not.toHaveBeenCalled();
|
||||
expect(runOcrRuntime).toHaveBeenCalledWith(
|
||||
"ocr",
|
||||
[
|
||||
"/tmp/ocr/input_ocr.png",
|
||||
JSON.stringify({ quality: "balanced", language: "ja", enhance: false }),
|
||||
],
|
||||
expect.objectContaining({ timeoutMs: expect.any(Number) }),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
engine: "rapidocr-onnx",
|
||||
requestedQuality: "balanced",
|
||||
actualQuality: "balanced",
|
||||
provider: "CPUExecutionProvider",
|
||||
runtimeVersion: "ocr-runtime-1",
|
||||
modelVersion: "pp-ocrv6-small",
|
||||
});
|
||||
});
|
||||
|
||||
it("enables calibrated enhancement for Best image OCR unless explicitly disabled", async () => {
|
||||
vi.mocked(runOcrRuntime).mockResolvedValueOnce(
|
||||
runtimeResponse({
|
||||
success: true,
|
||||
text: "Best text",
|
||||
engine: "rapidocr-onnx",
|
||||
requestedQuality: "best",
|
||||
actualQuality: "best",
|
||||
device: "cpu",
|
||||
provider: "CPUExecutionProvider",
|
||||
degraded: false,
|
||||
warnings: [],
|
||||
}),
|
||||
);
|
||||
|
||||
await extractText(INPUT, "/tmp/ocr", { quality: "best" });
|
||||
|
||||
expect(runOcrRuntime).toHaveBeenCalledWith(
|
||||
"ocr",
|
||||
["/tmp/ocr/input_ocr.png", JSON.stringify({ quality: "best", enhance: true })],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps the legacy tesseract engine to Fast", async () => {
|
||||
const result = await extractText(INPUT, "/tmp/ocr", { engine: "tesseract" });
|
||||
|
||||
expect(result.actualQuality).toBe("fast");
|
||||
expect(runAdaptiveTesseract).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects incomplete accurate-runtime metadata", async () => {
|
||||
vi.mocked(runOcrRuntime).mockResolvedValueOnce(
|
||||
runtimeResponse({
|
||||
success: true,
|
||||
text: "text",
|
||||
engine: "rapidocr-onnx",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(extractText(INPUT, "/tmp/ocr", { quality: "balanced" })).rejects.toThrow(
|
||||
"invalid metadata",
|
||||
);
|
||||
});
|
||||
|
||||
it("defensively rejects accurate image text above the UTF-8 durable-result budget", async () => {
|
||||
vi.mocked(runOcrRuntime).mockResolvedValueOnce(
|
||||
runtimeResponse({
|
||||
success: true,
|
||||
text: "界".repeat(333_334),
|
||||
engine: "rapidocr-onnx",
|
||||
requestedQuality: "balanced",
|
||||
actualQuality: "balanced",
|
||||
device: "cpu",
|
||||
provider: "CPUExecutionProvider",
|
||||
degraded: false,
|
||||
warnings: [],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(extractText(INPUT, "/tmp/ocr", { quality: "balanced" })).rejects.toThrow(
|
||||
"1,000,000 byte",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an accurate runtime that changes the selected tier", async () => {
|
||||
vi.mocked(runOcrRuntime).mockResolvedValueOnce(
|
||||
runtimeResponse({
|
||||
success: true,
|
||||
text: "text",
|
||||
engine: "rapidocr-onnx",
|
||||
requestedQuality: "best",
|
||||
actualQuality: "fast",
|
||||
device: "cpu",
|
||||
provider: "CPUExecutionProvider",
|
||||
degraded: true,
|
||||
warnings: ["fallback"],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(extractText(INPUT, "/tmp/ocr", { quality: "best" })).rejects.toThrow(
|
||||
"tier mismatch",
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+427
-331
@@ -1,383 +1,479 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("sharp", () => {
|
||||
const mockSharp = vi.fn(() => ({
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
vi.mock("sharp", () => ({
|
||||
default: vi.fn(() => ({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")),
|
||||
toFile: vi.fn().mockResolvedValue({ size: 3 }),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||
}));
|
||||
return { default: mockSharp };
|
||||
});
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../../packages/ai/src/bridge.js", () => ({
|
||||
runPythonWithProgress: vi.fn(),
|
||||
parseStdoutJson: vi.fn(),
|
||||
vi.mock("../../../packages/ai/src/ocr-runtime-dispatcher.js", () => ({
|
||||
runOcrRuntime: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../packages/ai/src/tesseract.js", () => ({
|
||||
runAdaptiveTesseract: vi.fn(),
|
||||
runTesseract: vi.fn(),
|
||||
}));
|
||||
|
||||
import sharp from "sharp";
|
||||
import { parseStdoutJson, runPythonWithProgress } from "../../../packages/ai/src/bridge.js";
|
||||
import { extractText } from "../../../packages/ai/src/ocr.js";
|
||||
import { runOcrRuntime } from "../../../packages/ai/src/ocr-runtime-dispatcher.js";
|
||||
import { runAdaptiveTesseract } from "../../../packages/ai/src/tesseract.js";
|
||||
|
||||
const FAKE_INPUT = Buffer.from("fake-image-data");
|
||||
const FAKE_OUTPUT_DIR = "/tmp/test-ocr";
|
||||
const INPUT = Buffer.from("image");
|
||||
|
||||
function runtimeResponse(result: unknown) {
|
||||
return {
|
||||
result,
|
||||
stderr: "",
|
||||
runtime: {
|
||||
generation: "ocr-runtime-1",
|
||||
artifactVersion: "1.0.0",
|
||||
target: "linux-amd64-cpu-py312" as const,
|
||||
providers: ["CPUExecutionProvider"],
|
||||
models: { detection: "sha256:detection" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(runPythonWithProgress).mockResolvedValue({
|
||||
stdout: '{"success": true, "text": "Hello World", "engine": "paddleocr"}',
|
||||
stderr: "",
|
||||
});
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
text: "Hello World",
|
||||
engine: "paddleocr",
|
||||
});
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")),
|
||||
toFile: vi.fn().mockResolvedValue({ size: 3 }),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
vi.mocked(runAdaptiveTesseract).mockResolvedValue({
|
||||
text: "text",
|
||||
engine: "tesseract",
|
||||
provider: "native",
|
||||
device: "cpu",
|
||||
});
|
||||
vi.mocked(runOcrRuntime).mockResolvedValue(
|
||||
runtimeResponse({
|
||||
success: true,
|
||||
text: "text",
|
||||
engine: "rapidocr-onnx",
|
||||
requestedQuality: "balanced",
|
||||
actualQuality: "balanced",
|
||||
device: "cpu",
|
||||
provider: "CPUExecutionProvider",
|
||||
degraded: false,
|
||||
warnings: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
describe("extractText error and progress behavior", () => {
|
||||
it("forwards progress and the AbortSignal to Tesseract", async () => {
|
||||
const onProgress = vi.fn();
|
||||
const controller = new AbortController();
|
||||
|
||||
describe("extractText", () => {
|
||||
describe("request serialization", () => {
|
||||
it("calls ocr.py with input path and options JSON", async () => {
|
||||
await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
await extractText(
|
||||
INPUT,
|
||||
"/tmp/ocr",
|
||||
{ quality: "fast", language: "ja", signal: controller.signal },
|
||||
onProgress,
|
||||
);
|
||||
|
||||
expect(runPythonWithProgress).toHaveBeenCalledWith(
|
||||
"ocr.py",
|
||||
[`${FAKE_OUTPUT_DIR}/input_ocr.png`, "{}"],
|
||||
expect.objectContaining({ timeout: expect.any(Number) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes quality option", async () => {
|
||||
await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR, { quality: "best" });
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
expect(JSON.parse(args[1])).toEqual({ quality: "best" });
|
||||
});
|
||||
|
||||
it("serializes language option", async () => {
|
||||
await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR, { language: "ja" });
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
expect(JSON.parse(args[1])).toEqual({ language: "ja" });
|
||||
});
|
||||
|
||||
it("serializes enhance option", async () => {
|
||||
await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR, { enhance: true });
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
expect(JSON.parse(args[1])).toEqual({ enhance: true });
|
||||
});
|
||||
|
||||
it("serializes deprecated engine option for backward compatibility", async () => {
|
||||
await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR, { engine: "tesseract" });
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
expect(JSON.parse(args[1])).toEqual({ engine: "tesseract" });
|
||||
});
|
||||
|
||||
it("serializes all options together", async () => {
|
||||
await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR, {
|
||||
quality: "fast",
|
||||
language: "en",
|
||||
enhance: false,
|
||||
});
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
expect(JSON.parse(args[1])).toEqual({
|
||||
quality: "fast",
|
||||
language: "en",
|
||||
enhance: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("resizes input to max 2048px before writing", async () => {
|
||||
await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
// Sharp is called with the input, then resize is called
|
||||
expect(sharp).toHaveBeenCalledWith(FAKE_INPUT);
|
||||
});
|
||||
|
||||
it("writes resized PNG to outputDir", async () => {
|
||||
await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
`${FAKE_OUTPUT_DIR}/input_ocr.png`,
|
||||
Buffer.from("mock-png-data"),
|
||||
);
|
||||
});
|
||||
expect(runAdaptiveTesseract).toHaveBeenCalledWith(
|
||||
"/tmp/ocr/input_ocr.png",
|
||||
expect.objectContaining({
|
||||
language: "ja",
|
||||
signal: controller.signal,
|
||||
timeoutMs: 600_000,
|
||||
maxStdoutBytes: 1_000_000,
|
||||
onProgress: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
const relayedProgress = vi.mocked(runAdaptiveTesseract).mock.calls[0]?.[1].onProgress;
|
||||
relayedProgress?.(42, "Recognizing text");
|
||||
expect(onProgress).toHaveBeenCalledWith(42, "Recognizing text");
|
||||
});
|
||||
|
||||
describe("response parsing", () => {
|
||||
it("returns OcrResult with text and engine", async () => {
|
||||
const result = await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
it("rejects explicit Korean Fast OCR before image processing or Tesseract dispatch", async () => {
|
||||
await expect(
|
||||
extractText(INPUT, "/tmp/ocr", { quality: "fast", language: "ko" }),
|
||||
).rejects.toThrow(
|
||||
"Fast OCR does not support Korean. Install the Accurate OCR bundle and choose Balanced or Best.",
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
text: "Hello World",
|
||||
engine: "paddleocr",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns text with special characters", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
text: "Price: $19.99\nDiscount: 15%",
|
||||
engine: "paddleocr",
|
||||
});
|
||||
|
||||
const result = await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
expect(result.text).toBe("Price: $19.99\nDiscount: 15%");
|
||||
});
|
||||
|
||||
it("returns empty text string when no text detected", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
text: "",
|
||||
engine: "paddleocr",
|
||||
});
|
||||
|
||||
const result = await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
expect(result.text).toBe("");
|
||||
});
|
||||
|
||||
it("returns engine information", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
text: "test",
|
||||
engine: "tesseract",
|
||||
});
|
||||
|
||||
const result = await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
expect(result.engine).toBe("tesseract");
|
||||
});
|
||||
|
||||
it("returns undefined engine when not provided by Python", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
text: "test",
|
||||
});
|
||||
|
||||
const result = await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
expect(result.engine).toBeUndefined();
|
||||
});
|
||||
expect(sharp).not.toHaveBeenCalled();
|
||||
expect(runAdaptiveTesseract).not.toHaveBeenCalled();
|
||||
expect(runOcrRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("timeout calculation", () => {
|
||||
it("uses minimum 600000ms timeout for small images", async () => {
|
||||
await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
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: [],
|
||||
}),
|
||||
);
|
||||
|
||||
// 800x600 = 0.48 MP, 0.48 * 30 * 1000 = 14400 < 600000
|
||||
const options = vi.mocked(runPythonWithProgress).mock.calls[0][2];
|
||||
expect(options.timeout).toBe(600000);
|
||||
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 });
|
||||
});
|
||||
|
||||
it("applies requested local-contrast preprocessing for Fast OCR", async () => {
|
||||
const recognitionPipeline = {
|
||||
clahe: vi.fn().mockReturnThis(),
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toFile: vi.fn().mockResolvedValue({ size: 12 }),
|
||||
};
|
||||
const pipeline = {
|
||||
clone: vi.fn(() => recognitionPipeline),
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toFile: vi.fn().mockResolvedValue({ size: 12 }),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||
};
|
||||
vi.mocked(sharp).mockReturnValueOnce(pipeline as unknown as ReturnType<typeof sharp>);
|
||||
|
||||
await extractText(INPUT, "/tmp/ocr", { quality: "fast", enhance: true });
|
||||
|
||||
expect(recognitionPipeline.clahe).toHaveBeenCalledWith({
|
||||
height: 75,
|
||||
maxSlope: 2,
|
||||
width: 100,
|
||||
});
|
||||
expect(pipeline.toFile).toHaveBeenCalledWith("/tmp/ocr/input_ocr.png");
|
||||
expect(recognitionPipeline.toFile).toHaveBeenCalledWith("/tmp/ocr/input_ocr_recognition.png");
|
||||
});
|
||||
|
||||
it("scales timeout for large images", async () => {
|
||||
// We need sharp to return large dimensions for the resized buffer
|
||||
// First call resizes the input, second call reads metadata of the resized buffer
|
||||
let _callCount = 0;
|
||||
vi.mocked(sharp).mockImplementation(() => {
|
||||
_callCount++;
|
||||
return {
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
it("automatically restores contrast on faint low-resolution Fast inputs", async () => {
|
||||
const statsPipeline = {
|
||||
grayscale: vi.fn().mockReturnThis(),
|
||||
stats: vi.fn().mockResolvedValue({
|
||||
channels: [{ mean: 225, stdev: 15 }],
|
||||
}),
|
||||
};
|
||||
const recognitionPipeline = {
|
||||
grayscale: vi.fn().mockReturnThis(),
|
||||
linear: vi.fn().mockReturnThis(),
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toFile: vi.fn().mockResolvedValue({ size: 12 }),
|
||||
};
|
||||
const pipeline = {
|
||||
clone: vi.fn().mockReturnValueOnce(statsPipeline).mockReturnValueOnce(recognitionPipeline),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 450, height: 640 }),
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toFile: vi.fn().mockResolvedValue({ size: 12 }),
|
||||
};
|
||||
vi.mocked(sharp).mockReturnValueOnce(pipeline as unknown as ReturnType<typeof sharp>);
|
||||
|
||||
const result = await extractText(INPUT, "/tmp/ocr", { quality: "fast" });
|
||||
|
||||
expect(recognitionPipeline.linear).toHaveBeenCalledWith(4, -650);
|
||||
expect(runAdaptiveTesseract).toHaveBeenCalledWith(
|
||||
"/tmp/ocr/input_ocr.png",
|
||||
expect.objectContaining({
|
||||
blockLayoutOnly: true,
|
||||
recognitionInputPath: "/tmp/ocr/input_ocr_recognition.png",
|
||||
}),
|
||||
);
|
||||
expect(result.warnings).toContain("Applied automatic low-contrast OCR preprocessing.");
|
||||
});
|
||||
|
||||
it("does not alter ordinary low-resolution Fast inputs", async () => {
|
||||
const statsPipeline = {
|
||||
grayscale: vi.fn().mockReturnThis(),
|
||||
stats: vi.fn().mockResolvedValue({
|
||||
channels: [{ mean: 138, stdev: 91 }],
|
||||
}),
|
||||
};
|
||||
const pipeline = {
|
||||
clone: vi.fn(() => statsPipeline),
|
||||
linear: vi.fn().mockReturnThis(),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 432, height: 648 }),
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toFile: vi.fn().mockResolvedValue({ size: 12 }),
|
||||
};
|
||||
vi.mocked(sharp).mockReturnValueOnce(pipeline as unknown as ReturnType<typeof sharp>);
|
||||
|
||||
const result = await extractText(INPUT, "/tmp/ocr", { quality: "fast" });
|
||||
|
||||
expect(pipeline.linear).not.toHaveBeenCalled();
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("prepares two bounded horizontal fallback tiles for large CJK scene text", async () => {
|
||||
const sourcePipeline = {
|
||||
metadata: vi.fn().mockResolvedValue({ width: 4_000, height: 3_000 }),
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toFile: vi.fn().mockResolvedValue({ size: 12 }),
|
||||
};
|
||||
const tilePipelines = Array.from({ length: 2 }, () => ({
|
||||
extract: vi.fn().mockReturnThis(),
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toFile: vi.fn().mockResolvedValue({ size: 12 }),
|
||||
}));
|
||||
vi.mocked(sharp)
|
||||
.mockReturnValueOnce(sourcePipeline as unknown as ReturnType<typeof sharp>)
|
||||
.mockReturnValueOnce(tilePipelines[0] as unknown as ReturnType<typeof sharp>)
|
||||
.mockReturnValueOnce(tilePipelines[1] as unknown as ReturnType<typeof sharp>);
|
||||
|
||||
await extractText(INPUT, "/tmp/ocr", { quality: "fast", language: "ja" });
|
||||
const fallbackInputProvider =
|
||||
vi.mocked(runAdaptiveTesseract).mock.calls[0]?.[1].fallbackInputProvider;
|
||||
await expect(fallbackInputProvider?.()).resolves.toEqual([
|
||||
"/tmp/ocr/input_ocr_scene_upper.png",
|
||||
"/tmp/ocr/input_ocr_scene_lower.png",
|
||||
]);
|
||||
|
||||
expect(tilePipelines[0].extract).toHaveBeenCalledWith({
|
||||
height: 1_500,
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 4_000,
|
||||
});
|
||||
expect(tilePipelines[1].extract).toHaveBeenCalledWith({
|
||||
height: 1_500,
|
||||
left: 0,
|
||||
top: 1_500,
|
||||
width: 4_000,
|
||||
});
|
||||
expect(runAdaptiveTesseract).toHaveBeenCalledWith(
|
||||
"/tmp/ocr/input_ocr.png",
|
||||
expect.objectContaining({
|
||||
fallbackInputProvider: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("lazily prepares a bounded dense-board enhancement for small CJK scenes", async () => {
|
||||
const sourcePipeline = {
|
||||
metadata: vi.fn().mockResolvedValue({ width: 1_200, height: 1_600 }),
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toFile: vi.fn().mockResolvedValue({ size: 12 }),
|
||||
};
|
||||
const densePipeline = {
|
||||
grayscale: vi.fn().mockReturnThis(),
|
||||
clahe: vi.fn().mockReturnThis(),
|
||||
sharpen: vi.fn().mockReturnThis(),
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toFile: vi.fn().mockResolvedValue({ size: 12 }),
|
||||
};
|
||||
vi.mocked(sharp)
|
||||
.mockReturnValueOnce(sourcePipeline as unknown as ReturnType<typeof sharp>)
|
||||
.mockReturnValueOnce(densePipeline as unknown as ReturnType<typeof sharp>);
|
||||
|
||||
await extractText(INPUT, "/tmp/ocr", { quality: "fast", language: "ja" });
|
||||
const denseCjkInputProvider =
|
||||
vi.mocked(runAdaptiveTesseract).mock.calls[0]?.[1].denseCjkInputProvider;
|
||||
expect(denseCjkInputProvider).toEqual(expect.any(Function));
|
||||
expect(densePipeline.grayscale).not.toHaveBeenCalled();
|
||||
|
||||
await expect(denseCjkInputProvider?.()).resolves.toBe("/tmp/ocr/input_ocr_dense_cjk.png");
|
||||
expect(sharp).toHaveBeenLastCalledWith("/tmp/ocr/input_ocr.png");
|
||||
expect(densePipeline.grayscale).toHaveBeenCalledTimes(1);
|
||||
expect(densePipeline.clahe).toHaveBeenCalledWith({
|
||||
height: 200,
|
||||
maxSlope: 2,
|
||||
width: 150,
|
||||
});
|
||||
expect(densePipeline.sharpen).toHaveBeenCalledWith({ sigma: 1 });
|
||||
expect(densePipeline.toFile).toHaveBeenCalledWith("/tmp/ocr/input_ocr_dense_cjk.png");
|
||||
});
|
||||
|
||||
it("does not offer dense-board preprocessing outside its bounded CJK gate", async () => {
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 5000, height: 4000 }),
|
||||
} as unknown as ReturnType<typeof sharp>;
|
||||
});
|
||||
toFile: vi.fn().mockResolvedValue({ size: 3 }),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 1_200, height: 1_600 }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
await extractText(INPUT, "/tmp/ocr", { quality: "fast", language: "en" });
|
||||
|
||||
// 5000*4000 = 20 MP, 20 * 30 * 1000 = 600000 = 600000 (equal to min)
|
||||
const options = vi.mocked(runPythonWithProgress).mock.calls[0][2];
|
||||
expect(options.timeout).toBeGreaterThanOrEqual(600000);
|
||||
expect(runAdaptiveTesseract).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.not.objectContaining({ denseCjkInputProvider: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not prepare CJK scene tiles for an explicit Latin language", async () => {
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toFile: vi.fn().mockResolvedValue({ size: 3 }),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 4_000, height: 3_000 }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
await extractText(INPUT, "/tmp/ocr", { quality: "fast", language: "en" });
|
||||
|
||||
expect(sharp).toHaveBeenCalledTimes(1);
|
||||
expect(runAdaptiveTesseract).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.not.objectContaining({ fallbackInputPaths: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a megapixel-scaled timeout for very large images", async () => {
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toFile: vi.fn().mockResolvedValue({ size: 3 }),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 6_000, height: 6_000 }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
await extractText(INPUT, "/tmp/ocr", { quality: "fast", language: "en" });
|
||||
|
||||
expect(runAdaptiveTesseract).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ timeoutMs: 1_080_000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects pathological image sides before conversion or detector tiling", async () => {
|
||||
const toFile = vi.fn().mockResolvedValue({ size: 3 });
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toFile,
|
||||
metadata: vi.fn().mockResolvedValue({ width: 40_001, height: 1 }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
await expect(extractText(INPUT, "/tmp/ocr", { quality: "best" })).rejects.toThrow(
|
||||
"dimension safety limit",
|
||||
);
|
||||
expect(toFile).not.toHaveBeenCalled();
|
||||
expect(runOcrRuntime).not.toHaveBeenCalled();
|
||||
expect(runAdaptiveTesseract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates image conversion failures", async () => {
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toFile: vi.fn().mockRejectedValue(new Error("invalid pixels")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
await expect(extractText(INPUT, "/tmp/ocr")).rejects.toThrow("invalid pixels");
|
||||
});
|
||||
|
||||
it("propagates scratch write failures", async () => {
|
||||
vi.mocked(sharp).mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toFile: vi.fn().mockRejectedValue(new Error("disk full")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
await expect(extractText(INPUT, "/tmp/ocr")).rejects.toThrow("disk full");
|
||||
expect(runAdaptiveTesseract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates Tesseract cancellation and process failures", async () => {
|
||||
const error = new Error("Tesseract OCR was canceled");
|
||||
error.name = "AbortError";
|
||||
vi.mocked(runAdaptiveTesseract).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(extractText(INPUT, "/tmp/ocr", { quality: "fast" })).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("throws with custom error from Python", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: false,
|
||||
error: "PaddleOCR initialization failed",
|
||||
});
|
||||
it("propagates accurate-runtime transport failures without retrying", async () => {
|
||||
vi.mocked(runOcrRuntime).mockRejectedValueOnce(new Error("OCR runtime exited unexpectedly"));
|
||||
|
||||
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow(
|
||||
"PaddleOCR initialization failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws fallback error when success: false without error string", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({ success: false });
|
||||
|
||||
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("OCR failed");
|
||||
});
|
||||
|
||||
it("propagates bridge timeout", async () => {
|
||||
vi.mocked(runPythonWithProgress).mockRejectedValue(new Error("Python script timed out"));
|
||||
|
||||
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("timed out");
|
||||
});
|
||||
|
||||
it("propagates OOM errors from bridge", async () => {
|
||||
vi.mocked(runPythonWithProgress).mockRejectedValue(
|
||||
new Error("Process killed (out of memory)"),
|
||||
);
|
||||
|
||||
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("out of memory");
|
||||
});
|
||||
|
||||
it("propagates parseStdoutJson errors", async () => {
|
||||
vi.mocked(parseStdoutJson).mockImplementation(() => {
|
||||
throw new Error("No JSON response from Python script");
|
||||
});
|
||||
|
||||
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow(
|
||||
"No JSON response from Python script",
|
||||
);
|
||||
});
|
||||
await expect(extractText(INPUT, "/tmp/ocr", { quality: "balanced" })).rejects.toThrow(
|
||||
"exited unexpectedly",
|
||||
);
|
||||
expect(runOcrRuntime).toHaveBeenCalledTimes(1);
|
||||
expect(runAdaptiveTesseract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("onProgress forwarding", () => {
|
||||
it("passes onProgress to bridge", async () => {
|
||||
const onProgress = vi.fn();
|
||||
await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR, {}, onProgress);
|
||||
it("emits progress heartbeats while the accurate runtime is busy", async () => {
|
||||
vi.useFakeTimers();
|
||||
let finishRuntime: ((value: ReturnType<typeof runtimeResponse>) => void) | undefined;
|
||||
vi.mocked(runOcrRuntime).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finishRuntime = resolve;
|
||||
}),
|
||||
);
|
||||
const onProgress = vi.fn();
|
||||
|
||||
expect(runPythonWithProgress).toHaveBeenCalledWith(
|
||||
"ocr.py",
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ onProgress }),
|
||||
try {
|
||||
const pending = extractText(INPUT, "/tmp/ocr", { quality: "balanced" }, onProgress);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(onProgress).toHaveBeenCalledWith(10, "Starting accurate OCR");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(onProgress).toHaveBeenCalledWith(10, "Running accurate OCR");
|
||||
|
||||
finishRuntime?.(
|
||||
runtimeResponse({
|
||||
success: true,
|
||||
text: "text",
|
||||
engine: "rapidocr-onnx",
|
||||
requestedQuality: "balanced",
|
||||
actualQuality: "balanced",
|
||||
device: "cpu",
|
||||
provider: "CPUExecutionProvider",
|
||||
degraded: false,
|
||||
warnings: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits onProgress when not provided", async () => {
|
||||
await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
const options = vi.mocked(runPythonWithProgress).mock.calls[0][2];
|
||||
expect(options.onProgress).toBeUndefined();
|
||||
});
|
||||
await pending;
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
describe("image downscaling", () => {
|
||||
it("caps input to 2048px using resize with inside fit", async () => {
|
||||
const resizeFn = vi.fn().mockReturnThis();
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
resize: resizeFn,
|
||||
png: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
it("propagates malformed accurate-runtime output", async () => {
|
||||
vi.mocked(runOcrRuntime).mockResolvedValueOnce(runtimeResponse("not an object"));
|
||||
|
||||
await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(resizeFn).toHaveBeenCalledWith({
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
});
|
||||
await expect(extractText(INPUT, "/tmp/ocr", { quality: "balanced" })).rejects.toThrow(
|
||||
"invalid metadata",
|
||||
);
|
||||
});
|
||||
|
||||
describe("multiline and unicode text", () => {
|
||||
it("handles multiline OCR text", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
text: "Line 1\nLine 2\nLine 3",
|
||||
engine: "paddleocr",
|
||||
});
|
||||
|
||||
const result = await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
expect(result.text).toBe("Line 1\nLine 2\nLine 3");
|
||||
it("preserves multiline Unicode text", async () => {
|
||||
vi.mocked(runAdaptiveTesseract).mockResolvedValueOnce({
|
||||
text: "こんにちは\n안녕하세요\n你好",
|
||||
engine: "tesseract",
|
||||
provider: "native",
|
||||
device: "cpu",
|
||||
});
|
||||
|
||||
it("handles unicode text from CJK languages", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
text: "你好世界",
|
||||
engine: "paddleocr",
|
||||
});
|
||||
|
||||
const result = await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
expect(result.text).toBe("你好世界");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sharp conversion errors", () => {
|
||||
it("propagates sharp conversion errors", async () => {
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
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(extractText(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("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({});
|
||||
});
|
||||
const result = await extractText(INPUT, "/tmp/ocr", { quality: "fast" });
|
||||
expect(result.text).toBe("こんにちは\n안녕하세요\n你好");
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,904 @@
|
||||
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getOcrRuntimeCapability,
|
||||
readActiveRuntime,
|
||||
readCommittedOcrRuntimeActivationIdentity,
|
||||
readPendingOcrRuntimeForHandoff,
|
||||
resolveAiDataDir,
|
||||
selectOcrRuntimeTarget,
|
||||
} from "../../../packages/ai/src/runtime-state.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
const runtimeSigningKey = generateKeyPairSync("ed25519");
|
||||
|
||||
function sortJson(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sortJson);
|
||||
if (typeof value !== "object" || value === null) return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entry]) => [key, sortJson(entry)]),
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
return `${JSON.stringify(sortJson(value))}\n`;
|
||||
}
|
||||
|
||||
interface MutableDescriptor {
|
||||
schemaVersion: unknown;
|
||||
family: unknown;
|
||||
generation: unknown;
|
||||
status: unknown;
|
||||
activatedAt: unknown;
|
||||
artifact: Record<string, unknown>;
|
||||
runtime: Record<string, unknown>;
|
||||
compatibility: Record<string, unknown>;
|
||||
capabilities: Record<string, unknown>;
|
||||
health: Record<string, unknown>;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("resolveAiDataDir", () => {
|
||||
it("keeps DATA_DIR authoritative over the verifier-only AI_DATA_DIR seam", () => {
|
||||
const dataDir = join(tmpdir(), "snapotter-data-root");
|
||||
vi.stubEnv("DATA_DIR", dataDir);
|
||||
vi.stubEnv("AI_DATA_DIR", join(tmpdir(), "snapotter-verifier-ai-root"));
|
||||
|
||||
expect(resolveAiDataDir()).toBe(join(dataDir, "ai"));
|
||||
});
|
||||
});
|
||||
|
||||
function createRuntimeFixture(): {
|
||||
aiDataDir: string;
|
||||
activationPath: string;
|
||||
descriptorPath: string;
|
||||
runtimeRoot: string;
|
||||
pythonPath: string;
|
||||
entrypoint: string;
|
||||
adapterPath: string;
|
||||
smallModelPath: string;
|
||||
mediumModelPath: string;
|
||||
sitePackagePath: string;
|
||||
signedIndexPath: string;
|
||||
} {
|
||||
const aiDataDir = mkdtempSync(join(tmpdir(), "snapotter-runtime-state-"));
|
||||
temporaryDirectories.push(aiDataDir);
|
||||
|
||||
const v3Root = join(aiDataDir, "v3");
|
||||
const runtimeRoot = join(v3Root, "runtimes", "ocr", "linux-amd64-cpu-py312", "generation-test");
|
||||
const pythonPath = join(runtimeRoot, "venv", "bin", "python");
|
||||
const entrypoint = join(runtimeRoot, "ocr_runner.py");
|
||||
const adapterPath = join(runtimeRoot, "ocr_runtime.py");
|
||||
const smallModelPath = join(runtimeRoot, "models", "small.onnx");
|
||||
const mediumModelPath = join(runtimeRoot, "models", "medium.onnx");
|
||||
const sitePackagePath = join(
|
||||
runtimeRoot,
|
||||
"venv",
|
||||
"lib",
|
||||
"python3.12",
|
||||
"site-packages",
|
||||
"rapidocr",
|
||||
"__init__.py",
|
||||
);
|
||||
const descriptorPath = join(v3Root, "active", "ocr.json");
|
||||
|
||||
mkdirSync(join(runtimeRoot, "venv", "bin"), { recursive: true });
|
||||
mkdirSync(join(runtimeRoot, "models"), { recursive: true });
|
||||
mkdirSync(join(sitePackagePath, ".."), { recursive: true });
|
||||
mkdirSync(join(v3Root, "active"), { recursive: true });
|
||||
writeFileSync(pythonPath, "#!/bin/sh\n", "utf-8");
|
||||
writeFileSync(entrypoint, "# test entrypoint\n", "utf-8");
|
||||
writeFileSync(adapterPath, "# test adapter\n", "utf-8");
|
||||
writeFileSync(smallModelPath, "small", "utf-8");
|
||||
writeFileSync(mediumModelPath, "medium", "utf-8");
|
||||
writeFileSync(sitePackagePath, "rapidocr-v1\n", "utf-8");
|
||||
chmodSync(pythonPath, 0o755);
|
||||
|
||||
const files = [
|
||||
["venv/bin/python", "#!/bin/sh\n", 0o755],
|
||||
["ocr_runner.py", "# test entrypoint\n", 0o644],
|
||||
["ocr_runtime.py", "# test adapter\n", 0o644],
|
||||
["models/small.onnx", "small", 0o644],
|
||||
["models/medium.onnx", "medium", 0o644],
|
||||
["venv/lib/python3.12/site-packages/rapidocr/__init__.py", "rapidocr-v1\n", 0o644],
|
||||
].map(([path, contents, mode]) => ({
|
||||
path,
|
||||
sha256: createHash("sha256")
|
||||
.update(contents as string)
|
||||
.digest("hex"),
|
||||
size: Buffer.byteLength(contents as string),
|
||||
mode,
|
||||
}));
|
||||
const artifact = {
|
||||
family: "ocr",
|
||||
target: "linux-amd64-cpu-py312",
|
||||
generation: "generation-test",
|
||||
version: "2.1.0",
|
||||
platform: "linux",
|
||||
arch: "amd64",
|
||||
archive: {
|
||||
file: "ocr-linux-amd64-cpu-py312.tar.gz",
|
||||
sha256: "a".repeat(64),
|
||||
size: 123,
|
||||
expandedSize: files.reduce((total, file) => total + file.size, 0),
|
||||
},
|
||||
files,
|
||||
runtime: {
|
||||
pythonPath: "venv/bin/python",
|
||||
entrypoint: "ocr_runner.py",
|
||||
adapterPath: "ocr_runtime.py",
|
||||
},
|
||||
models: {
|
||||
"pp-ocrv6-small": createHash("sha256").update("small").digest("hex"),
|
||||
"pp-ocrv6-medium": createHash("sha256").update("medium").digest("hex"),
|
||||
},
|
||||
compatibility: { protocolVersion: 1, snapotterVersion: "2.1.0" },
|
||||
capabilities: {
|
||||
qualities: ["balanced", "best"],
|
||||
providers: ["CPUExecutionProvider"],
|
||||
},
|
||||
resources: { minimumMemoryBytes: 4 * 1024 ** 3 },
|
||||
};
|
||||
const unsignedIndex = { schemaVersion: 1, artifacts: [artifact] };
|
||||
const signedIndex = {
|
||||
...unsignedIndex,
|
||||
signature: {
|
||||
keyId: "runtime-state-test-key",
|
||||
algorithm: "ed25519",
|
||||
value: sign(
|
||||
null,
|
||||
Buffer.from(canonicalJson(unsignedIndex)),
|
||||
runtimeSigningKey.privateKey,
|
||||
).toString("base64"),
|
||||
},
|
||||
};
|
||||
const signedIndexBytes = canonicalJson(signedIndex);
|
||||
const signedIndexSha256 = createHash("sha256").update(signedIndexBytes).digest("hex");
|
||||
const signedIndexPath = join(v3Root, "indexes", `${signedIndexSha256}.json`);
|
||||
mkdirSync(join(v3Root, "indexes"), { recursive: true });
|
||||
writeFileSync(signedIndexPath, signedIndexBytes, "utf-8");
|
||||
vi.stubEnv("OCR_RUNTIME_INDEX_KEY_ID", "runtime-state-test-key");
|
||||
vi.stubEnv(
|
||||
"OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64",
|
||||
Buffer.from(runtimeSigningKey.publicKey.export({ type: "spki", format: "pem" })).toString(
|
||||
"base64",
|
||||
),
|
||||
);
|
||||
|
||||
const descriptorBytes = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
family: "ocr",
|
||||
generation: "generation-test",
|
||||
status: "ready",
|
||||
activatedAt: "2026-07-13T00:00:00.000Z",
|
||||
artifact: {
|
||||
version: "2.1.0",
|
||||
target: "linux-amd64-cpu-py312",
|
||||
platform: "linux",
|
||||
arch: "amd64",
|
||||
sha256: "a".repeat(64),
|
||||
models: {
|
||||
"pp-ocrv6-small": createHash("sha256").update("small").digest("hex"),
|
||||
"pp-ocrv6-medium": createHash("sha256").update("medium").digest("hex"),
|
||||
},
|
||||
modelFiles: {
|
||||
"pp-ocrv6-small": {
|
||||
path: "runtimes/ocr/linux-amd64-cpu-py312/generation-test/models/small.onnx",
|
||||
sha256: createHash("sha256").update("small").digest("hex"),
|
||||
size: 5,
|
||||
},
|
||||
"pp-ocrv6-medium": {
|
||||
path: "runtimes/ocr/linux-amd64-cpu-py312/generation-test/models/medium.onnx",
|
||||
sha256: createHash("sha256").update("medium").digest("hex"),
|
||||
size: 6,
|
||||
},
|
||||
},
|
||||
signedIndex: {
|
||||
path: `indexes/${signedIndexSha256}.json`,
|
||||
sha256: signedIndexSha256,
|
||||
size: Buffer.byteLength(signedIndexBytes),
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
pythonPath: "runtimes/ocr/linux-amd64-cpu-py312/generation-test/venv/bin/python",
|
||||
entrypoint: "runtimes/ocr/linux-amd64-cpu-py312/generation-test/ocr_runner.py",
|
||||
integrityFiles: {
|
||||
python: {
|
||||
path: "runtimes/ocr/linux-amd64-cpu-py312/generation-test/venv/bin/python",
|
||||
sha256: createHash("sha256").update("#!/bin/sh\n").digest("hex"),
|
||||
size: 10,
|
||||
},
|
||||
entrypoint: {
|
||||
path: "runtimes/ocr/linux-amd64-cpu-py312/generation-test/ocr_runner.py",
|
||||
sha256: createHash("sha256").update("# test entrypoint\n").digest("hex"),
|
||||
size: 18,
|
||||
},
|
||||
adapter: {
|
||||
path: "runtimes/ocr/linux-amd64-cpu-py312/generation-test/ocr_runtime.py",
|
||||
sha256: createHash("sha256").update("# test adapter\n").digest("hex"),
|
||||
size: 15,
|
||||
},
|
||||
},
|
||||
},
|
||||
compatibility: {
|
||||
protocolVersion: 1,
|
||||
snapotterVersion: "2.1.0",
|
||||
},
|
||||
capabilities: {
|
||||
qualities: ["balanced", "best"],
|
||||
providers: ["CPUExecutionProvider"],
|
||||
},
|
||||
health: {
|
||||
status: "healthy",
|
||||
checkedAt: "2026-07-13T00:00:01.000Z",
|
||||
},
|
||||
});
|
||||
writeFileSync(descriptorPath, descriptorBytes, "utf-8");
|
||||
const activationPath = join(v3Root, "rollback", "ocr.json");
|
||||
mkdirSync(join(v3Root, "rollback"), { recursive: true });
|
||||
writeFileSync(
|
||||
activationPath,
|
||||
canonicalJson({
|
||||
schemaVersion: 1,
|
||||
family: "ocr",
|
||||
status: "committed",
|
||||
activatedGeneration: "generation-test",
|
||||
activatedDescriptorSha256: createHash("sha256").update(descriptorBytes).digest("hex"),
|
||||
previousDescriptorB64: null,
|
||||
previousGeneration: null,
|
||||
previousIndexPath: null,
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
return {
|
||||
aiDataDir,
|
||||
activationPath,
|
||||
descriptorPath,
|
||||
runtimeRoot,
|
||||
pythonPath,
|
||||
entrypoint,
|
||||
adapterPath,
|
||||
smallModelPath,
|
||||
mediumModelPath,
|
||||
sitePackagePath,
|
||||
signedIndexPath,
|
||||
};
|
||||
}
|
||||
|
||||
function mutateDescriptor(
|
||||
descriptorPath: string,
|
||||
mutate: (descriptor: MutableDescriptor) => void,
|
||||
): void {
|
||||
const descriptor = JSON.parse(readFileSync(descriptorPath, "utf-8")) as MutableDescriptor;
|
||||
mutate(descriptor);
|
||||
const descriptorBytes = JSON.stringify(descriptor);
|
||||
writeFileSync(descriptorPath, descriptorBytes, "utf-8");
|
||||
const activationPath = join(dirname(dirname(descriptorPath)), "rollback", "ocr.json");
|
||||
const activation = JSON.parse(readFileSync(activationPath, "utf-8")) as Record<string, unknown>;
|
||||
activation.activatedDescriptorSha256 = createHash("sha256").update(descriptorBytes).digest("hex");
|
||||
writeFileSync(activationPath, canonicalJson(activation), "utf-8");
|
||||
}
|
||||
|
||||
function mutateActivationState(
|
||||
activationPath: string,
|
||||
mutate: (state: Record<string, unknown>) => void,
|
||||
): void {
|
||||
const state = JSON.parse(readFileSync(activationPath, "utf-8")) as Record<string, unknown>;
|
||||
mutate(state);
|
||||
writeFileSync(activationPath, canonicalJson(state), "utf-8");
|
||||
}
|
||||
|
||||
const invalidDescriptorCases: Array<[string, (descriptor: MutableDescriptor) => void]> = [
|
||||
["schema version", (descriptor) => (descriptor.schemaVersion = 2)],
|
||||
["family", (descriptor) => (descriptor.family = "speech")],
|
||||
["generation", (descriptor) => (descriptor.generation = "")],
|
||||
["activation status", (descriptor) => (descriptor.status = "staging")],
|
||||
["activation timestamp", (descriptor) => (descriptor.activatedAt = "soon")],
|
||||
["artifact version", (descriptor) => (descriptor.artifact.version = "")],
|
||||
["artifact digest", (descriptor) => (descriptor.artifact.sha256 = "abc")],
|
||||
["signed index", (descriptor) => (descriptor.artifact.signedIndex = {})],
|
||||
["artifact target", (descriptor) => (descriptor.artifact.target = "linux-amd64-gpu")],
|
||||
["model digests", (descriptor) => (descriptor.artifact.models = {})],
|
||||
["model files", (descriptor) => (descriptor.artifact.modelFiles = {})],
|
||||
["runtime integrity files", (descriptor) => (descriptor.runtime.integrityFiles = {})],
|
||||
[
|
||||
"model file location",
|
||||
(descriptor) => {
|
||||
const files = descriptor.artifact.modelFiles as Record<string, Record<string, unknown>>;
|
||||
files["pp-ocrv6-small"].path =
|
||||
"runtimes/ocr/linux-amd64-cpu-py312/generation-test/ocr_runner.py";
|
||||
},
|
||||
],
|
||||
["protocol version", (descriptor) => (descriptor.compatibility.protocolVersion = 2)],
|
||||
["SnapOtter version", (descriptor) => (descriptor.compatibility.snapotterVersion = "1.0.0")],
|
||||
["health status", (descriptor) => (descriptor.health.status = "degraded")],
|
||||
["health timestamp", (descriptor) => (descriptor.health.checkedAt = "never")],
|
||||
["qualities", (descriptor) => (descriptor.capabilities.qualities = ["best"])],
|
||||
["providers", (descriptor) => (descriptor.capabilities.providers = [])],
|
||||
];
|
||||
|
||||
describe("selectOcrRuntimeTarget", () => {
|
||||
it("selects the Python 3.12 CPU artifact for Linux AMD64", () => {
|
||||
expect(selectOcrRuntimeTarget({ platform: "linux", arch: "x64" })).toBe(
|
||||
"linux-amd64-cpu-py312",
|
||||
);
|
||||
});
|
||||
|
||||
it("selects the Python 3.11 CPU artifact for Linux ARM64", () => {
|
||||
expect(selectOcrRuntimeTarget({ platform: "linux", arch: "arm64" })).toBe(
|
||||
"linux-arm64-cpu-py311",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed on unsupported platforms and architectures", () => {
|
||||
expect(selectOcrRuntimeTarget({ platform: "darwin", arch: "arm64" })).toBeNull();
|
||||
expect(selectOcrRuntimeTarget({ platform: "linux", arch: "ia32" })).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects accurate runtimes outside the official container ABI", () => {
|
||||
expect(
|
||||
selectOcrRuntimeTarget({
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
officialContainer: false,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readActiveRuntime", () => {
|
||||
it("returns a healthy compatible descriptor with contained absolute runtime paths", () => {
|
||||
const fixture = createRuntimeFixture();
|
||||
|
||||
const descriptor = readActiveRuntime("ocr", {
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
});
|
||||
|
||||
expect(descriptor).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
family: "ocr",
|
||||
generation: "generation-test",
|
||||
status: "ready",
|
||||
artifact: {
|
||||
target: "linux-amd64-cpu-py312",
|
||||
platform: "linux",
|
||||
arch: "amd64",
|
||||
models: {
|
||||
"pp-ocrv6-small": createHash("sha256").update("small").digest("hex"),
|
||||
"pp-ocrv6-medium": createHash("sha256").update("medium").digest("hex"),
|
||||
},
|
||||
},
|
||||
compatibility: {
|
||||
protocolVersion: 1,
|
||||
snapotterVersion: "2.1.0",
|
||||
},
|
||||
runtime: {
|
||||
root: fixture.runtimeRoot,
|
||||
pythonPath: fixture.pythonPath,
|
||||
entrypoint: fixture.entrypoint,
|
||||
integrityFiles: expect.any(Object),
|
||||
},
|
||||
capabilities: {
|
||||
qualities: ["balanced", "best"],
|
||||
providers: ["CPUExecutionProvider"],
|
||||
},
|
||||
health: { status: "healthy" },
|
||||
});
|
||||
});
|
||||
|
||||
it("routes only committed state while installer handoff can inspect exact pending state", () => {
|
||||
const fixture = createRuntimeFixture();
|
||||
const options = {
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux" as const,
|
||||
arch: "x64" as const,
|
||||
};
|
||||
mutateActivationState(fixture.activationPath, (state) => {
|
||||
state.status = "pending";
|
||||
});
|
||||
|
||||
expect(readActiveRuntime("ocr", options)).toBeNull();
|
||||
expect(readPendingOcrRuntimeForHandoff(options)).toMatchObject({
|
||||
generation: "generation-test",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a committed upgrade marker with a structurally valid previous descriptor", () => {
|
||||
const fixture = createRuntimeFixture();
|
||||
const previous = JSON.parse(readFileSync(fixture.descriptorPath, "utf-8"));
|
||||
previous.generation = "generation-previous";
|
||||
const previousBytes = canonicalJson(previous);
|
||||
mutateActivationState(fixture.activationPath, (state) => {
|
||||
state.previousDescriptorB64 = Buffer.from(previousBytes).toString("base64");
|
||||
state.previousGeneration = "generation-previous";
|
||||
state.previousIndexPath = previous.artifact.signedIndex.path;
|
||||
});
|
||||
|
||||
expect(
|
||||
readActiveRuntime("ocr", {
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
}),
|
||||
).toMatchObject({ generation: "generation-test" });
|
||||
});
|
||||
|
||||
it("fails closed for missing, corrupt, symlinked, or descriptor-mismatched activation state", () => {
|
||||
const fixtures = Array.from({ length: 4 }, () => createRuntimeFixture());
|
||||
const options = (aiDataDir: string) => ({
|
||||
aiDataDir,
|
||||
platform: "linux" as const,
|
||||
arch: "x64" as const,
|
||||
});
|
||||
|
||||
unlinkSync(fixtures[0].activationPath);
|
||||
writeFileSync(fixtures[1].activationPath, "{{not-json", "utf-8");
|
||||
const external = join(fixtures[2].aiDataDir, "external-activation.json");
|
||||
writeFileSync(external, readFileSync(fixtures[2].activationPath));
|
||||
unlinkSync(fixtures[2].activationPath);
|
||||
symlinkSync(external, fixtures[2].activationPath);
|
||||
mutateActivationState(fixtures[3].activationPath, (state) => {
|
||||
state.activatedDescriptorSha256 = "f".repeat(64);
|
||||
});
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
expect(readActiveRuntime("ocr", options(fixture.aiDataDir))).toBeNull();
|
||||
expect(readPendingOcrRuntimeForHandoff(options(fixture.aiDataDir))).toBeNull();
|
||||
expect(getOcrRuntimeCapability(options(fixture.aiDataDir))).toMatchObject({
|
||||
available: false,
|
||||
status: "invalid",
|
||||
reason: "descriptor-invalid",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a structurally corrupt activation record even when its active hash matches", () => {
|
||||
const fixture = createRuntimeFixture();
|
||||
mutateActivationState(fixture.activationPath, (state) => {
|
||||
state.previousDescriptorB64 = "not-base64***";
|
||||
});
|
||||
|
||||
expect(
|
||||
readActiveRuntime("ocr", {
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each(invalidDescriptorCases)("fails closed for an invalid %s", (_name, mutate) => {
|
||||
const fixture = createRuntimeFixture();
|
||||
mutateDescriptor(fixture.descriptorPath, mutate);
|
||||
|
||||
expect(
|
||||
readActiveRuntime("ocr", {
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("fails closed when the artifact is incompatible with the current host", () => {
|
||||
const fixture = createRuntimeFixture();
|
||||
|
||||
expect(
|
||||
readActiveRuntime("ocr", {
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "arm64",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
readActiveRuntime("ocr", {
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "darwin",
|
||||
arch: "x64",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects absolute paths and traversal even when they point to files", () => {
|
||||
const fixture = createRuntimeFixture();
|
||||
const outsidePath = join(fixture.aiDataDir, "outside-python");
|
||||
writeFileSync(outsidePath, "#!/bin/sh\n", "utf-8");
|
||||
|
||||
mutateDescriptor(fixture.descriptorPath, (descriptor) => {
|
||||
descriptor.runtime.pythonPath = outsidePath;
|
||||
});
|
||||
expect(
|
||||
readActiveRuntime("ocr", {
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
mutateDescriptor(fixture.descriptorPath, (descriptor) => {
|
||||
descriptor.runtime.pythonPath = "../outside-python";
|
||||
});
|
||||
expect(
|
||||
readActiveRuntime("ocr", {
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing files and symlinks in runtime paths", () => {
|
||||
const fixture = createRuntimeFixture();
|
||||
unlinkSync(fixture.pythonPath);
|
||||
symlinkSync(fixture.entrypoint, fixture.pythonPath);
|
||||
|
||||
expect(
|
||||
readActiveRuntime("ocr", {
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
unlinkSync(fixture.pythonPath);
|
||||
expect(
|
||||
readActiveRuntime("ocr", {
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing, resized, and symlinked model files", () => {
|
||||
const missing = createRuntimeFixture();
|
||||
unlinkSync(missing.smallModelPath);
|
||||
expect(
|
||||
readActiveRuntime("ocr", {
|
||||
aiDataDir: missing.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
const resized = createRuntimeFixture();
|
||||
writeFileSync(resized.mediumModelPath, "changed", "utf-8");
|
||||
expect(
|
||||
readActiveRuntime("ocr", {
|
||||
aiDataDir: resized.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
const symlinked = createRuntimeFixture();
|
||||
unlinkSync(symlinked.smallModelPath);
|
||||
symlinkSync(symlinked.mediumModelPath, symlinked.smallModelPath);
|
||||
expect(
|
||||
readActiveRuntime("ocr", {
|
||||
aiDataDir: symlinked.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects same-size model and execution-code corruption", () => {
|
||||
const model = createRuntimeFixture();
|
||||
writeFileSync(model.smallModelPath, "wrong", "utf-8");
|
||||
expect(
|
||||
readActiveRuntime("ocr", {
|
||||
aiDataDir: model.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
const adapter = createRuntimeFixture();
|
||||
writeFileSync(adapter.adapterPath, "# evil adapter\n", "utf-8");
|
||||
expect(
|
||||
readActiveRuntime("ocr", {
|
||||
aiDataDir: adapter.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rechecks filesystem identity and rejects same-size site-packages corruption", () => {
|
||||
const fixture = createRuntimeFixture();
|
||||
const options = {
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux" as const,
|
||||
arch: "x64" as const,
|
||||
};
|
||||
|
||||
expect(readActiveRuntime("ocr", options)).not.toBeNull();
|
||||
writeFileSync(fixture.sitePackagePath, "rapidocr-v2\n", "utf-8");
|
||||
|
||||
expect(readActiveRuntime("ocr", options)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects payload metadata that no longer matches its trusted signed index", () => {
|
||||
const fixture = createRuntimeFixture();
|
||||
const index = JSON.parse(readFileSync(fixture.signedIndexPath, "utf-8"));
|
||||
index.artifacts[0].files.at(-1).sha256 = "f".repeat(64);
|
||||
writeFileSync(fixture.signedIndexPath, canonicalJson(index), "utf-8");
|
||||
|
||||
expect(
|
||||
readActiveRuntime("ocr", {
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores missing, corrupt, non-canonical, and legacy state", () => {
|
||||
const fixture = createRuntimeFixture();
|
||||
const activeDir = join(fixture.aiDataDir, "v3", "active");
|
||||
const aiDataDir = fixture.aiDataDir;
|
||||
const options = { aiDataDir, platform: "linux" as const, arch: "x64" as const };
|
||||
const validJson = readFileSync(fixture.descriptorPath, "utf-8");
|
||||
|
||||
unlinkSync(fixture.descriptorPath);
|
||||
writeFileSync(join(aiDataDir, "installed.json"), validJson, "utf-8");
|
||||
writeFileSync(join(activeDir, "ocr-onnx.json"), validJson, "utf-8");
|
||||
expect(readActiveRuntime("ocr", options)).toBeNull();
|
||||
|
||||
writeFileSync(fixture.descriptorPath, "{{not-json", "utf-8");
|
||||
expect(readActiveRuntime("ocr", options)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a caller-controlled family path", () => {
|
||||
const fixture = createRuntimeFixture();
|
||||
const nonCanonicalPath = join(fixture.aiDataDir, "v3", "other.json");
|
||||
writeFileSync(nonCanonicalPath, readFileSync(fixture.descriptorPath, "utf-8"), "utf-8");
|
||||
|
||||
expect(
|
||||
readActiveRuntime("../other" as "ocr", {
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a symlinked active directory", () => {
|
||||
const fixture = createRuntimeFixture();
|
||||
const activeDir = join(fixture.aiDataDir, "v3", "active");
|
||||
const externalActiveDir = join(fixture.aiDataDir, "external-active");
|
||||
const descriptorJson = readFileSync(fixture.descriptorPath, "utf-8");
|
||||
|
||||
rmSync(activeDir, { recursive: true });
|
||||
mkdirSync(externalActiveDir);
|
||||
writeFileSync(join(externalActiveDir, "ocr.json"), descriptorJson, "utf-8");
|
||||
symlinkSync(externalActiveDir, activeDir, "dir");
|
||||
|
||||
expect(
|
||||
readActiveRuntime("ocr", {
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves the AI root from AI_DATA_DIR", () => {
|
||||
const fixture = createRuntimeFixture();
|
||||
vi.stubEnv("DATA_DIR", "");
|
||||
vi.stubEnv("AI_DATA_DIR", fixture.aiDataDir);
|
||||
|
||||
expect(readActiveRuntime("ocr", { platform: "linux", arch: "x64" })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("revalidates active payloads with an operator-supplied trust store", () => {
|
||||
const fixture = createRuntimeFixture();
|
||||
const trustStorePath = join(fixture.aiDataDir, "ocr-runtime-trust.json");
|
||||
writeFileSync(
|
||||
trustStorePath,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
keys: [
|
||||
{
|
||||
keyId: "runtime-state-test-key",
|
||||
algorithm: "ed25519",
|
||||
publicKey: runtimeSigningKey.publicKey.export({ type: "spki", format: "pem" }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
vi.stubEnv("OCR_RUNTIME_INDEX_KEY_ID", "");
|
||||
vi.stubEnv("OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64", "");
|
||||
vi.stubEnv("SNAPOTTER_OCR_RUNTIME_TRUST_STORE", trustStorePath);
|
||||
|
||||
expect(
|
||||
readActiveRuntime("ocr", {
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
}),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOcrRuntimeCapability", () => {
|
||||
it("reports only validated active runtime capabilities", () => {
|
||||
const fixture = createRuntimeFixture();
|
||||
|
||||
const capability = getOcrRuntimeCapability({
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
});
|
||||
|
||||
expect(capability).toMatchObject({
|
||||
available: true,
|
||||
status: "ready",
|
||||
qualities: ["balanced", "best"],
|
||||
providers: ["CPUExecutionProvider"],
|
||||
descriptor: { family: "ocr", generation: "generation-test" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty capabilities when runtime state is unavailable", () => {
|
||||
const aiDataDir = mkdtempSync(join(tmpdir(), "snapotter-runtime-state-empty-"));
|
||||
temporaryDirectories.push(aiDataDir);
|
||||
|
||||
expect(getOcrRuntimeCapability({ aiDataDir, platform: "linux", arch: "x64" })).toEqual({
|
||||
available: false,
|
||||
status: "missing",
|
||||
reason: "descriptor-missing",
|
||||
qualities: [],
|
||||
providers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("fails accurate-runtime admission closed when the effective memory limit drops", () => {
|
||||
const fixture = createRuntimeFixture();
|
||||
|
||||
expect(
|
||||
getOcrRuntimeCapability({
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
effectiveMemoryBytes: 4 * 1024 ** 3 - 1,
|
||||
}),
|
||||
).toMatchObject({
|
||||
available: false,
|
||||
status: "incompatible",
|
||||
reason: "insufficient-memory",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an unreadable identified memory controller as incompatible", () => {
|
||||
const fixture = createRuntimeFixture();
|
||||
const procFiles = new Map([
|
||||
["/proc/self/cgroup", "0::/docker/deadbeef\n"],
|
||||
[
|
||||
"/proc/self/mountinfo",
|
||||
"29 23 0:26 / /sys/fs/cgroup rw,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw\n",
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
getOcrRuntimeCapability({
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
physicalMemoryBytes: 8 * 1024 ** 3,
|
||||
readTextFile: (path) => {
|
||||
const value = procFiles.get(path);
|
||||
if (value === undefined) throw new Error("denied");
|
||||
return value;
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
available: false,
|
||||
status: "incompatible",
|
||||
reason: "memory-capacity-unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("distinguishes incompatible, invalid, and unsupported runtime state", () => {
|
||||
const incompatible = createRuntimeFixture();
|
||||
expect(
|
||||
getOcrRuntimeCapability({
|
||||
aiDataDir: incompatible.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "arm64",
|
||||
}),
|
||||
).toMatchObject({
|
||||
available: false,
|
||||
status: "incompatible",
|
||||
reason: "artifact-incompatible",
|
||||
});
|
||||
|
||||
const invalid = createRuntimeFixture();
|
||||
mutateDescriptor(invalid.descriptorPath, (descriptor) => {
|
||||
descriptor.artifact.arch = null;
|
||||
});
|
||||
expect(
|
||||
getOcrRuntimeCapability({
|
||||
aiDataDir: invalid.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
}),
|
||||
).toMatchObject({
|
||||
available: false,
|
||||
status: "invalid",
|
||||
reason: "descriptor-invalid",
|
||||
});
|
||||
|
||||
expect(
|
||||
getOcrRuntimeCapability({
|
||||
aiDataDir: incompatible.aiDataDir,
|
||||
platform: "darwin",
|
||||
arch: "arm64",
|
||||
}),
|
||||
).toMatchObject({
|
||||
available: false,
|
||||
status: "incompatible",
|
||||
reason: "unsupported-host",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("OCR activation identity", () => {
|
||||
it("reads the exact committed descriptor hash without walking runtime payloads", () => {
|
||||
const fixture = createRuntimeFixture();
|
||||
const raw = readFileSync(fixture.descriptorPath);
|
||||
const expected = {
|
||||
generation: "generation-test",
|
||||
descriptorSha256: createHash("sha256").update(raw).digest("hex"),
|
||||
};
|
||||
|
||||
expect(
|
||||
readCommittedOcrRuntimeActivationIdentity({
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
}),
|
||||
).toEqual(expected);
|
||||
expect(
|
||||
readActiveRuntime("ocr", {
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
})?.activationDescriptorSha256,
|
||||
).toBe(expected.descriptorSha256);
|
||||
|
||||
writeFileSync(fixture.descriptorPath, "not-json\n", "utf8");
|
||||
expect(
|
||||
readCommittedOcrRuntimeActivationIdentity({
|
||||
aiDataDir: fixture.aiDataDir,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockSpawn = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:child_process", () => ({ spawn: mockSpawn }));
|
||||
|
||||
import {
|
||||
clearTesseractLanguageInventoryCache,
|
||||
getInstalledTesseractLanguages,
|
||||
SUPPORTED_TESSERACT_TRAINEDDATA,
|
||||
} from "../../../packages/ai/src/tesseract-languages.js";
|
||||
|
||||
function createMockChild() {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kill = vi.fn(() => true);
|
||||
return child;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearTesseractLanguageInventoryCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("getInstalledTesseractLanguages", () => {
|
||||
it("keeps unsupported Korean traineddata out of the Fast OCR preflight inventory", () => {
|
||||
expect(SUPPORTED_TESSERACT_TRAINEDDATA).toEqual(["eng", "deu", "fra", "spa", "chi_sim", "jpn"]);
|
||||
expect(SUPPORTED_TESSERACT_TRAINEDDATA).not.toContain("Hangul");
|
||||
expect(SUPPORTED_TESSERACT_TRAINEDDATA).not.toContain("kor");
|
||||
});
|
||||
|
||||
it("parses and caches an exact list-langs inventory per executable", async () => {
|
||||
const first = createMockChild();
|
||||
mockSpawn.mockReturnValueOnce(first);
|
||||
|
||||
const firstPromise = getInstalledTesseractLanguages({
|
||||
executable: "/usr/local/bin/tesseract",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
"/usr/local/bin/tesseract",
|
||||
["--list-langs"],
|
||||
expect.objectContaining({ shell: false, windowsHide: true }),
|
||||
);
|
||||
first.stdout.write(
|
||||
'List of available languages in "/usr/local/share/tessdata/" (4):\neng\nHangul\nkor\nosd\n',
|
||||
);
|
||||
first.emit("close", 0, null);
|
||||
|
||||
await expect(firstPromise).resolves.toEqual(new Set(["eng", "Hangul", "kor", "osd"]));
|
||||
await expect(
|
||||
getInstalledTesseractLanguages({
|
||||
executable: "/usr/local/bin/tesseract",
|
||||
timeoutMs: 5_000,
|
||||
}),
|
||||
).resolves.toEqual(new Set(["eng", "Hangul", "kor", "osd"]));
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects malformed list-langs output instead of guessing", async () => {
|
||||
const child = createMockChild();
|
||||
mockSpawn.mockReturnValueOnce(child);
|
||||
|
||||
const resultPromise = getInstalledTesseractLanguages({
|
||||
executable: "tesseract",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
child.stdout.write("eng\njpn\n");
|
||||
child.emit("close", 0, null);
|
||||
|
||||
await expect(resultPromise).rejects.toThrow(
|
||||
"Tesseract --list-langs returned malformed output; cannot verify installed traineddata.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a declared language count that does not match the body", async () => {
|
||||
const child = createMockChild();
|
||||
mockSpawn.mockReturnValueOnce(child);
|
||||
|
||||
const resultPromise = getInstalledTesseractLanguages({
|
||||
executable: "tesseract",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
child.stdout.write('List of available languages in "/tmp/tessdata/" (2):\neng\n');
|
||||
child.emit("close", 0, null);
|
||||
|
||||
await expect(resultPromise).rejects.toThrow("declared 2 languages but returned 1");
|
||||
});
|
||||
|
||||
it("reports list-langs startup failure clearly", async () => {
|
||||
const child = createMockChild();
|
||||
mockSpawn.mockReturnValueOnce(child);
|
||||
|
||||
const resultPromise = getInstalledTesseractLanguages({
|
||||
executable: "/missing/tesseract",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
child.emit(
|
||||
"error",
|
||||
Object.assign(new Error("spawn /missing/tesseract ENOENT"), { code: "ENOENT" }),
|
||||
);
|
||||
|
||||
await expect(resultPromise).rejects.toThrow(
|
||||
"Tesseract executable not found while checking installed language packs.",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a nonzero list-langs exit with bounded diagnostics", async () => {
|
||||
const child = createMockChild();
|
||||
mockSpawn.mockReturnValueOnce(child);
|
||||
|
||||
const resultPromise = getInstalledTesseractLanguages({
|
||||
executable: "tesseract",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
child.stderr.write("failed to load tessdata");
|
||||
child.emit("close", 1, null);
|
||||
|
||||
await expect(resultPromise).rejects.toThrow(
|
||||
"Unable to inspect Tesseract language packs: --list-langs exited with code 1: failed to load tessdata",
|
||||
);
|
||||
});
|
||||
|
||||
it("terminates a hung list-langs preflight within its deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = createMockChild();
|
||||
mockSpawn.mockReturnValueOnce(child);
|
||||
|
||||
const resultPromise = getInstalledTesseractLanguages({
|
||||
executable: "tesseract",
|
||||
timeoutMs: 100,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
child.emit("close", null, "SIGTERM");
|
||||
await expect(resultPromise).rejects.toThrow(
|
||||
"Tesseract language-pack preflight timed out after 100ms",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,498 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { existsSync, mkdtempSync, readdirSync, rmSync, truncateSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { PassThrough } from "node:stream";
|
||||
import sharp from "sharp";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockSpawn, mockRunAdaptiveTesseract, mockStatfs } = vi.hoisted(() => ({
|
||||
mockSpawn: vi.fn(),
|
||||
mockRunAdaptiveTesseract: vi.fn(),
|
||||
mockStatfs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({ spawn: mockSpawn }));
|
||||
vi.mock("node:fs/promises", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("node:fs/promises")>()),
|
||||
statfs: mockStatfs,
|
||||
}));
|
||||
vi.mock("../../../packages/ai/src/tesseract.js", () => ({
|
||||
runAdaptiveTesseract: mockRunAdaptiveTesseract,
|
||||
runTesseract: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
MAX_PDF_OCR_OUTPUT_BYTES,
|
||||
MAX_PDF_OCR_PAGES,
|
||||
parsePdfPageSpec,
|
||||
preparePdfOcrPages,
|
||||
runTesseractPdf,
|
||||
} from "../../../packages/ai/src/tesseract-pdf.js";
|
||||
|
||||
interface MockChild extends EventEmitter {
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function createMockChild(): MockChild {
|
||||
const child = new EventEmitter() as MockChild;
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kill = vi.fn(() => true);
|
||||
return child;
|
||||
}
|
||||
|
||||
function completeChild(
|
||||
child: MockChild,
|
||||
{ stdout = "", stderr = "", code = 0 }: { stdout?: string; stderr?: string; code?: number } = {},
|
||||
) {
|
||||
queueMicrotask(() => {
|
||||
if (stdout) child.stdout.write(stdout);
|
||||
if (stderr) child.stderr.write(stderr);
|
||||
child.emit("close", code, null);
|
||||
});
|
||||
}
|
||||
|
||||
function mockSuccessfulGhostscript(totalPages = 3, pageBox = "[0 0 612 792]") {
|
||||
mockSpawn.mockImplementation((_executable: string, args: string[]) => {
|
||||
const child = createMockChild();
|
||||
if (args.some((arg) => arg.includes("pdfpagecount"))) {
|
||||
completeChild(child, { stdout: `${totalPages}\n` });
|
||||
} else if (args.some((arg) => arg.includes("/CropBox"))) {
|
||||
completeChild(child, { stdout: `${pageBox}\n` });
|
||||
} else {
|
||||
const outputArg = args.find((arg) => arg.startsWith("-sOutputFile="));
|
||||
if (!outputArg) throw new Error("render command did not include an output file");
|
||||
writeFileSync(outputArg.slice("-sOutputFile=".length), rasterBuffer);
|
||||
completeChild(child);
|
||||
}
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
let scratchDir: string;
|
||||
let inputPath: string;
|
||||
let rasterBuffer: Buffer;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockStatfs.mockResolvedValue({ bavail: 1_000_000n, bsize: 4_096n });
|
||||
scratchDir = mkdtempSync(join(tmpdir(), "snapotter-tesseract-pdf-"));
|
||||
inputPath = join(scratchDir, "input with spaces.pdf");
|
||||
writeFileSync(inputPath, "%PDF mock");
|
||||
rasterBuffer = await sharp({
|
||||
create: { width: 1, height: 1, channels: 3, background: "white" },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
mockRunAdaptiveTesseract.mockImplementation(async (pagePath: string) => ({
|
||||
text: `Text from ${basename(pagePath)}\n`,
|
||||
engine: "tesseract",
|
||||
provider: "native",
|
||||
device: "cpu",
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
rmSync(scratchDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("parsePdfPageSpec", () => {
|
||||
it("selects every page for all", () => {
|
||||
expect(parsePdfPageSpec(" all ", 4)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("expands ranges, removes duplicates, and restores document order", () => {
|
||||
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("rejects pages outside the document", () => {
|
||||
expect(() => parsePdfPageSpec("1,11", 10)).toThrow("document has 10 pages");
|
||||
});
|
||||
|
||||
it("rejects selections over the fixed safety cap before rasterization", () => {
|
||||
expect(MAX_PDF_OCR_PAGES).toBe(50);
|
||||
expect(() => parsePdfPageSpec("1-51", 100)).toThrow("Too many pages for OCR (max 50)");
|
||||
expect(() => parsePdfPageSpec("all", 51)).toThrow("Too many pages for OCR (max 50)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTesseractPdf", () => {
|
||||
it("can prepare ordered raster pages for the isolated accurate runtime", async () => {
|
||||
mockSuccessfulGhostscript(3);
|
||||
|
||||
const prepared = await preparePdfOcrPages(inputPath, scratchDir, { pages: "3,1" });
|
||||
|
||||
expect(prepared.pages.map(({ page }) => page)).toEqual([1, 3]);
|
||||
expect(prepared.pages.every(({ path }) => existsSync(path))).toBe(true);
|
||||
expect(prepared.remainingTimeoutMs()).toBeGreaterThan(0);
|
||||
await prepared.cleanup();
|
||||
expect(readdirSync(scratchDir)).toEqual(["input with spaces.pdf"]);
|
||||
});
|
||||
|
||||
it("rejects accurate PDF rasters that exceed the aggregate scratch-byte cap", async () => {
|
||||
mockSpawn.mockImplementation((_executable: string, args: string[]) => {
|
||||
const child = createMockChild();
|
||||
if (args.some((arg) => arg.includes("pdfpagecount"))) {
|
||||
completeChild(child, { stdout: "2\n" });
|
||||
} else if (args.some((arg) => arg.includes("/CropBox"))) {
|
||||
completeChild(child, { stdout: "[0 0 612 792]\n" });
|
||||
} else {
|
||||
const outputArg = args.find((arg) => arg.startsWith("-sOutputFile="));
|
||||
if (!outputArg) throw new Error("render command did not include an output file");
|
||||
const outputPath = outputArg.slice("-sOutputFile=".length);
|
||||
writeFileSync(outputPath, rasterBuffer);
|
||||
truncateSync(outputPath, 300 * 1024 * 1024);
|
||||
completeChild(child);
|
||||
}
|
||||
return child;
|
||||
});
|
||||
|
||||
await expect(preparePdfOcrPages(inputPath, scratchDir)).rejects.toThrow(
|
||||
"aggregate scratch limit",
|
||||
);
|
||||
|
||||
expect(readdirSync(scratchDir)).toEqual(["input with spaces.pdf"]);
|
||||
});
|
||||
|
||||
it("rejects an accurate PDF raster when scratch free space falls below its reserve", async () => {
|
||||
mockSuccessfulGhostscript(1);
|
||||
mockStatfs.mockResolvedValue({ bavail: 1n, bsize: 4_096n });
|
||||
|
||||
await expect(preparePdfOcrPages(inputPath, scratchDir)).rejects.toThrow(
|
||||
"free scratch space reserve",
|
||||
);
|
||||
|
||||
expect(readdirSync(scratchDir)).toEqual(["input with spaces.pdf"]);
|
||||
});
|
||||
|
||||
it("rasterizes selected pages without a shell and returns ordered page text and metadata", async () => {
|
||||
mockSuccessfulGhostscript();
|
||||
const onProgress = vi.fn();
|
||||
|
||||
const result = await runTesseractPdf(inputPath, scratchDir, {
|
||||
pages: "3,1",
|
||||
language: "ja",
|
||||
ghostscriptPath: "/usr/local/bin/gs",
|
||||
tesseractPath: "/usr/local/bin/tesseract",
|
||||
onProgress,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
text: "--- Page 1 ---\n\nText from page-1.png\n\n--- Page 3 ---\n\nText from page-3.png",
|
||||
pages: 2,
|
||||
pageNumbers: [1, 3],
|
||||
engine: "tesseract",
|
||||
provider: "native",
|
||||
device: "cpu",
|
||||
});
|
||||
expect(mockRunAdaptiveTesseract).toHaveBeenCalledTimes(2);
|
||||
expect(mockRunAdaptiveTesseract.mock.calls[0][1]).toMatchObject({
|
||||
language: "ja",
|
||||
tesseractPath: "/usr/local/bin/tesseract",
|
||||
});
|
||||
|
||||
for (const [executable, args, options] of mockSpawn.mock.calls) {
|
||||
expect(executable).toBe("/usr/local/bin/gs");
|
||||
expect(options).toMatchObject({ shell: false, windowsHide: true });
|
||||
expect(args).toContain("-dSAFER");
|
||||
}
|
||||
const renderCalls = mockSpawn.mock.calls.filter(([, args]) =>
|
||||
(args as string[]).some((arg) => arg.startsWith("-sOutputFile=")),
|
||||
);
|
||||
expect(renderCalls).toHaveLength(2);
|
||||
expect(renderCalls[0][1]).toEqual(
|
||||
expect.arrayContaining(["-dFirstPage=1", "-dLastPage=1", "-r300"]),
|
||||
);
|
||||
expect(renderCalls[1][1]).toEqual(
|
||||
expect.arrayContaining(["-dFirstPage=3", "-dLastPage=3", "-r300"]),
|
||||
);
|
||||
expect(onProgress).toHaveBeenCalledWith(100, "Tesseract PDF OCR complete");
|
||||
});
|
||||
|
||||
it("applies requested local-contrast preprocessing to Fast PDF pages", async () => {
|
||||
rasterBuffer = await sharp({
|
||||
create: { width: 800, height: 600, channels: 3, background: "#888888" },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
mockSuccessfulGhostscript(1);
|
||||
|
||||
await runTesseractPdf(inputPath, scratchDir, { pages: "1", enhance: true });
|
||||
|
||||
expect(basename(mockRunAdaptiveTesseract.mock.calls[0][0])).toBe("enhanced-page-1.png");
|
||||
});
|
||||
|
||||
it("reduces DPI to keep oversized pages under dimension and pixel limits", async () => {
|
||||
mockSuccessfulGhostscript(1, "[0 0 3600 3600]");
|
||||
|
||||
await runTesseractPdf(inputPath, scratchDir, { pages: "1", dpi: 300 });
|
||||
|
||||
const renderCall = mockSpawn.mock.calls.find(([, args]) =>
|
||||
(args as string[]).some((arg) => arg.startsWith("-sOutputFile=")),
|
||||
);
|
||||
expect(renderCall?.[1]).toContain("-r100");
|
||||
});
|
||||
|
||||
it("rejects pages that cannot meet the minimum OCR raster quality", async () => {
|
||||
mockSuccessfulGhostscript(1, "[0 0 7200 7200]");
|
||||
|
||||
await expect(runTesseractPdf(inputPath, scratchDir, { pages: "1", dpi: 300 })).rejects.toThrow(
|
||||
"72 DPI quality floor",
|
||||
);
|
||||
expect(mockRunAdaptiveTesseract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies PDF UserUnit before calculating the safe raster DPI", async () => {
|
||||
mockSuccessfulGhostscript(1, "[0 0 72 72]\n100");
|
||||
|
||||
await expect(runTesseractPdf(inputPath, scratchDir, { pages: "1", dpi: 300 })).rejects.toThrow(
|
||||
"72 DPI quality floor",
|
||||
);
|
||||
expect(
|
||||
mockSpawn.mock.calls.some(([, args]) =>
|
||||
(args as string[]).some((arg) => arg.startsWith("-sOutputFile=")),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(mockRunAdaptiveTesseract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a raster whose actual dimensions exceed the calculated cap", async () => {
|
||||
rasterBuffer = await sharp({
|
||||
create: { width: 6_001, height: 1, channels: 3, background: "white" },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
mockSuccessfulGhostscript(1);
|
||||
|
||||
await expect(runTesseractPdf(inputPath, scratchDir, { pages: "1" })).rejects.toThrow(
|
||||
"unsafe raster dimensions",
|
||||
);
|
||||
expect(mockRunAdaptiveTesseract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes one deadline and AbortSignal through to every Tesseract page", async () => {
|
||||
mockSuccessfulGhostscript(2);
|
||||
const controller = new AbortController();
|
||||
|
||||
await runTesseractPdf(inputPath, scratchDir, {
|
||||
timeoutMs: 12_000,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(mockRunAdaptiveTesseract).toHaveBeenCalledTimes(2);
|
||||
for (const [, options] of mockRunAdaptiveTesseract.mock.calls) {
|
||||
expect(options.signal).toBe(controller.signal);
|
||||
expect(options.timeoutMs).toBeGreaterThan(0);
|
||||
expect(options.timeoutMs).toBeLessThanOrEqual(12_000);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a monotonic deadline for prepared accurate-runtime pages", async () => {
|
||||
vi.useFakeTimers();
|
||||
const monotonicNow = vi.spyOn(performance, "now").mockReturnValue(0);
|
||||
vi.setSystemTime(new Date("2026-07-13T00:00:00.000Z"));
|
||||
mockSuccessfulGhostscript(1);
|
||||
|
||||
const prepared = await preparePdfOcrPages(inputPath, scratchDir, { timeoutMs: 100 });
|
||||
monotonicNow.mockReturnValue(25);
|
||||
vi.setSystemTime(new Date("2026-07-12T00:00:00.000Z"));
|
||||
|
||||
expect(prepared.remainingTimeoutMs()).toBe(75);
|
||||
await prepared.cleanup();
|
||||
});
|
||||
|
||||
it("uses a monotonic aggregate deadline across PDF OCR wall-clock jumps", async () => {
|
||||
vi.useFakeTimers();
|
||||
const monotonicNow = vi.spyOn(performance, "now").mockReturnValue(0);
|
||||
vi.setSystemTime(new Date("2026-07-13T00:00:00.000Z"));
|
||||
mockSuccessfulGhostscript(2);
|
||||
mockRunAdaptiveTesseract.mockImplementation(async (pagePath: string) => {
|
||||
if (mockRunAdaptiveTesseract.mock.calls.length === 1) {
|
||||
monotonicNow.mockReturnValue(60);
|
||||
vi.setSystemTime(new Date("2026-07-12T00:00:00.000Z"));
|
||||
}
|
||||
return {
|
||||
text: `Text from ${basename(pagePath)}\n`,
|
||||
engine: "tesseract",
|
||||
provider: "native",
|
||||
device: "cpu",
|
||||
};
|
||||
});
|
||||
|
||||
await runTesseractPdf(inputPath, scratchDir, { timeoutMs: 100 });
|
||||
|
||||
expect(mockRunAdaptiveTesseract.mock.calls[0]?.[1].timeoutMs).toBe(100);
|
||||
expect(mockRunAdaptiveTesseract.mock.calls[1]?.[1].timeoutMs).toBe(40);
|
||||
});
|
||||
|
||||
it("enforces one aggregate text budget across a 50-page PDF", async () => {
|
||||
mockSuccessfulGhostscript(50);
|
||||
const textPerPage = "x".repeat(Math.ceil(MAX_PDF_OCR_OUTPUT_BYTES / 50));
|
||||
mockRunAdaptiveTesseract.mockResolvedValue({
|
||||
text: textPerPage,
|
||||
engine: "tesseract",
|
||||
provider: "native",
|
||||
device: "cpu",
|
||||
});
|
||||
|
||||
await expect(runTesseractPdf(inputPath, scratchDir, { pages: "all" })).rejects.toThrow(
|
||||
"aggregate output limit",
|
||||
);
|
||||
|
||||
expect(mockRunAdaptiveTesseract).toHaveBeenCalledTimes(50);
|
||||
});
|
||||
|
||||
it("gives each Tesseract page only the remaining aggregate stdout budget", async () => {
|
||||
mockSuccessfulGhostscript(2);
|
||||
mockRunAdaptiveTesseract
|
||||
.mockResolvedValueOnce({
|
||||
text: "x".repeat(100),
|
||||
engine: "tesseract",
|
||||
provider: "native",
|
||||
device: "cpu",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
text: "done",
|
||||
engine: "tesseract",
|
||||
provider: "native",
|
||||
device: "cpu",
|
||||
});
|
||||
|
||||
await runTesseractPdf(inputPath, scratchDir, { pages: "all" });
|
||||
|
||||
const firstBudget = mockRunAdaptiveTesseract.mock.calls[0][1].maxStdoutBytes;
|
||||
const secondBudget = mockRunAdaptiveTesseract.mock.calls[1][1].maxStdoutBytes;
|
||||
expect(firstBudget).toBeLessThan(MAX_PDF_OCR_OUTPUT_BYTES);
|
||||
expect(secondBudget).toBe(
|
||||
firstBudget - Buffer.byteLength(`${"x".repeat(100)}\n\n--- Page 2 ---\n\n`),
|
||||
);
|
||||
});
|
||||
|
||||
it("cleans generated page files when page OCR fails", async () => {
|
||||
mockSuccessfulGhostscript(1);
|
||||
let generatedPage = "";
|
||||
mockRunAdaptiveTesseract.mockImplementation(async (pagePath: string) => {
|
||||
generatedPage = pagePath;
|
||||
expect(existsSync(pagePath)).toBe(true);
|
||||
throw new Error("OCR failed");
|
||||
});
|
||||
|
||||
await expect(runTesseractPdf(inputPath, scratchDir)).rejects.toThrow("OCR failed");
|
||||
|
||||
expect(generatedPage).not.toBe("");
|
||||
expect(existsSync(generatedPage)).toBe(false);
|
||||
expect(readdirSync(scratchDir)).toEqual(["input with spaces.pdf"]);
|
||||
});
|
||||
|
||||
it("rejects invalid page specs before any raster page is created", async () => {
|
||||
mockSuccessfulGhostscript(3);
|
||||
|
||||
await expect(runTesseractPdf(inputPath, scratchDir, { pages: "1,,2" })).rejects.toThrow(
|
||||
"Invalid page selection",
|
||||
);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
expect(mockRunAdaptiveTesseract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports an actionable error when Ghostscript is missing", async () => {
|
||||
mockSpawn.mockImplementation(() => {
|
||||
const child = createMockChild();
|
||||
queueMicrotask(() => {
|
||||
child.emit("error", Object.assign(new Error("spawn gs ENOENT"), { code: "ENOENT" }));
|
||||
});
|
||||
return child;
|
||||
});
|
||||
|
||||
await expect(runTesseractPdf(inputPath, scratchDir)).rejects.toThrow(
|
||||
"Ghostscript executable not found. Install Ghostscript or set GS_PATH.",
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels an in-flight Ghostscript process and removes scratch output", async () => {
|
||||
const child = createMockChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
const controller = new AbortController();
|
||||
|
||||
const resultPromise = runTesseractPdf(inputPath, scratchDir, { signal: controller.signal });
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalled());
|
||||
controller.abort();
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
child.emit("close", null, "SIGTERM");
|
||||
await expect(resultPromise).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(readdirSync(scratchDir)).toEqual(["input with spaces.pdf"]);
|
||||
});
|
||||
|
||||
it("does not miss cancellation that races with Ghostscript startup", async () => {
|
||||
const child = createMockChild();
|
||||
const controller = new AbortController();
|
||||
mockSpawn.mockImplementation(() => {
|
||||
controller.abort();
|
||||
return child;
|
||||
});
|
||||
|
||||
const resultPromise = runTesseractPdf(inputPath, scratchDir, { signal: controller.signal });
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
child.emit("close", null, "SIGTERM");
|
||||
await expect(resultPromise).rejects.toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
|
||||
it("cleans scratch output even when a progress callback throws", async () => {
|
||||
mockSuccessfulGhostscript(1);
|
||||
|
||||
await expect(
|
||||
runTesseractPdf(inputPath, scratchDir, {
|
||||
onProgress: () => {
|
||||
throw new Error("progress failed");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("progress failed");
|
||||
|
||||
expect(readdirSync(scratchDir)).toEqual(["input with spaces.pdf"]);
|
||||
});
|
||||
|
||||
it("force-kills a Ghostscript process that ignores the overall timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = createMockChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const resultPromise = runTesseractPdf(inputPath, scratchDir, { timeoutMs: 100 });
|
||||
let settled = false;
|
||||
void resultPromise
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
})
|
||||
.catch(() => {});
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(child.kill).toHaveBeenLastCalledWith("SIGKILL");
|
||||
expect(settled).toBe(false);
|
||||
child.emit("close", null, "SIGKILL");
|
||||
await expect(resultPromise).rejects.toThrow("PDF OCR timed out after 100ms");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,953 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockSpawn = vi.hoisted(() => vi.fn());
|
||||
const mockGetInstalledTesseractLanguages = vi.hoisted(() => vi.fn());
|
||||
const mockGetCachedTesseractLanguages = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:child_process", () => ({ spawn: mockSpawn }));
|
||||
vi.mock("../../../packages/ai/src/tesseract-languages.js", () => ({
|
||||
getCachedTesseractLanguages: mockGetCachedTesseractLanguages,
|
||||
getInstalledTesseractLanguages: mockGetInstalledTesseractLanguages,
|
||||
}));
|
||||
|
||||
import {
|
||||
getTesseractRuntimeMetadata,
|
||||
resolveTesseractLanguage,
|
||||
runAdaptiveTesseract,
|
||||
runTesseract,
|
||||
selectTesseractLanguageFamily,
|
||||
selectTesseractLayout,
|
||||
} from "../../../packages/ai/src/tesseract.js";
|
||||
|
||||
const TSV_HEADER =
|
||||
"level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext";
|
||||
|
||||
function tsvWords(...words: Array<{ confidence: number; line?: number; text: string }>): string {
|
||||
return [
|
||||
TSV_HEADER,
|
||||
...words.map(
|
||||
(word, index) =>
|
||||
`5\t1\t1\t1\t${word.line ?? 1}\t${index + 1}\t0\t0\t20\t10\t${word.confidence}\t${word.text}`,
|
||||
),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function createMockChild() {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kill = vi.fn(() => true);
|
||||
return child;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const fullInventory = new Set(["eng", "deu", "fra", "spa", "chi_sim", "jpn", "osd"]);
|
||||
mockGetInstalledTesseractLanguages.mockResolvedValue(fullInventory);
|
||||
mockGetCachedTesseractLanguages.mockReturnValue(fullInventory);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("resolveTesseractLanguage", () => {
|
||||
it.each([
|
||||
["en", "eng"],
|
||||
["de", "deu"],
|
||||
["fr", "fra"],
|
||||
["es", "spa"],
|
||||
["zh", "chi_sim"],
|
||||
["ja", "jpn"],
|
||||
] as const)("maps %s to the installed Tesseract language %s", (language, expected) => {
|
||||
expect(resolveTesseractLanguage(language)).toBe(expected);
|
||||
});
|
||||
|
||||
it("uses every supported language for auto detection", () => {
|
||||
expect(resolveTesseractLanguage("auto")).toBe("eng+deu+fra+spa+chi_sim+jpn");
|
||||
});
|
||||
|
||||
it("rejects unsupported runtime language values", () => {
|
||||
expect(() => resolveTesseractLanguage("it" as "en")).toThrow('Unsupported OCR language "it"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTesseract", () => {
|
||||
it("runs Tesseract without a shell and returns stdout with runtime metadata", async () => {
|
||||
const child = createMockChild();
|
||||
const onProgress = vi.fn();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const resultPromise = runTesseract("/tmp/input file.png", {
|
||||
language: "ja",
|
||||
onProgress,
|
||||
tesseractPath: "/usr/local/bin/tesseract",
|
||||
});
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
"/usr/local/bin/tesseract",
|
||||
["/tmp/input file.png", "stdout", "-l", "jpn"],
|
||||
{
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
child.stdout.write("Recognized text\n");
|
||||
child.emit("close", 0, null);
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({
|
||||
text: "Recognized text\n",
|
||||
engine: "tesseract",
|
||||
provider: "native",
|
||||
device: "cpu",
|
||||
});
|
||||
expect(onProgress).toHaveBeenNthCalledWith(1, 0, "Starting Tesseract OCR");
|
||||
expect(onProgress).toHaveBeenLastCalledWith(100, "Tesseract OCR complete");
|
||||
});
|
||||
|
||||
it("defaults to all supported languages", async () => {
|
||||
const child = createMockChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const resultPromise = runTesseract("/tmp/input.png");
|
||||
expect(mockSpawn.mock.calls[0][1]).toEqual([
|
||||
"/tmp/input.png",
|
||||
"stdout",
|
||||
"-l",
|
||||
"eng+deu+fra+spa+chi_sim+jpn",
|
||||
]);
|
||||
child.emit("close", 0, null);
|
||||
|
||||
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);
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
it("uses the installed supported subset for auto on a partial native host", async () => {
|
||||
const child = createMockChild();
|
||||
mockGetCachedTesseractLanguages.mockReturnValueOnce(new Set(["eng", "osd"]));
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const resultPromise = runTesseract("/tmp/input.png");
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
||||
expect(mockSpawn.mock.calls[0][1]).toEqual(["/tmp/input.png", "stdout", "-l", "eng"]);
|
||||
child.emit("close", 0, null);
|
||||
|
||||
await resultPromise;
|
||||
});
|
||||
|
||||
it("fails an explicit missing language with cross-platform installation guidance", async () => {
|
||||
mockGetCachedTesseractLanguages.mockReturnValueOnce(new Set(["eng", "osd"]));
|
||||
|
||||
await expect(runTesseract("/tmp/input.png", { language: "ja" })).rejects.toThrow(
|
||||
'Tesseract language "ja" is unavailable: missing traineddata "jpn". Install Debian/Ubuntu package tesseract-ocr-jpn or the equivalent traineddata pack (Homebrew: brew install tesseract-lang), then restart SnapOtter.',
|
||||
);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the real Debian package name for missing Simplified Chinese traineddata", async () => {
|
||||
mockGetCachedTesseractLanguages.mockReturnValueOnce(new Set(["eng", "osd"]));
|
||||
|
||||
await expect(runTesseract("/tmp/input.png", { language: "zh" })).rejects.toThrow(
|
||||
"Install Debian/Ubuntu package tesseract-ocr-chi-sim",
|
||||
);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a raw Korean language value before spawning Tesseract", async () => {
|
||||
mockGetCachedTesseractLanguages.mockReturnValueOnce(undefined);
|
||||
|
||||
await expect(runTesseract("/tmp/korean.png", { language: "ko" as never })).rejects.toThrow(
|
||||
'Unsupported OCR language "ko"',
|
||||
);
|
||||
expect(mockGetInstalledTesseractLanguages).not.toHaveBeenCalled();
|
||||
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"]));
|
||||
|
||||
await expect(runTesseract("/tmp/input.png")).rejects.toThrow(
|
||||
"Tesseract has no supported traineddata installed. Install at least tesseract-ocr-eng",
|
||||
);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes stderr and the exit code when Tesseract fails", async () => {
|
||||
const child = createMockChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const resultPromise = runTesseract("/tmp/input.png", { language: "en" });
|
||||
child.stderr.write("Error opening data file");
|
||||
child.emit("close", 1, null);
|
||||
|
||||
await expect(resultPromise).rejects.toThrow(
|
||||
"Tesseract exited with code 1: Error opening data file",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves valid OCR output below the configured memory limit", async () => {
|
||||
const child = createMockChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const resultPromise = runTesseract("/tmp/input.png", { maxOutputBytes: 32 });
|
||||
child.stdout.write("Recognized ");
|
||||
child.stdout.write("text\n");
|
||||
child.emit("close", 0, null);
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({ text: "Recognized text\n" });
|
||||
});
|
||||
|
||||
it("terminates instead of retaining stdout beyond the configured memory limit", async () => {
|
||||
const child = createMockChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const resultPromise = runTesseract("/tmp/input.png", { maxOutputBytes: 32 });
|
||||
child.stdout.write(Buffer.alloc(33, 97));
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
child.emit("close", null, "SIGTERM");
|
||||
await expect(resultPromise).rejects.toThrow("Tesseract stdout exceeded 32 bytes");
|
||||
});
|
||||
|
||||
it("terminates instead of retaining stderr beyond the configured memory limit", async () => {
|
||||
const child = createMockChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const resultPromise = runTesseract("/tmp/input.png", { maxOutputBytes: 32 });
|
||||
child.stderr.write(Buffer.alloc(33, 97));
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
child.emit("close", null, "SIGTERM");
|
||||
await expect(resultPromise).rejects.toThrow("Tesseract stderr exceeded 32 bytes");
|
||||
});
|
||||
|
||||
it("supports an independent stdout allowance without shrinking stderr diagnostics", async () => {
|
||||
const child = createMockChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const resultPromise = runTesseract("/tmp/input.png", {
|
||||
maxStdoutBytes: 4,
|
||||
maxStderrBytes: 32,
|
||||
});
|
||||
child.stderr.write("diagnostic");
|
||||
child.stdout.write("12345");
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
child.emit("close", null, "SIGTERM");
|
||||
await expect(resultPromise).rejects.toThrow("Tesseract stdout exceeded 4 bytes");
|
||||
});
|
||||
|
||||
it("reports an actionable error when the Tesseract executable is missing", async () => {
|
||||
const child = createMockChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const resultPromise = runTesseract("/tmp/input.png");
|
||||
const error = Object.assign(new Error("spawn tesseract ENOENT"), { code: "ENOENT" });
|
||||
child.emit("error", error);
|
||||
|
||||
await expect(resultPromise).rejects.toThrow(
|
||||
"Tesseract executable not found. Install Tesseract or set TESSERACT_PATH.",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not spawn when the request is already aborted", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const resultPromise = runTesseract("/tmp/input.png", { signal: controller.signal });
|
||||
|
||||
await expect(resultPromise).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("terminates an in-flight process when the request is aborted", async () => {
|
||||
const child = createMockChild();
|
||||
const controller = new AbortController();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const resultPromise = runTesseract("/tmp/input.png", { signal: controller.signal });
|
||||
controller.abort();
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
child.emit("close", null, "SIGTERM");
|
||||
await expect(resultPromise).rejects.toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
|
||||
it("does not miss cancellation that races with process startup", async () => {
|
||||
const child = createMockChild();
|
||||
const controller = new AbortController();
|
||||
mockSpawn.mockImplementation(() => {
|
||||
controller.abort();
|
||||
return child;
|
||||
});
|
||||
|
||||
const resultPromise = runTesseract("/tmp/input.png", { signal: controller.signal });
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
child.emit("close", null, "SIGTERM");
|
||||
await expect(resultPromise).rejects.toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
|
||||
it("terminates and rejects a process that exceeds its timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = createMockChild();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const resultPromise = runTesseract("/tmp/input.png", { timeoutMs: 100 });
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
child.emit("close", null, "SIGTERM");
|
||||
await expect(resultPromise).rejects.toThrow("Tesseract OCR timed out after 100ms");
|
||||
});
|
||||
|
||||
it("force-kills but retains ownership until the process closes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = createMockChild();
|
||||
const controller = new AbortController();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const resultPromise = runTesseract("/tmp/input.png", { signal: controller.signal });
|
||||
let rejected = false;
|
||||
void resultPromise.catch(() => {
|
||||
rejected = true;
|
||||
});
|
||||
const rejection = expect(resultPromise).rejects.toMatchObject({ name: "AbortError" });
|
||||
controller.abort();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(child.kill).toHaveBeenNthCalledWith(1, "SIGTERM");
|
||||
expect(child.kill).toHaveBeenNthCalledWith(2, "SIGKILL");
|
||||
expect(rejected).toBe(false);
|
||||
child.emit("close", null, "SIGKILL");
|
||||
await rejection;
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectTesseractLayout", () => {
|
||||
it("selects sparse layout when confidence-weighted coverage materially improves", () => {
|
||||
const block = tsvWords({ confidence: 55, text: "fragment" });
|
||||
const sparse = tsvWords(
|
||||
{ confidence: 94, text: "complete" },
|
||||
{ confidence: 93, line: 2, text: "receipt text" },
|
||||
);
|
||||
|
||||
expect(selectTesseractLayout(block, sparse)).toEqual({
|
||||
pageSegmentationMode: 11,
|
||||
text: "complete\nreceipt text",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps block layout when the sparse score gain is only noise", () => {
|
||||
const block = tsvWords({ confidence: 95, text: "invoice total" });
|
||||
const sparse = tsvWords({ confidence: 96, text: "invoice total" });
|
||||
|
||||
expect(selectTesseractLayout(block, sparse)).toEqual({
|
||||
pageSegmentationMode: 6,
|
||||
text: "invoice total",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a conservative gain before sparse text volume can replace block layout", () => {
|
||||
const block = tsvWords({ confidence: 95, text: "invoice total" });
|
||||
const sparse = tsvWords(
|
||||
{ confidence: 95, text: "invoice total" },
|
||||
{ confidence: 85, line: 2, text: "tax" },
|
||||
);
|
||||
|
||||
expect(selectTesseractLayout(block, sparse)).toEqual({
|
||||
pageSegmentationMode: 6,
|
||||
text: "invoice total",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed TSV and non-finite word confidence", () => {
|
||||
expect(() => selectTesseractLayout("not tsv", tsvWords())).toThrow("malformed TSV");
|
||||
expect(() =>
|
||||
selectTesseractLayout(tsvWords({ confidence: Number.NaN, text: "invoice" }), tsvWords()),
|
||||
).toThrow("malformed TSV confidence");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectTesseractLanguageFamily", () => {
|
||||
it("selects the CJK family only with material script evidence", () => {
|
||||
const latin = tsvWords({ confidence: 80, text: "receipt 505" });
|
||||
const cjk = tsvWords({ confidence: 85, text: "領収書 505" });
|
||||
|
||||
expect(selectTesseractLanguageFamily(latin, cjk)).toBe("jpn+chi_sim");
|
||||
});
|
||||
|
||||
it("keeps the Latin family when a candidate contains one CJK hallucination", () => {
|
||||
const latin = tsvWords({ confidence: 90, text: "invoice total" });
|
||||
const cjk = tsvWords({ confidence: 92, text: "invoice 合 total" });
|
||||
|
||||
expect(selectTesseractLanguageFamily(latin, cjk)).toBe("eng+deu+fra+spa");
|
||||
});
|
||||
|
||||
it("keeps the Latin family when a noisy candidate has weak CJK density", () => {
|
||||
const latin = tsvWords({ confidence: 90, text: "invoice total 505" });
|
||||
const cjk = tsvWords({ confidence: 50, text: "合計 invoice" });
|
||||
|
||||
expect(selectTesseractLanguageFamily(latin, cjk)).toBe("eng+deu+fra+spa");
|
||||
});
|
||||
|
||||
it("selects a mixed CJK address when comparative evidence is stronger", () => {
|
||||
const latin = tsvWords({ confidence: 65, text: "Tokyo Chiyoda Railway 1234567890" });
|
||||
const cjk = tsvWords({
|
||||
confidence: 95,
|
||||
text: "東京都千代田 Chiyoda Railway 1234567890",
|
||||
});
|
||||
|
||||
expect(selectTesseractLanguageFamily(latin, cjk)).toBe("jpn+chi_sim");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runAdaptiveTesseract", () => {
|
||||
it("rejects a raw Korean language value before inventory preflight or recognition", async () => {
|
||||
await expect(
|
||||
runAdaptiveTesseract("/tmp/korean.png", { language: "ko" as never }),
|
||||
).rejects.toThrow('Unsupported OCR language "ko"');
|
||||
|
||||
expect(mockGetInstalledTesseractLanguages).not.toHaveBeenCalled();
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs an English-only auto host without probing unavailable CJK packs", async () => {
|
||||
const block = createMockChild();
|
||||
const sparse = createMockChild();
|
||||
mockGetInstalledTesseractLanguages.mockResolvedValue(new Set(["eng", "osd"]));
|
||||
mockGetCachedTesseractLanguages.mockReturnValue(new Set(["eng", "osd"]));
|
||||
mockSpawn.mockReturnValueOnce(block).mockReturnValueOnce(sparse);
|
||||
|
||||
const resultPromise = runAdaptiveTesseract("/tmp/english.png", {
|
||||
language: "auto",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
||||
expect(mockSpawn.mock.calls[0]?.[1]).toContain("eng");
|
||||
block.stdout.write(tsvWords({ confidence: 90, text: "English text" }));
|
||||
block.emit("close", 0, null);
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
||||
expect(mockSpawn.mock.calls[1]?.[1]).toContain("eng");
|
||||
expect(mockSpawn.mock.calls.flatMap((call) => call[1])).not.toContain("jpn+chi_sim");
|
||||
sparse.stdout.write(tsvWords({ confidence: 80, text: "English text" }));
|
||||
sparse.emit("close", 0, null);
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({ text: "English text" });
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("probes only installed members when both auto families remain viable", async () => {
|
||||
const latinBlock = createMockChild();
|
||||
const cjkBlock = createMockChild();
|
||||
const cjkSparse = createMockChild();
|
||||
mockGetInstalledTesseractLanguages.mockResolvedValue(new Set(["eng", "jpn", "osd"]));
|
||||
mockGetCachedTesseractLanguages.mockReturnValue(new Set(["eng", "jpn", "osd"]));
|
||||
mockSpawn
|
||||
.mockReturnValueOnce(latinBlock)
|
||||
.mockReturnValueOnce(cjkBlock)
|
||||
.mockReturnValueOnce(cjkSparse);
|
||||
|
||||
const resultPromise = runAdaptiveTesseract("/tmp/mixed.png", {
|
||||
language: "auto",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
||||
expect(mockSpawn.mock.calls[0]?.[1]).toContain("eng");
|
||||
latinBlock.stdout.write(tsvWords({ confidence: 60, text: "receipt" }));
|
||||
latinBlock.emit("close", 0, null);
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
||||
expect(mockSpawn.mock.calls[1]?.[1]).toContain("jpn");
|
||||
cjkBlock.stdout.write(tsvWords({ confidence: 95, text: "日本語文字列" }));
|
||||
cjkBlock.emit("close", 0, null);
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(3));
|
||||
expect(mockSpawn.mock.calls[2]?.[1]).toContain("jpn");
|
||||
cjkSparse.stdout.write(tsvWords({ confidence: 80, text: "日本語文字列" }));
|
||||
cjkSparse.emit("close", 0, null);
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({ text: "日本語文字列" });
|
||||
const languageArgs = mockSpawn.mock.calls.map((call) => call[1][3]);
|
||||
expect(languageArgs).toEqual(["eng", "jpn", "jpn"]);
|
||||
});
|
||||
|
||||
it("runs bounded block and sparse TSV candidates without a shell", async () => {
|
||||
const block = createMockChild();
|
||||
const sparse = createMockChild();
|
||||
mockSpawn.mockReturnValueOnce(block).mockReturnValueOnce(sparse);
|
||||
|
||||
const resultPromise = runAdaptiveTesseract("/tmp/receipt.png", {
|
||||
language: "ja",
|
||||
timeoutMs: 5_000,
|
||||
maxStdoutBytes: 1_000_000,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
||||
expect(mockSpawn.mock.calls[0]?.[1]).toEqual([
|
||||
"/tmp/receipt.png",
|
||||
"stdout",
|
||||
"-l",
|
||||
"jpn",
|
||||
"--psm",
|
||||
"6",
|
||||
"tsv",
|
||||
]);
|
||||
block.stdout.write(tsvWords({ confidence: 55, text: "fragment" }));
|
||||
block.emit("close", 0, null);
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
||||
expect(mockSpawn.mock.calls[1]?.[1]).toEqual([
|
||||
"/tmp/receipt.png",
|
||||
"stdout",
|
||||
"-l",
|
||||
"jpn",
|
||||
"--psm",
|
||||
"11",
|
||||
"tsv",
|
||||
]);
|
||||
sparse.stdout.write(
|
||||
tsvWords(
|
||||
{ confidence: 94, text: "complete" },
|
||||
{ confidence: 93, line: 2, text: "receipt text" },
|
||||
),
|
||||
);
|
||||
sparse.emit("close", 0, null);
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({
|
||||
text: "complete\nreceipt text",
|
||||
engine: "tesseract",
|
||||
provider: "native",
|
||||
device: "cpu",
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers weak CJK scene text through bounded horizontal tile fallbacks", async () => {
|
||||
const primaryBlock = createMockChild();
|
||||
const primarySparse = createMockChild();
|
||||
const upperBlock = createMockChild();
|
||||
const upperSparse = createMockChild();
|
||||
const lowerBlock = createMockChild();
|
||||
const lowerSparse = createMockChild();
|
||||
mockSpawn
|
||||
.mockReturnValueOnce(primaryBlock)
|
||||
.mockReturnValueOnce(primarySparse)
|
||||
.mockReturnValueOnce(upperBlock)
|
||||
.mockReturnValueOnce(upperSparse)
|
||||
.mockReturnValueOnce(lowerBlock)
|
||||
.mockReturnValueOnce(lowerSparse);
|
||||
|
||||
const resultPromise = runAdaptiveTesseract("/tmp/board.png", {
|
||||
fallbackInputPaths: ["/tmp/board-upper.png", "/tmp/board-lower.png"],
|
||||
language: "ja",
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
||||
primaryBlock.stdout.write(tsvWords({ confidence: 40, text: "僅" }));
|
||||
primaryBlock.emit("close", 0, null);
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
||||
primarySparse.stdout.write(tsvWords({ confidence: 35, text: "断" }));
|
||||
primarySparse.emit("close", 0, null);
|
||||
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(3));
|
||||
expect(mockSpawn.mock.calls[2]?.[1]).toEqual([
|
||||
"/tmp/board-upper.png",
|
||||
"stdout",
|
||||
"-l",
|
||||
"jpn",
|
||||
"--psm",
|
||||
"6",
|
||||
"tsv",
|
||||
]);
|
||||
upperBlock.stdout.write(
|
||||
tsvWords({
|
||||
confidence: 94,
|
||||
text: "上段仕様表日本語文字列一二三四五六七八九十上段仕様表日本語文字列一二三四五六七八九十",
|
||||
}),
|
||||
);
|
||||
upperBlock.emit("close", 0, null);
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(4));
|
||||
upperSparse.stdout.write(tsvWords({ confidence: 50, text: "上段" }));
|
||||
upperSparse.emit("close", 0, null);
|
||||
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(5));
|
||||
lowerBlock.stdout.write(
|
||||
tsvWords({
|
||||
confidence: 93,
|
||||
text: "下段仕様表日本語文字列十一十二十三十四十五下段仕様表日本語文字列十一十二十三十四十五",
|
||||
}),
|
||||
);
|
||||
lowerBlock.emit("close", 0, null);
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(6));
|
||||
lowerSparse.stdout.write(tsvWords({ confidence: 45, text: "下段" }));
|
||||
lowerSparse.emit("close", 0, null);
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
text: "上段仕様表日本語文字列一二三四五六七八九十上段仕様表日本語文字列一二三四五六七八九十\n下段仕様表日本語文字列十一十二十三十四十五下段仕様表日本語文字列十一十二十三十四十五",
|
||||
});
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it("does not spend tile fallback work when the primary CJK result is strong", async () => {
|
||||
const primaryBlock = createMockChild();
|
||||
const primarySparse = createMockChild();
|
||||
mockSpawn.mockReturnValueOnce(primaryBlock).mockReturnValueOnce(primarySparse);
|
||||
|
||||
const resultPromise = runAdaptiveTesseract("/tmp/board.png", {
|
||||
fallbackInputPaths: ["/tmp/board-upper.png", "/tmp/board-lower.png"],
|
||||
language: "ja",
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
||||
primaryBlock.stdout.write(
|
||||
tsvWords({
|
||||
confidence: 95,
|
||||
text: "十分な日本語文字列を含む通常の認識結果です一二三四五六七八九十",
|
||||
}),
|
||||
);
|
||||
primaryBlock.emit("close", 0, null);
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
||||
primarySparse.stdout.write(tsvWords({ confidence: 50, text: "断片" }));
|
||||
primarySparse.emit("close", 0, null);
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
text: "十分な日本語文字列を含む通常の認識結果です一二三四五六七八九十",
|
||||
});
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("recovers a dense CJK board through one enhanced block candidate", async () => {
|
||||
const primaryBlock = createMockChild();
|
||||
const primarySparse = createMockChild();
|
||||
const enhancedBlock = createMockChild();
|
||||
const denseCjkInputProvider = vi.fn().mockResolvedValue("/tmp/board-dense.png");
|
||||
mockSpawn
|
||||
.mockReturnValueOnce(primaryBlock)
|
||||
.mockReturnValueOnce(primarySparse)
|
||||
.mockReturnValueOnce(enhancedBlock);
|
||||
|
||||
const resultPromise = runAdaptiveTesseract("/tmp/board.png", {
|
||||
denseCjkInputProvider,
|
||||
language: "ja",
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
||||
primaryBlock.stdout.write(tsvWords({ confidence: 45, text: "日本語の短い断片" }));
|
||||
primaryBlock.emit("close", 0, null);
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
||||
primarySparse.stdout.write(
|
||||
tsvWords({ confidence: 60, text: "日本語の中程度の断片一二三四五六七八九十" }),
|
||||
);
|
||||
primarySparse.emit("close", 0, null);
|
||||
|
||||
await vi.waitFor(() => expect(denseCjkInputProvider).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(3));
|
||||
expect(mockSpawn.mock.calls[2]?.[1]).toEqual([
|
||||
"/tmp/board-dense.png",
|
||||
"stdout",
|
||||
"-l",
|
||||
"jpn",
|
||||
"--psm",
|
||||
"6",
|
||||
"tsv",
|
||||
]);
|
||||
const recovered =
|
||||
"高信頼の日本語仕様表一二三四五六七八九十高信頼の日本語仕様表一二三四五六七八九十" +
|
||||
"高信頼の日本語仕様表一二三四五六七八九十高信頼の日本語仕様表一二三四五六七八九十" +
|
||||
"高信頼の日本語仕様表一二三四五六七八九十";
|
||||
enhancedBlock.stdout.write(
|
||||
tsvWords(
|
||||
{ confidence: 90, text: "|" },
|
||||
{ confidence: 82, text: recovered },
|
||||
{ confidence: 91, text: "||" },
|
||||
),
|
||||
);
|
||||
enhancedBlock.emit("close", 0, null);
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({ text: recovered });
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("does not preprocess a strong CJK primary result", async () => {
|
||||
const primaryBlock = createMockChild();
|
||||
const primarySparse = createMockChild();
|
||||
const denseCjkInputProvider = vi.fn().mockResolvedValue("/tmp/board-dense.png");
|
||||
mockSpawn.mockReturnValueOnce(primaryBlock).mockReturnValueOnce(primarySparse);
|
||||
|
||||
const resultPromise = runAdaptiveTesseract("/tmp/board.png", {
|
||||
denseCjkInputProvider,
|
||||
language: "ja",
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
||||
primaryBlock.stdout.write(
|
||||
tsvWords({
|
||||
confidence: 95,
|
||||
text: "十分な日本語文字列を含む通常の認識結果です一二三四五六七八九十",
|
||||
}),
|
||||
);
|
||||
primaryBlock.emit("close", 0, null);
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
||||
primarySparse.stdout.write(tsvWords({ confidence: 50, text: "断片" }));
|
||||
primarySparse.emit("close", 0, null);
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
text: "十分な日本語文字列を含む通常の認識結果です一二三四五六七八九十",
|
||||
});
|
||||
expect(denseCjkInputProvider).not.toHaveBeenCalled();
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("probes bounded language families for auto without seven-language dilution", async () => {
|
||||
const latinBlock = createMockChild();
|
||||
const cjkBlock = createMockChild();
|
||||
const cjkSparse = createMockChild();
|
||||
mockSpawn
|
||||
.mockReturnValueOnce(latinBlock)
|
||||
.mockReturnValueOnce(cjkBlock)
|
||||
.mockReturnValueOnce(cjkSparse);
|
||||
|
||||
const resultPromise = runAdaptiveTesseract("/tmp/mixed.png", {
|
||||
language: "auto",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
||||
expect(mockSpawn.mock.calls[0]?.[1]).toContain("eng+deu+fra+spa");
|
||||
latinBlock.stdout.write(tsvWords({ confidence: 80, text: "receipt 505" }));
|
||||
latinBlock.emit("close", 0, null);
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
||||
expect(mockSpawn.mock.calls[1]?.[1]).toContain("jpn+chi_sim");
|
||||
cjkBlock.stdout.write(tsvWords({ confidence: 90, text: "領収書 505" }));
|
||||
cjkBlock.emit("close", 0, null);
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(3));
|
||||
expect(mockSpawn.mock.calls[2]?.[1]).toEqual([
|
||||
"/tmp/mixed.png",
|
||||
"stdout",
|
||||
"-l",
|
||||
"jpn+chi_sim",
|
||||
"--psm",
|
||||
"11",
|
||||
"tsv",
|
||||
]);
|
||||
cjkSparse.stdout.write(tsvWords({ confidence: 70, text: "領収書" }));
|
||||
cjkSparse.emit("close", 0, null);
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({ text: "領収書 505" });
|
||||
expect(mockSpawn.mock.calls.flatMap((call) => call[1] as string[])).not.toContain(
|
||||
"eng+deu+fra+spa+chi_sim+jpn",
|
||||
);
|
||||
});
|
||||
|
||||
it("probes auto script on the original before recognizing a preprocessed image", async () => {
|
||||
const latinProbe = createMockChild();
|
||||
const cjkProbe = createMockChild();
|
||||
const processedBlock = createMockChild();
|
||||
const processedSparse = createMockChild();
|
||||
mockSpawn
|
||||
.mockReturnValueOnce(latinProbe)
|
||||
.mockReturnValueOnce(cjkProbe)
|
||||
.mockReturnValueOnce(processedBlock)
|
||||
.mockReturnValueOnce(processedSparse);
|
||||
|
||||
const resultPromise = runAdaptiveTesseract("/tmp/original.png", {
|
||||
language: "auto",
|
||||
recognitionInputPath: "/tmp/low-contrast.png",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
||||
expect(mockSpawn.mock.calls[0]?.[1]).toEqual([
|
||||
"/tmp/original.png",
|
||||
"stdout",
|
||||
"-l",
|
||||
"eng+deu+fra+spa",
|
||||
"--psm",
|
||||
"6",
|
||||
"tsv",
|
||||
]);
|
||||
latinProbe.stdout.write(tsvWords({ confidence: 90, text: "invoice total 505" }));
|
||||
latinProbe.emit("close", 0, null);
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
||||
expect(mockSpawn.mock.calls[1]?.[1]?.[0]).toBe("/tmp/original.png");
|
||||
cjkProbe.stdout.write(tsvWords({ confidence: 50, text: "invoice 合 total" }));
|
||||
cjkProbe.emit("close", 0, null);
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(3));
|
||||
expect(mockSpawn.mock.calls[2]?.[1]).toEqual([
|
||||
"/tmp/low-contrast.png",
|
||||
"stdout",
|
||||
"-l",
|
||||
"eng+deu+fra+spa",
|
||||
"--psm",
|
||||
"6",
|
||||
"tsv",
|
||||
]);
|
||||
processedBlock.stdout.write(tsvWords({ confidence: 95, text: "invoice total 505" }));
|
||||
processedBlock.emit("close", 0, null);
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(4));
|
||||
expect(mockSpawn.mock.calls[3]?.[1]?.[0]).toBe("/tmp/low-contrast.png");
|
||||
processedSparse.stdout.write(tsvWords({ confidence: 70, text: "invoice" }));
|
||||
processedSparse.emit("close", 0, null);
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({ text: "invoice total 505" });
|
||||
});
|
||||
|
||||
it("can retain block layout for calibrated low-contrast recognition", async () => {
|
||||
const latinProbe = createMockChild();
|
||||
const cjkProbe = createMockChild();
|
||||
const processedBlock = createMockChild();
|
||||
mockSpawn
|
||||
.mockReturnValueOnce(latinProbe)
|
||||
.mockReturnValueOnce(cjkProbe)
|
||||
.mockReturnValueOnce(processedBlock);
|
||||
|
||||
const resultPromise = runAdaptiveTesseract("/tmp/original.png", {
|
||||
blockLayoutOnly: true,
|
||||
language: "auto",
|
||||
recognitionInputPath: "/tmp/low-contrast.png",
|
||||
});
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(1));
|
||||
latinProbe.stdout.write(tsvWords({ confidence: 90, text: "invoice total 505" }));
|
||||
latinProbe.emit("close", 0, null);
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
||||
cjkProbe.stdout.write(tsvWords({ confidence: 50, text: "invoice 合 total" }));
|
||||
cjkProbe.emit("close", 0, null);
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(3));
|
||||
processedBlock.stdout.write(tsvWords({ confidence: 95, text: "invoice total 505" }));
|
||||
processedBlock.emit("close", 0, null);
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({ text: "invoice total 505" });
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("shares one deadline across sequential layout candidates", async () => {
|
||||
vi.useFakeTimers();
|
||||
const monotonicNow = vi.spyOn(performance, "now").mockReturnValue(0);
|
||||
const block = createMockChild();
|
||||
const sparse = createMockChild();
|
||||
mockSpawn.mockReturnValueOnce(block).mockReturnValueOnce(sparse);
|
||||
|
||||
const resultPromise = runAdaptiveTesseract("/tmp/form.png", {
|
||||
language: "en",
|
||||
timeoutMs: 100,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(40);
|
||||
monotonicNow.mockReturnValue(40);
|
||||
block.stdout.write(tsvWords({ confidence: 95, text: "form" }));
|
||||
block.emit("close", 0, null);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(35);
|
||||
|
||||
expect(sparse.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
sparse.emit("close", null, "SIGTERM");
|
||||
await expect(resultPromise).rejects.toThrow("timed out");
|
||||
});
|
||||
|
||||
it("reserves bounded termination grace across all three auto candidates", async () => {
|
||||
vi.useFakeTimers();
|
||||
const monotonicNow = vi.spyOn(performance, "now").mockReturnValue(0);
|
||||
const latinBlock = createMockChild();
|
||||
const cjkBlock = createMockChild();
|
||||
const cjkSparse = createMockChild();
|
||||
mockSpawn
|
||||
.mockReturnValueOnce(latinBlock)
|
||||
.mockReturnValueOnce(cjkBlock)
|
||||
.mockReturnValueOnce(cjkSparse);
|
||||
|
||||
const resultPromise = runAdaptiveTesseract("/tmp/mixed.png", {
|
||||
language: "auto",
|
||||
timeoutMs: 200,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
monotonicNow.mockReturnValue(20);
|
||||
latinBlock.stdout.write(tsvWords({ confidence: 80, text: "receipt 505" }));
|
||||
latinBlock.emit("close", 0, null);
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
monotonicNow.mockReturnValue(40);
|
||||
cjkBlock.stdout.write(tsvWords({ confidence: 90, text: "領収書 505" }));
|
||||
cjkBlock.emit("close", 0, null);
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(3));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(110);
|
||||
expect(cjkSparse.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(cjkSparse.kill).toHaveBeenCalledWith("SIGKILL");
|
||||
cjkSparse.emit("close", null, "SIGKILL");
|
||||
await expect(resultPromise).rejects.toThrow("timed out");
|
||||
});
|
||||
|
||||
it("uses a monotonic aggregate deadline across wall-clock jumps", async () => {
|
||||
vi.useFakeTimers();
|
||||
const monotonicNow = vi.spyOn(performance, "now").mockReturnValue(0);
|
||||
vi.setSystemTime(new Date("2026-01-02T00:00:00Z"));
|
||||
const block = createMockChild();
|
||||
const sparse = createMockChild();
|
||||
mockSpawn.mockReturnValueOnce(block).mockReturnValueOnce(sparse);
|
||||
|
||||
const resultPromise = runAdaptiveTesseract("/tmp/form.png", {
|
||||
language: "en",
|
||||
timeoutMs: 100,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
monotonicNow.mockReturnValue(20);
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
block.stdout.write(tsvWords({ confidence: 95, text: "form" }));
|
||||
block.emit("close", 0, null);
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(55);
|
||||
expect(sparse.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
sparse.emit("close", null, "SIGTERM");
|
||||
await expect(resultPromise).rejects.toThrow("timed out");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTesseractRuntimeMetadata", () => {
|
||||
it("reports the native CPU provider on every host", () => {
|
||||
expect(getTesseractRuntimeMetadata()).toEqual({
|
||||
engine: "tesseract",
|
||||
provider: "native",
|
||||
device: "cpu",
|
||||
});
|
||||
});
|
||||
});
|
||||
+99
-47
@@ -8,6 +8,7 @@ vi.mock("sharp", () => {
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")),
|
||||
toFile: vi.fn().mockResolvedValue({ size: 13 }),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||
}));
|
||||
return { default: mockSharp };
|
||||
@@ -29,6 +30,15 @@ vi.mock("../../../packages/ai/src/bridge.js", () => ({
|
||||
shutdownDispatcher: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../packages/ai/src/ocr-runtime-dispatcher.js", () => ({
|
||||
runOcrRuntime: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../packages/ai/src/tesseract.js", () => ({
|
||||
runAdaptiveTesseract: vi.fn(),
|
||||
runTesseract: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import tool functions
|
||||
import { removeBackground } from "../../../packages/ai/src/background-removal.js";
|
||||
// Import the mocked bridge functions
|
||||
@@ -40,8 +50,10 @@ import { detectFaceLandmarks } from "../../../packages/ai/src/face-landmarks.js"
|
||||
import { inpaint } from "../../../packages/ai/src/inpainting.js";
|
||||
import { noiseRemoval } from "../../../packages/ai/src/noise-removal.js";
|
||||
import { extractText } from "../../../packages/ai/src/ocr.js";
|
||||
import { runOcrRuntime } from "../../../packages/ai/src/ocr-runtime-dispatcher.js";
|
||||
import { removeRedEye } from "../../../packages/ai/src/red-eye-removal.js";
|
||||
import { restorePhoto } from "../../../packages/ai/src/restoration.js";
|
||||
import { runAdaptiveTesseract } from "../../../packages/ai/src/tesseract.js";
|
||||
import { upscale } from "../../../packages/ai/src/upscaling.js";
|
||||
|
||||
const FAKE_INPUT = Buffer.from("fake-image-data");
|
||||
@@ -61,6 +73,33 @@ beforeEach(() => {
|
||||
stdout: '{"success": true}',
|
||||
stderr: "",
|
||||
});
|
||||
vi.mocked(runAdaptiveTesseract).mockResolvedValue({
|
||||
text: "Hello World",
|
||||
engine: "tesseract",
|
||||
device: "cpu",
|
||||
provider: "native",
|
||||
});
|
||||
vi.mocked(runOcrRuntime).mockResolvedValue({
|
||||
result: {
|
||||
success: true,
|
||||
text: "Accurate text",
|
||||
engine: "rapidocr-onnx",
|
||||
requestedQuality: "best",
|
||||
actualQuality: "best",
|
||||
device: "cpu",
|
||||
provider: "CPUExecutionProvider",
|
||||
degraded: false,
|
||||
warnings: [],
|
||||
},
|
||||
stderr: "",
|
||||
runtime: {
|
||||
generation: "test",
|
||||
artifactVersion: "2.1.0",
|
||||
target: "linux-amd64-cpu-py312",
|
||||
providers: ["CPUExecutionProvider"],
|
||||
models: { detection: "digest" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -229,57 +268,68 @@ describe("upscale", () => {
|
||||
// ── extractText (OCR) ─────────────────────────────────────────────────
|
||||
|
||||
describe("extractText (OCR)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
text: "Hello World",
|
||||
engine: "paddleocr",
|
||||
});
|
||||
});
|
||||
|
||||
it("calls runPythonWithProgress with ocr.py", async () => {
|
||||
it("uses built-in Tesseract for Fast without entering the Python bridge", async () => {
|
||||
await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(runPythonWithProgress).toHaveBeenCalledWith(
|
||||
"ocr.py",
|
||||
expect.arrayContaining([expect.stringContaining("input_ocr.png")]),
|
||||
expect.objectContaining({ timeout: expect.any(Number) }),
|
||||
expect(runAdaptiveTesseract).toHaveBeenCalledWith(
|
||||
expect.stringContaining("input_ocr.png"),
|
||||
expect.objectContaining({ timeoutMs: expect.any(Number) }),
|
||||
);
|
||||
expect(runPythonWithProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns OcrResult with text and engine", async () => {
|
||||
it("returns truthful complete Fast metadata", async () => {
|
||||
const result = await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
text: "Hello World",
|
||||
engine: "paddleocr",
|
||||
engine: "tesseract",
|
||||
requestedQuality: "fast",
|
||||
actualQuality: "fast",
|
||||
device: "cpu",
|
||||
provider: "native",
|
||||
degraded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes quality and language options", async () => {
|
||||
it("passes Best and language options to the isolated runtime", async () => {
|
||||
await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR, {
|
||||
quality: "best",
|
||||
language: "en",
|
||||
});
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
const [, args] = vi.mocked(runOcrRuntime).mock.calls[0];
|
||||
const optionsArg = args[1];
|
||||
expect(JSON.parse(optionsArg)).toEqual({ quality: "best", language: "en" });
|
||||
expect(JSON.parse(optionsArg)).toEqual({
|
||||
quality: "best",
|
||||
language: "en",
|
||||
enhance: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when OCR fails", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: false,
|
||||
error: "No text detected",
|
||||
it("propagates Tesseract failures", async () => {
|
||||
vi.mocked(runAdaptiveTesseract).mockRejectedValueOnce(new Error("Tesseract failed"));
|
||||
|
||||
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("Tesseract failed");
|
||||
});
|
||||
|
||||
it("rejects an accurate runtime failure without falling back", async () => {
|
||||
vi.mocked(runOcrRuntime).mockResolvedValueOnce({
|
||||
result: { success: false, error: "Accurate OCR failed" },
|
||||
stderr: "",
|
||||
runtime: {
|
||||
generation: "test",
|
||||
artifactVersion: "2.1.0",
|
||||
target: "linux-amd64-cpu-py312",
|
||||
providers: ["CPUExecutionProvider"],
|
||||
models: {},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("No text detected");
|
||||
});
|
||||
|
||||
it("uses fallback error message when no error string provided", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({ success: false });
|
||||
|
||||
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("OCR failed");
|
||||
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR, { quality: "best" })).rejects.toThrow(
|
||||
"Accurate OCR failed",
|
||||
);
|
||||
expect(runAdaptiveTesseract).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -885,11 +935,11 @@ describe("restorePhoto", () => {
|
||||
// ── error propagation from runPythonWithProgress ──────────────────────
|
||||
|
||||
describe("error propagation from bridge", () => {
|
||||
it("propagates bridge rejection through tool functions", async () => {
|
||||
vi.mocked(runPythonWithProgress).mockRejectedValue(new Error("Python script timed out"));
|
||||
it("propagates isolated OCR runtime transport rejection", async () => {
|
||||
vi.mocked(runOcrRuntime).mockRejectedValue(new Error("OCR runtime timed out"));
|
||||
|
||||
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow(
|
||||
"Python script timed out",
|
||||
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR, { quality: "balanced" })).rejects.toThrow(
|
||||
"OCR runtime timed out",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1604,18 +1654,12 @@ describe("option serialization", () => {
|
||||
|
||||
describe("OCR dynamic timeout", () => {
|
||||
it("uses megapixel-based timeout for large images", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
text: "Hello",
|
||||
engine: "paddleocr",
|
||||
});
|
||||
|
||||
await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
// sharp metadata returns 800x600 = 0.48MP
|
||||
// timeout = max(600_000, 0.48 * 30 * 1000) = 600_000
|
||||
const options = vi.mocked(runPythonWithProgress).mock.calls[0][2];
|
||||
expect(options.timeout).toBeGreaterThanOrEqual(600_000);
|
||||
const options = vi.mocked(runAdaptiveTesseract).mock.calls[0][1];
|
||||
expect(options.timeoutMs).toBeGreaterThanOrEqual(600_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1647,13 +1691,21 @@ describe("parseStdoutJson throws in tool pipeline", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates parse error through extractText", async () => {
|
||||
vi.mocked(parseStdoutJson).mockImplementation(() => {
|
||||
throw new Error("No JSON response from Python script");
|
||||
it("rejects malformed isolated OCR runtime metadata", async () => {
|
||||
vi.mocked(runOcrRuntime).mockResolvedValueOnce({
|
||||
result: "malformed",
|
||||
stderr: "",
|
||||
runtime: {
|
||||
generation: "test",
|
||||
artifactVersion: "2.1.0",
|
||||
target: "linux-amd64-cpu-py312",
|
||||
providers: ["CPUExecutionProvider"],
|
||||
models: {},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow(
|
||||
"No JSON response from Python script",
|
||||
await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR, { quality: "balanced" })).rejects.toThrow(
|
||||
"invalid metadata",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user