mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
1. passport-photo 404 vs 501: add base route at /api/v1/tools/passport-photo
that returns 501 FEATURE_NOT_INSTALLED when the AI bundle is missing,
matching other AI tools. The /generate sub-route is Sharp-only (no
sidecar) so it correctly skips the isToolInstalled guard.
2. AuthGuard analytics consent race: don't evaluate shouldShowConsent()
until analyticsConfig has been fetched (guard on analyticsConfig !== null).
Prevents redirect to /analytics-consent before config is loaded.
3. Fragile sidebar Settings selector: add openSettings(page) helper to
E2E helpers that checks sidebar visibility with fallback to button role.
Replace all 134 occurrences of page.locator("aside").getByText("Settings")
across 18 test files.
469 lines
18 KiB
TypeScript
469 lines
18 KiB
TypeScript
import { expect, openSettings, test, uploadTestImage, waitForProcessing } from "./helpers";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GUI Resilience: Error handling, form validation, state reset, stability
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Error Boundaries & 404 Handling
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Error Boundaries", () => {
|
|
test("navigating to nonexistent tool shows error state, not white screen", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await page.goto("/nonexistent-tool-xyz");
|
|
|
|
// The ToolPage component renders "Tool not found" inside AppLayout
|
|
await expect(page.getByText("Tool not found")).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Sidebar should still be accessible (not a white screen)
|
|
const sidebar = page.locator("aside");
|
|
await expect(sidebar).toBeVisible();
|
|
});
|
|
|
|
test("error boundary fallback has Go Home button", async ({ loggedInPage: page }) => {
|
|
// The ErrorBoundary wraps the entire app and shows a "Go Home" button on
|
|
// uncaught render errors. We verify the ErrorBoundary class exists in App.tsx
|
|
// by checking the component renders normally (no error state).
|
|
// For the actual fallback, we verify the button text exists in the source.
|
|
await page.goto("/");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
// The app should render without triggering the error boundary
|
|
await expect(page.locator("main")).toBeVisible();
|
|
|
|
// Verify the error boundary is mounted by checking the app renders children
|
|
await expect(page.getByText("Upload from computer")).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test("multiple invalid tool routes all show error state consistently", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
const invalidRoutes = ["/nonexistent-tool-xyz", "/fake-tool-abc", "/definitely-not-a-tool"];
|
|
|
|
for (const route of invalidRoutes) {
|
|
await page.goto(route);
|
|
await expect(page.getByText("Tool not found")).toBeVisible({ timeout: 10_000 });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Server Error Handling: File Upload Validation
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("File Upload Validation", () => {
|
|
test("non-image file upload (.txt) is rejected or ignored gracefully", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await page.goto("/resize");
|
|
|
|
// Create a .txt file via the file chooser
|
|
const fileChooserPromise = page.waitForEvent("filechooser");
|
|
const dropzone = page.locator("[class*='border-dashed']").first();
|
|
await dropzone.click();
|
|
const fileChooser = await fileChooserPromise;
|
|
|
|
// The accept filter on the input is "image/*" so the browser may reject
|
|
// the file, or the app may ignore it. Either way, no crash should occur.
|
|
const fs = await import("node:fs");
|
|
const path = await import("node:path");
|
|
const tmpDir = path.join(process.cwd(), "test-results");
|
|
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
|
const txtPath = path.join(tmpDir, "not-an-image.txt");
|
|
fs.writeFileSync(txtPath, "This is not an image file.");
|
|
|
|
await fileChooser.setFiles(txtPath);
|
|
await page.waitForTimeout(1000);
|
|
|
|
// The page should not crash. Either a dropzone remains or an error is shown.
|
|
const pageContent = await page.textContent("body");
|
|
expect(pageContent).toBeDefined();
|
|
// No uncaught exception should have crashed the app
|
|
await expect(page.locator("body")).not.toHaveText(/undefined|null.*error/i);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Form Validation States: Login Page
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Login Form Validation", () => {
|
|
test.use({ storageState: { cookies: [], origins: [] } });
|
|
|
|
test("login button disabled when username is empty", async ({ page }) => {
|
|
await page.goto("/login");
|
|
const loginBtn = page.getByRole("button", { name: /login/i });
|
|
|
|
// Only fill password
|
|
await page.getByLabel("Password").fill("somepassword");
|
|
await expect(loginBtn).toBeDisabled();
|
|
});
|
|
|
|
test("login button disabled when password is empty", async ({ page }) => {
|
|
await page.goto("/login");
|
|
const loginBtn = page.getByRole("button", { name: /login/i });
|
|
|
|
// Only fill username
|
|
await page.getByLabel("Username").fill("someuser");
|
|
await expect(loginBtn).toBeDisabled();
|
|
});
|
|
|
|
test("login button disabled when both fields are empty", async ({ page }) => {
|
|
await page.goto("/login");
|
|
const loginBtn = page.getByRole("button", { name: /login/i });
|
|
|
|
await expect(loginBtn).toBeDisabled();
|
|
});
|
|
|
|
test("wrong credentials show error message", async ({ page }) => {
|
|
await page.goto("/login");
|
|
|
|
await page.getByLabel("Username").fill("wrong-user");
|
|
await page.getByLabel("Password").fill("wrong-password");
|
|
await page.getByRole("button", { name: /login/i }).click();
|
|
|
|
// Error message should appear (text-destructive class)
|
|
await expect(page.getByText(/invalid|incorrect|error/i)).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Should stay on login page
|
|
await expect(page).toHaveURL(/\/login/);
|
|
});
|
|
|
|
test("error message clears on next submission attempt", async ({ page }) => {
|
|
await page.goto("/login");
|
|
|
|
// Trigger error
|
|
await page.getByLabel("Username").fill("bad-user");
|
|
await page.getByLabel("Password").fill("bad-pass");
|
|
await page.getByRole("button", { name: /login/i }).click();
|
|
await expect(page.getByText(/invalid|incorrect|error/i)).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Modify fields and resubmit
|
|
await page.getByLabel("Username").fill("another-bad-user");
|
|
await page.getByLabel("Password").fill("another-bad-pass");
|
|
await page.getByRole("button", { name: /login/i }).click();
|
|
|
|
// The button should show "Logging in..." briefly (loading state works)
|
|
// And eventually show a new error (no crash)
|
|
await expect(page.getByText(/invalid|incorrect|error|logging/i)).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Form Validation: Change Password Page
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Change Password Form Validation", () => {
|
|
test.use({ storageState: { cookies: [], origins: [] } });
|
|
|
|
test("change password button disabled when fields are empty", async ({ page }) => {
|
|
await page.goto("/change-password");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
const submitBtn = page.getByRole("button", { name: /change password/i });
|
|
await expect(submitBtn).toBeDisabled();
|
|
});
|
|
|
|
test("mismatched passwords show error", async ({ page }) => {
|
|
await page.goto("/change-password");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
// Use exact label match for "New password" to avoid matching
|
|
// the "Generate strong password" button text
|
|
await page.getByLabel("Current password").fill("admin");
|
|
await page.getByLabel("New password", { exact: true }).fill("NewPass123");
|
|
await page.getByLabel("Confirm new password").fill("DifferentPass456");
|
|
|
|
await page.getByRole("button", { name: /change password/i }).click();
|
|
|
|
// The client-side validation catches mismatch before the API call
|
|
await expect(page.getByText(/do not match/i)).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Form Validation: Add Member (People settings section)
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Add Member Form Validation", () => {
|
|
test("adding a duplicate username shows error", async ({ loggedInPage: page }) => {
|
|
// Open settings dialog and navigate to People section
|
|
await openSettings(page);
|
|
|
|
// Navigate to People section
|
|
await page.getByRole("button", { name: /people/i }).click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Click "Add Members" to show the form
|
|
const addBtn = page.getByRole("button", { name: /add members/i });
|
|
await addBtn.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Fill in a username that already exists ("admin" is the default user)
|
|
await page.locator("input[placeholder='Username']").fill("admin");
|
|
await page.locator("input[placeholder='Password']").fill("StrongPass123");
|
|
|
|
// Submit the form
|
|
await page.getByRole("button", { name: /create/i }).click();
|
|
|
|
// Should show an error (duplicate username or user-already-exists)
|
|
await expect(page.getByText(/already exists|duplicate|conflict|taken|failed/i)).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Form Validation: QR Generate (no-file tool)
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("QR Generate Form Validation", () => {
|
|
test("download button disabled when text input is empty", async ({ loggedInPage: page }) => {
|
|
await page.goto("/qr-generate");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
// The download button should be disabled when no data is entered
|
|
const downloadBtn = page.locator("[data-testid='qr-generate-download']");
|
|
await expect(downloadBtn).toBeVisible({ timeout: 5_000 });
|
|
await expect(downloadBtn).toBeDisabled();
|
|
});
|
|
|
|
test("download button enabled after entering text", async ({ loggedInPage: page }) => {
|
|
await page.goto("/qr-generate");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
// Enter data in the URL field (default content type)
|
|
const urlInput = page.locator("[data-testid='qr-input-url']");
|
|
await urlInput.fill("https://example.com");
|
|
|
|
// Now the download button should be enabled
|
|
const downloadBtn = page.locator("[data-testid='qr-generate-download']");
|
|
await expect(downloadBtn).toBeEnabled({ timeout: 5_000 });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tool Form Validation: Process Button State
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Tool Form Validation", () => {
|
|
test("resize process button requires a file to be uploaded", async ({ loggedInPage: page }) => {
|
|
await page.goto("/resize");
|
|
|
|
// Before uploading, the resize button should be visible but the dropzone
|
|
// should be shown instead of the process area
|
|
const resizeBtn = page.getByRole("button", { name: "Resize" });
|
|
const dropzone = page.locator("[class*='border-dashed']").first();
|
|
|
|
// Dropzone should be visible (no file uploaded yet)
|
|
await expect(dropzone).toBeVisible();
|
|
|
|
// The Resize button is in the settings panel. Check if it is disabled
|
|
// or if processing is blocked by requiring a file selection first.
|
|
if (await resizeBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
await expect(resizeBtn).toBeDisabled();
|
|
}
|
|
});
|
|
|
|
test("compress process button requires a file to be uploaded", async ({ loggedInPage: page }) => {
|
|
await page.goto("/compress");
|
|
|
|
const compressBtn = page.getByRole("button", { name: "Compress" });
|
|
const dropzone = page.locator("[class*='border-dashed']").first();
|
|
|
|
await expect(dropzone).toBeVisible();
|
|
|
|
if (await compressBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
await expect(compressBtn).toBeDisabled();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Toast Behavior
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Toast Behavior", () => {
|
|
test("toasts do not block main UI interaction", async ({ loggedInPage: page }) => {
|
|
// Sonner's Toaster component lazily renders its container on first toast,
|
|
// so we can't rely on a DOM element existing before any toast fires.
|
|
// Instead, verify the main content area is fully interactive.
|
|
await page.waitForLoadState("domcontentloaded");
|
|
await expect(page.locator("main")).toBeVisible({ timeout: 10_000 });
|
|
|
|
// The body should have content (page loaded correctly)
|
|
const content = await page.textContent("body");
|
|
expect(content).toBeDefined();
|
|
expect(content?.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("success toast after processing auto-dismisses", async ({ loggedInPage: page }) => {
|
|
await page.goto("/resize");
|
|
await uploadTestImage(page);
|
|
|
|
// Set a width and process
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
await waitForProcessing(page);
|
|
|
|
// Wait for the download link to appear (processing complete)
|
|
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
// The page should still be interactive after processing
|
|
// (toasts don't block interaction)
|
|
await expect(page.locator("main")).toBeVisible();
|
|
const sidebar = page.locator("aside");
|
|
await expect(sidebar).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State Reset: Upload -> Navigate Away -> Come Back
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("State Reset on Navigation", () => {
|
|
test("upload, process, navigate away, come back: state is clean", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
// Upload and process in resize
|
|
await page.goto("/resize");
|
|
await uploadTestImage(page);
|
|
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
await waitForProcessing(page);
|
|
|
|
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
// Navigate away to home
|
|
await page.goto("/");
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// Come back to resize
|
|
await page.goto("/resize");
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// State should be clean: dropzone visible, no download link
|
|
await expect(page.getByText("Upload from computer")).toBeVisible({ timeout: 5_000 });
|
|
await expect(page.getByRole("link", { name: /download/i })).not.toBeVisible();
|
|
});
|
|
|
|
test("upload, clear, upload again: no orphaned state", async ({ loggedInPage: page }) => {
|
|
await page.goto("/resize");
|
|
|
|
// First upload
|
|
await uploadTestImage(page);
|
|
await expect(page.getByText(/test-image/i).first()).toBeVisible();
|
|
|
|
// Clear files
|
|
const clearBtn = page.getByText("Clear all");
|
|
if (await clearBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await clearBtn.click();
|
|
await page.waitForTimeout(500);
|
|
}
|
|
|
|
// Dropzone should reappear
|
|
await expect(page.getByText("Upload from computer")).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Upload again
|
|
await uploadTestImage(page);
|
|
await expect(page.getByText(/test-image/i).first()).toBeVisible();
|
|
|
|
// No blob images from the first upload should remain in an orphaned state
|
|
// (only the current upload's blob should exist)
|
|
const blobImages = page.locator("img[src^='blob:']");
|
|
const count = await blobImages.count();
|
|
expect(count).toBeLessThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Memory/Stability: Rapid Navigation
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Memory and Stability", () => {
|
|
test("rapid navigation between 10 tool pages renders each without errors", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
const toolRoutes = [
|
|
"/resize",
|
|
"/crop",
|
|
"/rotate",
|
|
"/convert",
|
|
"/compress",
|
|
"/sharpening",
|
|
"/adjust-colors",
|
|
"/strip-metadata",
|
|
"/bulk-rename",
|
|
"/favicon",
|
|
];
|
|
|
|
for (const route of toolRoutes) {
|
|
await page.goto(route);
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
// Each tool page should render its name or show the tool layout
|
|
// (settings panel or no-dropzone panel)
|
|
const body = page.locator("body");
|
|
await expect(body).toBeVisible();
|
|
|
|
// No JavaScript error should have crashed the page
|
|
const content = await page.textContent("body");
|
|
expect(content).toBeDefined();
|
|
expect(content?.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
test("open and close Settings dialog 20 times without slowdown", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
const timings: number[] = [];
|
|
|
|
for (let i = 0; i < 20; i++) {
|
|
const start = Date.now();
|
|
|
|
// Open settings
|
|
await openSettings(page);
|
|
|
|
const openTime = Date.now() - start;
|
|
timings.push(openTime);
|
|
|
|
// Close settings via Escape
|
|
await page.keyboard.press("Escape");
|
|
await expect(page.locator("h2").filter({ hasText: "Settings" })).not.toBeVisible({
|
|
timeout: 5_000,
|
|
});
|
|
}
|
|
|
|
// The last open should not be significantly slower than the first
|
|
// Allow 3x tolerance for CI variability
|
|
const firstOpen = timings[0];
|
|
const lastOpen = timings[timings.length - 1];
|
|
expect(lastOpen).toBeLessThan(Math.max(firstOpen * 3, 2000));
|
|
});
|
|
|
|
test("10x upload/clear cycle without crash or leak", async ({ loggedInPage: page }) => {
|
|
await page.goto("/resize");
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
// Upload
|
|
await uploadTestImage(page);
|
|
await expect(page.getByText(/test-image/i).first()).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Clear files
|
|
const clearBtn = page.getByText("Clear all");
|
|
if (await clearBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await clearBtn.click();
|
|
await page.waitForTimeout(300);
|
|
}
|
|
|
|
// Dropzone should reappear
|
|
await expect(page.getByText("Upload from computer")).toBeVisible({ timeout: 5_000 });
|
|
}
|
|
|
|
// After 10 cycles, the page should still be responsive
|
|
await expect(page.locator("main")).toBeVisible();
|
|
const content = await page.textContent("body");
|
|
expect(content).toBeDefined();
|
|
expect(content?.length).toBeGreaterThan(0);
|
|
});
|
|
});
|