mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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";
|
||||
|
||||
@@ -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-");
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 643 KiB After Width: | Height: | Size: 156 KiB |
@@ -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 },
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
Reference in New Issue
Block a user