Files
SnapOtter/tests/helpers/fuzz-policy.ts
T
SnapOtterandGitHub c0dc037860 fix(fuzz): bound settings-fuzz inputs, cap split tiles, budget the slow codecs, steady flakes (#696)
Follow-up to #695, greening the last nightly jobs it exposed. split gains a 400-tile product cap (a 100x100 split was a 10,000-file ZIP and ~20s of work). The settings-fuzz bounds its image inputs to 640px and gives the tools whose cost is output-driven (border, gif-tools, split) or codec-driven (heic/webp-to-avif) honest per-case budgets, since #649's settle-job wiring made every case wait for the real encode. The delete-team serial spec waits with toHaveCount(0) so the success toast can't trip strict mode, and type-to-search allows a route announcer's programmatic reading focus so it works on WebKit. Confirmed on a nightly dispatch: Extended Matrix (all 4 shards), Serial Bucket, Cross-Browser, and Coverage all green; Docker Container E2E's failures were GitHub runner reclamation (exit 137, tests passing throughout), which cleared on the #695 dispatch and is unaffected by this change.
2026-07-31 06:56:44 +08:00

174 lines
5.4 KiB
TypeScript

import type { Tool } from "@snapotter/shared";
const DEFAULT_FUZZ_RUNS = 25;
const DEFAULT_FUZZ_SEED = 20_260_724;
const MAX_FUZZ_RUNS = 10_000;
const MAX_FUZZ_SEED = 2_147_483_647;
const TARGET_STARTUP_BUFFER_MS = 60_000;
const MAX_DIAGNOSTIC_SETTINGS_LENGTH = 2_048;
export type FuzzCostClass = "standard" | "long" | "slow-codec" | "heavy";
export interface FuzzConfig {
runs: number;
seed: number;
seedSource: "default" | "FUZZ_SEED" | "FC_SEED";
}
export interface FuzzBudget {
costClass: FuzzCostClass;
caseTimeoutMs: number;
targetTimeoutMs: number;
}
interface FuzzTool {
id: string;
executionHint: Tool["executionHint"];
}
interface FuzzCaseMetadata {
toolId: string;
seed: number;
run: number;
settings: unknown;
timeoutMs: number;
}
const CASE_TIMEOUTS_MS: Record<FuzzCostClass, number> = {
standard: 8_000,
long: 12_000,
"slow-codec": 15_000,
// These tools scale with settings the schema permits to a bounded but large
// extreme: a 2000px border on a ~5000px canvas, a 400-tile split, or a resize
// to the shared 64-megapixel output cap. All are correct and bounded, and all
// legitimately run tens of seconds on a loaded runner without crashing, which
// is the only thing this lane checks. (Whether the product should allow a
// 64MP gif resize at all is a separate, deliberate review.)
heavy: 45_000,
};
export const FUZZ_COST_OVERRIDES = {
"webp-to-avif": "slow-codec",
"webp-to-gif": "slow-codec",
// AVIF/HEIC encodes are slow; a heic input cannot be downscaled by the fuzz
// (sharp cannot re-encode it), so it runs at full fixture size.
"heic-to-avif": "slow-codec",
// Resize to the 64-megapixel output cap across frames runs tens of seconds.
"gif-tools": "heavy",
// A 2000px border makes a ~5000px canvas plus a large gaussian shadow blur.
border: "heavy",
// Up to 400 per-tile JXL/AVIF encodes plus a ZIP; bounded and correct.
split: "heavy",
} as const satisfies Record<string, FuzzCostClass>;
function parseInteger(
name: string,
value: string,
{ min, max }: { min: number; max: number },
): number {
if (!/^-?\d+$/.test(value)) {
throw new Error(`${name} must be an integer`);
}
const parsed = Number(value);
if (!Number.isSafeInteger(parsed)) {
throw new Error(`${name} must be an integer`);
}
if (parsed < min || parsed > max) {
throw new Error(`${name} must be between ${min} and ${max}`);
}
return parsed;
}
export function parseFuzzConfig(
environment: Readonly<Record<string, string | undefined>>,
): FuzzConfig {
const runs =
environment.FUZZ_RUNS === undefined
? DEFAULT_FUZZ_RUNS
: parseInteger("FUZZ_RUNS", environment.FUZZ_RUNS, { min: 1, max: MAX_FUZZ_RUNS });
const canonicalSeed = environment.FUZZ_SEED;
const deprecatedSeed = environment.FC_SEED;
if (canonicalSeed !== undefined && deprecatedSeed !== undefined) {
const parsedCanonical = parseInteger("FUZZ_SEED", canonicalSeed, {
min: 0,
max: MAX_FUZZ_SEED,
});
const parsedDeprecated = parseInteger("FC_SEED", deprecatedSeed, {
min: 0,
max: MAX_FUZZ_SEED,
});
if (parsedCanonical !== parsedDeprecated) {
throw new Error("FUZZ_SEED and deprecated FC_SEED differ");
}
return { runs, seed: parsedCanonical, seedSource: "FUZZ_SEED" };
}
if (canonicalSeed !== undefined) {
return {
runs,
seed: parseInteger("FUZZ_SEED", canonicalSeed, { min: 0, max: MAX_FUZZ_SEED }),
seedSource: "FUZZ_SEED",
};
}
if (deprecatedSeed !== undefined) {
return {
runs,
seed: parseInteger("FC_SEED", deprecatedSeed, { min: 0, max: MAX_FUZZ_SEED }),
seedSource: "FC_SEED",
};
}
return { runs, seed: DEFAULT_FUZZ_SEED, seedSource: "default" };
}
export function fuzzBudgetFor(tool: FuzzTool, runs: number): FuzzBudget {
if (!Number.isInteger(runs) || runs < 1 || runs > MAX_FUZZ_RUNS) {
throw new Error(`fuzz runs must be an integer between 1 and ${MAX_FUZZ_RUNS}`);
}
const costClass =
FUZZ_COST_OVERRIDES[tool.id as keyof typeof FUZZ_COST_OVERRIDES] ??
(tool.executionHint === "long" ? "long" : "standard");
const caseTimeoutMs = CASE_TIMEOUTS_MS[costClass];
return {
costClass,
caseTimeoutMs,
targetTimeoutMs: TARGET_STARTUP_BUFFER_MS + (runs + 1) * caseTimeoutMs,
};
}
function formatSettings(settings: unknown): string {
let serialized: string;
try {
serialized = JSON.stringify(settings) ?? String(settings);
} catch {
serialized = "[unserializable settings]";
}
if (serialized.length <= MAX_DIAGNOSTIC_SETTINGS_LENGTH) return serialized;
return `${serialized.slice(0, MAX_DIAGNOSTIC_SETTINGS_LENGTH)}…`;
}
export async function runFuzzCaseWithWatchdog<T>(
metadata: FuzzCaseMetadata,
operation: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(
new Error(
`[fuzz-timeout] tool=${metadata.toolId} seed=${metadata.seed} run=${metadata.run} ` +
`timeoutMs=${metadata.timeoutMs} settings=${formatSettings(metadata.settings)}`,
),
);
}, metadata.timeoutMs);
});
try {
return await Promise.race([operation(controller.signal), timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}