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
+30 -1
View File
@@ -83,6 +83,35 @@ jobs:
- uses: ./.github/actions/setup
- run: pnpm vitest run tests/integration/ --reporter=verbose --shard=${{ matrix.shard }}/4
test-e2e-smoke:
name: E2E Smoke (Chromium)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/setup
- name: Get Playwright version
id: pw-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Install Playwright Chromium
run: pnpm playwright install --with-deps chromium
- name: Run smoke specs
run: pnpm playwright test tests/e2e/smoke.spec.ts tests/e2e/tools-all.spec.ts tests/e2e/navigation.spec.ts tests/e2e/home-page.spec.ts --project=chromium
env:
PW_WORKERS: "1"
- name: Upload report on failure
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: failure()
with:
name: e2e-smoke-report
path: playwright-report/
retention-days: 7
pip-audit:
name: Python Dependency Audit
runs-on: ubuntu-latest
@@ -122,7 +151,7 @@ jobs:
build:
name: Build
runs-on: ubuntu-latest
needs: [lint, typecheck, test-unit, test-integration]
needs: [lint, typecheck, test-unit, test-integration, test-e2e-smoke]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/setup
+3 -1
View File
@@ -16,7 +16,9 @@ jobs:
runs-on: ubuntu-latest
permissions:
actions: write
contents: read
# The action writes signatures/cla.json to the cla-signatures branch;
# read-only contents made it fail with "Resource not accessible".
contents: write
pull-requests: write
statuses: write
steps:
+34
View File
@@ -0,0 +1,34 @@
name: Mutation Testing
on:
workflow_dispatch:
schedule:
- cron: "0 4 * * 0"
permissions:
contents: read
jobs:
mutation-image-engine:
name: Stryker (image-engine)
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/setup
- name: Restore incremental mutation state
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: packages/image-engine/reports/stryker-incremental.json
key: stryker-incremental-${{ github.sha }}
restore-keys: |
stryker-incremental-
- name: Run mutation tests
run: pnpm --filter @snapotter/image-engine exec stryker run
- name: Upload mutation report
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: always()
with:
name: mutation-report-image-engine
path: packages/image-engine/reports/mutation/
retention-days: 30
+207
View File
@@ -0,0 +1,207 @@
name: Nightly
on:
workflow_dispatch:
schedule:
- cron: "0 3 * * *"
permissions:
contents: read
env:
SYSTEM_DEPS: libheif-examples libheif-plugin-x265 libheif-plugin-libde265 libimage-exiftool-perl imagemagick ghostscript libjxl-tools libopenjp2-tools
jobs:
e2e-full:
name: E2E Full (${{ matrix.shard }}/4)
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install system dependencies
run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends $SYSTEM_DEPS
- uses: ./.github/actions/setup
- name: Get Playwright version
id: pw-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Install Playwright Chromium
run: pnpm playwright install --with-deps chromium
- name: Run full e2e suite (shard)
run: pnpm playwright test --project=chromium --shard=${{ matrix.shard }}/4
env:
PW_WORKERS: "2"
- name: Upload report on failure
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: failure()
with:
name: e2e-full-report-shard-${{ matrix.shard }}
path: playwright-report/
retention-days: 7
e2e-serial:
name: E2E Serial Bucket
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install system dependencies
run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends $SYSTEM_DEPS
- uses: ./.github/actions/setup
- name: Get Playwright version
id: pw-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Install Playwright Chromium
run: pnpm playwright install --with-deps chromium
- name: Run serial bucket (global-state specs)
run: pnpm playwright test --project=chromium-serial --workers=1
- name: Upload report on failure
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: failure()
with:
name: e2e-serial-report
path: playwright-report/
retention-days: 7
e2e-cross-browser:
name: E2E Cross-Browser (Firefox + WebKit)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/setup
- name: Get Playwright version
id: pw-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ~/.cache/ms-playwright
key: playwright-all-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Install Playwright browsers
run: pnpm playwright install --with-deps chromium firefox webkit
- name: Run cross-browser spec
run: pnpm playwright test --project=firefox --project=webkit
env:
PW_WORKERS: "1"
- name: Upload report on failure
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: failure()
with:
name: e2e-cross-browser-report
path: playwright-report/
retention-days: 7
docker-e2e:
name: Docker Container E2E
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run container test suite
run: docker compose -f docker/docker-compose.test.yml up --build --exit-code-from test-e2e
extended-matrix:
name: Extended Matrix + Fuzz
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install system dependencies
run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends $SYSTEM_DEPS
- name: Allow ImageMagick to read EPS/PS via Ghostscript delegate
run: |
POLICY_FILE=$(find /etc/ImageMagick* -name policy.xml 2>/dev/null | head -1)
if [ -n "$POLICY_FILE" ]; then
sudo sed -i 's/<policy domain="coder" rights="none" pattern="EPS"/<policy domain="coder" rights="read" pattern="EPS"/' "$POLICY_FILE"
sudo sed -i 's/<policy domain="coder" rights="none" pattern="PS"/<policy domain="coder" rights="read" pattern="PS"/' "$POLICY_FILE"
fi
- uses: ./.github/actions/setup
- name: Run integration suite with full matrix and fuzz enabled
run: pnpm vitest run tests/integration/ --reporter=verbose
env:
FULL_MATRIX: "1"
FUZZ: "1"
FUZZ_RUNS: "50"
api-fuzz:
name: Schemathesis API Fuzz
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install system dependencies
run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends $SYSTEM_DEPS
- uses: ./.github/actions/setup
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Install Schemathesis
run: pip install schemathesis
- name: Start API server
run: |
mkdir -p /tmp/st-data
AUTH_ENABLED=false ANALYTICS_ENABLED=false DB_PATH=/tmp/st-data/st.db \
WORKSPACE_PATH=/tmp/st-data/workspace DATA_DIR=/tmp/st-data \
pnpm --filter @snapotter/api dev &
for i in $(seq 1 60); do
if curl -fsS http://localhost:13490/api/v1/health > /dev/null 2>&1; then
echo "API up after ${i}s"; exit 0
fi
sleep 1
done
echo "API failed to start"; exit 1
- name: Fuzz tool endpoints from the OpenAPI spec
run: |
schemathesis run http://localhost:13490/api/v1/openapi.yaml \
--url http://localhost:13490 \
--checks not_a_server_error \
--include-path-regex "^/api/v1/(tools|health|info)" \
--max-examples 25 \
--report junit \
--report-dir st-report
- name: Upload fuzz report
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: always()
with:
name: schemathesis-report
path: st-report/
retention-days: 14
coverage-report:
name: Coverage Report
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install system dependencies
run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends $SYSTEM_DEPS
- name: Allow ImageMagick to read EPS/PS via Ghostscript delegate
run: |
POLICY_FILE=$(find /etc/ImageMagick* -name policy.xml 2>/dev/null | head -1)
if [ -n "$POLICY_FILE" ]; then
sudo sed -i 's/<policy domain="coder" rights="none" pattern="EPS"/<policy domain="coder" rights="read" pattern="EPS"/' "$POLICY_FILE"
sudo sed -i 's/<policy domain="coder" rights="none" pattern="PS"/<policy domain="coder" rights="read" pattern="PS"/' "$POLICY_FILE"
fi
- uses: ./.github/actions/setup
- name: Run tests with coverage
run: pnpm vitest run --coverage tests/unit/ tests/integration/
- name: Upload coverage artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: coverage-report
path: coverage/
retention-days: 14
@@ -0,0 +1,52 @@
name: Update Visual Baselines
on:
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
update-baselines:
name: Regenerate linux visual baselines
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/setup
- name: Get Playwright version
id: pw-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Install Playwright Chromium
run: pnpm playwright install --with-deps chromium
- name: Regenerate baselines
run: pnpm playwright test --project=chromium-visual --update-snapshots
env:
PW_WORKERS: "2"
- name: Open PR with refreshed baselines
env:
GH_TOKEN: ${{ github.token }}
run: |
if git diff --quiet -- 'tests/e2e/__screenshots__'; then
untracked=$(git ls-files --others --exclude-standard -- 'tests/e2e/__screenshots__')
if [ -z "$untracked" ]; then
echo "No baseline changes."
exit 0
fi
fi
BRANCH="chore/visual-baselines-${GITHUB_RUN_ID}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add tests/e2e/__screenshots__
git commit -m "test(e2e): refresh linux visual baselines"
git push origin "$BRANCH"
gh pr create --title "test(e2e): refresh linux visual baselines" \
--body "Automated baseline refresh from the update-visual-baselines workflow. Review the image diffs before merging." \
--base main --head "$BRANCH"
+4 -1
View File
@@ -68,4 +68,7 @@ scripts/setup-cloudflare-email.sh
apps/videos
remotion/
demo
pentest-results/
pentest-results/
# Stryker mutation testing
.stryker-tmp/
packages/image-engine/reports/
+7 -7
View File
@@ -55,14 +55,14 @@ export function ensureAiDirs(): void {
mkdirSync(MODELS_DIR, { recursive: true });
mkdirSync(join(AI_DIR, "pip-cache"), { recursive: true });
} catch (err: unknown) {
// Never refuse to boot over the AI data dir. On native checkouts the
// default DATA_DIR (/data) is often uncreatable (ENOENT/EROFS on a
// sealed macOS root, EACCES on restrictive volumes); AI tools simply
// report as not installed until DATA_DIR points somewhere writable.
const code = (err as NodeJS.ErrnoException).code;
if (code === "EACCES") {
console.error(
`WARNING: Cannot create AI directories under "${AI_DIR}". AI features will be unavailable. Check volume permissions (PUID/PGID).`,
);
return;
}
throw err;
console.error(
`WARNING: Cannot create AI directories under "${AI_DIR}" (${code}). AI features will be unavailable. Set DATA_DIR to a writable path (or check volume permissions / PUID / PGID in Docker).`,
);
}
}
+9
View File
@@ -10,6 +10,7 @@ import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db, schema } from "../db/index.js";
import { auditLog } from "../lib/audit.js";
import { requirePermission } from "../permissions.js";
import { requireAuth } from "../plugins/auth.js";
@@ -86,6 +87,14 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
}
}
if (entries.length > 0) {
auditLog(request.log, "SETTINGS_UPDATED", {
adminId: admin.id,
username: admin.username,
keys: entries.map((e) => e.key),
});
}
return reply.send({ ok: true, updatedCount: entries.length });
});
+3
View File
@@ -103,6 +103,9 @@ export function registerBulkRename(app: FastifyInstance) {
details: err instanceof Error ? err.message : "Unknown error",
});
}
// The ZIP stream already started; end the connection so clients see a
// truncated transfer instead of hanging forever.
reply.raw.destroy(err instanceof Error ? err : new Error(String(err)));
}
});
}
+10
View File
@@ -106,6 +106,13 @@ export function registerFavicon(app: FastifyInstance) {
buf = await autoOrient(buf);
}
// Force a full pixel decode now. Header validation alone lets
// truncated files through, and a decode failure after reply.hijack()
// cannot be turned into an error response anymore.
if (validation.format !== "svg") {
await sharp(buf).stats();
}
decodedFiles.push({ buffer: buf, filename: file.filename });
} catch (err) {
const reason = err instanceof Error ? err.message : "Unknown decode error";
@@ -206,6 +213,9 @@ export function registerFavicon(app: FastifyInstance) {
details: err instanceof Error ? err.message : "Unknown error",
});
}
// The ZIP stream already started; end the connection so clients see a
// truncated transfer instead of hanging forever.
reply.raw.destroy(err instanceof Error ? err : new Error(String(err)));
}
});
}
+10
View File
@@ -139,6 +139,13 @@ export function registerSplit(app: FastifyInstance) {
const fullW = metadata.width ?? 0;
const fullH = metadata.height ?? 0;
// Force a full pixel decode before streaming starts. Metadata alone
// lets truncated files through, and a decode failure after
// reply.hijack() cannot become an error response anymore.
if (validation.format !== "svg") {
await sharp(fileBuffer).stats();
}
let cols = settings.columns;
let rows = settings.rows;
if (settings.tileWidth && settings.tileHeight) {
@@ -221,6 +228,9 @@ export function registerSplit(app: FastifyInstance) {
details: err instanceof Error ? err.message : "Unknown error",
});
}
// The ZIP stream already started; end the connection so clients see a
// truncated transfer instead of hanging forever.
reply.raw.destroy(err instanceof Error ? err : new Error(String(err)));
}
});
+91
View File
@@ -0,0 +1,91 @@
/**
* Pure display-mode map: toolId -> DisplayMode.
*
* This module intentionally has no React imports so that Playwright specs and
* Node-side test generators can import it directly. It is the single source of
* truth for display modes; tool-registry.tsx merges it into registry entries.
*/
export type DisplayMode =
| "side-by-side"
| "before-after"
| "live-preview"
| "no-comparison"
| "interactive-crop"
| "interactive-eraser"
| "interactive-split"
| "no-dropzone"
| "custom-results";
export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
// Essentials
resize: "side-by-side",
crop: "interactive-crop",
rotate: "side-by-side",
convert: "no-comparison",
compress: "before-after",
"strip-metadata": "no-comparison",
"edit-metadata": "no-comparison",
// Color adjustments
"adjust-colors": "live-preview",
sharpening: "before-after",
// Watermark & overlay
"watermark-text": "before-after",
"watermark-image": "before-after",
"text-overlay": "before-after",
compose: "before-after",
"meme-generator": "no-dropzone",
// Utilities
info: "no-comparison",
compare: "before-after",
"find-duplicates": "custom-results",
"color-palette": "no-comparison",
"qr-generate": "no-dropzone",
"html-to-image": "no-dropzone",
"barcode-read": "before-after",
"image-to-base64": "custom-results",
// Layout & composition
collage: "no-dropzone",
stitch: "no-comparison",
split: "interactive-split",
border: "live-preview",
beautify: "live-preview",
// Format & conversion
"svg-to-raster": "before-after",
vectorize: "before-after",
"gif-tools": "before-after",
// Optimization extras
"bulk-rename": "before-after",
favicon: "before-after",
"image-to-pdf": "before-after",
"optimize-for-web": "before-after",
"pdf-to-image": "no-dropzone",
// Adjustments extra
"replace-color": "before-after",
"color-blindness": "before-after",
// AI tools
"remove-background": "before-after",
upscale: "before-after",
ocr: "before-after",
"blur-faces": "before-after",
"enhance-faces": "before-after",
"erase-object": "interactive-eraser",
"smart-crop": "before-after",
"image-enhancement": "before-after",
colorize: "before-after",
"noise-removal": "before-after",
"passport-photo": "custom-results",
"red-eye-removal": "before-after",
"restore-photo": "before-after",
"transparency-fixer": "before-after",
"content-aware-resize": "side-by-side",
"ai-canvas-expand": "before-after",
};
+78 -142
View File
@@ -10,19 +10,16 @@ import type { Crop } from "react-image-crop";
import type { BgPreviewState } from "@/components/common/image-viewer";
import type { EraserCanvasRef } from "@/components/tools/eraser-canvas";
import type { PreviewTransform } from "@/components/tools/rotate-settings";
import { TOOL_DISPLAY_MODES } from "./tool-display-modes";
// ── Display modes ──────────────────────────────────────────────────
// The DisplayMode type and per-tool map live in tool-display-modes.ts (a pure
// module with no React imports) so tests can introspect them. Re-exported here
// for existing importers.
export type DisplayMode =
| "side-by-side"
| "before-after"
| "live-preview"
| "no-comparison"
| "interactive-crop"
| "interactive-eraser"
| "interactive-split"
| "no-dropzone"
| "custom-results";
export type { DisplayMode } from "./tool-display-modes";
import type { DisplayMode } from "./tool-display-modes";
// ── Crop and eraser prop types ─────────────────────────────────────
@@ -371,161 +368,100 @@ function EraseObjectSettingsWrapper(props: { eraserProps?: EraserProps }) {
}
// ── The registry ───────────────────────────────────────────────────
// Display modes come from TOOL_DISPLAY_MODES (tool-display-modes.ts); entries
// here hold only the React-side pieces (Settings, ResultsPanel, livePreview,
// accept). The merge below throws at module load if either side is missing a
// tool, so drift between the two files cannot ship.
export const toolRegistry = new Map<string, ToolRegistryEntry>([
type RegistryEntryConfig = Omit<ToolRegistryEntry, "displayMode">;
const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [
// Essentials
["resize", { displayMode: "side-by-side", Settings: ResizeSettings }],
["crop", { displayMode: "interactive-crop", Settings: CropSettingsWrapper as never }],
[
"rotate",
{
displayMode: "side-by-side",
livePreview: true,
Settings: RotateSettings as never,
},
],
["convert", { displayMode: "no-comparison", Settings: ConvertSettings }],
["compress", { displayMode: "before-after", Settings: CompressSettings }],
["strip-metadata", { displayMode: "no-comparison", Settings: StripMetadataSettings }],
["edit-metadata", { displayMode: "no-comparison", Settings: EditMetadataSettings }],
["resize", { Settings: ResizeSettings }],
["crop", { Settings: CropSettingsWrapper as never }],
["rotate", { livePreview: true, Settings: RotateSettings as never }],
["convert", { Settings: ConvertSettings }],
["compress", { Settings: CompressSettings }],
["strip-metadata", { Settings: StripMetadataSettings }],
["edit-metadata", { Settings: EditMetadataSettings }],
// Color adjustments (consolidated)
[
"adjust-colors",
{
displayMode: "live-preview" as DisplayMode,
livePreview: true,
Settings: makeColorSettingsComponent("adjust-colors") as never,
},
{ livePreview: true, Settings: makeColorSettingsComponent("adjust-colors") as never },
],
// Sharpening
["sharpening", { displayMode: "before-after", Settings: SharpeningSettings }],
["sharpening", { Settings: SharpeningSettings }],
// Watermark & Overlay
["watermark-text", { displayMode: "before-after", Settings: WatermarkTextSettings }],
["watermark-image", { displayMode: "before-after", Settings: WatermarkImageSettings }],
["text-overlay", { displayMode: "before-after", Settings: TextOverlaySettings }],
["compose", { displayMode: "before-after", Settings: ComposeSettings }],
[
"meme-generator",
{
displayMode: "no-dropzone",
Settings: MemeGeneratorSettings,
ResultsPanel: MemeGeneratorPreview,
},
],
["watermark-text", { Settings: WatermarkTextSettings }],
["watermark-image", { Settings: WatermarkImageSettings }],
["text-overlay", { Settings: TextOverlaySettings }],
["compose", { Settings: ComposeSettings }],
["meme-generator", { Settings: MemeGeneratorSettings, ResultsPanel: MemeGeneratorPreview }],
// Utilities
["info", { displayMode: "no-comparison", Settings: InfoSettings }],
["compare", { displayMode: "before-after", Settings: CompareSettings }],
[
"find-duplicates",
{
displayMode: "custom-results",
Settings: FindDuplicatesSettings,
ResultsPanel: FindDuplicatesResults,
},
],
["color-palette", { displayMode: "no-comparison", Settings: ColorPaletteSettings }],
[
"qr-generate",
{ displayMode: "no-dropzone", Settings: QrGenerateSettings, ResultsPanel: QrGeneratePreview },
],
[
"html-to-image",
{
displayMode: "no-dropzone",
Settings: HtmlToImageSettings,
ResultsPanel: HtmlToImageResults,
},
],
["barcode-read", { displayMode: "before-after", Settings: BarcodeReadSettings }],
[
"image-to-base64",
{
displayMode: "custom-results",
Settings: ImageToBase64Settings,
ResultsPanel: ImageToBase64Results,
},
],
["info", { Settings: InfoSettings }],
["compare", { Settings: CompareSettings }],
["find-duplicates", { Settings: FindDuplicatesSettings, ResultsPanel: FindDuplicatesResults }],
["color-palette", { Settings: ColorPaletteSettings }],
["qr-generate", { Settings: QrGenerateSettings, ResultsPanel: QrGeneratePreview }],
["html-to-image", { Settings: HtmlToImageSettings, ResultsPanel: HtmlToImageResults }],
["barcode-read", { Settings: BarcodeReadSettings }],
["image-to-base64", { Settings: ImageToBase64Settings, ResultsPanel: ImageToBase64Results }],
// Layout & Composition
[
"collage",
{ displayMode: "no-dropzone", Settings: CollageSettings, ResultsPanel: CollagePreview },
],
["stitch", { displayMode: "no-comparison", Settings: StitchSettings }],
[
"split",
{ displayMode: "interactive-split", Settings: SplitSettings, ResultsPanel: SplitCanvas },
],
["border", { displayMode: "live-preview", livePreview: true, Settings: BorderSettings as never }],
[
"beautify",
{ displayMode: "live-preview", livePreview: true, Settings: BeautifySettings as never },
],
["collage", { Settings: CollageSettings, ResultsPanel: CollagePreview }],
["stitch", { Settings: StitchSettings }],
["split", { Settings: SplitSettings, ResultsPanel: SplitCanvas }],
["border", { livePreview: true, Settings: BorderSettings as never }],
["beautify", { livePreview: true, Settings: BeautifySettings as never }],
// Format & Conversion
[
"svg-to-raster",
{ displayMode: "before-after", accept: ".svg,.svgz", Settings: SvgToRasterSettings },
],
["vectorize", { displayMode: "before-after", Settings: VectorizeSettings }],
["gif-tools", { displayMode: "before-after", Settings: GifToolsSettings }],
["svg-to-raster", { accept: ".svg,.svgz", Settings: SvgToRasterSettings }],
["vectorize", { Settings: VectorizeSettings }],
["gif-tools", { Settings: GifToolsSettings }],
// Optimization extras
["bulk-rename", { displayMode: "before-after", Settings: BulkRenameSettings }],
["favicon", { displayMode: "before-after", Settings: FaviconSettings }],
["image-to-pdf", { displayMode: "before-after", Settings: ImageToPdfSettings }],
["optimize-for-web", { displayMode: "before-after", Settings: OptimizeForWebSettings }],
[
"pdf-to-image",
{ displayMode: "no-dropzone", Settings: PdfToImageSettings, ResultsPanel: PdfToImagePreview },
],
["bulk-rename", { Settings: BulkRenameSettings }],
["favicon", { Settings: FaviconSettings }],
["image-to-pdf", { Settings: ImageToPdfSettings }],
["optimize-for-web", { Settings: OptimizeForWebSettings }],
["pdf-to-image", { Settings: PdfToImageSettings, ResultsPanel: PdfToImagePreview }],
// Adjustments extra
["replace-color", { displayMode: "before-after", Settings: ReplaceColorSettings }],
["color-blindness", { displayMode: "before-after", Settings: ColorBlindnessSettings }],
["replace-color", { Settings: ReplaceColorSettings }],
["color-blindness", { Settings: ColorBlindnessSettings }],
// AI Tools
["remove-background", { displayMode: "before-after", Settings: RemoveBgSettings }],
["upscale", { displayMode: "before-after", Settings: UpscaleSettings }],
["ocr", { displayMode: "before-after", Settings: OcrSettings }],
["blur-faces", { displayMode: "before-after", Settings: BlurFacesSettings }],
["enhance-faces", { displayMode: "before-after", Settings: EnhanceFacesSettings }],
[
"erase-object",
{
displayMode: "interactive-eraser",
Settings: EraseObjectSettingsWrapper as never,
},
],
["smart-crop", { displayMode: "before-after", Settings: SmartCropSettings }],
[
"image-enhancement",
{
displayMode: "before-after" as DisplayMode,
livePreview: true,
Settings: ImageEnhancementSettings as never,
},
],
["colorize", { displayMode: "before-after", Settings: ColorizeSettings }],
["noise-removal", { displayMode: "before-after", Settings: NoiseRemovalSettings }],
[
"passport-photo",
{
displayMode: "custom-results",
Settings: PassportPhotoSettings,
ResultsPanel: PassportPhotoPreview,
},
],
["red-eye-removal", { displayMode: "before-after", Settings: RedEyeRemovalSettings }],
["restore-photo", { displayMode: "before-after", Settings: RestorePhotoSettings }],
["transparency-fixer", { displayMode: "before-after", Settings: TransparencyFixerSettings }],
["content-aware-resize", { displayMode: "side-by-side", Settings: ContentAwareResizeSettings }],
["ai-canvas-expand", { displayMode: "before-after", Settings: AiCanvasExpandSettings }],
]);
["remove-background", { Settings: RemoveBgSettings }],
["upscale", { Settings: UpscaleSettings }],
["ocr", { Settings: OcrSettings }],
["blur-faces", { Settings: BlurFacesSettings }],
["enhance-faces", { Settings: EnhanceFacesSettings }],
["erase-object", { Settings: EraseObjectSettingsWrapper as never }],
["smart-crop", { Settings: SmartCropSettings }],
["image-enhancement", { livePreview: true, Settings: ImageEnhancementSettings as never }],
["colorize", { Settings: ColorizeSettings }],
["noise-removal", { Settings: NoiseRemovalSettings }],
["passport-photo", { Settings: PassportPhotoSettings, ResultsPanel: PassportPhotoPreview }],
["red-eye-removal", { Settings: RedEyeRemovalSettings }],
["restore-photo", { Settings: RestorePhotoSettings }],
["transparency-fixer", { Settings: TransparencyFixerSettings }],
["content-aware-resize", { Settings: ContentAwareResizeSettings }],
["ai-canvas-expand", { Settings: AiCanvasExpandSettings }],
];
export const toolRegistry = new Map<string, ToolRegistryEntry>(
ENTRY_CONFIG.map(([toolId, entry]) => {
const displayMode = TOOL_DISPLAY_MODES[toolId];
if (!displayMode) {
throw new Error(`Tool "${toolId}" has no display mode in tool-display-modes.ts`);
}
return [toolId, { ...entry, displayMode }];
}),
);
export function getToolRegistryEntry(toolId: string): ToolRegistryEntry | undefined {
return toolRegistry.get(toolId);
+13
View File
@@ -22,6 +22,19 @@ export default defineConfig({
},
},
},
// vite preview serves the production build for e2e runs; it needs the same
// /api proxy the dev server has (the app always calls relative /api).
preview: {
host: true,
port: Number(process.env.PORT) || 1351,
proxy: {
"/api": {
target: process.env.VITE_API_URL || "http://localhost:13490",
timeout: 300_000,
proxyTimeout: 300_000,
},
},
},
build: {
rollupOptions: {},
},
+8 -1
View File
@@ -29,7 +29,14 @@ services:
context: ..
dockerfile: docker/Dockerfile.test
container_name: SnapOtter-test-e2e
command: ["sh", "-c", "npx playwright install --with-deps chromium && pnpm test:e2e"]
# Visual project is excluded: linux baselines are managed by the
# update-visual-baselines workflow, not generated inside the container.
command:
[
"sh",
"-c",
"npx playwright install --with-deps chromium && pnpm playwright test --project=chromium && pnpm playwright test --project=chromium-serial --workers=1",
]
environment:
- NODE_ENV=test
- AUTH_ENABLED=true
+5 -2
View File
@@ -36,7 +36,7 @@
"test:coverage": "vitest run --coverage",
"test:ci": "vitest run --coverage --reporter=verbose",
"test:all": "vitest run --coverage && playwright test",
"test:e2e": "playwright test",
"test:e2e": "playwright test --project=chromium && playwright test --project=chromium-serial --workers=1 && playwright test --project=chromium-visual",
"test:e2e:ui": "playwright test --ui",
"test:e2e:landing": "playwright test --config playwright.landing.config.ts",
"test:e2e:docs": "playwright test --config playwright.docs.config.ts",
@@ -54,6 +54,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.16",
"@fast-check/vitest": "^0.4.1",
"@playwright/test": "^1.60.0",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
@@ -68,13 +69,15 @@
"@types/adm-zip": "^0.5.8",
"@vitest/coverage-v8": "^3.2.6",
"adm-zip": "^0.5.17",
"fast-check": "^4.8.0",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^16.4.0",
"semantic-release": "^25.0.3",
"turbo": "^2.9.16",
"typescript": "^5.7.0",
"vitest": "^3.2.6"
"vitest": "^3.2.6",
"zod-fast-check": "^0.10.1"
},
"license": "AGPL-3.0",
"pnpm": {
+4 -1
View File
@@ -9,7 +9,8 @@
"lint": "biome check src/",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
"clean": "rm -rf dist",
"test:mutation": "stryker run"
},
"dependencies": {
"@snapotter/shared": "workspace:*",
@@ -17,6 +18,8 @@
"sharp": "^0.34.5"
},
"devDependencies": {
"@stryker-mutator/core": "^9.6.1",
"@stryker-mutator/vitest-runner": "^9.6.1",
"typescript": "^5.7.0",
"vitest": "^3.2.6"
},
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "https://raw.githubusercontent.com/stryker-mutator/stryker-js/master/packages/api/schema/stryker-core.json",
"testRunner": "vitest",
"plugins": ["@stryker-mutator/vitest-runner"],
"mutate": ["src/**/*.ts", "!src/types.ts", "!src/index.ts"],
"incremental": true,
"incrementalFile": "reports/stryker-incremental.json",
"reporters": ["html", "clear-text", "progress"],
"htmlReporter": { "fileName": "reports/mutation/mutation.html" },
"thresholds": { "high": 80, "low": 60, "break": null },
"tempDirName": ".stryker-tmp",
"ignoreStatic": true
}
+48 -5
View File
@@ -6,6 +6,18 @@ const testDbPath = path.join(__dirname, "test-results", ".e2e-db", "snapotter.db
const TEST_WEB_PORT = 2349;
// Specs that mutate global server state (settings, users, roles, API keys)
// or assert on global lists/timing. These run in the chromium-serial project
// with --workers=1; everything else parallelizes safely.
const SERIAL_SPECS =
/gui-settings-|settings\.spec|rbac|security|people|api\.spec|state-bleed|full-session|gui-file-carry|i18n|theme|gui-performance/;
// Screenshot-comparison specs. Separate project because baselines are
// platform-specific: they run locally (darwin baselines) and via the
// update-visual-baselines workflow, but not in the nightly linux run until
// linux baselines are committed.
const VISUAL_SPECS = /visual-regression|gui-visual-/;
export default defineConfig({
testDir: "./tests/e2e",
timeout: 30_000,
@@ -17,10 +29,16 @@ export default defineConfig({
caret: "hide",
},
},
snapshotPathTemplate: "{testDir}/__screenshots__/{testFilePath}/{arg}{ext}",
// Platform-suffixed baselines: darwin baselines serve local runs on macOS,
// linux baselines (generated by the update-visual-baselines workflow) serve CI.
snapshotPathTemplate: "{testDir}/__screenshots__/{testFilePath}/{arg}-{platform}{ext}",
fullyParallel: false,
retries: 0,
workers: 1,
retries: process.env.CI ? 1 : 0,
// Files run across workers; tests within a file stay ordered. The serial
// bucket is pinned to --workers=1 by its run command. Default is 2: the
// dev-mode webServers saturate beyond that and 30s-timeout tests start
// flaking. Raise via PW_WORKERS on stronger setups.
workers: process.env.PW_WORKERS ? Number(process.env.PW_WORKERS) : 2,
reporter: "html",
use: {
baseURL: `http://localhost:${TEST_WEB_PORT}`,
@@ -38,6 +56,25 @@ export default defineConfig({
...devices["Desktop Chrome"],
storageState: authFile,
},
testIgnore: [SERIAL_SPECS, VISUAL_SPECS],
dependencies: ["setup"],
},
{
name: "chromium-serial",
use: {
...devices["Desktop Chrome"],
storageState: authFile,
},
testMatch: SERIAL_SPECS,
dependencies: ["setup"],
},
{
name: "chromium-visual",
use: {
...devices["Desktop Chrome"],
storageState: authFile,
},
testMatch: VISUAL_SPECS,
dependencies: ["setup"],
},
{
@@ -72,18 +109,24 @@ export default defineConfig({
SKIP_MUST_CHANGE_PASSWORD: "true",
ANALYTICS_ENABLED: "false",
DB_PATH: testDbPath,
// The in-repo docker/feature-manifest.json makes the API think it is
// inside Docker and try to mkdir /data; point it somewhere writable.
DATA_DIR: path.join(__dirname, "test-results", ".e2e-data"),
},
timeout: 30_000,
},
{
command: "pnpm --filter @snapotter/web dev",
// Production build + static preview: the dev server's on-demand
// transform saturates under parallel workers and flakes 30s-timeout
// tests. The build adds ~40s once per run and removes that whole class.
command: "pnpm --filter @snapotter/web build && pnpm --filter @snapotter/web preview",
port: TEST_WEB_PORT,
reuseExistingServer: !process.env.CI,
env: {
PORT: String(TEST_WEB_PORT),
VITE_API_URL: "http://localhost:13490",
},
timeout: 30_000,
timeout: 240_000,
},
],
});
+999 -12
View File
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
/**
* Generates the hostile-input fixtures in tests/fixtures/hostile/.
*
* Deterministic on purpose: re-running produces byte-identical files so the
* committed fixtures never churn. No dependencies (the bomb PNG is built by
* hand instead of with sharp).
*
* Run from the repo root: node scripts/generate-hostile-fixtures.mjs
*/
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import zlib from "node:zlib";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const outDir = join(root, "tests", "fixtures", "hostile");
mkdirSync(outDir, { recursive: true });
// --- 1. truncated.jpg: a real JPEG cut off at 40% ---------------------------
const realJpeg = readFileSync(join(root, "tests", "fixtures", "sample-photo.jpg"));
writeFileSync(join(outDir, "truncated.jpg"), realJpeg.subarray(0, Math.floor(realJpeg.length * 0.4)));
// --- 2. zero-byte.png --------------------------------------------------------
writeFileSync(join(outDir, "zero-byte.png"), Buffer.alloc(0));
// --- 3. garbage.jpg: 8KB of seeded pseudo-random bytes ----------------------
const garbage = Buffer.alloc(8192);
let state = 0xdeadbeef;
for (let i = 0; i < garbage.length; i++) {
// LCG (numerical recipes constants) for deterministic noise
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
garbage[i] = state & 0xff;
}
writeFileSync(join(outDir, "garbage.jpg"), garbage);
// --- 4. png-bytes.jpg: valid PNG content with a lying .jpg extension --------
const realPng = readFileSync(join(root, "tests", "fixtures", "test-200x150.png"));
writeFileSync(join(outDir, "png-bytes.jpg"), realPng);
// --- 5. bomb-50000x50000.png: tiny file whose header claims 2,500 megapixels
// A decompression-bomb shape: valid signature + IHDR declaring 50000x50000,
// with a minimal IDAT. Anything that trusts the header and allocates dies;
// the API must reject it via its megapixel limit instead.
function crc32(buf) {
let c = 0xffffffff;
const table = new Int32Array(256);
for (let i = 0; i < 256; i++) {
let v = i;
for (let j = 0; j < 8; j++) v = v & 1 ? 0xedb88320 ^ (v >>> 1) : v >>> 1;
table[i] = v;
}
for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const typeBuf = Buffer.from(type);
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])));
return Buffer.concat([len, typeBuf, data, crcBuf]);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(50000, 0); // width
ihdr.writeUInt32BE(50000, 4); // height
ihdr[8] = 8; // bit depth
ihdr[9] = 0; // grayscale
// One deflated row's worth of zeroes; far less data than the header promises.
const idat = zlib.deflateSync(Buffer.alloc(1024));
writeFileSync(
join(outDir, "bomb-50000x50000.png"),
Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk("IHDR", ihdr),
chunk("IDAT", idat),
chunk("IEND", Buffer.alloc(0)),
]),
);
console.log(`Hostile fixtures written to ${outDir}`);

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]);
}
});
});
+14 -7
View File
@@ -1,4 +1,3 @@
import crypto from "node:crypto";
import os from "node:os";
import path from "node:path";
import { defineConfig } from "vitest/config";
@@ -12,9 +11,6 @@ const webNodeModules = path.resolve(__dirname, "apps/web/node_modules");
// Resolve landing-workspace packages.
const landingNodeModules = path.resolve(__dirname, "apps/landing/node_modules");
// Temp dir for integration test DB + workspace (set BEFORE any app code loads)
const testDir = path.join(os.tmpdir(), `SnapOtter-test-${crypto.randomUUID().slice(0, 8)}`);
export default defineConfig({
esbuild: {
jsx: "automatic",
@@ -26,9 +22,12 @@ export default defineConfig({
pool: "forks",
poolOptions: {
forks: {
singleFork: true,
// Parallel forks; each fork gets an isolated DB + workspace via
// tests/setup/per-fork-env.ts. CI runners have 4 vCPUs.
maxForks: process.env.CI ? 4 : Math.max(2, Math.floor(os.availableParallelism() / 2)),
},
},
setupFiles: ["tests/setup/per-fork-env.ts"],
exclude: [
"tests/e2e/**",
"tests/e2e-docs/**",
@@ -46,8 +45,7 @@ export default defineConfig({
AUTH_ENABLED: "true",
DEFAULT_USERNAME: "admin",
DEFAULT_PASSWORD: "Adminpass1",
DB_PATH: path.join(testDir, "test.db"),
WORKSPACE_PATH: path.join(testDir, "workspace"),
// DB_PATH and WORKSPACE_PATH are set per-fork in tests/setup/per-fork-env.ts
MAX_UPLOAD_SIZE_MB: "10",
MAX_BATCH_SIZE: "10",
RATE_LIMIT_PER_MIN: "10000",
@@ -61,6 +59,15 @@ export default defineConfig({
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
// Ratchet: measured 2026-06-10 at lines 77.7 / branches 83.7 /
// functions 86.5 over unit+integration. Raise when coverage rises;
// never lower without a written reason.
thresholds: {
lines: 75,
branches: 81,
functions: 84,
statements: 75,
},
include: [
"packages/image-engine/src/**",
"apps/api/src/**",