test: testing overhaul -- CI e2e gates, parallel suites, generated matrices, mutation testing (#215)

Closes the "e2e never runs in CI" hole. Adds per-PR e2e smoke gate,
nightly full-suite workflows, parallel vitest forks (per-fork DBs),
Playwright parallel/serial/visual projects against production builds,
metadata-generated test suites (drift guards, hostile inputs, format
matrix, pairwise settings, property-based fuzz), Stryker mutation
testing, Schemathesis API fuzz, coverage ratchet, and fixes for three
session-poisoning bugs that caused 200+ serial-bucket failures.

Bug fix included: favicon/split/bulk-rename could hang clients forever
when ZIP streaming failed after reply.hijack().
This commit is contained in:
SnapOtter
2026-06-10 22:01:13 +08:00
committed by GitHub
parent 3b8d529b44
commit 4ec39c556f
62 changed files with 2888 additions and 298 deletions
+7 -7
View File
@@ -55,14 +55,14 @@ export function ensureAiDirs(): void {
mkdirSync(MODELS_DIR, { recursive: true });
mkdirSync(join(AI_DIR, "pip-cache"), { recursive: true });
} catch (err: unknown) {
// Never refuse to boot over the AI data dir. On native checkouts the
// default DATA_DIR (/data) is often uncreatable (ENOENT/EROFS on a
// sealed macOS root, EACCES on restrictive volumes); AI tools simply
// report as not installed until DATA_DIR points somewhere writable.
const code = (err as NodeJS.ErrnoException).code;
if (code === "EACCES") {
console.error(
`WARNING: Cannot create AI directories under "${AI_DIR}". AI features will be unavailable. Check volume permissions (PUID/PGID).`,
);
return;
}
throw err;
console.error(
`WARNING: Cannot create AI directories under "${AI_DIR}" (${code}). AI features will be unavailable. Set DATA_DIR to a writable path (or check volume permissions / PUID / PGID in Docker).`,
);
}
}
+9
View File
@@ -10,6 +10,7 @@ import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db, schema } from "../db/index.js";
import { auditLog } from "../lib/audit.js";
import { requirePermission } from "../permissions.js";
import { requireAuth } from "../plugins/auth.js";
@@ -86,6 +87,14 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
}
}
if (entries.length > 0) {
auditLog(request.log, "SETTINGS_UPDATED", {
adminId: admin.id,
username: admin.username,
keys: entries.map((e) => e.key),
});
}
return reply.send({ ok: true, updatedCount: entries.length });
});
+3
View File
@@ -103,6 +103,9 @@ export function registerBulkRename(app: FastifyInstance) {
details: err instanceof Error ? err.message : "Unknown error",
});
}
// The ZIP stream already started; end the connection so clients see a
// truncated transfer instead of hanging forever.
reply.raw.destroy(err instanceof Error ? err : new Error(String(err)));
}
});
}
+10
View File
@@ -106,6 +106,13 @@ export function registerFavicon(app: FastifyInstance) {
buf = await autoOrient(buf);
}
// Force a full pixel decode now. Header validation alone lets
// truncated files through, and a decode failure after reply.hijack()
// cannot be turned into an error response anymore.
if (validation.format !== "svg") {
await sharp(buf).stats();
}
decodedFiles.push({ buffer: buf, filename: file.filename });
} catch (err) {
const reason = err instanceof Error ? err.message : "Unknown decode error";
@@ -206,6 +213,9 @@ export function registerFavicon(app: FastifyInstance) {
details: err instanceof Error ? err.message : "Unknown error",
});
}
// The ZIP stream already started; end the connection so clients see a
// truncated transfer instead of hanging forever.
reply.raw.destroy(err instanceof Error ? err : new Error(String(err)));
}
});
}
+10
View File
@@ -139,6 +139,13 @@ export function registerSplit(app: FastifyInstance) {
const fullW = metadata.width ?? 0;
const fullH = metadata.height ?? 0;
// Force a full pixel decode before streaming starts. Metadata alone
// lets truncated files through, and a decode failure after
// reply.hijack() cannot become an error response anymore.
if (validation.format !== "svg") {
await sharp(fileBuffer).stats();
}
let cols = settings.columns;
let rows = settings.rows;
if (settings.tileWidth && settings.tileHeight) {
@@ -221,6 +228,9 @@ export function registerSplit(app: FastifyInstance) {
details: err instanceof Error ? err.message : "Unknown error",
});
}
// The ZIP stream already started; end the connection so clients see a
// truncated transfer instead of hanging forever.
reply.raw.destroy(err instanceof Error ? err : new Error(String(err)));
}
});
+91
View File
@@ -0,0 +1,91 @@
/**
* Pure display-mode map: toolId -> DisplayMode.
*
* This module intentionally has no React imports so that Playwright specs and
* Node-side test generators can import it directly. It is the single source of
* truth for display modes; tool-registry.tsx merges it into registry entries.
*/
export type DisplayMode =
| "side-by-side"
| "before-after"
| "live-preview"
| "no-comparison"
| "interactive-crop"
| "interactive-eraser"
| "interactive-split"
| "no-dropzone"
| "custom-results";
export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
// Essentials
resize: "side-by-side",
crop: "interactive-crop",
rotate: "side-by-side",
convert: "no-comparison",
compress: "before-after",
"strip-metadata": "no-comparison",
"edit-metadata": "no-comparison",
// Color adjustments
"adjust-colors": "live-preview",
sharpening: "before-after",
// Watermark & overlay
"watermark-text": "before-after",
"watermark-image": "before-after",
"text-overlay": "before-after",
compose: "before-after",
"meme-generator": "no-dropzone",
// Utilities
info: "no-comparison",
compare: "before-after",
"find-duplicates": "custom-results",
"color-palette": "no-comparison",
"qr-generate": "no-dropzone",
"html-to-image": "no-dropzone",
"barcode-read": "before-after",
"image-to-base64": "custom-results",
// Layout & composition
collage: "no-dropzone",
stitch: "no-comparison",
split: "interactive-split",
border: "live-preview",
beautify: "live-preview",
// Format & conversion
"svg-to-raster": "before-after",
vectorize: "before-after",
"gif-tools": "before-after",
// Optimization extras
"bulk-rename": "before-after",
favicon: "before-after",
"image-to-pdf": "before-after",
"optimize-for-web": "before-after",
"pdf-to-image": "no-dropzone",
// Adjustments extra
"replace-color": "before-after",
"color-blindness": "before-after",
// AI tools
"remove-background": "before-after",
upscale: "before-after",
ocr: "before-after",
"blur-faces": "before-after",
"enhance-faces": "before-after",
"erase-object": "interactive-eraser",
"smart-crop": "before-after",
"image-enhancement": "before-after",
colorize: "before-after",
"noise-removal": "before-after",
"passport-photo": "custom-results",
"red-eye-removal": "before-after",
"restore-photo": "before-after",
"transparency-fixer": "before-after",
"content-aware-resize": "side-by-side",
"ai-canvas-expand": "before-after",
};
+78 -142
View File
@@ -10,19 +10,16 @@ import type { Crop } from "react-image-crop";
import type { BgPreviewState } from "@/components/common/image-viewer";
import type { EraserCanvasRef } from "@/components/tools/eraser-canvas";
import type { PreviewTransform } from "@/components/tools/rotate-settings";
import { TOOL_DISPLAY_MODES } from "./tool-display-modes";
// ── Display modes ──────────────────────────────────────────────────
// The DisplayMode type and per-tool map live in tool-display-modes.ts (a pure
// module with no React imports) so tests can introspect them. Re-exported here
// for existing importers.
export type DisplayMode =
| "side-by-side"
| "before-after"
| "live-preview"
| "no-comparison"
| "interactive-crop"
| "interactive-eraser"
| "interactive-split"
| "no-dropzone"
| "custom-results";
export type { DisplayMode } from "./tool-display-modes";
import type { DisplayMode } from "./tool-display-modes";
// ── Crop and eraser prop types ─────────────────────────────────────
@@ -371,161 +368,100 @@ function EraseObjectSettingsWrapper(props: { eraserProps?: EraserProps }) {
}
// ── The registry ───────────────────────────────────────────────────
// Display modes come from TOOL_DISPLAY_MODES (tool-display-modes.ts); entries
// here hold only the React-side pieces (Settings, ResultsPanel, livePreview,
// accept). The merge below throws at module load if either side is missing a
// tool, so drift between the two files cannot ship.
export const toolRegistry = new Map<string, ToolRegistryEntry>([
type RegistryEntryConfig = Omit<ToolRegistryEntry, "displayMode">;
const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [
// Essentials
["resize", { displayMode: "side-by-side", Settings: ResizeSettings }],
["crop", { displayMode: "interactive-crop", Settings: CropSettingsWrapper as never }],
[
"rotate",
{
displayMode: "side-by-side",
livePreview: true,
Settings: RotateSettings as never,
},
],
["convert", { displayMode: "no-comparison", Settings: ConvertSettings }],
["compress", { displayMode: "before-after", Settings: CompressSettings }],
["strip-metadata", { displayMode: "no-comparison", Settings: StripMetadataSettings }],
["edit-metadata", { displayMode: "no-comparison", Settings: EditMetadataSettings }],
["resize", { Settings: ResizeSettings }],
["crop", { Settings: CropSettingsWrapper as never }],
["rotate", { livePreview: true, Settings: RotateSettings as never }],
["convert", { Settings: ConvertSettings }],
["compress", { Settings: CompressSettings }],
["strip-metadata", { Settings: StripMetadataSettings }],
["edit-metadata", { Settings: EditMetadataSettings }],
// Color adjustments (consolidated)
[
"adjust-colors",
{
displayMode: "live-preview" as DisplayMode,
livePreview: true,
Settings: makeColorSettingsComponent("adjust-colors") as never,
},
{ livePreview: true, Settings: makeColorSettingsComponent("adjust-colors") as never },
],
// Sharpening
["sharpening", { displayMode: "before-after", Settings: SharpeningSettings }],
["sharpening", { Settings: SharpeningSettings }],
// Watermark & Overlay
["watermark-text", { displayMode: "before-after", Settings: WatermarkTextSettings }],
["watermark-image", { displayMode: "before-after", Settings: WatermarkImageSettings }],
["text-overlay", { displayMode: "before-after", Settings: TextOverlaySettings }],
["compose", { displayMode: "before-after", Settings: ComposeSettings }],
[
"meme-generator",
{
displayMode: "no-dropzone",
Settings: MemeGeneratorSettings,
ResultsPanel: MemeGeneratorPreview,
},
],
["watermark-text", { Settings: WatermarkTextSettings }],
["watermark-image", { Settings: WatermarkImageSettings }],
["text-overlay", { Settings: TextOverlaySettings }],
["compose", { Settings: ComposeSettings }],
["meme-generator", { Settings: MemeGeneratorSettings, ResultsPanel: MemeGeneratorPreview }],
// Utilities
["info", { displayMode: "no-comparison", Settings: InfoSettings }],
["compare", { displayMode: "before-after", Settings: CompareSettings }],
[
"find-duplicates",
{
displayMode: "custom-results",
Settings: FindDuplicatesSettings,
ResultsPanel: FindDuplicatesResults,
},
],
["color-palette", { displayMode: "no-comparison", Settings: ColorPaletteSettings }],
[
"qr-generate",
{ displayMode: "no-dropzone", Settings: QrGenerateSettings, ResultsPanel: QrGeneratePreview },
],
[
"html-to-image",
{
displayMode: "no-dropzone",
Settings: HtmlToImageSettings,
ResultsPanel: HtmlToImageResults,
},
],
["barcode-read", { displayMode: "before-after", Settings: BarcodeReadSettings }],
[
"image-to-base64",
{
displayMode: "custom-results",
Settings: ImageToBase64Settings,
ResultsPanel: ImageToBase64Results,
},
],
["info", { Settings: InfoSettings }],
["compare", { Settings: CompareSettings }],
["find-duplicates", { Settings: FindDuplicatesSettings, ResultsPanel: FindDuplicatesResults }],
["color-palette", { Settings: ColorPaletteSettings }],
["qr-generate", { Settings: QrGenerateSettings, ResultsPanel: QrGeneratePreview }],
["html-to-image", { Settings: HtmlToImageSettings, ResultsPanel: HtmlToImageResults }],
["barcode-read", { Settings: BarcodeReadSettings }],
["image-to-base64", { Settings: ImageToBase64Settings, ResultsPanel: ImageToBase64Results }],
// Layout & Composition
[
"collage",
{ displayMode: "no-dropzone", Settings: CollageSettings, ResultsPanel: CollagePreview },
],
["stitch", { displayMode: "no-comparison", Settings: StitchSettings }],
[
"split",
{ displayMode: "interactive-split", Settings: SplitSettings, ResultsPanel: SplitCanvas },
],
["border", { displayMode: "live-preview", livePreview: true, Settings: BorderSettings as never }],
[
"beautify",
{ displayMode: "live-preview", livePreview: true, Settings: BeautifySettings as never },
],
["collage", { Settings: CollageSettings, ResultsPanel: CollagePreview }],
["stitch", { Settings: StitchSettings }],
["split", { Settings: SplitSettings, ResultsPanel: SplitCanvas }],
["border", { livePreview: true, Settings: BorderSettings as never }],
["beautify", { livePreview: true, Settings: BeautifySettings as never }],
// Format & Conversion
[
"svg-to-raster",
{ displayMode: "before-after", accept: ".svg,.svgz", Settings: SvgToRasterSettings },
],
["vectorize", { displayMode: "before-after", Settings: VectorizeSettings }],
["gif-tools", { displayMode: "before-after", Settings: GifToolsSettings }],
["svg-to-raster", { accept: ".svg,.svgz", Settings: SvgToRasterSettings }],
["vectorize", { Settings: VectorizeSettings }],
["gif-tools", { Settings: GifToolsSettings }],
// Optimization extras
["bulk-rename", { displayMode: "before-after", Settings: BulkRenameSettings }],
["favicon", { displayMode: "before-after", Settings: FaviconSettings }],
["image-to-pdf", { displayMode: "before-after", Settings: ImageToPdfSettings }],
["optimize-for-web", { displayMode: "before-after", Settings: OptimizeForWebSettings }],
[
"pdf-to-image",
{ displayMode: "no-dropzone", Settings: PdfToImageSettings, ResultsPanel: PdfToImagePreview },
],
["bulk-rename", { Settings: BulkRenameSettings }],
["favicon", { Settings: FaviconSettings }],
["image-to-pdf", { Settings: ImageToPdfSettings }],
["optimize-for-web", { Settings: OptimizeForWebSettings }],
["pdf-to-image", { Settings: PdfToImageSettings, ResultsPanel: PdfToImagePreview }],
// Adjustments extra
["replace-color", { displayMode: "before-after", Settings: ReplaceColorSettings }],
["color-blindness", { displayMode: "before-after", Settings: ColorBlindnessSettings }],
["replace-color", { Settings: ReplaceColorSettings }],
["color-blindness", { Settings: ColorBlindnessSettings }],
// AI Tools
["remove-background", { displayMode: "before-after", Settings: RemoveBgSettings }],
["upscale", { displayMode: "before-after", Settings: UpscaleSettings }],
["ocr", { displayMode: "before-after", Settings: OcrSettings }],
["blur-faces", { displayMode: "before-after", Settings: BlurFacesSettings }],
["enhance-faces", { displayMode: "before-after", Settings: EnhanceFacesSettings }],
[
"erase-object",
{
displayMode: "interactive-eraser",
Settings: EraseObjectSettingsWrapper as never,
},
],
["smart-crop", { displayMode: "before-after", Settings: SmartCropSettings }],
[
"image-enhancement",
{
displayMode: "before-after" as DisplayMode,
livePreview: true,
Settings: ImageEnhancementSettings as never,
},
],
["colorize", { displayMode: "before-after", Settings: ColorizeSettings }],
["noise-removal", { displayMode: "before-after", Settings: NoiseRemovalSettings }],
[
"passport-photo",
{
displayMode: "custom-results",
Settings: PassportPhotoSettings,
ResultsPanel: PassportPhotoPreview,
},
],
["red-eye-removal", { displayMode: "before-after", Settings: RedEyeRemovalSettings }],
["restore-photo", { displayMode: "before-after", Settings: RestorePhotoSettings }],
["transparency-fixer", { displayMode: "before-after", Settings: TransparencyFixerSettings }],
["content-aware-resize", { displayMode: "side-by-side", Settings: ContentAwareResizeSettings }],
["ai-canvas-expand", { displayMode: "before-after", Settings: AiCanvasExpandSettings }],
]);
["remove-background", { Settings: RemoveBgSettings }],
["upscale", { Settings: UpscaleSettings }],
["ocr", { Settings: OcrSettings }],
["blur-faces", { Settings: BlurFacesSettings }],
["enhance-faces", { Settings: EnhanceFacesSettings }],
["erase-object", { Settings: EraseObjectSettingsWrapper as never }],
["smart-crop", { Settings: SmartCropSettings }],
["image-enhancement", { livePreview: true, Settings: ImageEnhancementSettings as never }],
["colorize", { Settings: ColorizeSettings }],
["noise-removal", { Settings: NoiseRemovalSettings }],
["passport-photo", { Settings: PassportPhotoSettings, ResultsPanel: PassportPhotoPreview }],
["red-eye-removal", { Settings: RedEyeRemovalSettings }],
["restore-photo", { Settings: RestorePhotoSettings }],
["transparency-fixer", { Settings: TransparencyFixerSettings }],
["content-aware-resize", { Settings: ContentAwareResizeSettings }],
["ai-canvas-expand", { Settings: AiCanvasExpandSettings }],
];
export const toolRegistry = new Map<string, ToolRegistryEntry>(
ENTRY_CONFIG.map(([toolId, entry]) => {
const displayMode = TOOL_DISPLAY_MODES[toolId];
if (!displayMode) {
throw new Error(`Tool "${toolId}" has no display mode in tool-display-modes.ts`);
}
return [toolId, { ...entry, displayMode }];
}),
);
export function getToolRegistryEntry(toolId: string): ToolRegistryEntry | undefined {
return toolRegistry.get(toolId);
+13
View File
@@ -22,6 +22,19 @@ export default defineConfig({
},
},
},
// vite preview serves the production build for e2e runs; it needs the same
// /api proxy the dev server has (the app always calls relative /api).
preview: {
host: true,
port: Number(process.env.PORT) || 1351,
proxy: {
"/api": {
target: process.env.VITE_API_URL || "http://localhost:13490",
timeout: 300_000,
proxyTimeout: 300_000,
},
},
},
build: {
rollupOptions: {},
},