mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
* 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
154 lines
4.9 KiB
TypeScript
154 lines
4.9 KiB
TypeScript
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",
|
|
);
|
|
});
|
|
});
|