Files
SnapOtter/tests/unit/shared/ocr-korean-product-contract.test.ts
SnapOtterandGitHub 44f5aea326 fix(ci): repair the chronically-failing nightly workflow (#624)
The scheduled Nightly had been red for over a week across nearly every job. This
root-causes and fixes each one. All were pre-existing: missing CI provisioning,
specs that drifted as the app grew, a job too heavy for its timeout, and a fuzz
that was never configured for file-upload endpoints. None came from the recent
security merge.

- Coverage + Docker Container E2E: install tesseract and its language packs so
  the built-in Fast OCR tests stop throwing spawn ENOENT; gate two repo-file and
  release-workflow tests that cannot run inside the slimmed container image.
- E2E (Full, Serial, Cross-Browser, Device Matrix): refresh specs that drifted
  behind the app (tool renames, the now admin-only Tools tab, dropped About copy,
  locator collisions scoped to the right region). One real product fix rode
  along: /config/auth was refetched six times per tool-page load, so cache it
  behind a single shared fetch, dropping the tool page from 13 to 8 API calls.
- Extended Matrix + Fuzz: shard the integration suite four ways so the full
  format x tool matrix plus property fuzz fits its budget instead of overrunning
  the 90-minute ceiling every night.
- Schemathesis: exclude the tools with bespoke handlers that process
  synchronously in-request (they hang the fuzz on adversarial input) and suppress
  Hypothesis's data-generation health checks, which fire because file-upload
  endpoints reject the fuzzer's random bytes. not_a_server_error still runs on
  every generated case (5000+ per run).
- Stabilize two long-tail flakes: raise the avif matrix per-test cap from 240s to
  600s, and assert toHaveCount(0) on the deleted user row so a transient success
  toast no longer trips a strict-mode violation.

Verified end to end: the full Nightly workflow is green on this branch (all 14
jobs), and PR CI is green.
2026-07-24 03:54:50 +08:00

165 lines
6.0 KiB
TypeScript

import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { loadTranslations, SUPPORTED_LOCALES } from "@snapotter/shared";
import yaml from "js-yaml";
import { describe, expect, it } from "vitest";
const root = join(import.meta.dirname, "../../..");
const localizedCodes = SUPPORTED_LOCALES.map((locale) => locale.code).filter(
(locale) => locale !== "en",
);
type OpenApiContract = {
components: {
schemas: {
FeatureNotInstalledError: {
properties: {
requestedQuality: { enum: string[]; description: string };
guidance: { description: string };
};
};
};
};
paths: Record<
string,
{
post: {
requestBody: {
content: {
"multipart/form-data": {
schema: {
properties: {
file: { maxLength?: number };
settings: { description: string };
};
};
};
};
};
};
}
>;
};
function readOpenApi(locale: string): OpenApiContract {
const suffix = locale === "en" ? "" : `.${locale}`;
return yaml.load(
readFileSync(join(root, `apps/api/src/openapi${suffix}.yaml`), "utf8"),
) as OpenApiContract;
}
describe("Korean OCR product contract", () => {
it("publishes the Fast incompatibility response in every OpenAPI locale", () => {
for (const locale of SUPPORTED_LOCALES.map((item) => item.code)) {
const spec = readOpenApi(locale);
const properties = spec.components.schemas.FeatureNotInstalledError.properties;
expect(properties.requestedQuality.enum, locale).toEqual(["fast", "balanced", "best"]);
expect(properties.requestedQuality.description, locale).toMatch(/Fast.*Korean/i);
expect(properties.guidance.description, locale).toMatch(/guidance/i);
for (const route of ["/api/v1/tools/image/ocr", "/api/v1/tools/pdf/ocr-pdf"]) {
const settings =
spec.paths[route].post.requestBody.content["multipart/form-data"].schema.properties
.settings.description;
expect(settings, `${locale} ${route}`).toContain("Korean never selects fast");
expect(settings, `${locale} ${route}`).toContain("legacy tesseract alias");
}
}
});
it("publishes the encoded-input ceiling on both OCR routes, not unrelated PDF tools", () => {
for (const locale of SUPPORTED_LOCALES.map((item) => item.code)) {
const spec = readOpenApi(locale);
const fileLimit = (route: string) =>
spec.paths[route].post.requestBody.content["multipart/form-data"].schema.properties.file
.maxLength;
expect(fileLimit("/api/v1/tools/image/ocr"), `${locale} image OCR`).toBe(536_870_912);
expect(fileLimit("/api/v1/tools/pdf/ocr-pdf"), `${locale} PDF OCR`).toBe(536_870_912);
expect(fileLimit("/api/v1/tools/pdf/flatten-pdf"), `${locale} flatten PDF`).toBeUndefined();
}
});
it("describes Korean tier compatibility accurately on the landing page", () => {
const source = readFileSync(join(root, "apps/landing/src/data/tool-seo.ts"), "utf8");
expect(source).toContain("Korean requires the Balanced or Best tier");
expect(source).toContain("Fast returns an incompatibility error for Korean");
expect(source).not.toContain(
"The same language choices are available for the Fast, Balanced, and Best tiers.",
);
});
it("provides localized accessible guidance in all 21 app locales", async () => {
const english = (await loadTranslations("en")).toolSettings.ocr.fastKoreanUnsupported;
expect(english).toMatch(/Fast OCR.*Korean.*Accurate OCR pack.*Balanced or Best/);
for (const locale of localizedCodes) {
const message = (await loadTranslations(locale)).toolSettings.ocr.fastKoreanUnsupported;
expect(message, locale).toBeTruthy();
expect(message, locale).not.toBe(english);
}
});
it("documents the compatibility boundary in all localized OCR guides", () => {
const documents = [
"api/ai.md",
"guide/deployment.md",
"tools/image/ocr.md",
"tools/pdf/ocr-pdf.md",
];
for (const locale of localizedCodes) {
for (const document of documents) {
const content = readFileSync(join(root, "apps/docs", locale, document), "utf8");
expect(content, `${locale}/${document}`).toContain("<!-- korean-ocr-contract:start -->");
expect(content, `${locale}/${document}`).toContain("`fast-korean-unsupported`");
expect(content, `${locale}/${document}`).toContain("`FEATURE_INCOMPATIBLE`");
expect(content, `${locale}/${document}`).toContain("Linux amd64");
expect(content, `${locale}/${document}`).toContain("arm64");
expect(content, `${locale}/${document}`).toContain("NVIDIA");
}
}
});
it("publishes the measured 25 MiB Fast OCR footprint without stale 24/29 MiB copy", () => {
// Reads root DOCKERHUB.md, which .dockerignore strips from the shipped image;
// this repo-content contract runs in PR CI, not inside the container.
if (existsSync("/.dockerenv")) return;
const documents = [
"api/ai.md",
"guide/deployment.md",
"tools/image/ocr.md",
"tools/pdf/ocr-pdf.md",
];
const files = [
"README.md",
"DOCKERHUB.md",
"llms.txt",
"apps/landing/public/llms.txt",
...documents.map((document) => join("apps/docs", document)),
...localizedCodes.flatMap((locale) =>
documents.map((document) => join("apps/docs", locale, document)),
),
];
expect(files).toHaveLength(88);
for (const file of files) {
const footprintLines = readFileSync(join(root, file), "utf8")
.split("\n")
.filter(
(line) =>
/OCR|Tesseract|`fast`/i.test(line) &&
line.includes("MiB") &&
/\b(?:24|25|29)\b/.test(line),
);
expect(footprintLines, file).toHaveLength(1);
expect(footprintLines[0], file).toMatch(/\b25\b/);
expect(footprintLines[0], file).not.toMatch(/\b24\b/);
expect(footprintLines[0], file).not.toMatch(/\b29\b/);
}
});
});