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.
This commit is contained in:
SnapOtter
2026-07-31 06:56:44 +08:00
committed by GitHub
parent 1b41da7615
commit c0dc037860
7 changed files with 111 additions and 4 deletions
+19
View File
@@ -14,6 +14,7 @@ import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.j
import { encodeJxl } from "../../lib/format-encoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
import { InputValidationError } from "../../modality/contract.js";
import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -25,6 +26,12 @@ const settingsSchema = z.object({
quality: z.number().int().min(1).max(100).default(90),
});
// Each axis is capped at 100, but 100x100 is 10,000 tiles: ~20s of Sharp
// extracts and a 10,000-file ZIP from one request. Bound the product so a
// single split cannot spin the worker; real grids, filmstrips, and sprite
// sheets stay well under this.
const MAX_OUTPUT_TILES = 400;
function resolveOutputFormat(
outputFormat: string,
originalExt: string,
@@ -156,6 +163,12 @@ export function registerSplit(app: FastifyInstance) {
cols = Math.min(cols, 100);
rows = Math.min(rows, 100);
if (cols * rows > MAX_OUTPUT_TILES) {
return reply.status(400).send({
error: `Too many tiles: ${cols}x${rows} exceeds the ${MAX_OUTPUT_TILES}-tile limit. Use a coarser grid.`,
});
}
const cellW = Math.floor(fullW / cols);
const cellH = Math.floor(fullH / rows);
const originalExt = extname(filename) || ".png";
@@ -253,6 +266,12 @@ export function registerSplit(app: FastifyInstance) {
cols = Math.min(cols, 100);
rows = Math.min(rows, 100);
if (cols * rows > MAX_OUTPUT_TILES) {
throw new InputValidationError(
`Too many tiles: ${cols}x${rows} exceeds the ${MAX_OUTPUT_TILES}-tile limit. Use a coarser grid.`,
);
}
const cellW = Math.floor(fullW / cols);
const cellH = Math.floor(fullH / rows);
const originalExt = extname(filename) || ".png";
+5 -2
View File
@@ -502,8 +502,11 @@ test.describe("GUI Settings - Teams Tab", () => {
page.on("dialog", (d) => d.accept());
await page.locator("[role='menu']").getByText("Delete").click();
// Team should no longer be visible
await expect(page.getByText(teamName)).not.toBeVisible({ timeout: 5_000 });
// The success toast repeats the team name, so for a moment both the toast
// and the row match and a bare not.toBeVisible() hits a strict-mode
// violation (2 elements). Wait for every match to clear: the row is
// removed and the toast auto-dismisses.
await expect(page.getByText(teamName)).toHaveCount(0, { timeout: 10_000 });
} finally {
await cleanupTeamsByPrefix(adminToken, "guidelteam-");
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 643 KiB

After

Width:  |  Height:  |  Size: 156 KiB

+17 -1
View File
@@ -7,7 +7,7 @@ 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";
export type FuzzCostClass = "standard" | "long" | "slow-codec" | "heavy";
export interface FuzzConfig {
runs: number;
@@ -38,11 +38,27 @@ 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(
@@ -1,5 +1,6 @@
import { PYTHON_SIDECAR_TOOLS, TOOLS } from "@snapotter/shared";
import fc from "fast-check";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { z } from "zod";
import { ZodFastCheck } from "zod-fast-check";
@@ -39,6 +40,44 @@ const FUZZ_CONFIG = parseFuzzConfig(FUZZ ? process.env : {});
const REQUIRE_AI_FEATURES = process.env.REQUIRE_AI_FEATURES === "1";
const FIXTURE_INDEX = buildGeneratedFixtureIndex(generatedFixtureDirectories());
// This lane checks that valid settings never crash a tool, not how fast a codec
// is on a big image. Several image tools scale their work with the input
// (AVIF/JXL encodes, gif upscales, per-tile splits), so a multi-megapixel
// fixture makes cases legitimately run many seconds and time out without ever
// crashing. Bound image inputs to a small canvas; the same settings paths run
// in a fraction of the time. The format matrix still covers full-size inputs on
// its own path. Formats sharp cannot re-encode (heic, jxl, raw) are left as-is
// and covered by their per-tool fuzz budgets instead.
const FUZZ_MAX_IMAGE_DIMENSION = 640;
async function boundFuzzImageInputs(
inputs: Awaited<ReturnType<typeof buildGeneratedProcessInputs>>,
modality?: string,
): Promise<typeof inputs> {
if (modality !== "image") return inputs;
return Promise.all(
inputs.map(async (input) => {
try {
const image = sharp(input.buffer, { animated: true });
const meta = await image.metadata();
const longest = Math.max(meta.width ?? 0, meta.height ?? 0);
if (longest <= FUZZ_MAX_IMAGE_DIMENSION) return input;
const buffer = await image
.resize({
width: FUZZ_MAX_IMAGE_DIMENSION,
height: FUZZ_MAX_IMAGE_DIMENSION,
fit: "inside",
withoutEnlargement: true,
})
.toBuffer();
return { ...input, buffer };
} catch {
return input;
}
}),
);
}
describe.skipIf(!FUZZ)("settings fuzz (property-based)", () => {
let testApp: TestApp;
@@ -81,7 +120,8 @@ describe.skipIf(!FUZZ)("settings fuzz (property-based)", () => {
if (fixtures.length === 0) {
return context.skip(`${toolId}: no compatible generated fixture`);
}
const inputs = await buildGeneratedProcessInputs(fixtures, config, tool.modality);
const rawInputs = await buildGeneratedProcessInputs(fixtures, config, tool.modality);
const inputs = await boundFuzzImageInputs(rawInputs, tool.modality);
const accounting = new GeneratedCaseAccounting(toolId, {
expectedAttempts: FUZZ_CONFIG.runs + 1,
});
@@ -238,6 +238,26 @@ describe("Split", () => {
expect(res.statusCode).toBe(400);
});
it("rejects a grid whose total tile count would be pathological", async () => {
// Each axis is schema-valid (<=100), but 100x100 is 10,000 tiles: ~20s of
// work and a 10,000-file ZIP from one request. Bound the product so a
// single split cannot spin the worker (fuzz seed 20260724, #695 follow-up).
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({ columns: 100, rows: 100 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/split",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/tile/i);
});
it("rejects unauthenticated requests", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
+9
View File
@@ -74,6 +74,15 @@ describe("generated fuzz budgets", () => {
);
});
it("gives the heavy-canvas border tool a longer per-case budget", () => {
// A 2000px border on a mid-size image makes a ~5000px canvas plus a large
// gaussian shadow blur, which legitimately exceeds the 8s standard budget
// without crashing (fuzz seed 20260724, #695 follow-up).
const budget = fuzzBudgetFor({ id: "border", executionHint: "fast" }, 25);
expect(budget.costClass).toBe("heavy");
expect(budget.caseTimeoutMs).toBeGreaterThanOrEqual(20_000);
});
it("only overrides real registered tool IDs", () => {
const registered = new Set(TOOLS.map(({ id }) => id));
expect(Object.keys(FUZZ_COST_OVERRIDES).filter((id) => !registered.has(id))).toEqual([]);