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

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

+26 -16
View File
@@ -1,4 +1,4 @@
import { expect, openSettings, test } from "./helpers";
import { changePasswordViaApi, expect, login, openSettings, putSettings, test } from "./helpers";
// ---------------------------------------------------------------------------
// Settings Dialog -- General, System Settings, About tabs
@@ -204,11 +204,10 @@ test.describe("GUI Settings - General Tab", () => {
await page.waitForURL(/\/fullscreen/, { timeout: 10_000 });
expect(page.url()).toContain("/fullscreen");
// Restore original value
await openSettings(page);
await page.locator("select").first().selectOption(originalValue);
await page.getByRole("button", { name: /save settings/i }).click();
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
// Restore via API: the fullscreen layout has no reachable settings entry,
// and an unrestored value poisons every later test on the shared server.
const restore = await putSettings(page, { defaultToolView: originalValue });
expect(restore.ok).toBeTruthy();
});
test("shows App Version string", async ({ loggedInPage: page }) => {
@@ -225,7 +224,12 @@ test.describe("GUI Settings - General Tab", () => {
await expect(page.getByRole("button", { name: /save settings/i })).toBeVisible();
});
test("logout button redirects to /login", async ({ loggedInPage: page }) => {
test("logout button redirects to /login", async ({ browser }) => {
// Isolated session: logging out revokes the token server-side, and the
// shared storageState token must survive for every later test in the run.
const context = await browser.newContext({ storageState: undefined });
const page = await context.newPage();
await login(page);
await openSettings(page);
const logoutBtn = page.getByRole("button", { name: /log out/i });
@@ -236,6 +240,7 @@ test.describe("GUI Settings - General Tab", () => {
// Should be on the login page
expect(page.url()).toContain("/login");
await context.close();
});
});
@@ -277,7 +282,7 @@ test.describe("GUI Settings - System Settings Tab", () => {
await openSettings(page);
await page.getByRole("button", { name: /system settings/i }).click();
await expect(page.getByText("Language")).toBeVisible();
await expect(page.getByText("Language", { exact: true })).toBeVisible();
await expect(page.getByText("Language for the interface")).toBeVisible();
const langSelect = page.locator("select").filter({ has: page.locator("option[value='en']") });
await expect(langSelect).toBeVisible();
@@ -737,12 +742,17 @@ test.describe("GUI Settings - Audit Log Tab", () => {
await openSettings(page);
await page.getByRole("button", { name: /security/i }).click();
// Client policy requires 8+ chars, so a temporary password is used and
// reverted via API right after (the current session token survives).
await page.getByPlaceholder("Current Password").fill("admin");
await page.getByPlaceholder("New Password").first().fill("admin");
await page.getByPlaceholder("Confirm New Password").fill("admin");
await page.getByPlaceholder("New Password").first().fill("Testpass123");
await page.getByPlaceholder("Confirm New Password").fill("Testpass123");
await page.getByRole("button", { name: /change password/i }).click();
await expect(page.getByText("Password changed successfully")).toBeVisible({ timeout: 5_000 });
const revert = await changePasswordViaApi(page, "Testpass123", "admin");
expect(revert.ok).toBeTruthy();
// Navigate to audit log and filter by PASSWORD_CHANGED
await page.getByRole("button", { name: /audit log/i }).click();
await expect(page.locator("table thead")).toBeVisible({ timeout: 10_000 });
@@ -790,7 +800,7 @@ test.describe("GUI Settings - System Settings (extended)", () => {
test("changed Language persists after dialog re-open", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /system settings/i }).click();
await expect(page.getByText("Language")).toBeVisible();
await expect(page.getByText("Language", { exact: true })).toBeVisible();
const langSelect = page.locator("select").filter({ has: page.locator("option[value='en']") });
const originalValue = await langSelect.inputValue();
@@ -815,12 +825,12 @@ test.describe("GUI Settings - System Settings (extended)", () => {
// Verify the language select persisted the new value
const langSelect2 = page.locator("select").filter({ has: page.locator("option[value='en']") });
const persisted = await langSelect2.inputValue();
expect(persisted).toBe(newValue);
// Restore original locale
await langSelect2.selectOption(originalValue);
await page.locator("button").filter({ hasText: /save/i }).first().click();
await page.waitForTimeout(2_000);
// Restore via API before asserting: a failed assertion must not leave the
// server in a non-English locale for every later test.
const restore = await putSettings(page, { defaultLocale: originalValue });
expect(restore.ok).toBeTruthy();
expect(persisted).toBe(newValue);
});
test("changed Login Attempt Limit persists after dialog re-open", async ({
+11 -5
View File
@@ -1,4 +1,4 @@
import { expect, openSettings, test } from "./helpers";
import { changePasswordViaApi, expect, openSettings, test } from "./helpers";
// ---------------------------------------------------------------------------
// Settings Dialog -- Security (change password) and API Keys tabs
@@ -82,12 +82,15 @@ test.describe("GUI Settings - Security Tab", () => {
// Change password from admin -> admin (same value, to avoid breaking other tests)
await page.getByPlaceholder("Current Password").fill("admin");
await page.getByPlaceholder("New Password").first().fill("admin");
await page.getByPlaceholder("Confirm New Password").fill("admin");
await page.getByPlaceholder("New Password").first().fill("Testpass123");
await page.getByPlaceholder("Confirm New Password").fill("Testpass123");
await page.getByRole("button", { name: /change password/i }).click();
await expect(page.getByText("Password changed successfully")).toBeVisible({ timeout: 5_000 });
const revert = await changePasswordViaApi(page, "Testpass123", "admin");
expect(revert.ok).toBeTruthy();
});
test("form fields are cleared after successful password change", async ({
@@ -97,12 +100,15 @@ test.describe("GUI Settings - Security Tab", () => {
await page.getByRole("button", { name: /security/i }).click();
await page.getByPlaceholder("Current Password").fill("admin");
await page.getByPlaceholder("New Password").first().fill("admin");
await page.getByPlaceholder("Confirm New Password").fill("admin");
await page.getByPlaceholder("New Password").first().fill("Testpass123");
await page.getByPlaceholder("Confirm New Password").fill("Testpass123");
await page.getByRole("button", { name: /change password/i }).click();
await expect(page.getByText("Password changed successfully")).toBeVisible({ timeout: 5_000 });
const revert = await changePasswordViaApi(page, "Testpass123", "admin");
expect(revert.ok).toBeTruthy();
// All fields should be cleared after success
await expect(page.getByPlaceholder("Current Password")).toHaveValue("");
await expect(page.getByPlaceholder("New Password").first()).toHaveValue("");
+17 -4
View File
@@ -717,7 +717,9 @@ test.describe("GUI Expanded Tool Coverage", () => {
test("none background tab hides background controls", async ({ loggedInPage: page }) => {
await page.goto("/beautify");
await page.getByRole("button", { name: "None" }).click();
// Multiple sections (background, shadow, frame) each have a "None"
// option; the background tabs are the first group.
await page.getByRole("button", { name: "None" }).first().click();
});
test("iPhone frame option is selectable", async ({ loggedInPage: page }) => {
@@ -863,7 +865,7 @@ test.describe("GUI Expanded Tool Coverage", () => {
await page.getByRole("button", { name: "Percentage" }).click();
// Percentage input should be visible
await expect(page.locator("#gif-percentage")).toBeVisible();
await expect(page.locator("#gif-pct")).toBeVisible();
});
test("resize pixel mode width input accepts values", async ({ loggedInPage: page }) => {
@@ -1191,10 +1193,12 @@ test.describe("GUI Expanded Tool Coverage", () => {
// FAVICON (expanded -- settings and processing)
// ========================================================================
test.describe("Favicon Expanded", () => {
test("shows mstile sizes in generated list", async ({ loggedInPage: page }) => {
test("shows generated icon sizes in list", async ({ loggedInPage: page }) => {
await page.goto("/favicon");
await expect(page.getByText("mstile-150x150.png")).toBeVisible();
// Current generated set (mstile was dropped from FAVICON_SIZES)
await expect(page.getByText("android-chrome-512x512.png")).toBeVisible();
await expect(page.getByText("apple-touch-icon.png")).toBeVisible();
});
test("undo after favicon generation returns to settings", async ({ loggedInPage: page }) => {
@@ -1375,6 +1379,15 @@ test.describe("GUI Expanded Tool Coverage", () => {
test.describe("Navigate Away Resets (expanded tools)", () => {
test("ai-canvas-expand: navigate away resets state", async ({ loggedInPage: page }) => {
await page.goto("/ai-canvas-expand");
// On servers without the AI bundle the page shows an install prompt
// instead of a dropzone; there is no state to reset.
const uploadVisible = await page
.getByText("Upload from computer")
.isVisible({ timeout: 3000 })
.catch(() => false);
test.skip(!uploadVisible, "AI canvas-expand bundle not installed");
await uploadTestImage(page);
await page.locator("#cac-top").fill("20");
+28
View File
@@ -0,0 +1,28 @@
import { TOOLS } from "../../packages/shared/src/constants";
import { expect, test } from "./helpers";
// ---------------------------------------------------------------------------
// Visual baseline per tool page, generated from the shared TOOLS catalog.
//
// Runs in the chromium-visual project only. Baselines are platform-suffixed:
// darwin baselines come from local runs, linux baselines from the
// update-visual-baselines workflow (which opens a PR with refreshed goldens).
// AI tool pages capture whatever the default install state shows, which in CI
// is the install prompt.
// ---------------------------------------------------------------------------
test.describe("Tool page visual baselines", () => {
for (const tool of TOOLS) {
test(`${tool.id} page matches baseline`, async ({ loggedInPage: page }) => {
await page.goto(`/${tool.id}`);
// Settle: the tool name renders after the lazy settings chunk loads.
await expect(page.getByText(tool.name, { exact: false }).first()).toBeVisible();
await page.waitForLoadState("networkidle");
await expect(page).toHaveScreenshot(`tool-${tool.id}.png`, {
fullPage: false,
animations: "disabled",
});
});
}
});
+65 -2
View File
@@ -113,8 +113,14 @@ export async function uploadTestImage(page: Page): Promise<void> {
const testImagePath = getTestImagePath();
const fileChooserPromise = page.waitForEvent("filechooser");
const dropzone = page.locator("[class*='border-dashed']").first();
await dropzone.click();
// Prefer the explicit upload button; on some tool pages the first
// border-dashed element is a settings section, not the dropzone.
const uploadButton = page.getByRole("button", { name: /upload from computer/i }).first();
if (await uploadButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await uploadButton.click();
} else {
await page.locator("[class*='border-dashed']").first().click();
}
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(testImagePath);
@@ -141,10 +147,60 @@ export async function waitForProcessing(page: Page, timeoutMs = 30_000) {
// (all "chromium" project tests already have auth via storageState,
// but this provides backward compatibility for tests that use it)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// putSettings() — write system settings through the API using the page's
// bearer token (the app stores it in localStorage, so page.request alone
// sends no auth).
// ---------------------------------------------------------------------------
async function getAuthToken(page: Page): Promise<string | null> {
return page.evaluate(() => localStorage.getItem("snapotter-token")).catch(() => null);
}
export async function putSettings(
page: Page,
data: Record<string, string>,
): Promise<{ ok: boolean; status: number }> {
const token = await getAuthToken(page);
const res = await page.request.put("/api/v1/settings", {
headers: token ? { authorization: `Bearer ${token}` } : {},
data,
});
return { ok: res.ok(), status: res.status() };
}
/**
* changePasswordViaApi() revert a password change without driving the UI.
* The current session token survives a password change (the API only revokes
* other sessions), so tests that successfully change the admin password MUST
* call this to restore it before finishing.
*/
export async function changePasswordViaApi(
page: Page,
currentPassword: string,
newPassword: string,
): Promise<{ ok: boolean; status: number }> {
const token = await getAuthToken(page);
const res = await page.request.post("/api/auth/change-password", {
headers: token ? { authorization: `Bearer ${token}` } : {},
data: { currentPassword, newPassword },
});
return { ok: res.ok(), status: res.status() };
}
export const test = base.extend<{ loggedInPage: Page }>({
loggedInPage: async ({ page }, use) => {
// storageState is already loaded by the project config, just navigate
await page.goto("/");
// Self-heal global server settings a crashed predecessor may have left
// mutated (e.g. defaultToolView=fullscreen redirects "/" and hides the
// sidebar, cascading failures through every later test on the shared DB).
const healed = await putSettings(page, { defaultToolView: "sidebar", defaultLocale: "en" });
if (!healed.ok) {
console.warn(`loggedInPage settings heal failed with status ${healed.status}`);
}
if (page.url().includes("/fullscreen")) {
await page.goto("/");
}
await use(page);
},
});
@@ -170,6 +226,13 @@ export async function isAiSidecarRunning(page: Page): Promise<boolean> {
// ---------------------------------------------------------------------------
export async function openSettings(page: Page): Promise<void> {
const sidebar = page.locator("aside");
if (!(await sidebar.isVisible({ timeout: 2000 }).catch(() => false))) {
// Fullscreen grid layout hides the aside behind a banner "Sidebar" toggle.
const sidebarToggle = page.getByRole("button", { name: /^sidebar$/i });
if (await sidebarToggle.isVisible({ timeout: 1000 }).catch(() => false)) {
await sidebarToggle.click();
}
}
if (await sidebar.isVisible({ timeout: 2000 }).catch(() => false)) {
await sidebar.getByText("Settings").click();
} else {
+5 -2
View File
@@ -1,8 +1,11 @@
import { expect, test, uploadTestImage } from "./helpers";
test.describe("Home Page", () => {
test("shows SnapOtter branding in dropzone", async ({ loggedInPage: page }) => {
await expect(page.getByText("SnapOtter").first()).toBeVisible();
test("shows branding and dropzone prompt", async ({ loggedInPage: page }) => {
// The wordmark renders as a logo image, not text; the document title is
// the stable brand assertion.
await expect(page).toHaveTitle(/SnapOtter/i);
await expect(page.getByText("Drop your images here")).toBeVisible();
});
test("dropzone shows upload button", async ({ loggedInPage: page }) => {
+5 -4
View File
@@ -1,4 +1,5 @@
import { test as base, expect } from "@playwright/test";
import { authFile } from "../../playwright.config";
import { login, openSettings } from "./helpers";
const API = process.env.API_URL || "http://localhost:13490";
@@ -136,7 +137,7 @@ async function deleteRoleByName(adminToken: string, name: string): Promise<void>
base.describe("RBAC Full — People Management UI", () => {
base.use({
storageState: "test-results/.auth/user.json",
storageState: authFile,
});
base.test(
@@ -169,7 +170,7 @@ base.describe("RBAC Full — People Management UI", () => {
base.describe("RBAC Full — Roles Management UI", () => {
base.use({
storageState: "test-results/.auth/user.json",
storageState: authFile,
});
base.test("admin sees Roles tab in settings", async ({ page }) => {
@@ -202,7 +203,7 @@ base.describe("RBAC Full — Roles Management UI", () => {
base.describe("RBAC Full — Audit Log UI", () => {
base.use({
storageState: "test-results/.auth/user.json",
storageState: authFile,
});
base.test("admin sees Audit Log tab in settings", async ({ page }) => {
@@ -239,7 +240,7 @@ base.describe("RBAC Full — Audit Log UI", () => {
base.describe("RBAC Full — API Key Scoping UI", () => {
base.use({
storageState: "test-results/.auth/user.json",
storageState: authFile,
});
base.test("API Keys section has permission scoping toggle", async ({ page }) => {
+1 -1
View File
@@ -66,7 +66,7 @@ test.describe("Smoke tests", () => {
// The dropzone should be visible
await expect(page.getByText("Upload from computer")).toBeVisible();
await expect(page.getByText("Drop files here")).toBeVisible();
await expect(page.getByText("Drop your images here")).toBeVisible();
});
test("sidebar is visible on desktop", async ({ loggedInPage: page }) => {
+27 -84
View File
@@ -1,72 +1,32 @@
import { TOOL_DISPLAY_MODES } from "../../apps/web/src/lib/tool-display-modes";
import { TOOLS } from "../../packages/shared/src/constants";
import { TOOL_BUNDLE_MAP } from "../../packages/shared/src/features";
import { expect, test, uploadTestImage } from "./helpers";
// ---------------------------------------------------------------------------
// Test that EVERY tool page loads, shows correct name, and has the right UI.
// This covers the full 37-tool catalog from the PRD.
// Every tool page must load, show its name, and render the right UI shell.
// Generated from the shared TOOLS catalog + the display-mode map, so a newly
// added tool is covered automatically and a missing registry entry fails here.
// ---------------------------------------------------------------------------
const TOOLS_WITH_DROPZONE = [
{ id: "resize", name: "Resize" },
{ id: "crop", name: "Crop" },
{ id: "rotate", name: "Rotate" },
{ id: "convert", name: "Convert" },
{ id: "compress", name: "Compress" },
{ id: "strip-metadata", name: "Remove Metadata" },
{ id: "edit-metadata", name: "Edit Metadata" },
{ id: "bulk-rename", name: "Bulk Rename" },
{ id: "image-to-pdf", name: "Image to PDF" },
{ id: "favicon", name: "Favicon" },
{ id: "adjust-colors", name: "Adjust Colors" },
{ id: "replace-color", name: "Replace" },
{ id: "remove-background", name: "Remove Background" },
{ id: "upscale", name: "Upscal" },
{ id: "erase-object", name: "Object Eraser" },
{ id: "ocr", name: "OCR" },
{ id: "blur-faces", name: "Face" },
{ id: "smart-crop", name: "Smart Crop" },
{ id: "watermark-text", name: "Text Watermark" },
{ id: "watermark-image", name: "Image Watermark" },
{ id: "text-overlay", name: "Text Overlay" },
{ id: "compose", name: "Image Composition" },
{ id: "info", name: "Image Info" },
{ id: "compare", name: "Image Compare" },
{ id: "find-duplicates", name: "Find Duplicates" },
{ id: "color-palette", name: "Color Palette" },
{ id: "barcode-read", name: "Barcode" },
{ id: "collage", name: "Collage", customDropzone: true },
{ id: "stitch", name: "Stitch" },
{ id: "split", name: "Image Splitting" },
{ id: "border", name: "Border" },
{ id: "svg-to-raster", name: "SVG to Raster" },
{ id: "vectorize", name: "Image to SVG" },
{ id: "gif-tools", name: "GIF" },
{ id: "noise-removal", name: "Noise Removal" },
{ id: "transparency-fixer", name: "PNG Transparency Fixer" },
];
const TOOLS_WITHOUT_DROPZONE = [{ id: "qr-generate", name: "QR Code" }];
const AI_TOOL_IDS = new Set([
"remove-background",
"upscale",
"erase-object",
"ocr",
"blur-faces",
"smart-crop",
"noise-removal",
"transparency-fixer",
]);
const NO_DROPZONE_MODES = new Set(["no-dropzone"]);
test.describe("All tool pages render", () => {
for (const tool of TOOLS_WITH_DROPZONE) {
test(`${tool.name} (/${tool.id}) loads with dropzone`, async ({ loggedInPage: page }) => {
for (const tool of TOOLS) {
const displayMode = TOOL_DISPLAY_MODES[tool.id];
const isAiTool = tool.id in TOOL_BUNDLE_MAP;
test(`${tool.name} (/${tool.id}) renders its UI shell`, async ({ loggedInPage: page }) => {
expect(displayMode, `tool "${tool.id}" missing from tool-display-modes.ts`).toBeTruthy();
await page.goto(`/${tool.id}`);
// Tool name should be visible
// Tool name should be visible (header renders the shared TOOLS name)
await expect(page.getByText(tool.name, { exact: false }).first()).toBeVisible();
// AI tools may show install prompt instead of dropzone when feature is not installed
if (AI_TOOL_IDS.has(tool.id)) {
// AI tools may show an install prompt instead of a dropzone when the
// model bundle is not installed.
if (isAiTool) {
const uploadVisible = await page.getByText("Upload from computer").isVisible();
if (!uploadVisible) {
await expect(
@@ -76,41 +36,24 @@ test.describe("All tool pages render", () => {
}
}
// Should show dropzone (some tools like collage use custom upload text)
const uploadText = (tool as any).customDropzone
? page.getByText(/upload/i).first()
: page.getByText("Upload from computer");
await expect(uploadText).toBeVisible();
// Collage has a custom layout (no Files/Settings headings)
if (!(tool as any).customDropzone) {
// Should show Files section
await expect(page.getByText("Files").first()).toBeVisible();
// Should show Settings section
if (NO_DROPZONE_MODES.has(displayMode)) {
// Custom-input tools (meme-generator, qr-generate, collage, html-to-image,
// pdf-to-image) render their own input UI; just require the settings panel.
await expect(page.getByText("Settings").first()).toBeVisible();
return;
}
});
}
for (const tool of TOOLS_WITHOUT_DROPZONE) {
test(`${tool.name} (/${tool.id}) loads without dropzone`, async ({ loggedInPage: page }) => {
await page.goto(`/${tool.id}`);
// Tool name should be visible
await expect(page.getByText(tool.name, { exact: false }).first()).toBeVisible();
// Should show settings
// Standard dropzone tools
await expect(page.getByText("Upload from computer")).toBeVisible();
await expect(page.getByText("Files").first()).toBeVisible();
await expect(page.getByText("Settings").first()).toBeVisible();
// Should NOT show the file upload dropzone
await expect(page.getByText("Upload from computer")).not.toBeVisible();
});
}
});
test.describe("Tool pages accept file upload", () => {
// Test a representative subset (testing all 35 would be very slow)
// Representative subset across display modes (uploading on all tools would
// be slow; per-tool processing flows live in gui-tools-*.spec.ts)
const REPRESENTATIVE_TOOLS = [
"resize",
"compress",
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File
+77
View File
@@ -0,0 +1,77 @@
import type { PictAxis } from "./zod-pict.js";
/**
* Deterministic greedy pairwise (order-2) covering-array generator.
*
* Guarantees that every pair of values from any two axes appears in at least
* one generated case, which is the standard interaction-coverage target.
* In-repo and dependency-free on purpose: the natural alternative (pict-node)
* compiles Microsoft PICT from C++ at install time, which breaks on machines
* without a working native toolchain. For our axis counts (<= ~10 per tool)
* the greedy construction is within a few cases of PICT's optimum.
*
* Determinism matters: same axes in, same cases out, so CI runs are
* reproducible. No randomness is used anywhere.
*/
export function pairwise(axes: PictAxis[]): Record<string, unknown>[] {
if (axes.length === 0) return [];
if (axes.length === 1) {
return axes[0].values.map((v) => ({ [axes[0].key]: v }));
}
// Track uncovered pairs by value indexes: "i|j|a|b" with i < j.
const uncovered = new Set<string>();
for (let i = 0; i < axes.length; i++) {
for (let j = i + 1; j < axes.length; j++) {
for (let a = 0; a < axes[i].values.length; a++) {
for (let b = 0; b < axes[j].values.length; b++) {
uncovered.add(`${i}|${j}|${a}|${b}`);
}
}
}
}
const pairKey = (x: number, vx: number, y: number, vy: number): string =>
x < y ? `${x}|${y}|${vx}|${vy}` : `${y}|${x}|${vy}|${vx}`;
const cases: number[][] = [];
while (uncovered.size > 0) {
// Seed the case with the first uncovered pair (insertion order is stable).
const seed = uncovered.values().next().value as string;
const [i, j, a, b] = seed.split("|").map(Number);
const chosen: number[] = new Array(axes.length).fill(-1);
chosen[i] = a;
chosen[j] = b;
// Fill the remaining axes greedily: pick the value covering the most
// still-uncovered pairs against the axes already chosen.
for (let k = 0; k < axes.length; k++) {
if (chosen[k] !== -1) continue;
let bestValue = 0;
let bestScore = -1;
for (let v = 0; v < axes[k].values.length; v++) {
let score = 0;
for (let m = 0; m < axes.length; m++) {
if (chosen[m] === -1 || m === k) continue;
if (uncovered.has(pairKey(k, v, m, chosen[m]))) score++;
}
if (score > bestScore) {
bestScore = score;
bestValue = v;
}
}
chosen[k] = bestValue;
}
for (let x = 0; x < axes.length; x++) {
for (let y = x + 1; y < axes.length; y++) {
uncovered.delete(`${x}|${y}|${chosen[x]}|${chosen[y]}`);
}
}
cases.push(chosen);
}
return cases.map((indexes) =>
Object.fromEntries(axes.map((axis, k) => [axis.key, axis.values[indexes[k]]])),
);
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Minimal valid settings per tool, used by the generated matrices
* (format-matrix-generated, hostile-inputs) when posting to tool routes.
*
* Default is {} (most schemas make every field optional). Tools whose schema
* rejects {} get an explicit minimal override here. The "defaults are valid"
* test in format-matrix-generated.test.ts safeParses every entry against the
* live schema, so a schema change that invalidates an entry fails at PR time
* and names the tool.
*/
export const TOOL_SETTINGS_OVERRIDES: Record<string, unknown> = {
resize: { width: 64 },
crop: { left: 0, top: 0, width: 50, height: 50 },
convert: { format: "png" },
"watermark-text": { text: "Test" },
"text-overlay": { text: "Test" },
"passport-photo": { countryCode: "us" },
};
export function defaultSettingsFor(toolId: string): unknown {
return TOOL_SETTINGS_OVERRIDES[toolId] ?? {};
}
+167
View File
@@ -0,0 +1,167 @@
import type { z } from "zod";
/**
* Derives PICT combinatorial axes from a tool's Zod settings schema.
*
* Enums and booleans enumerate their members; bounded numbers contribute
* min/mid/max; optional fields add `undefined` so "field omitted" is part of
* the matrix. Free-form strings, arrays, and nested objects are skipped here
* (the fast-check fuzz layer covers those).
*
* Zod v3 internals (`_def`) are accessed deliberately; if a Zod upgrade breaks
* this helper, the pairwise suite fails loudly at collection time.
*/
export interface PictAxis {
key: string;
values: unknown[];
}
interface ZodDefLike {
typeName?: string;
schema?: ZodSchemaLike;
innerType?: ZodSchemaLike;
in?: ZodSchemaLike;
shape?: () => Record<string, ZodSchemaLike>;
values?: unknown;
options?: ZodSchemaLike[];
checks?: Array<{ kind: string; value?: number }>;
value?: unknown;
}
interface ZodSchemaLike {
_def?: ZodDefLike;
}
/** Unwraps effects/defaults/optional/nullable wrappers around a schema. */
function unwrap(schema: ZodSchemaLike): ZodSchemaLike {
let current = schema;
for (let i = 0; i < 10; i++) {
const def = current._def;
if (!def) return current;
if (def.typeName === "ZodEffects" && def.schema) current = def.schema;
else if (def.typeName === "ZodPipeline" && def.in) current = def.in;
else if (
(def.typeName === "ZodDefault" ||
def.typeName === "ZodOptional" ||
def.typeName === "ZodNullable") &&
def.innerType
)
current = def.innerType;
else return current;
}
return current;
}
function isOmittable(schema: ZodSchemaLike): boolean {
const t = schema._def?.typeName;
return t === "ZodOptional" || t === "ZodDefault" || t === "ZodNullable";
}
function deriveValues(field: ZodSchemaLike): unknown[] | null {
const omittable = isOmittable(field);
const inner = unwrap(field);
const def = inner._def;
if (!def) return null;
let values: unknown[] | null = null;
switch (def.typeName) {
case "ZodEnum":
values = [...(def.values as string[])];
break;
case "ZodNativeEnum":
values = Object.values(def.values as Record<string, unknown>);
break;
case "ZodBoolean":
values = [true, false];
break;
case "ZodLiteral":
values = [def.value];
break;
case "ZodNumber": {
const checks = def.checks ?? [];
let min: number | undefined;
let max: number | undefined;
let isInt = false;
for (const c of checks) {
if (c.kind === "min") min = c.value;
if (c.kind === "max") max = c.value;
if (c.kind === "int") isInt = true;
}
const lo = min ?? 0;
const hi = max ?? Math.max(lo + 100, 100);
const mid = isInt ? Math.round((lo + hi) / 2) : (lo + hi) / 2;
values = [...new Set([lo, mid, hi])];
break;
}
case "ZodUnion": {
const merged = (def.options ?? []).flatMap((option) => deriveValues(option) ?? []);
values = merged.length > 0 ? [...new Set(merged)] : null;
break;
}
default:
values = null;
}
if (values && omittable) values = [...values, undefined];
return values;
}
/**
* Returns the combinatorial axes for a settings schema, or [] when the schema
* is not an object or has no enumerable fields.
*/
export function deriveAxes(schema: z.ZodType<unknown, z.ZodTypeDef, unknown>): PictAxis[] {
const obj = unwrap(schema as ZodSchemaLike);
if (obj._def?.typeName !== "ZodObject" || typeof obj._def.shape !== "function") return [];
const axes: PictAxis[] = [];
for (const [key, field] of Object.entries(obj._def.shape())) {
const values = deriveValues(field);
// An axis needs at least two values to contribute to pair coverage.
if (values && values.length >= 2) axes.push({ key, values });
}
return axes;
}
/** Removes undefined-valued keys so "omitted" really means omitted. */
export function compactCase(combo: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(combo).filter(([, v]) => v !== undefined));
}
/**
* Recursively collects every string sub-schema that carries a regex check
* (hex colors and similar). zod-fast-check cannot generate for those without
* an override, so the fuzz suite overrides each collected instance.
*/
export function collectRegexStringSchemas(schema: unknown): unknown[] {
const found: unknown[] = [];
const seen = new Set<unknown>();
const visit = (node: ZodSchemaLike | undefined): void => {
if (!node || typeof node !== "object" || seen.has(node)) return;
seen.add(node);
const def = node._def;
if (!def) return;
if (def.typeName === "ZodString") {
const hasRegex = (def.checks ?? []).some((c) => c.kind === "regex");
if (hasRegex) found.push(node);
return;
}
if (def.typeName === "ZodObject" && typeof def.shape === "function") {
for (const field of Object.values(def.shape())) visit(field);
return;
}
// Wrappers and containers
visit(def.schema);
visit(def.innerType);
visit(def.in);
visit((def as { type?: ZodSchemaLike }).type); // ZodArray element
for (const option of def.options ?? []) visit(option);
};
visit(schema as ZodSchemaLike);
return found;
}
@@ -0,0 +1,146 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { TOOLS } from "@snapotter/shared";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getRegisteredToolIds, getToolConfig } from "../../apps/api/src/routes/tool-factory.js";
import { defaultSettingsFor, TOOL_SETTINGS_OVERRIDES } from "../helpers/tool-default-settings.js";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
/**
* Registry-generated tool x format matrix.
*
* Every registered tool is exercised against every input format fixture with
* its minimal valid settings. The invariant is the factory's error contract:
* success (200/202), clean rejection (400/413/415/422), or AI-not-installed
* (501). A 500 or an undecodable "successful" output is a bug.
*
* PR runs use the core web formats; FULL_MATRIX=1 (nightly) unlocks all
* fixtures in tests/fixtures/formats/.
*/
const FORMATS_DIR = join(__dirname, "..", "fixtures", "formats");
const CORE_FORMATS = [
"sample.png",
"sample.jpg",
"sample.webp",
"sample.gif",
"sample.svg",
"sample.heic",
];
const fixtureFiles = process.env.FULL_MATRIX
? readdirSync(FORMATS_DIR).filter((f) => !f.startsWith("."))
: CORE_FORMATS;
const ALLOWED_STATUSES = new Set([200, 202, 400, 413, 415, 422, 501]);
/** Content types whose payloads are not raster images (skip pixel decode). */
const NON_RASTER_OUTPUT = new Set([
"application/pdf",
"application/json",
"application/zip",
"image/svg+xml",
"text/plain",
]);
describe("tool x format matrix (generated)", () => {
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
it("settings overrides only reference registered tools", () => {
const registered = new Set(getRegisteredToolIds());
for (const toolId of Object.keys(TOOL_SETTINGS_OVERRIDES)) {
expect(registered.has(toolId), `override for unknown tool "${toolId}"`).toBe(true);
}
});
it("default settings are valid for every registered tool", () => {
const invalid: string[] = [];
for (const toolId of getRegisteredToolIds()) {
const config = getToolConfig(toolId);
if (!config) continue;
const result = config.settingsSchema.safeParse(defaultSettingsFor(toolId));
if (!result.success) {
invalid.push(
`${toolId}: ${result.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`,
);
}
}
expect(
invalid,
`tools needing TOOL_SETTINGS_OVERRIDES entries:\n${invalid.join("\n")}`,
).toEqual([]);
});
for (const tool of TOOLS) {
const toolId = tool.id;
it(`${toolId} handles every input format cleanly`, async () => {
for (const fixture of fixtureFiles) {
const content = readFileSync(join(FORMATS_DIR, fixture));
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: fixture, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify(defaultSettingsFor(toolId)) },
]);
const res = await testApp.app.inject({
method: "POST",
url: `/api/v1/tools/${toolId}`,
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
// Custom-route tools can 404 on the standard path; covered elsewhere.
if (res.statusCode === 404) return;
expect(
ALLOWED_STATUSES.has(res.statusCode),
`${toolId} x ${fixture}: status ${res.statusCode}: ${res.body.slice(0, 300)}`,
).toBe(true);
if (res.statusCode === 200) {
const resType = (res.headers["content-type"]?.toString() ?? "").split(";")[0];
if (resType !== "application/json") {
// Tools like bulk-rename/favicon/split stream a ZIP directly.
if (resType === "application/zip") {
expect(
res.rawPayload.subarray(0, 2).toString("latin1"),
`${toolId} x ${fixture}: ZIP response is not a ZIP`,
).toBe("PK");
}
continue;
}
const payload = JSON.parse(res.body) as { downloadUrl?: string };
if (!payload.downloadUrl) continue;
const dl = await testApp.app.inject({
method: "GET",
url: payload.downloadUrl,
headers: { authorization: `Bearer ${adminToken}` },
});
expect(dl.statusCode, `${toolId} x ${fixture}: download failed`).toBe(200);
const outType = dl.headers["content-type"]?.toString() ?? "";
const isRaster =
!NON_RASTER_OUTPUT.has(outType.split(";")[0]) && outType.startsWith("image/");
const sharpDecodable =
isRaster &&
!["image/heic", "image/heif", "image/x-icon", "image/qoi"].includes(
outType.split(";")[0],
);
if (sharpDecodable) {
// The processed output must actually decode; a corrupt "success" is a bug.
const meta = await sharp(dl.rawPayload).metadata();
expect(meta.width, `${toolId} x ${fixture}: output not decodable`).toBeGreaterThan(0);
}
}
}
}, 240_000);
}
});
+96
View File
@@ -0,0 +1,96 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { TOOLS } from "@snapotter/shared";
import fc from "fast-check";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { z } from "zod";
import { ZodFastCheck } from "zod-fast-check";
import { getToolConfig } from "../../apps/api/src/routes/tool-factory.js";
import { collectRegexStringSchemas } from "../helpers/zod-pict.js";
import { buildTestApp, type TestApp } from "./test-server.js";
/**
* Property-based settings fuzz: random VALID settings (derived from each
* tool's own Zod schema via zod-fast-check) must never produce crash-class
* failures. Complements the deterministic pairwise matrix with arbitrary
* strings/numbers that humans and AIs never think to write.
*
* Nightly-only (FUZZ=1); FUZZ_RUNS controls depth (default 25).
*/
const FUZZ = !!process.env.FUZZ;
const NUM_RUNS = Number(process.env.FUZZ_RUNS ?? 25);
const CRASH_PATTERN =
/TypeError|undefined is not|null is not|Cannot read propert|is not a function/i;
describe.skipIf(!FUZZ)("settings fuzz (property-based)", () => {
let testApp: TestApp;
let inputPng: Buffer;
beforeAll(async () => {
testApp = await buildTestApp();
inputPng = readFileSync(join(__dirname, "..", "fixtures", "test-200x150.png"));
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
// The registry is populated by buildTestApp() in beforeAll, so tool configs
// are looked up inside the test body; registry-exempt tools no-op here.
for (const tool of TOOLS) {
const toolId = tool.id;
it(`${toolId} never crashes on schema-valid settings`, async () => {
const config = getToolConfig(toolId);
if (!config) return;
let arbitrary: fc.Arbitrary<unknown>;
try {
let zfc = ZodFastCheck();
// zod-fast-check cannot generate regex-constrained strings (hex
// colors and friends); override every regex-checked string field
// with plausible color constants. Values that still fail the regex
// are discarded by the fc.pre() below.
for (const sub of collectRegexStringSchemas(config.settingsSchema)) {
zfc = zfc.override(
sub as z.ZodTypeAny,
fc.constantFrom("#ff0000", "#000000", "#ffffff", "#00ff7f", "#ff000080"),
);
}
arbitrary = zfc.inputOf(config.settingsSchema as z.ZodTypeAny);
} catch {
// Schema uses constructs zod-fast-check cannot derive (refinements over
// multiple fields, transforms); the pairwise matrix still covers it.
return;
}
try {
await fc.assert(
fc.asyncProperty(arbitrary, async (settings) => {
const parsed = config.settingsSchema.safeParse(settings);
fc.pre(parsed.success);
try {
await config.process(inputPng, parsed.data, "test-200x150.png");
} catch (err) {
if (!(err instanceof Error)) {
throw new Error(`${toolId} threw a non-Error: ${String(err)}`);
}
if (CRASH_PATTERN.test(err.message)) {
throw new Error(`${toolId} crashed on ${JSON.stringify(settings)}: ${err.message}`);
}
// Clean operational failure: acceptable.
}
}),
{ numRuns: NUM_RUNS, interruptAfterTimeLimit: 180_000 },
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Generator dead-ends (un-derivable sub-schema or every value failing
// a refinement) mean this tool cannot be fuzzed generically; the
// pairwise matrix still covers it. Real property failures rethrow.
if (/Unable to generate valid values|precondition/i.test(message)) return;
throw err;
}
expect(true).toBe(true);
}, 240_000);
}
});
+105
View File
@@ -0,0 +1,105 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { TOOLS } from "@snapotter/shared";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { TOOL_DISPLAY_MODES } from "../../apps/web/src/lib/tool-display-modes.js";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
/**
* Hostile-input matrix: every tool route must reject malformed, truncated,
* lying, or bomb-shaped files with a clean 4xx (or 501 for uninstalled AI
* bundles). A 500, a hang, or a success response for garbage is a bug in the
* tool, not in this test.
*
* Fixtures come from scripts/generate-hostile-fixtures.mjs (committed).
*/
const HOSTILE_DIR = join(__dirname, "..", "fixtures", "hostile");
/** Fixtures that are unreadable garbage: the server must NOT report success. */
const GARBAGE_FIXTURES = ["truncated.jpg", "zero-byte.png", "garbage.jpg", "bomb-50000x50000.png"];
/** Valid PNG bytes behind a lying .jpg extension: success or clean 4xx are both
* fine (tools sniff content, some require a specific input type); 5xx is not. */
const MISMATCH_FIXTURE = "png-bytes.jpg";
const REJECT_STATUSES = new Set([400, 413, 415, 422, 501]);
// Tools that never decode the uploaded pixel data: no-dropzone generators take
// input from settings, bulk-rename zips bytes verbatim, and the metadata tools
// operate on metadata segments only. Succeeding on a file with a valid header
// but broken pixel data is correct behavior for them; everything else must
// reject.
const INPUT_AGNOSTIC = new Set(
TOOLS.filter((t) => TOOL_DISPLAY_MODES[t.id] === "no-dropzone").map((t) => t.id),
);
INPUT_AGNOSTIC.add("bulk-rename");
INPUT_AGNOSTIC.add("edit-metadata");
INPUT_AGNOSTIC.add("strip-metadata");
INPUT_AGNOSTIC.add("info");
INPUT_AGNOSTIC.add("image-to-base64");
/** Server-error statuses; 501 (feature not installed) is a clean rejection. */
const SERVER_ERRORS = [500, 502, 503, 504];
describe("hostile input matrix", () => {
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function postFile(toolId: string, fixtureName: string) {
const content = readFileSync(join(HOSTILE_DIR, fixtureName));
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: fixtureName, contentType: "application/octet-stream", content },
{ name: "settings", content: "{}" },
]);
const started = Date.now();
const res = await testApp.app.inject({
method: "POST",
url: `/api/v1/tools/${toolId}`,
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
return { res, elapsedMs: Date.now() - started };
}
for (const tool of TOOLS) {
const toolId = tool.id;
it(`${toolId} rejects hostile files cleanly`, async () => {
for (const fixture of GARBAGE_FIXTURES) {
const { res, elapsedMs } = await postFile(toolId, fixture);
expect(
SERVER_ERRORS.includes(res.statusCode),
`${toolId} returned ${res.statusCode} for ${fixture}: ${res.body.slice(0, 300)}`,
).toBe(false);
expect(elapsedMs, `${toolId} took ${elapsedMs}ms on ${fixture}`).toBeLessThan(15_000);
if (INPUT_AGNOSTIC.has(toolId)) continue;
expect(
REJECT_STATUSES.has(res.statusCode),
`${toolId} did not reject ${fixture} (got ${res.statusCode})`,
).toBe(true);
// Error responses must be structured JSON, not stack traces
const parsed = JSON.parse(res.body) as { error?: string };
expect(parsed.error, `${toolId} 4xx body has no error field for ${fixture}`).toBeTruthy();
}
// Lying extension with valid content: anything but a server error is fine
const { res } = await postFile(toolId, MISMATCH_FIXTURE);
expect(
SERVER_ERRORS.includes(res.statusCode),
`${toolId} returned ${res.statusCode} for ${MISMATCH_FIXTURE}: ${res.body.slice(0, 300)}`,
).toBe(false);
}, 120_000);
}
});
+108
View File
@@ -0,0 +1,108 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { TOOLS } from "@snapotter/shared";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getToolConfig } from "../../apps/api/src/routes/tool-factory.js";
import { pairwise } from "../helpers/pairwise.js";
import { defaultSettingsFor } from "../helpers/tool-default-settings.js";
import { compactCase, deriveAxes } from "../helpers/zod-pict.js";
import { buildTestApp, type TestApp } from "./test-server.js";
/**
* Pairwise settings matrix: a covering array over each tool's settings schema
* (every pair of axis values appears at least once), filtered through the
* schema's own refinements, with each survivor run through the tool's process
* function directly.
*
* Invariant: a tool either succeeds or fails with a real, descriptive Error.
* TypeErrors and undefined-access crashes are the AI-written-code failure
* class this suite exists to catch.
*
* PR runs cover the core tools; FULL_MATRIX=1 (nightly) covers every tool.
*/
const CORE_TOOLS = [
"resize",
"crop",
"rotate",
"convert",
"compress",
"adjust-colors",
"watermark-text",
"border",
];
const MAX_CASES_PER_TOOL = 40;
const CRASH_PATTERN =
/TypeError|undefined is not|null is not|Cannot read propert|is not a function/i;
describe("pairwise settings matrix", () => {
let testApp: TestApp;
let inputPng: Buffer;
beforeAll(async () => {
testApp = await buildTestApp();
inputPng = readFileSync(join(__dirname, "..", "fixtures", "test-200x150.png"));
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
// The registry is populated by buildTestApp() in beforeAll, so the
// FULL_MATRIX tool list comes from the static TOOLS catalog and configs are
// looked up inside the test body; registry-exempt tools no-op here.
const toolIds = process.env.FULL_MATRIX ? TOOLS.map((t) => t.id) : CORE_TOOLS;
for (const toolId of toolIds) {
it(`${toolId} survives its pairwise settings matrix`, async () => {
const config = getToolConfig(toolId);
if (!config) {
expect(process.env.FULL_MATRIX, `core tool "${toolId}" is not registered`).toBeTruthy();
return;
}
const axes = deriveAxes(config.settingsSchema);
if (axes.length < 2) {
// Not enough enumerable axes for pair coverage; fuzz covers this tool.
return;
}
// Merge combos over the tool's minimal valid settings so required
// fields that are not enumerable axes (e.g. watermark text) are present.
const base = defaultSettingsFor(toolId) as Record<string, unknown>;
const combos = pairwise(axes);
const cases = combos
.map((combo) => ({ ...base, ...compactCase(combo) }))
.map((combo) => config.settingsSchema.safeParse(combo))
.filter((parsed): parsed is { success: true; data: unknown } => parsed.success)
.slice(0, MAX_CASES_PER_TOOL);
expect(
cases.length,
`${toolId}: every pairwise combo was rejected by the schema`,
).toBeGreaterThan(0);
for (const parsed of cases) {
try {
const result = await config.process(inputPng, parsed.data, "test-200x150.png");
expect(
result.buffer.length,
`${toolId} produced empty output for ${JSON.stringify(parsed.data)}`,
).toBeGreaterThan(0);
} catch (err) {
// Clean operational failures (e.g. crop area outside image) are
// acceptable; crash-class errors are not.
expect(
err,
`${toolId} threw a non-Error for ${JSON.stringify(parsed.data)}`,
).toBeInstanceOf(Error);
const message = (err as Error).message;
expect(
CRASH_PATTERN.test(message),
`${toolId} crashed on ${JSON.stringify(parsed.data)}: ${message}`,
).toBe(false);
}
}
}, 240_000);
}
});
+104
View File
@@ -0,0 +1,104 @@
import { TOOLS } from "@snapotter/shared";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getRegisteredToolIds, getToolConfig } from "../../apps/api/src/routes/tool-factory.js";
import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
/**
* Drift guards between the shared TOOLS catalog and the API.
*
* Two intentional asymmetries exist and are pinned exactly:
* - REGISTRY_EXEMPT: tools whose contract does not fit the single-buffer
* process fn (multi-file, ZIP/JSON output, no-input generators, custom AI
* routes). They expose an HTTP route but are not in the pipeline/batch
* registry. If one of these gains registry support, remove it here.
* - LEGACY_ALIASES: extra registered toolIds kept for backwards-compatible
* URLs (consolidated into adjust-colors).
*/
const REGISTRY_EXEMPT = new Set([
"barcode-read",
"bulk-rename",
"collage",
"color-palette",
"compare",
"compose",
"erase-object",
"favicon",
"find-duplicates",
"html-to-image",
"image-to-base64",
"image-to-pdf",
"info",
"ocr",
"pdf-to-image",
"qr-generate",
"stitch",
"svg-to-raster",
"watermark-image",
]);
const LEGACY_ALIASES = new Set([
"brightness-contrast",
"saturation",
"color-channels",
"color-effects",
]);
describe("tool route drift", () => {
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
it("every non-exempt TOOLS entry has a registered process fn", () => {
const registered = new Set(getRegisteredToolIds());
const missing = TOOLS.filter((t) => !REGISTRY_EXEMPT.has(t.id) && !registered.has(t.id)).map(
(t) => t.id,
);
expect(missing, `tools not registered on the API: ${missing.join(", ")}`).toEqual([]);
});
it("registry-exempt list is not stale", () => {
const registered = new Set(getRegisteredToolIds());
for (const id of REGISTRY_EXEMPT) {
expect(
registered.has(id),
`"${id}" is in REGISTRY_EXEMPT but IS registered now; remove it from the exempt list`,
).toBe(false);
}
});
it("every registered tool exposes a settings schema and process fn", () => {
for (const id of getRegisteredToolIds()) {
const config = getToolConfig(id);
expect(config?.settingsSchema, `tool "${id}" has no settings schema`).toBeTruthy();
expect(typeof config?.process, `tool "${id}" has no process fn`).toBe("function");
}
});
it("no orphan registrations (registered but missing from TOOLS, excluding legacy aliases)", () => {
const ids = new Set(TOOLS.map((t) => t.id));
for (const id of getRegisteredToolIds()) {
if (LEGACY_ALIASES.has(id)) continue;
expect(ids.has(id), `registered tool "${id}" has no TOOLS definition`).toBe(true);
}
});
it("every TOOLS entry answers on POST /api/v1/tools/:toolId (no dead routes)", async () => {
for (const tool of TOOLS) {
const res = await testApp.app.inject({
method: "POST",
url: `/api/v1/tools/${tool.id}`,
headers: { authorization: `Bearer ${adminToken}`, "content-type": "application/json" },
payload: {},
});
expect(res.statusCode, `tool "${tool.id}" has no live POST route (got 404)`).not.toBe(404);
}
}, 60_000);
});
+13
View File
@@ -0,0 +1,13 @@
import crypto from "node:crypto";
import os from "node:os";
import path from "node:path";
// Each Vitest fork gets its own SQLite DB + workspace so test files can run
// in parallel. setupFiles run before any test file (and therefore before any
// app module) loads, so apps/api/src/config.ts captures the per-fork paths.
const forkDir = path.join(
os.tmpdir(),
`SnapOtter-test-${process.pid}-${crypto.randomUUID().slice(0, 8)}`,
);
process.env.DB_PATH = path.join(forkDir, "test.db");
process.env.WORKSPACE_PATH = path.join(forkDir, "workspace");
@@ -601,3 +601,39 @@ describe("verifyBundleModels", () => {
expect(mod.verifyBundleModels("background-removal")).toBeNull();
});
});
describe("ensureAiDirs", () => {
it("creates AI directories when the manifest exists and DATA_DIR is writable", () => {
writeTestManifest({});
mod.ensureAiDirs();
expect(existsSync(join(aiDir, "venv"))).toBe(true);
expect(existsSync(modelsDir)).toBe(true);
expect(existsSync(join(aiDir, "pip-cache"))).toBe(true);
});
it("warns instead of throwing when DATA_DIR is uncreatable", async () => {
// Point DATA_DIR below a regular file so mkdir fails (ENOTDIR), the same
// failure class as the default /data on a sealed macOS root (ENOENT).
const blocker = join(tempDir, "blocker");
writeFileSync(blocker, "not a directory");
process.env.DATA_DIR = join(blocker, "data");
writeTestManifest({});
vi.resetModules();
mod = await import("../../../apps/api/src/lib/feature-status.js");
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => mod.ensureAiDirs()).not.toThrow();
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Cannot create AI directories"));
errorSpy.mockRestore();
});
it("is a no-op outside managed environments (no manifest, no /.dockerenv)", async () => {
process.env.FEATURE_MANIFEST_PATH = join(tempDir, "missing-manifest.json");
process.env.DATA_DIR = join(tempDir, "fresh-data");
vi.resetModules();
mod = await import("../../../apps/api/src/lib/feature-status.js");
mod.ensureAiDirs();
expect(existsSync(join(tempDir, "fresh-data"))).toBe(false);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { pairwise } from "../helpers/pairwise.js";
describe("pairwise covering-array generator", () => {
it("covers every pair of values across all axis pairs", () => {
const axes = [
{ key: "fit", values: ["contain", "cover", "fill", "inside"] },
{ key: "format", values: ["png", "jpeg", "webp"] },
{ key: "withMetadata", values: [true, false] },
{ key: "quality", values: [1, 50, 100] },
];
const cases = pairwise(axes);
for (let i = 0; i < axes.length; i++) {
for (let j = i + 1; j < axes.length; j++) {
for (const vi of axes[i].values) {
for (const vj of axes[j].values) {
const covered = cases.some((c) => c[axes[i].key] === vi && c[axes[j].key] === vj);
expect(covered, `pair ${axes[i].key}=${vi} x ${axes[j].key}=${vj} not covered`).toBe(
true,
);
}
}
}
}
});
it("produces far fewer cases than the full cartesian product", () => {
const axes = [
{ key: "a", values: [1, 2, 3, 4] },
{ key: "b", values: [1, 2, 3] },
{ key: "c", values: [true, false] },
{ key: "d", values: ["x", "y", "z"] },
];
const cases = pairwise(axes);
// Cartesian product is 72; pairwise needs at least 12 (largest axis pair).
expect(cases.length).toBeGreaterThanOrEqual(12);
expect(cases.length).toBeLessThan(30);
});
it("is deterministic", () => {
const axes = [
{ key: "a", values: [1, 2, 3] },
{ key: "b", values: ["x", "y"] },
{ key: "c", values: [true, false] },
];
expect(pairwise(axes)).toEqual(pairwise(axes));
});
it("handles degenerate inputs", () => {
expect(pairwise([])).toEqual([]);
expect(pairwise([{ key: "only", values: [1, 2] }])).toEqual([{ only: 1 }, { only: 2 }]);
});
});
@@ -0,0 +1,50 @@
import { TOOLS } from "@snapotter/shared";
import { describe, expect, it } from "vitest";
import { TOOL_DISPLAY_MODES } from "@/lib/tool-display-modes";
import { toolRegistry } from "@/lib/tool-registry";
/**
* Drift guards: the shared TOOLS catalog, the frontend registry, and the
* display-mode map must always describe the same set of tools. A new tool
* that misses one of the three fails here at PR time instead of shipping
* a dead tool page.
*/
describe("tool registry drift", () => {
it("every TOOLS entry has a frontend registry entry", () => {
for (const tool of TOOLS) {
expect(toolRegistry.has(tool.id), `tool "${tool.id}" missing from tool-registry.tsx`).toBe(
true,
);
}
});
it("every TOOLS entry has a display mode", () => {
for (const tool of TOOLS) {
expect(
TOOL_DISPLAY_MODES[tool.id],
`tool "${tool.id}" missing from tool-display-modes.ts`,
).toBeTruthy();
}
});
it("registry has no orphan entries (tools removed from TOOLS but not the registry)", () => {
const ids = new Set(TOOLS.map((t) => t.id));
for (const id of toolRegistry.keys()) {
expect(ids.has(id), `registry entry "${id}" has no TOOLS definition`).toBe(true);
}
});
it("display-mode map has no orphan entries", () => {
const ids = new Set(TOOLS.map((t) => t.id));
for (const id of Object.keys(TOOL_DISPLAY_MODES)) {
expect(ids.has(id), `display-mode entry "${id}" has no TOOLS definition`).toBe(true);
}
});
it("every registry entry has a Settings component and a valid display mode", () => {
for (const [id, entry] of toolRegistry) {
expect(entry.Settings, `tool "${id}" has no Settings component`).toBeTruthy();
expect(entry.displayMode, `tool "${id}" has no displayMode`).toBe(TOOL_DISPLAY_MODES[id]);
}
});
});