Files
SnapOtter/tests/unit/features/ocr-runtime-build-contract.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

360 lines
17 KiB
TypeScript

import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const root = join(import.meta.dirname, "../../..");
const manifest = JSON.parse(readFileSync(join(root, "docker/feature-manifest.json"), "utf8"));
const models = JSON.parse(readFileSync(join(root, "docker/ocr-runtime-models.json"), "utf8"));
const buildScript = readFileSync(join(root, "docker/build-ocr-runtime.sh"), "utf8");
const verifierScript = readFileSync(join(root, "docker/verify-ocr-runtime.sh"), "utf8");
const legacyBuildScript = readFileSync(join(root, "docker/build-bundle.sh"), "utf8");
const dockerfile = readFileSync(join(root, "docker/Dockerfile"), "utf8");
const ciWorkflow = readFileSync(join(root, ".github/workflows/ci.yml"), "utf8");
const deploymentGuide = readFileSync(join(root, "apps/docs/guide/deployment.md"), "utf8");
const pdfOcrGuide = readFileSync(join(root, "apps/docs/tools/pdf/ocr-pdf.md"), "utf8");
const openApi = readFileSync(join(root, "apps/api/src/openapi.yaml"), "utf8");
const featureRoutes = readFileSync(join(root, "apps/api/src/routes/features.ts"), "utf8");
const adapterSource = readFileSync(join(root, "packages/ai/python/ocr_runtime.py"), "utf8");
const tesseractSource = readFileSync(join(root, "packages/ai/src/tesseract.ts"), "utf8");
const TARGETS = ["linux-amd64-cpu-py312", "linux-arm64-cpu-py311"] as const;
const MEASURED_RUNTIME_SIZES = {
"linux-amd64-cpu-py312": {
compressed: 244_622_462,
expanded: 510_762_511,
},
"linux-arm64-cpu-py311": {
compressed: 218_031_562,
expanded: 428_565_655,
},
} as const;
const MODEL_IDS = [
"pp-ocrv6-small-det",
"pp-ocrv6-small-rec",
"pp-ocrv6-medium-det",
"pp-ocrv6-medium-rec",
"korean-pp-ocrv5-mobile-rec",
"pp-lcnet-x0-25-textline-ori",
"pp-lcnet-x1-0-doc-ori",
"pp-ocrv6-dictionary",
"korean-pp-ocrv5-dictionary",
"best-v1-calibration",
];
const LEGAL_ASSET_IDS = ["snapotter-agpl", "apache-2.0", "antlr-4.9.3-license"];
function requirementsFor(target: (typeof TARGETS)[number]): string {
const suffix = target.includes("amd64") ? "amd64" : "arm64";
return readFileSync(join(root, `docker/ocr-runtime-requirements-${suffix}.txt`), "utf8");
}
function requirementLines(contents: string): string[] {
return contents
.split("\n")
.map((line) => line.replace(/\s+#.*$/, "").trim())
.filter((line) => line && !line.startsWith("#") && !line.startsWith("--"));
}
describe("OCR v3 runtime artifact contract", () => {
it("revalidates the post-install runtime against a genuine ephemeral Ed25519 signature", () => {
expect(verifierScript).toContain('generateKeyPairSync("ed25519")');
expect(verifierScript).toContain(
"sign(null, Buffer.from(canonical(unsignedIndex)), privateKey)",
);
expect(verifierScript).toContain('algorithm: "ed25519"');
expect(verifierScript).toContain('OCR_RUNTIME_INDEX_KEY_ID="ci-runtime-verifier"');
expect(verifierScript).toContain("OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64");
expect(verifierScript).not.toContain("verified-inside-trusted-ci-boundary");
});
it("enables Node's environment proxy support and documents private-network trust", () => {
expect(dockerfile).toMatch(/\bNODE_USE_ENV_PROXY=1\b/);
for (const variable of ["HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "NODE_EXTRA_CA_CERTS"]) {
expect(deploymentGuide).toContain(`\`${variable}\``);
}
expect(deploymentGuide).toContain("/etc/snapotter/custom-ca.pem");
});
it("documents legacy engine precedence consistently for image and PDF OCR", () => {
expect(pdfOcrGuide).toContain("| engine | string | No | - |");
expect(pdfOcrGuide).toContain(
"If `quality` and the deprecated `engine` field are both omitted",
);
expect(openApi.match(/When both quality and engine are omitted/g)).toHaveLength(2);
});
it("uses the short-write-safe primitive for offline multipart uploads", () => {
expect(featureRoutes).toContain("writeBufferFully,");
expect(featureRoutes).toContain("await writeBufferFully(file, chunk);");
expect(featureRoutes).not.toContain("await file.write(chunk);");
});
it("declares only portable CPU targets with representative measured estimates", () => {
expect(dockerfile).toContain("ARG SNAPOTTER_OFFICIAL_CONTAINER=0");
expect(dockerfile).toContain(
"SNAPOTTER_OFFICIAL_CONTAINER=$" + "{SNAPOTTER_OFFICIAL_CONTAINER}",
);
expect(dockerfile).not.toContain("SNAPOTTER_OFFICIAL_CONTAINER=1");
const ocr = manifest.bundles.ocr;
expect(ocr.runtimeFormatVersion).toBe(3);
expect(ocr.runtimeFamily).toBe("ocr");
expect(ocr.archives).toBeUndefined();
expect(Object.keys(ocr.targets).sort()).toEqual([...TARGETS].sort());
for (const target of TARGETS) {
const entry = ocr.targets[target];
expect(entry.file).toBe(`v3/ocr-${target}.tar.gz`);
expect(entry.compressedSizeEstimate).toBe(MEASURED_RUNTIME_SIZES[target].compressed);
expect(entry.extractedSizeEstimate).toBe(MEASURED_RUNTIME_SIZES[target].expanded);
expect(entry.sizeKind).toBe("measured-estimate");
expect(entry.minimumMemoryBytes).toBe(4 * 1024 ** 3);
expect(entry.requirements).toMatch(/^docker\/ocr-runtime-requirements-(amd64|arm64)\.txt$/);
}
expect(buildScript).toContain('"minimumMemoryBytes": 4 * 1024 * 1024 * 1024');
});
it("removes unused Tesseract OSD data and restricts Fast to non-OSD layouts", () => {
const osdPath = "/usr/share/tesseract-ocr/5/tessdata/osd.traineddata";
expect(dockerfile).toContain(`rm -f ${osdPath}`);
expect(dockerfile).toContain(`test ! -e ${osdPath}`);
expect(tesseractSource.match(/pageSegmentationMode\?: ([^;]+);/)?.[1]).toBe("6 | 11");
for (const mode of [0, 1, 12]) {
expect(tesseractSource).not.toMatch(new RegExp(`pageSegmentationMode:\\s*${mode}(?!\\d)`));
}
});
it("keeps integration CI aligned with the exact Fast OCR base payload", () => {
const integrationJob = ciWorkflow.match(
/^ {2}test-integration:\n[\s\S]*?(?=^ {2}test-e2e-smoke:)/m,
)?.[0];
expect(integrationJob).toBeDefined();
const systemDependencyRunBlock = integrationJob?.match(
/^ {6}- name: Install system dependencies[^\n]*\n {8}run: \|\n(?<run>[\s\S]*?)(?=^ {6}- )/m,
)?.groups?.run;
expect(systemDependencyRunBlock).toBeDefined();
const expectedPackages = [
"tesseract-ocr",
"tesseract-ocr-eng",
"tesseract-ocr-deu",
"tesseract-ocr-fra",
"tesseract-ocr-spa",
"tesseract-ocr-chi-sim",
"tesseract-ocr-jpn",
];
const systemDependencyLines = systemDependencyRunBlock?.split("\n") ?? [];
const installStart = systemDependencyLines.findIndex((line) =>
line.includes("sudo apt-get install -y --no-install-recommends"),
);
expect(installStart).toBeGreaterThanOrEqual(0);
const installCommand: string[] = [];
for (let index = installStart; index < systemDependencyLines.length; index++) {
const line = systemDependencyLines[index];
installCommand.push(line);
if (!line.trimEnd().endsWith("\\")) break;
}
const installedPackages =
installCommand.join("\n").match(/\btesseract-ocr(?:-[a-z0-9-]+)?\b/g) ?? [];
expect([...new Set(installedPackages)].sort()).toEqual([...expectedPackages].sort());
const osdPath = "/usr/share/tesseract-ocr/5/tessdata/osd.traineddata";
const orderedCommands: Array<[string, (line: string) => boolean]> = [
["OSD removal", (line) => line.trim() === `sudo rm -f ${osdPath}`],
["OSD absence assertion", (line) => line.trim() === `test ! -e ${osdPath}`],
["expected inventory", (line) => line.trim() === "expected=(chi_sim deu eng fra jpn spa)"],
[
"sorted Tesseract inventory",
(line) =>
line.trim() ===
"mapfile -t actual < <(tesseract --list-langs 2>/dev/null | tail -n +2 | LC_ALL=C sort)",
],
[
"exact inventory assertion",
(line) =>
/\[\[ "\$\{actual\[\*\]\}" == "\$\{expected\[\*\]\}" \]\] \|\| \{/.test(line.trim()),
],
];
let previousCommand = installStart + installCommand.length - 1;
for (const [label, matches] of orderedCommands) {
const command = systemDependencyLines.findIndex(matches);
expect(command, `${label} must follow the integration apt install command`).toBeGreaterThan(
previousCommand,
);
previousCommand = command;
}
});
it("validates Redis without executing a target-architecture binary during cross-builds", () => {
expect(dockerfile).toContain("dpkg-query -W -f='${Version}\\n' redis-server");
expect(dockerfile).not.toContain("redis-server --version");
});
it("keeps Korean traineddata out of the official Fast OCR base image", () => {
expect(dockerfile).not.toContain("tesseract-ocr-script-hang");
expect(dockerfile).not.toContain("tesseract-ocr-kor");
expect(dockerfile).not.toContain("Hangul.traineddata");
expect(dockerfile).not.toContain("kor.traineddata");
expect(dockerfile).not.toMatch(/tessdata[-_]best.*(?:Hangul|kor)/i);
});
it("pins the complete CPU runtime and rejects Paddle, CUDA, and ranges", () => {
for (const target of TARGETS) {
const contents = requirementsFor(target);
const lines = requirementLines(contents);
expect(contents).not.toMatch(/paddle|cuda|onnxruntime-gpu/i);
expect(lines.some((line) => /^rapidocr==3\.9\.1\b/.test(line))).toBe(true);
expect(lines.some((line) => /^onnxruntime==1\.20\.1\b/.test(line))).toBe(true);
expect(lines.some((line) => /^pillow==12\.3\.0\b/.test(line))).toBe(true);
expect(lines.some((line) => /^protobuf==5\.29\.6\b/.test(line))).toBe(true);
expect(lines.some((line) => /^urllib3==2\.7\.0\b/.test(line))).toBe(true);
for (const line of lines) {
expect(line, `unlocked requirement in ${target}`).toMatch(
/^[A-Za-z0-9_.-]+==[^\s]+\s+--hash=sha256:[a-f0-9]{64}$/,
);
}
}
const amd64 = requirementsFor("linux-amd64-cpu-py312");
const arm64 = requirementsFor("linux-arm64-cpu-py311");
expect(amd64).toContain("bb71a814f66517a65628c9e4a2bb530a6edd2cd5d87ffa0af0f6f773a027d99e");
expect(arm64).toContain("f6243e34d74423bdd1edf0ae9596dd61023b260f546ee17d701723915f06a9f7");
expect(amd64).toContain("78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91");
expect(arm64).toContain("bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd");
expect(amd64).not.toContain("f6243e34d74423bdd1edf0ae9596dd61023b260f546ee17d701723915f06a9f7");
expect(arm64).not.toContain("bb71a814f66517a65628c9e4a2bb530a6edd2cd5d87ffa0af0f6f773a027d99e");
});
it("binds every official model object to an immutable revision and literal metadata", () => {
expect(models.schemaVersion).toBe(1);
expect(models.family).toBe("ocr");
const allObjects = [...models.models, ...models.localAssets];
expect(allObjects.map((model: { id: string }) => model.id).sort()).toEqual(
[...MODEL_IDS].sort(),
);
for (const model of models.models) {
expect(model.repository).toMatch(/^(PaddlePaddle|RapidAI)\//);
expect(model.revision).toMatch(/^[a-f0-9]{40}$/);
const expectedUrl = model.repository.startsWith("PaddlePaddle/")
? `https://huggingface.co/${model.repository}/resolve/${model.revision}/${model.file}`
: `https://www.modelscope.cn/models/${model.repository}/resolve/${model.revision}/${model.file}`;
expect(model.url).toBe(expectedUrl);
expect(model.url).not.toMatch(/\/(main|master|latest)\//i);
expect(model.sha256).toMatch(/^[a-f0-9]{64}$/);
expect(model.size).toBeGreaterThan(0);
expect(model.license).toBe("Apache-2.0");
expect(model.path).toMatch(/^models\/[A-Za-z0-9._/-]+$/);
expect(adapterSource, `${model.path} is not consumed by the adapter`).toContain(
model.path.replace(/^models\//, ""),
);
}
for (const asset of models.localAssets) {
const source = join(root, asset.source);
const contents = readFileSync(source);
expect(asset.url).toBeUndefined();
expect(asset.path).toBe("models/best-v1-calibration.json");
expect(asset.sha256).toMatch(/^[a-f0-9]{64}$/);
expect(asset.sha256).toBe(createHash("sha256").update(contents).digest("hex"));
expect(asset.size).toBe(contents.byteLength);
expect(adapterSource).toContain(asset.path.replace(/^models\//, ""));
}
});
it("ships hash-bound license texts and a complete machine-readable attribution inventory", () => {
expect(models.legalAssets.map((asset: { id: string }) => asset.id).sort()).toEqual(
[...LEGAL_ASSET_IDS].sort(),
);
for (const asset of models.legalAssets) {
expect(asset.path).toMatch(/^(LICENSES|THIRD_PARTY_LICENSES)\/[A-Za-z0-9._/-]+$/);
expect(asset.sha256).toMatch(/^[a-f0-9]{64}$/);
expect(asset.size).toBeGreaterThan(0);
expect(asset.license).toMatch(/^(AGPL-3\.0|Apache-2\.0|BSD-3-Clause)$/);
if (asset.source) {
const source = join(root, asset.source);
const contents = readFileSync(source);
expect(asset.sha256).toBe(createHash("sha256").update(contents).digest("hex"));
expect(asset.size).toBe(contents.byteLength);
} else {
expect(asset.url).toMatch(/^https:\/\//);
}
}
expect(buildScript).toContain('manifest.get("legalAssets", [])');
expect(buildScript).toContain('runtime_root / "THIRD_PARTY_NOTICES.json"');
expect(buildScript).toContain('site_packages.glob("*.dist-info")');
expect(buildScript).toContain("package has no included license material");
expect(buildScript).toContain('"legalMaterials": sorted(legal_objects');
expect(buildScript).toContain('"sourceUrl"');
expect(buildScript).toContain('"licenseFiles"');
});
it("builds a native complete venv and emits installer-compatible exact metadata", () => {
expect(buildScript).toContain("uname -m");
expect(buildScript).toContain("-m venv --copies");
expect(buildScript).toContain("--require-hashes");
expect(buildScript).toContain("--no-deps");
expect(buildScript).toContain("ocr_runtime.py");
expect(buildScript).toContain("ocr_runner.py");
expect(buildScript).toContain('"files"');
expect(buildScript).toContain('"expandedSize"');
expect(buildScript).toContain('"pythonPath"');
expect(buildScript).toContain('"entrypoint"');
expect(buildScript).toContain('"adapterPath"');
expect(buildScript).toContain('"models"');
expect(buildScript).toContain('"modelObjects": sorted(model_objects');
expect(buildScript).toContain('"provenance"');
expect(buildScript).toContain('"sourceImageDigest"');
expect(buildScript).toContain('"sourceCommit"');
expect(buildScript).toContain('"builderId"');
expect(buildScript).toContain('"CPUExecutionProvider"');
expect(buildScript).toContain("ocr_runtime_entrypoint.py");
expect(buildScript).toContain('"$' + '{RUNTIME_ROOT}/ocr_runner.py" --smoke');
expect(buildScript).toContain("--sort=name");
expect(buildScript).toContain("gzip -n");
expect(buildScript).toContain('site_packages.glob("*.dist-info/RECORD")');
expect(buildScript).toContain("wheel RECORD path escapes the runtime");
expect(buildScript).toContain('csv.writer(output, lineterminator="\\n")');
expect(buildScript.indexOf("except FileNotFoundError:")).toBeLessThan(
buildScript.indexOf("duplicate retained wheel RECORD path"),
);
expect(buildScript).not.toMatch(/site-packages.*delta|paddle|onnxruntime-gpu|libcuda/i);
});
it("stops model downloads at the signed object size instead of reading to EOF", () => {
expect(buildScript).toContain('expected_size = object_size(obj.get("size"), label)');
expect(buildScript).toContain("remaining = expected_size + 1");
expect(buildScript).toContain("response.read(min(1024 * 1024, remaining))");
expect(buildScript).not.toContain("shutil.copyfileobj(response, output");
});
it("delegates OCR before the legacy shared-site-package bundle builder", () => {
const delegate = legacyBuildScript.indexOf('if [[ "$' + '{BUNDLE_ID}" == "ocr" ]]');
const legacyDelta = legacyBuildScript.indexOf("Recording base site-packages");
expect(delegate).toBeGreaterThanOrEqual(0);
expect(delegate).toBeLessThan(legacyDelta);
expect(legacyBuildScript).toContain("build-ocr-runtime.sh");
});
it("ships every declared build input", () => {
// The native artifact is assembled inside the final app image, so every
// repository-local legal input must be present there rather than merely in
// the outer GitHub Actions checkout.
expect(dockerfile).toContain("COPY LICENSE ./LICENSE");
for (const path of [
"docker/build-ocr-runtime.sh",
"docker/ocr-runtime-models.json",
"docker/ocr-runtime-requirements-amd64.txt",
"docker/ocr-runtime-requirements-arm64.txt",
"docker/ocr-best-v1-calibration.json",
"LICENSE",
"packages/ai/python/ocr_runtime.py",
"packages/ai/python/ocr_runtime_entrypoint.py",
]) {
expect(existsSync(join(root, path)), `${path} is missing`).toBe(true);
}
});
});