Files
SnapOtter/tests/helpers/generated-case-accounting.ts
T
SnapOtterandGitHub 1b41da7615 ci(nightly): repair five job classes broken by the #649 QA hardening (#695)
Nightly has been red since 07-28; per-PR CI and main are green. Two investigations traced all five failing classes to #649: extended-matrix required AI bundles it never installs (removed), nightly SYSTEM_DEPS drifted from ci.yml (added libreoffice + a doc-binaries composite for pandoc/pdfcpu), the generated-case classifier only skipped ffmpeg (widened to pdfcpu/soffice/pandoc + excluded repo-audit specs from the lean docker image), a settings spec capped loginAttemptLimit and 429-cascaded the serial bucket (restore via API), and type-to-search refused keystrokes under a route announcer's programmatic focus (guard added). A nightly dispatch on the branch confirmed all five classes green.
2026-07-31 02:09:39 +08:00

186 lines
6.8 KiB
TypeScript

interface FeatureUnavailableInput {
toolId: string;
statusCode: number;
code?: unknown;
requireAiFeatures: boolean;
}
export interface GeneratedCaseSummary {
attempted: number;
accepted: number;
rejected: number;
skipped: number;
skips: GeneratedSkipSummary[];
}
export const GENERATED_SKIP_CATEGORIES = [
"optional-feature",
"missing-fixture",
"missing-tool-config",
"unsupported-generator",
// CI integration shards ship qpdf and ghostscript but deliberately not
// ffmpeg, so every media tool is unrunnable there for a reason that says
// nothing about the product. Without a category for it those cases fall
// outside the accounting, the contract sees zero accepted cases, and the gate
// goes red in CI while passing on any machine that has ffmpeg installed.
"missing-host-binary",
] as const;
/**
* True when a response says the host has no processing engine.
*
* Three shapes, because the gap surfaces at three depths. Input validation
* refuses up front with 503 ENGINE_UNAVAILABLE. A tool whose input needs no
* probing is admitted and fails inside the sync window, which returns 422 with
* the worker's detail. And the engine itself words it two ways: "binary not
* found" when nothing is on PATH, or a spawn ENOENT when a configured path does
* not exist. Matching only the first is how a local simulation can pass while
* CI, which has no ffmpeg at all, still fails.
*
* Deliberately narrow: a real ffmpeg crash carries an exit code and stderr and
* has to stay a failure.
*/
export function isEngineUnavailableResponse(statusCode: number, body: string): boolean {
if (statusCode === 503 && /ENGINE_UNAVAILABLE/.test(body)) return true;
return isEngineUnavailableFailure(body);
}
/**
* The same gap seen from the worker instead of the route.
*
* A tool whose input needs no probing, images-to-video being the obvious one,
* is admitted normally and only discovers the missing engine when it is
* spawned. Match each engine's own "not found" wording, which it raises before
* spawning anything. The lean docker test image ships without pdfcpu,
* LibreOffice (soffice), and pandoc as well as ffmpeg, so all four gate the
* same way. A real crash carries an exit code and stderr and has to stay a
* failure.
*/
export function isEngineUnavailableFailure(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error ?? "");
if (/\b(ffmpeg|ffprobe|pdfcpu|soffice|pandoc)\b[^"]*?\bbinary not found\b/i.test(message)) {
return true;
}
return /spawn\s+\S*(ffmpeg|ffprobe|pdfcpu|soffice|pandoc)\S*\s+ENOENT/i.test(message);
}
export type GeneratedSkipCategory = (typeof GENERATED_SKIP_CATEGORIES)[number];
export interface GeneratedSkipSummary {
category: GeneratedSkipCategory;
reason: string;
count: number;
}
interface GeneratedCaseAccountingOptions {
expectedAttempts?: number;
}
/** Per-tool coverage accounting for generated settings and format campaigns. */
export class GeneratedCaseAccounting {
readonly #toolId: string;
#attempted = 0;
#accepted = 0;
#rejected = 0;
#skipped = 0;
readonly #skipCounts = new Map<string, GeneratedSkipSummary>();
readonly #expectedAttempts: number | undefined;
constructor(toolId: string, options: GeneratedCaseAccountingOptions = {}) {
this.#toolId = toolId;
this.#expectedAttempts = options.expectedAttempts;
}
attempt(): void {
this.#attempted += 1;
}
accept(): void {
this.#accepted += 1;
}
reject(): void {
this.#rejected += 1;
}
skip(category: GeneratedSkipCategory, reason: string): void {
if (
!GENERATED_SKIP_CATEGORIES.includes(category) ||
reason.length === 0 ||
reason.length > 240
) {
throw new Error(`${this.#toolId}: generated skip reason must be 1-240 characters`);
}
this.#skipped += 1;
const key = `${category}\u0000${reason}`;
const current = this.#skipCounts.get(key);
if (current) current.count += 1;
else this.#skipCounts.set(key, { category, reason, count: 1 });
}
assertCovered(): GeneratedCaseSummary {
const summary = {
attempted: this.#attempted,
accepted: this.#accepted,
rejected: this.#rejected,
skipped: this.#skipped,
skips: [...this.#skipCounts.values()],
};
if (this.#expectedAttempts !== undefined && summary.attempted !== this.#expectedAttempts) {
throw new Error(
`${this.#toolId}: generated run count mismatch (expected=${this.#expectedAttempts}, attempted=${summary.attempted})`,
);
}
if (summary.attempted !== summary.accepted + summary.rejected + summary.skipped) {
throw new Error(
`${this.#toolId}: generated accounting is not conserved (attempted=${summary.attempted}, accepted=${summary.accepted}, rejected=${summary.rejected}, skipped=${summary.skipped})`,
);
}
// A tool the host cannot run was not tested, but it did not regress either,
// and the shard it runs on is documented as shipping no ffmpeg. Demanding
// an accepted case there turns a known environment gap into a permanently
// red gate.
//
// Not every case has to be skipped for this to hold: a tool is offered
// fixtures it legitimately refuses, so images-to-video sees 65 host gaps
// and 4 clean rejections. Requiring skipped to equal attempted missed that
// and kept the gate red. What matters is that nothing was accepted, at
// least one case hit the host gap, and every skip is that gap rather than
// some other reason.
//
// This cannot mask a real failure. A case that actually breaks fails its
// own assertion long before it reaches the accounting, and `rejected` only
// ever counts a status the matrix already allows. On a host that has
// ffmpeg there are no host-gap skips at all, so the branch never opens.
const fullyGatedOnHost =
summary.attempted > 0 &&
summary.skipped > 0 &&
summary.skips.every((skip) => skip.category === "missing-host-binary");
if (summary.attempted === 0 || (summary.accepted === 0 && !fullyGatedOnHost)) {
throw new Error(
`${this.#toolId}: generated coverage incomplete (attempted=${summary.attempted}, accepted=${summary.accepted}, rejected=${summary.rejected}, skipped=${summary.skipped})`,
);
}
return summary;
}
}
/**
* Missing optional AI features are explicit skips in ordinary generated runs,
* but are failures when the caller declares an installed-feature campaign.
*/
export function featureUnavailableDisposition({
toolId,
statusCode,
code,
requireAiFeatures,
}: FeatureUnavailableInput): "continue" | "skip" {
const unavailable =
statusCode === 501 && (code === "FEATURE_NOT_INSTALLED" || code === "FEATURE_INCOMPATIBLE");
if (!unavailable) return "continue";
if (requireAiFeatures) {
throw new Error(`${toolId}: required AI feature returned 501 ${String(code)}`);
}
return "skip";
}