Files
SnapOtter/tests/unit/ai/tesseract-languages.test.ts
T
SnapOtterandGitHub 991c981529 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
2026-07-15 03:34:24 +08:00

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",
);
});
});