import { TOOLS } from "@snapotter/shared"; import { expect, openSettings, test, uploadTestImage } from "./helpers"; // Tool routes are section-prefixed at runtime (/
/); resolve the // real route from the shared catalog rather than the bare id. const routeFor = (id: string): string => TOOLS.find((t) => t.id === id)?.route ?? `/${id}`; // --------------------------------------------------------------------------- // Login Page (unauthenticated) // --------------------------------------------------------------------------- test.describe("Login Page", () => { test.use({ storageState: { cookies: [], origins: [] } }); test("renders split layout with form and marketing text", async ({ page }) => { await page.goto("/login"); // Left side: form panel await expect(page.getByRole("heading", { name: /login/i })).toBeVisible(); await expect(page.getByLabel("Username")).toBeVisible(); await expect(page.getByLabel("Password")).toBeVisible(); await expect(page.getByRole("button", { name: /login/i })).toBeVisible(); // Right side: marketing text (hidden on mobile, visible on lg+) await expect(page.getByText("Your files. Stay yours.")).toBeVisible(); }); test("username and password inputs start empty", async ({ page }) => { await page.goto("/login"); await expect(page.getByLabel("Username")).toHaveValue(""); await expect(page.getByLabel("Password")).toHaveValue(""); }); test("login button is disabled when fields are empty", async ({ page }) => { await page.goto("/login"); const loginBtn = page.getByRole("button", { name: /login/i }); await expect(loginBtn).toBeDisabled(); }); test("login button enables when both fields are filled", async ({ page }) => { await page.goto("/login"); const loginBtn = page.getByRole("button", { name: /login/i }); await expect(loginBtn).toBeDisabled(); await page.getByLabel("Username").fill("admin"); await expect(loginBtn).toBeDisabled(); await page.getByLabel("Password").fill("admin"); await expect(loginBtn).toBeEnabled(); }); test("successful login redirects to /", async ({ page }) => { await page.goto("/login"); await page.getByLabel("Username").fill("admin"); await page.getByLabel("Password").fill("admin"); await page.getByRole("button", { name: /login/i }).click(); await page.waitForURL("/", { timeout: 15_000 }); await expect(page).toHaveURL("/"); }); test("failed login shows error and stays on login page", async ({ page }) => { await page.goto("/login"); await page.getByLabel("Username").fill("wrong-user"); await page.getByLabel("Password").fill("wrong-pass"); await page.getByRole("button", { name: /login/i }).click(); await expect(page.getByText(/invalid|incorrect|error/i).first()).toBeVisible({ timeout: 10_000, }); await expect(page).toHaveURL(/\/login/); }); test("pressing Enter in password field submits the form", async ({ page }) => { await page.goto("/login"); await page.getByLabel("Username").fill("admin"); await page.getByLabel("Password").fill("admin"); await page.getByLabel("Password").press("Enter"); await page.waitForURL("/", { timeout: 15_000 }); await expect(page).toHaveURL("/"); }); test("tab order is username -> password -> login button", async ({ page }) => { await page.goto("/login"); // Focus the username field first await page.getByLabel("Username").focus(); await expect(page.getByLabel("Username")).toBeFocused(); // Tab to password await page.keyboard.press("Tab"); await expect(page.getByLabel("Password")).toBeFocused(); // Fill both fields so login button becomes enabled await page.getByLabel("Username").fill("admin"); await page.getByLabel("Password").fill("admin"); // Focus password again, then Tab to login button await page.getByLabel("Password").focus(); await page.keyboard.press("Tab"); await expect(page.getByRole("button", { name: /login/i })).toBeFocused(); }); test("login button only fills username keeps it disabled", async ({ page }) => { await page.goto("/login"); const loginBtn = page.getByRole("button", { name: /login/i }); await page.getByLabel("Username").fill("admin"); // Only username is filled, password is still empty await expect(loginBtn).toBeDisabled(); // Now fill only password (clear username) await page.getByLabel("Username").fill(""); await page.getByLabel("Password").fill("admin"); await expect(loginBtn).toBeDisabled(); }); test("SnapOtter branding is visible on login page", async ({ page }) => { await page.goto("/login"); await expect(page.getByText("SnapOtter").first()).toBeVisible(); }); // Login rate limiting moved to login-security.spec.ts: it must run in the // serial bucket because it lowers the global login attempt cap (which the e2e // env raises so parallel sign-ins do not 429) and restores it. }); // --------------------------------------------------------------------------- // Home Page (authenticated) // --------------------------------------------------------------------------- test.describe("Home Page - Before Upload", () => { test("shows the catalog search box and tool grid", async ({ loggedInPage: page }) => { // 2.0 home is a tool catalog (search + a grid of tool-card links), not a // dropzone. Files are uploaded on tool pages, not on the home page. await expect(page.locator("input[data-search-input]")).toBeVisible(); await expect(page.getByRole("link", { name: /^Resize/ }).first()).toBeVisible(); }); test("tool panel is visible with search bar and categories", async ({ loggedInPage: page }) => { await expect(page.getByPlaceholder(/search/i).first()).toBeVisible(); await expect(page.getByText("Essentials").first()).toBeVisible(); }); test("each tool category header is visible in tool panel", async ({ loggedInPage: page }) => { const toolPanel = page.locator("aside, [class*='tool-panel'], section").filter({ hasText: "Essentials", }); await expect(toolPanel.getByText("Essentials").first()).toBeVisible(); await expect(toolPanel.getByText("Optimization").first()).toBeVisible(); }); test("clicking a tool card navigates to its tool page", async ({ loggedInPage: page }) => { // Catalog tools are links to /
/ (compress is an image tool). await page .getByRole("link", { name: /^Compress/ }) .first() .click(); await expect(page).toHaveURL("/image/compress"); }); test("search filters tools in tool panel", async ({ loggedInPage: page }) => { const searchInput = page.getByPlaceholder(/search/i).first(); await searchInput.fill("resize"); // Resize tool should remain visible await expect(page.getByText("Resize").first()).toBeVisible(); }); }); test.describe("Home Page - After Upload", () => { // Removed: 2.0 has no upload-on-home flow, so the post-upload file info // (green checkmark, filename, file size) no longer exists on the home page. // Removed: the "Change file" button belonged to the deleted upload-on-home UX. // Removed: the post-upload "Quick Actions" button row was deleted in 2.0; the // home page is now a tool catalog with no per-file action shortcuts. test("catalog grid shows tools grouped by category", async ({ loggedInPage: page }) => { // 2.0 replaced the post-upload "All Tools" list with the home catalog grid, // grouped into category sections (e.g. Essentials). await expect(page.getByText("Essentials").first()).toBeVisible(); await expect(page.getByRole("link", { name: /^Resize/ }).first()).toBeVisible(); }); test("clicking a tool card navigates to the tool page", async ({ loggedInPage: page }) => { // Catalog tools are links to /
/ (resize is an image tool). await page .getByRole("link", { name: /^Resize/ }) .first() .click(); await expect(page).toHaveURL("/image/resize"); }); // Removed: 2.0 has no upload-on-home flow. The "image viewer after upload", // "Change file" reset, and the multi-upload file-count badge all belonged to // the deleted home dropzone. Files now upload on tool pages, so these post- // upload home behaviors no longer exist. }); // --------------------------------------------------------------------------- // Fullscreen Grid Page (/fullscreen) // --------------------------------------------------------------------------- // Removed: 2.0 deleted the /fullscreen route and its "Fullscreen Grid Page" // (the show/hide details toggle, the standalone category grid, etc.). App.tsx // has no /fullscreen route, so it now falls through to NotFoundPage. The home // page ("/") is the tool catalog, so all of these assertions moved there. // --------------------------------------------------------------------------- // Tool Page (/:toolId) - tested with "resize" // --------------------------------------------------------------------------- test.describe("Tool Page - Resize", () => { test("shows tool icon and name", async ({ loggedInPage: page }) => { await page.goto("/image/resize"); await expect(page.getByText("Resize").first()).toBeVisible(); }); test("shows dropzone with dashed border before upload", async ({ loggedInPage: page }) => { await page.goto("/image/resize"); const dropzone = page.locator("[class*='border-dashed']").first(); await expect(dropzone).toBeVisible(); await expect(page.getByText("Upload from computer")).toBeVisible(); }); test("after upload shows Files section, Settings section, and Process button", async ({ loggedInPage: page, }) => { await page.goto("/image/resize"); await uploadTestImage(page); // Files section await expect(page.getByText("Files").first()).toBeVisible(); // Settings section await expect(page.getByText("Settings").first()).toBeVisible(); }); test("invalid tool ID shows not found message", async ({ loggedInPage: page }) => { // A single-segment unknown path matches neither "/" nor "/:section/:toolId", // so App.tsx falls through to the "*" route -> NotFoundPage, which renders // t.common.pageNotFound ("Page not found"). (A two-segment unknown path would // instead hit ToolPage's own "Tool not found" guard.) await page.goto("/this-tool-does-not-exist-xyz"); await expect(page.getByText("Page not found")).toBeVisible(); }); }); // --------------------------------------------------------------------------- // Tool Page - Parameterized structure tests across multiple tools // --------------------------------------------------------------------------- const DROPZONE_TOOLS = [ // Essentials { id: "resize", name: "Resize" }, { id: "crop", name: "Crop" }, { id: "rotate", name: "Rotate" }, { id: "convert", name: "Convert" }, { id: "compress", name: "Compress" }, // Optimization { id: "optimize-for-web", name: "Optimize for Web" }, { id: "strip-metadata", name: "Remove Image Metadata" }, { id: "edit-metadata", name: "Edit Image Metadata" }, { id: "bulk-rename", name: "Bulk Rename" }, { id: "image-to-pdf", name: "Image to PDF" }, { id: "favicon", name: "Favicon Generator" }, // Adjustments { id: "adjust-colors", name: "Adjust Colors" }, { id: "sharpening", name: "Sharpen Image" }, { id: "replace-color", name: "Replace & Invert Color" }, { id: "color-blindness", name: "Color Blindness Simulation" }, // AI Tools { id: "remove-background", name: "Remove Background" }, { id: "upscale", name: "Image Upscaling" }, { id: "erase-object", name: "Object Eraser" }, { id: "ocr", name: "Extract Text from Image (OCR)" }, { id: "blur-faces", name: "Blur Faces & PII" }, { id: "smart-crop", name: "Smart Crop" }, { id: "image-enhancement", name: "Image Enhancement" }, { id: "enhance-faces", name: "Face Enhancement" }, { id: "colorize", name: "AI Colorization" }, { id: "noise-removal", name: "Noise Removal" }, { id: "red-eye-removal", name: "Red Eye Removal" }, { id: "restore-photo", name: "Photo Restoration" }, { id: "passport-photo", name: "Passport Photo" }, { id: "content-aware-resize", name: "Content-Aware Resize" }, { id: "ai-canvas-expand", name: "AI Canvas Expand" }, { id: "transparency-fixer", name: "PNG Transparency Fixer" }, // Watermark & Overlay { id: "watermark-text", name: "Text Watermark" }, { id: "watermark-image", name: "Image Watermark" }, { id: "text-overlay", name: "Text Overlay" }, { id: "compose", name: "Image Composition" }, // Utilities { 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 Reader" }, { id: "image-to-base64", name: "Image to Base64" }, // Layout & Composition { id: "stitch", name: "Stitch Images" }, { id: "split", name: "Split Image" }, { id: "border", name: "Border & Frame" }, { id: "beautify", name: "Beautify Screenshot" }, // Format & Conversion { id: "svg-to-raster", name: "SVG to Raster" }, { id: "vectorize", name: "Image to SVG" }, { id: "gif-tools", name: "GIF Tools" }, ]; const NO_DROPZONE_TOOLS = [ { id: "qr-generate", name: "QR Code Generator" }, { id: "meme-generator", name: "Meme Generator" }, { id: "collage", name: "Collage" }, { id: "pdf-to-image", name: "PDF to Image" }, ]; test.describe("Tool Page - Common Structure (Dropzone Tools)", () => { for (const tool of DROPZONE_TOOLS) { test(`${tool.name} (/${tool.id}) shows tool name and dropzone`, async ({ loggedInPage: page, }) => { await page.goto(routeFor(tool.id)); // Tool name should be visible await expect(page.getByText(tool.name).first()).toBeVisible(); // Dropzone should be visible const dropzone = page.locator("[class*='border-dashed']").first(); await expect(dropzone).toBeVisible(); }); } }); test.describe("Tool Page - Common Structure (No-Dropzone Tools)", () => { for (const tool of NO_DROPZONE_TOOLS) { test(`${tool.name} (/${tool.id}) shows tool name without standard dropzone`, async ({ loggedInPage: page, }) => { await page.goto(routeFor(tool.id)); // Tool name should be visible await expect(page.getByText(tool.name).first()).toBeVisible(); }); } }); test.describe("Tool Page - Settings and Process Flow", () => { test("resize: settings panel appears after upload with process button", async ({ loggedInPage: page, }) => { await page.goto("/image/resize"); await uploadTestImage(page); await expect(page.getByText("Settings").first()).toBeVisible(); // The resize submit button mounts with the settings panel after upload. // Its label is the tool name ("Resize"), so target it by test id. await expect(page.getByTestId("resize-submit")).toBeVisible(); }); test("compress: settings panel appears after upload", async ({ loggedInPage: page }) => { await page.goto("/image/compress"); await uploadTestImage(page); await expect(page.getByText("Settings").first()).toBeVisible(); }); test("convert: settings panel appears after upload", async ({ loggedInPage: page }) => { await page.goto("/image/convert"); await uploadTestImage(page); await expect(page.getByText("Settings").first()).toBeVisible(); }); test("resize: download link appears after processing", async ({ loggedInPage: page }) => { await page.goto("/image/resize"); await uploadTestImage(page); // Set a width so the submit button is enabled (canProcess needs a dimension). await page.locator("#resize-width").fill("80"); const submitBtn = page.getByTestId("resize-submit"); await expect(submitBtn).toBeEnabled(); await submitBtn.click(); // The download is an that appears once the // result is ready. await expect(page.getByTestId("resize-download")).toBeVisible({ timeout: 15_000, }); }); test("mobile: settings panel is collapsible on tool page", async ({ browser }) => { const context = await browser.newContext({ viewport: { width: 375, height: 667 }, }); const page = await context.newPage(); await page.goto("/login"); await page.getByLabel("Username").fill("admin"); await page.getByLabel("Password").fill("admin"); await page.getByRole("button", { name: /login/i }).click(); await page.waitForURL("/", { timeout: 15_000 }); await page.goto("/image/resize"); await uploadTestImage(page); // On mobile, settings may be behind a toggle button const settingsToggle = page.getByRole("button", { name: /settings/i }).first(); if (await settingsToggle.isVisible({ timeout: 3000 }).catch(() => false)) { await settingsToggle.click(); await page.waitForTimeout(300); // Settings content should be visible after clicking toggle await expect(page.getByText("Settings").first()).toBeVisible(); } await context.close(); }); }); // --------------------------------------------------------------------------- // Automate Page (/automate) // --------------------------------------------------------------------------- test.describe("Automate Page", () => { test("shows pipeline builder with empty state", async ({ loggedInPage: page }) => { await page.goto("/automate"); await expect(page.getByText("Pipeline Builder")).toBeVisible(); await expect(page.getByText("No steps yet")).toBeVisible(); // t.automate.addToolsPrompt renders both in the canvas header subtitle and in // the PipelineBuilder empty state, so scope to the first match. await expect(page.getByText("Add tools from the palette to get started").first()).toBeVisible(); }); test("tool palette is visible with searchable list", async ({ loggedInPage: page }) => { await page.goto("/automate"); await expect(page.getByText("Tool Palette")).toBeVisible(); await expect(page.getByPlaceholder(/search/i).first()).toBeVisible(); }); test("process button is disabled when no steps are configured", async ({ loggedInPage: page, }) => { await page.goto("/automate"); const processBtn = page.getByRole("button", { name: /process/i }).first(); await expect(processBtn).toBeDisabled(); }); test("search filters tools in the tool palette", async ({ loggedInPage: page }) => { await page.goto("/automate"); const searchInput = page.getByPlaceholder(/search/i).first(); await searchInput.fill("resize"); // Resize should be visible in the palette await expect(page.getByText("Resize").first()).toBeVisible(); }); test("clicking a tool in palette adds it as a pipeline step", async ({ loggedInPage: page }) => { await page.goto("/automate"); // The empty state should be shown await expect(page.getByText("No steps yet")).toBeVisible(); // Click a tool in the palette to add it as a step const resizeTool = page.locator("[data-tool-id='resize']").first(); if (await resizeTool.isVisible({ timeout: 3000 }).catch(() => false)) { await resizeTool.click(); } else { // Fallback: click the Resize text in the palette area await page.getByText("Resize").first().click(); } await page.waitForTimeout(500); // Empty state should be gone - step should be added await expect(page.getByText("No steps yet")).not.toBeVisible(); }); test("Save button appears when pipeline has steps", async ({ loggedInPage: page }) => { await page.goto("/automate"); // Save button should not be visible before adding steps await expect(page.getByRole("button", { name: /^Save$/i })).not.toBeVisible(); // Add a tool step const resizeTool = page.locator("[data-tool-id='resize']").first(); if (await resizeTool.isVisible({ timeout: 3000 }).catch(() => false)) { await resizeTool.click(); } else { await page.getByText("Resize").first().click(); } await page.waitForTimeout(500); // Save button should now be visible await expect(page.getByRole("button", { name: /^Save$/i })).toBeVisible(); }); test("process button remains disabled with steps but no file", async ({ loggedInPage: page }) => { await page.goto("/automate"); // Add a tool step const resizeTool = page.locator("[data-tool-id='resize']").first(); if (await resizeTool.isVisible({ timeout: 3000 }).catch(() => false)) { await resizeTool.click(); } else { await page.getByText("Resize").first().click(); } await page.waitForTimeout(500); // Process button should still be disabled without a file const processBtn = page.getByRole("button", { name: /process/i }).first(); await expect(processBtn).toBeDisabled(); }); }); // --------------------------------------------------------------------------- // Files Page (/files) // --------------------------------------------------------------------------- test.describe("Files Page", () => { test("renders file management layout on desktop", async ({ loggedInPage: page }) => { await page.goto("/files"); // "My Files" renders twice: a visually-hidden page

and the visible // FilesNav

. Target the level-2 nav heading so we assert the visible one. await expect(page.getByRole("heading", { name: "My Files", level: 2 })).toBeVisible(); // Navigation items await expect(page.getByRole("button", { name: /recent/i }).first()).toBeVisible(); }); }); // --------------------------------------------------------------------------- // Top-Nav Navigation // --------------------------------------------------------------------------- // 2.0 removed the desktop sidebar. Navigation now lives in the top-nav header // (role banner): nav links Tools (->"/"), Automate, Editor (badge "Beta"), and // Files, plus a Help button and an avatar dropdown (data-testid="user-menu") // that holds Settings + Logout. The /fullscreen "Grid" link is gone. test.describe("Top-Nav Navigation", () => { test("top-nav shows Tools, Automate, Editor, and Files links", async ({ loggedInPage: page }) => { const banner = page.getByRole("banner"); await expect(banner).toBeVisible(); await expect(banner.getByRole("link", { name: "Tools" })).toBeVisible(); await expect(banner.getByRole("link", { name: "Automate" })).toBeVisible(); // The Editor link carries a "Beta" badge, so its accessible name is "Editor Beta". await expect(banner.getByRole("link", { name: /Editor/ })).toBeVisible(); await expect(banner.getByRole("link", { name: "Files" })).toBeVisible(); }); test("top-nav has a Help button and an avatar menu containing Settings", async ({ loggedInPage: page, }) => { const banner = page.getByRole("banner"); await expect(banner.getByRole("button", { name: /help/i }).first()).toBeVisible(); // The avatar dropdown exposes Settings (and Logout). await page.getByTestId("user-menu").click(); await expect(page.getByRole("button", { name: "Settings", exact: true })).toBeVisible(); }); test("Tools link navigates to home /", async ({ loggedInPage: page }) => { await page.goto("/automate"); await page.getByRole("banner").getByRole("link", { name: "Tools" }).click(); await expect(page).toHaveURL("/"); }); test("Automate link navigates to /automate", async ({ loggedInPage: page }) => { await page.getByRole("banner").getByRole("link", { name: "Automate" }).click(); await expect(page).toHaveURL("/automate"); }); test("Editor link navigates to /editor", async ({ loggedInPage: page }) => { await page .getByRole("banner") .getByRole("link", { name: /Editor/ }) .click(); await expect(page).toHaveURL("/editor"); }); test("Files link navigates to /files", async ({ loggedInPage: page }) => { await page.getByRole("banner").getByRole("link", { name: "Files" }).click(); await expect(page).toHaveURL("/files"); }); test("active nav link is highlighted on the home page", async ({ loggedInPage: page }) => { // On "/", the Tools link gets the active treatment (bg-muted in the light variant). const toolsLink = page.getByRole("banner").getByRole("link", { name: "Tools" }); await expect(toolsLink).toHaveClass(/bg-muted/); }); test("Help button opens HelpDialog modal", async ({ loggedInPage: page }) => { await page.getByRole("banner").getByRole("button", { name: /help/i }).first().click(); // Help dialog header await expect(page.getByRole("heading", { name: "Help" })).toBeVisible(); }); test("Settings opens SettingsDialog modal", async ({ loggedInPage: page }) => { await openSettings(page); await expect(page.getByRole("heading", { name: "General" })).toBeVisible(); await expect(page.getByRole("button", { name: "Security" })).toBeVisible(); }); }); // --------------------------------------------------------------------------- // Top-Nav Controls (theme + language) // --------------------------------------------------------------------------- // 2.0 removed the footer. The theme toggle and language selector now live on the // right side of the top nav (desktop only). The theme button's title is the // lowercase "Toggle theme" (top-nav.tsx ThemeToggle). test.describe("Top-Nav Controls", () => { test("theme toggle button is visible with sun or moon icon", async ({ loggedInPage: page }) => { const themeBtn = page.locator("button[title='Toggle theme']"); await expect(themeBtn).toBeVisible(); // Should contain an SVG icon (Sun or Moon) await expect(themeBtn.locator("svg")).toBeVisible(); }); test("theme toggle switches between sun and moon icons", async ({ loggedInPage: page }) => { const themeBtn = page.locator("button[title='Toggle theme']"); await expect(themeBtn).toBeVisible(); const hadDark = await page.evaluate(() => document.documentElement.classList.contains("dark")); await themeBtn.click(); await page.waitForTimeout(300); const hasDark = await page.evaluate(() => document.documentElement.classList.contains("dark")); expect(hasDark).not.toBe(hadDark); }); test("language button is visible and shows English", async ({ loggedInPage: page }) => { const langBtn = page.locator("button[title='Language']"); await expect(langBtn).toBeVisible(); await expect(langBtn).toContainText("English"); await expect(langBtn.locator("svg")).toBeVisible(); }); // Removed: 2.0 deleted the footer and its privacy link. The chrome no longer // links to /privacy (the page still exists and is covered in "Routing Edge // Cases"); the avatar dropdown exposes Docs/API Reference instead. test("theme persists after page reload", async ({ loggedInPage: page }) => { const themeBtn = page.locator("button[title='Toggle theme']"); await expect(themeBtn).toBeVisible(); // Toggle theme await themeBtn.click(); await page.waitForTimeout(300); const themeAfterToggle = await page.evaluate(() => document.documentElement.classList.contains("dark"), ); // Reload the page await page.reload(); await page.waitForTimeout(500); const themeAfterReload = await page.evaluate(() => document.documentElement.classList.contains("dark"), ); // Theme should persist across reload expect(themeAfterReload).toBe(themeAfterToggle); }); }); // --------------------------------------------------------------------------- // Drag-and-Drop Upload // --------------------------------------------------------------------------- test.describe("Drag-and-Drop Upload", () => { test("dropzone accepts dropped files via DataTransfer", async ({ loggedInPage: page }) => { // 2.0 has no home dropzone; uploads happen on a tool page. The Dropzone // renders
with the "Drop your files // here" prompt and an "Upload from computer" button. await page.goto("/image/resize"); const dropzone = page.locator("section[aria-label='File drop zone']"); await expect(dropzone).toBeVisible(); await expect(page.getByText("Drop your files here")).toBeVisible(); // Upload via the file chooser flow (same onFiles handler as drag-and-drop) await uploadTestImage(page); // After upload, the selected file name should appear in the Files list await expect(page.getByText(/test-image/i).first()).toBeVisible(); }); }); // --------------------------------------------------------------------------- // Files Page Layout // --------------------------------------------------------------------------- test.describe("Files Page Layout", () => { test("desktop shows nav column with list and details", async ({ loggedInPage: page }) => { await page.goto("/files"); // FilesNav renders the visible level-2 "My Files" heading (a separate sr-only //

with the same text also exists, so scope to the nav heading). await expect(page.getByRole("heading", { name: "My Files", level: 2 })).toBeVisible(); // Nav items: Recent and Upload Files (files-nav.tsx) await expect(page.getByRole("button", { name: /recent/i }).first()).toBeVisible(); await expect(page.getByRole("button", { name: /upload files/i }).first()).toBeVisible(); }); test("mobile shows tabbed layout with Recent and Upload tabs", async ({ browser }) => { const context = await browser.newContext({ viewport: { width: 375, height: 667 }, }); const page = await context.newPage(); await page.goto("/login"); await page.getByLabel("Username").fill("admin"); await page.getByLabel("Password").fill("admin"); await page.getByRole("button", { name: /login/i }).click(); await page.waitForURL("/", { timeout: 15_000 }); await page.goto("/files"); // Mobile tabs (files-page.tsx): "Recent" and "Upload Files" await expect(page.getByRole("button", { name: "Recent" })).toBeVisible(); await expect(page.getByRole("button", { name: /upload files/i })).toBeVisible(); // The FilesNav desktop heading (hidden md:block) is not rendered on mobile. await expect(page.getByRole("heading", { name: "My Files", level: 3 })).not.toBeVisible(); await context.close(); }); }); // --------------------------------------------------------------------------- // Routing Edge Cases // --------------------------------------------------------------------------- test.describe("Routing Edge Cases", () => { test("invalid tool ID shows error state", async ({ loggedInPage: page }) => { // A two-segment path matches "/:section/:toolId", so ToolPage mounts and hits // its own guard (t.toolPage.notFound = "Tool not found") when the id is // unknown. (A single-segment path would instead fall to NotFoundPage; see the // "Tool Page - Resize > invalid tool ID" test.) await page.goto("/image/nonexistent-tool-abc123"); await expect(page.getByText("Tool not found")).toBeVisible(); }); test("legacy /brightness-contrast redirects to /adjust-colors", async ({ loggedInPage: page, }) => { await page.goto("/brightness-contrast"); await expect(page).toHaveURL("/image/adjust-colors"); }); test("legacy /saturation redirects to /adjust-colors", async ({ loggedInPage: page }) => { await page.goto("/saturation"); await expect(page).toHaveURL("/image/adjust-colors"); }); test("legacy /color-channels redirects to /adjust-colors", async ({ loggedInPage: page }) => { await page.goto("/color-channels"); await expect(page).toHaveURL("/image/adjust-colors"); }); test("/privacy renders the privacy policy page", async ({ loggedInPage: page }) => { await page.goto("/privacy"); await expect(page.getByRole("heading", { name: "Privacy Policy" })).toBeVisible(); await expect(page.getByRole("link", { name: "Back" })).toBeVisible(); }); test("/privacy Back link navigates home", async ({ loggedInPage: page }) => { await page.goto("/privacy"); await page.getByRole("link", { name: "Back" }).click(); await expect(page).toHaveURL("/"); }); test("legacy /color-effects redirects to /adjust-colors", async ({ loggedInPage: page }) => { await page.goto("/color-effects"); await expect(page).toHaveURL("/image/adjust-colors"); }); test("/login renders the login page even when already authenticated", async ({ loggedInPage: page, }) => { // 2.0 does not auto-redirect authenticated users away from /login: AuthGuard // lets the login route render unconditionally and LoginPage has no // redirect-on-auth effect. The form stays put. await page.goto("/login"); await expect(page).toHaveURL(/\/login/); await expect(page.getByRole("heading", { name: /login/i })).toBeVisible(); }); }); // --------------------------------------------------------------------------- // Browser Back/Forward Navigation // --------------------------------------------------------------------------- test.describe("Browser Back/Forward Navigation", () => { test("browser back button returns to previous page", async ({ loggedInPage: page }) => { // Navigate: Home -> Editor -> back should return to Home (/fullscreen is gone). await page.goto("/editor"); await expect(page).toHaveURL("/editor"); await page.goBack(); await expect(page).toHaveURL("/"); }); test("browser forward button returns to next page after going back", async ({ loggedInPage: page, }) => { await page.goto("/editor"); await expect(page).toHaveURL("/editor"); await page.goBack(); await expect(page).toHaveURL("/"); await page.goForward(); await expect(page).toHaveURL("/editor"); }); test("multi-step back/forward through several pages", async ({ loggedInPage: page }) => { // Navigate: Home -> /automate -> /files -> back -> back -> forward await page.goto("/automate"); await expect(page).toHaveURL("/automate"); await page.goto("/files"); await expect(page).toHaveURL("/files"); await page.goBack(); await expect(page).toHaveURL("/automate"); await page.goBack(); await expect(page).toHaveURL("/"); await page.goForward(); await expect(page).toHaveURL("/automate"); }); // Removed: 2.0 deleted the /fullscreen route, so there is no fullscreen page to // preserve across a refresh. Refresh-persistence is still covered for /automate, // tool pages, /files, and /editor. test("page refresh preserves route on /automate", async ({ loggedInPage: page }) => { await page.goto("/automate"); await expect(page).toHaveURL("/automate"); await page.reload(); await expect(page).toHaveURL("/automate"); await expect(page.getByText("Pipeline Builder")).toBeVisible(); }); test("page refresh preserves route on tool page", async ({ loggedInPage: page }) => { // Tool routes are section-prefixed in 2.0 (/image/resize, not /resize). await page.goto("/image/resize"); await expect(page).toHaveURL("/image/resize"); await page.reload(); await expect(page).toHaveURL("/image/resize"); await expect(page.getByText("Resize").first()).toBeVisible(); }); test("page refresh preserves route on /files", async ({ loggedInPage: page }) => { await page.goto("/files"); await expect(page).toHaveURL("/files"); await page.reload(); await expect(page).toHaveURL("/files"); // Scope to the visible FilesNav heading (an sr-only

shares the text). await expect(page.getByRole("heading", { name: "My Files", level: 2 })).toBeVisible(); }); }); // --------------------------------------------------------------------------- // Mobile Navigation (375x667) // --------------------------------------------------------------------------- test.describe("Mobile Navigation", () => { // Removed: 2.0 deleted the mobile hamburger sidebar overlay (and its backdrop). // Mobile navigation is a fixed bottom nav (nav.fixed) with Tools, Automate, // Editor, Files, and a Settings button; the tests below cover it. test("bottom nav navigates between all main sections", async ({ browser }) => { const context = await browser.newContext({ viewport: { width: 375, height: 667 }, }); const page = await context.newPage(); await page.goto("/login"); await page.getByLabel("Username").fill("admin"); await page.getByLabel("Password").fill("admin"); await page.getByRole("button", { name: /login/i }).click(); await page.waitForURL("/", { timeout: 15_000 }); const bottomNav = page.locator("nav.fixed"); // Navigate to Automate await bottomNav.getByText("Automate").click(); await expect(page).toHaveURL("/automate"); // Navigate to Files await bottomNav.getByText("Files").click(); await expect(page).toHaveURL("/files"); // Navigate back to Tools await bottomNav.getByText("Tools").click(); await expect(page).toHaveURL("/"); await context.close(); }); test("bottom nav has Editor link", async ({ browser }) => { const context = await browser.newContext({ viewport: { width: 375, height: 667 }, }); const page = await context.newPage(); await page.goto("/login"); await page.getByLabel("Username").fill("admin"); await page.getByLabel("Password").fill("admin"); await page.getByRole("button", { name: /login/i }).click(); await page.waitForURL("/", { timeout: 15_000 }); const bottomNav = page.locator("nav.fixed"); await expect(bottomNav.getByText("Editor")).toBeVisible(); await bottomNav.getByText("Editor").click(); await expect(page).toHaveURL("/editor"); await context.close(); }); // Removed: the mobile hamburger "Grid" link (and the /fullscreen route it // pointed to) no longer exist in 2.0. test("bottom nav Settings button opens the Settings dialog", async ({ browser }) => { const context = await browser.newContext({ viewport: { width: 375, height: 667 }, }); const page = await context.newPage(); await page.goto("/login"); await page.getByLabel("Username").fill("admin"); await page.getByLabel("Password").fill("admin"); await page.getByRole("button", { name: /login/i }).click(); await page.waitForURL("/", { timeout: 15_000 }); // The fixed bottom nav exposes a Settings button (mobile-bottom-nav.tsx) that // opens the Settings dialog rather than navigating. const bottomNav = page.locator("nav.fixed"); await bottomNav.getByRole("button", { name: /settings/i }).click(); await expect(page.getByRole("dialog")).toBeVisible(); await expect(page.getByRole("heading", { name: "General" })).toBeVisible(); await context.close(); }); }); // --------------------------------------------------------------------------- // Editor Page (/editor) // --------------------------------------------------------------------------- test.describe("Editor Page", () => { test("editor page renders with canvas area", async ({ loggedInPage: page }) => { await page.goto("/editor"); await expect(page).toHaveURL("/editor"); // Editor should have a canvas or main editor area await expect(page.getByText(/editor|canvas|draw/i).first()).toBeVisible(); }); test("editor page is reachable from the top-nav Editor link", async ({ loggedInPage: page }) => { // 2.0 removed the sidebar; the Editor link (with a "Beta" badge) lives in the // top-nav banner. await page .getByRole("banner") .getByRole("link", { name: /Editor/ }) .click(); await expect(page).toHaveURL("/editor"); }); test("page refresh preserves /editor route", async ({ loggedInPage: page }) => { await page.goto("/editor"); await expect(page).toHaveURL("/editor"); await page.reload(); await expect(page).toHaveURL("/editor"); }); }); // --------------------------------------------------------------------------- // Settings Dialog - Tab Navigation // --------------------------------------------------------------------------- test.describe("Settings Dialog Tabs", () => { test("settings dialog shows all navigation sections", async ({ loggedInPage: page }) => { await openSettings(page); // Core sections should be listed in nav await expect(page.getByRole("button", { name: "General" })).toBeVisible(); await expect(page.getByRole("button", { name: "Security" })).toBeVisible(); await expect(page.getByRole("button", { name: "About" })).toBeVisible(); }); test("clicking Security tab shows security settings", async ({ loggedInPage: page }) => { await openSettings(page); await page.getByRole("button", { name: "Security" }).click(); await page.waitForTimeout(300); // Security section content should appear (the section heading plus a couple // of sub-section headings all read "Security", so scope to the first). await expect(page.getByRole("heading", { name: "Security" }).first()).toBeVisible(); }); test("clicking About tab shows version info", async ({ loggedInPage: page }) => { await openSettings(page); await page.getByRole("button", { name: "About" }).click(); await page.waitForTimeout(300); // About section should show version await expect(page.getByText(/version/i).first()).toBeVisible(); }); test("switching between tabs preserves dialog open state", async ({ loggedInPage: page }) => { await openSettings(page); // Switch to Security await page.getByRole("button", { name: "Security" }).click(); await page.waitForTimeout(200); await expect(page.getByRole("dialog")).toBeVisible(); // Switch to About await page.getByRole("button", { name: "About" }).click(); await page.waitForTimeout(200); await expect(page.getByRole("dialog")).toBeVisible(); // Switch back to General await page.getByRole("button", { name: "General" }).click(); await page.waitForTimeout(200); await expect(page.getByRole("dialog")).toBeVisible(); await expect(page.getByRole("heading", { name: "General" })).toBeVisible(); }); }); // --------------------------------------------------------------------------- // Language Selector // --------------------------------------------------------------------------- test.describe("Language Selector", () => { test("language button opens language dropdown", async ({ loggedInPage: page }) => { const langBtn = page.locator("button[title='Language']"); await expect(langBtn).toBeVisible(); await langBtn.click(); await page.waitForTimeout(300); // Language options should appear await expect(page.getByText(/espa|fran|deutsch|portugu/i).first()).toBeVisible(); }); test("selecting a language updates the button label", async ({ loggedInPage: page }) => { const langBtn = page.locator("button[title='Language']"); await expect(langBtn).toContainText("English"); await langBtn.click(); await page.waitForTimeout(300); // Pick a different language and check the button updates const spanishOption = page.getByText(/espa/i).first(); if (await spanishOption.isVisible({ timeout: 3000 }).catch(() => false)) { await spanishOption.click(); await page.waitForTimeout(500); // Button should no longer say "English" await expect(langBtn).not.toContainText("English"); // Reset back to English await langBtn.click(); await page.waitForTimeout(300); await page.getByText("English").first().click(); } }); }); // --------------------------------------------------------------------------- // Home Catalog - Tool Visibility // --------------------------------------------------------------------------- // 2.0 removed the standalone /fullscreen grid; the home catalog ("/") now shows // every tool grouped by section then category. The AI image tools live under the // "Enhance & AI" category (t.categories.enhance). test.describe("Home Catalog - AI Tool Visibility", () => { test("AI tools are visible in the home catalog", async ({ loggedInPage: page }) => { await page.goto("/"); // The "Enhance & AI" category header groups the AI image tools. await expect(page.getByText("Enhance & AI").first()).toBeVisible(); // At least one AI tool card should be listed await expect(page.getByRole("link", { name: /Remove Background/ }).first()).toBeVisible(); }); test("tool cards display an icon and name", async ({ loggedInPage: page }) => { await page.goto("/"); // A tool card should have visible text for its name const resizeLink = page.getByRole("link", { name: /^Resize/ }).first(); await expect(resizeLink).toBeVisible(); // And it should contain an SVG icon await expect(resizeLink.locator("svg").first()).toBeVisible(); }); }); // --------------------------------------------------------------------------- // Automate Page - Additional Coverage // --------------------------------------------------------------------------- test.describe("Automate Page - Pipeline Management", () => { test("removing a pipeline step returns to empty state", async ({ loggedInPage: page }) => { await page.goto("/automate"); // Add a tool step const resizeTool = page.locator("[data-tool-id='resize']").first(); if (await resizeTool.isVisible({ timeout: 3000 }).catch(() => false)) { await resizeTool.click(); } else { await page.getByText("Resize").first().click(); } await page.waitForTimeout(500); // Step should be added await expect(page.getByText("No steps yet")).not.toBeVisible(); // Each step card has a Remove button (title="Remove" -> t.automate.removeStep). // Removing the only step returns the builder to its empty state. await page.locator("button[title='Remove']").first().click(); await expect(page.getByText("No steps yet")).toBeVisible(); }); test("adding multiple steps shows ordered step list", async ({ loggedInPage: page }) => { await page.goto("/automate"); // Add first tool const resizeTool = page.locator("[data-tool-id='resize']").first(); if (await resizeTool.isVisible({ timeout: 3000 }).catch(() => false)) { await resizeTool.click(); } else { await page.getByText("Resize").first().click(); } await page.waitForTimeout(300); // Add second tool const compressTool = page.locator("[data-tool-id='compress']").first(); if (await compressTool.isVisible({ timeout: 3000 }).catch(() => false)) { await compressTool.click(); } else { await page.getByText("Compress").first().click(); } await page.waitForTimeout(300); // Both tools should be listed as steps await expect(page.getByText("Resize").first()).toBeVisible(); await expect(page.getByText("Compress").first()).toBeVisible(); }); }); // --------------------------------------------------------------------------- // Routing - Additional Edge Cases // --------------------------------------------------------------------------- test.describe("Routing - Additional Edge Cases", () => { test("/change-password page renders for authenticated user", async ({ loggedInPage: page }) => { await page.goto("/change-password"); // Should render the change password form await expect(page.getByText(/change password|new password/i).first()).toBeVisible(); }); test("navigating to /editor and back preserves home state", async ({ loggedInPage: page }) => { // Start at home await expect(page).toHaveURL("/"); // Navigate to editor await page.goto("/editor"); await expect(page).toHaveURL("/editor"); // Navigate back await page.goBack(); await expect(page).toHaveURL("/"); // Home content should be intact: the 2.0 home is the tool catalog (search box // + tool-card links), not a dropzone. await expect(page.locator("input[data-search-input]")).toBeVisible(); await expect(page.getByRole("link", { name: /^Resize/ }).first()).toBeVisible(); }); test("deep-linking to a specific tool page works", async ({ loggedInPage: page }) => { // Tool routes are section-prefixed; resolve via the shared catalog. await page.goto(routeFor("watermark-text")); await expect(page).toHaveURL("/image/watermark-text"); await expect(page.getByText("Text Watermark").first()).toBeVisible(); }); test("double navigation to same page does not break state", async ({ loggedInPage: page }) => { await page.goto("/automate"); await expect(page).toHaveURL("/automate"); await page.goto("/automate"); await expect(page).toHaveURL("/automate"); // Page should still work correctly await expect(page.getByText("Pipeline Builder")).toBeVisible(); }); test("navigating from a legacy redirect to another page works", async ({ loggedInPage: page, }) => { await page.goto("/brightness-contrast"); await expect(page).toHaveURL("/image/adjust-colors"); await page.goto("/files"); await expect(page).toHaveURL("/files"); await page.goBack(); await expect(page).toHaveURL("/image/adjust-colors"); }); }); // --------------------------------------------------------------------------- // Tool Page - Mobile Structure (375x667) // --------------------------------------------------------------------------- test.describe("Tool Page - Mobile Structure", () => { const MOBILE_TOOLS = [ { id: "resize", name: "Resize" }, { id: "compress", name: "Compress" }, { id: "convert", name: "Convert" }, { id: "crop", name: "Crop" }, { id: "watermark-text", name: "Text Watermark" }, { id: "adjust-colors", name: "Adjust Colors" }, ]; for (const tool of MOBILE_TOOLS) { test(`mobile: ${tool.name} (/${tool.id}) shows tool name and dropzone`, async ({ browser }) => { const context = await browser.newContext({ viewport: { width: 375, height: 667 }, }); const page = await context.newPage(); await page.goto("/login"); await page.getByLabel("Username").fill("admin"); await page.getByLabel("Password").fill("admin"); await page.getByRole("button", { name: /login/i }).click(); await page.waitForURL("/", { timeout: 15_000 }); await page.goto(routeFor(tool.id)); // Tool name should be visible await expect(page.getByText(tool.name).first()).toBeVisible(); // Dropzone should be visible on mobile too const dropzone = page.locator("[class*='border-dashed']").first(); await expect(dropzone).toBeVisible(); // No horizontal overflow const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth); const clientWidth = await page.evaluate(() => document.documentElement.clientWidth); expect(scrollWidth).toBeLessThanOrEqual(clientWidth); await context.close(); }); } });