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().
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
|
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: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
@@ -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 ({
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
After Width: | Height: | Size: 74 B |
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 51 KiB |
@@ -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]]])),
|
||||
);
|
||||
}
|
||||
@@ -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] ?? {};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
});
|
||||