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 { encodeJxl } from "../../lib/format-encoders.js";
|
||||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
|
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
|
||||||
|
import { InputValidationError } from "../../modality/contract.js";
|
||||||
import { registerToolProcessFn } from "../tool-factory.js";
|
import { registerToolProcessFn } from "../tool-factory.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
@@ -25,6 +26,12 @@ const settingsSchema = z.object({
|
|||||||
quality: z.number().int().min(1).max(100).default(90),
|
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(
|
function resolveOutputFormat(
|
||||||
outputFormat: string,
|
outputFormat: string,
|
||||||
originalExt: string,
|
originalExt: string,
|
||||||
@@ -156,6 +163,12 @@ export function registerSplit(app: FastifyInstance) {
|
|||||||
cols = Math.min(cols, 100);
|
cols = Math.min(cols, 100);
|
||||||
rows = Math.min(rows, 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 cellW = Math.floor(fullW / cols);
|
||||||
const cellH = Math.floor(fullH / rows);
|
const cellH = Math.floor(fullH / rows);
|
||||||
const originalExt = extname(filename) || ".png";
|
const originalExt = extname(filename) || ".png";
|
||||||
@@ -253,6 +266,12 @@ export function registerSplit(app: FastifyInstance) {
|
|||||||
cols = Math.min(cols, 100);
|
cols = Math.min(cols, 100);
|
||||||
rows = Math.min(rows, 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 cellW = Math.floor(fullW / cols);
|
||||||
const cellH = Math.floor(fullH / rows);
|
const cellH = Math.floor(fullH / rows);
|
||||||
const originalExt = extname(filename) || ".png";
|
const originalExt = extname(filename) || ".png";
|
||||||
|
|||||||
@@ -502,8 +502,11 @@ test.describe("GUI Settings - Teams Tab", () => {
|
|||||||
page.on("dialog", (d) => d.accept());
|
page.on("dialog", (d) => d.accept());
|
||||||
await page.locator("[role='menu']").getByText("Delete").click();
|
await page.locator("[role='menu']").getByText("Delete").click();
|
||||||
|
|
||||||
// Team should no longer be visible
|
// The success toast repeats the team name, so for a moment both the toast
|
||||||
await expect(page.getByText(teamName)).not.toBeVisible({ timeout: 5_000 });
|
// 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 {
|
} finally {
|
||||||
await cleanupTeamsByPrefix(adminToken, "guidelteam-");
|
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 TARGET_STARTUP_BUFFER_MS = 60_000;
|
||||||
const MAX_DIAGNOSTIC_SETTINGS_LENGTH = 2_048;
|
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 {
|
export interface FuzzConfig {
|
||||||
runs: number;
|
runs: number;
|
||||||
@@ -38,11 +38,27 @@ const CASE_TIMEOUTS_MS: Record<FuzzCostClass, number> = {
|
|||||||
standard: 8_000,
|
standard: 8_000,
|
||||||
long: 12_000,
|
long: 12_000,
|
||||||
"slow-codec": 15_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 = {
|
export const FUZZ_COST_OVERRIDES = {
|
||||||
"webp-to-avif": "slow-codec",
|
"webp-to-avif": "slow-codec",
|
||||||
"webp-to-gif": "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>;
|
} as const satisfies Record<string, FuzzCostClass>;
|
||||||
|
|
||||||
function parseInteger(
|
function parseInteger(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { PYTHON_SIDECAR_TOOLS, TOOLS } from "@snapotter/shared";
|
import { PYTHON_SIDECAR_TOOLS, TOOLS } from "@snapotter/shared";
|
||||||
import fc from "fast-check";
|
import fc from "fast-check";
|
||||||
|
import sharp from "sharp";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { ZodFastCheck } from "zod-fast-check";
|
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 REQUIRE_AI_FEATURES = process.env.REQUIRE_AI_FEATURES === "1";
|
||||||
const FIXTURE_INDEX = buildGeneratedFixtureIndex(generatedFixtureDirectories());
|
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)", () => {
|
describe.skipIf(!FUZZ)("settings fuzz (property-based)", () => {
|
||||||
let testApp: TestApp;
|
let testApp: TestApp;
|
||||||
|
|
||||||
@@ -81,7 +120,8 @@ describe.skipIf(!FUZZ)("settings fuzz (property-based)", () => {
|
|||||||
if (fixtures.length === 0) {
|
if (fixtures.length === 0) {
|
||||||
return context.skip(`${toolId}: no compatible generated fixture`);
|
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, {
|
const accounting = new GeneratedCaseAccounting(toolId, {
|
||||||
expectedAttempts: FUZZ_CONFIG.runs + 1,
|
expectedAttempts: FUZZ_CONFIG.runs + 1,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -238,6 +238,26 @@ describe("Split", () => {
|
|||||||
expect(res.statusCode).toBe(400);
|
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 () => {
|
it("rejects unauthenticated requests", async () => {
|
||||||
const { body, contentType } = createMultipartPayload([
|
const { body, contentType } = createMultipartPayload([
|
||||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
{ 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", () => {
|
it("only overrides real registered tool IDs", () => {
|
||||||
const registered = new Set(TOOLS.map(({ id }) => id));
|
const registered = new Set(TOOLS.map(({ id }) => id));
|
||||||
expect(Object.keys(FUZZ_COST_OVERRIDES).filter((id) => !registered.has(id))).toEqual([]);
|
expect(Object.keys(FUZZ_COST_OVERRIDES).filter((id) => !registered.has(id))).toEqual([]);
|
||||||
|
|||||||
Reference in New Issue
Block a user