mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Renames 18 ambiguous or hard-to-search tool names so image tools self-qualify like the other modalities ("Compress" becomes "Compress Image"), and cleans up a few awkward names. Propagated across search (constants.ts), display (en.ts + 20 locales), the OpenAPI base spec + 20 locale specs, and the docs tool-page headings in 21 languages. Removes the duplicate "Normalize Audio" summary shared by the video and audio endpoints. Tool ids and routes are unchanged, so no API paths or bookmarks break.
1277 lines
50 KiB
TypeScript
1277 lines
50 KiB
TypeScript
import { TOOLS } from "@snapotter/shared";
|
|
import { expect, openSettings, test, uploadTestImage } from "./helpers";
|
|
|
|
// Tool routes are section-prefixed at runtime (/<section>/<toolId>); 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 /<section>/<toolId> (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 /<section>/<toolId> (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: "OCR / Text Extraction" },
|
|
{ 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 <a data-testid="resize-download"> 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 <h1> and the visible
|
|
// FilesNav <h3>. Target the level-3 nav heading so we assert the visible one.
|
|
await expect(page.getByRole("heading", { name: "My Files", level: 3 })).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 <section aria-label="File drop zone"> 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-3 "My Files" heading (a separate sr-only
|
|
// <h1> with the same text also exists, so scope to the nav heading).
|
|
await expect(page.getByRole("heading", { name: "My Files", level: 3 })).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.getByText("Back")).toBeVisible();
|
|
});
|
|
|
|
test("/privacy Back link navigates home", async ({ loggedInPage: page }) => {
|
|
await page.goto("/privacy");
|
|
|
|
await page.getByText("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 <h1> shares the text).
|
|
await expect(page.getByRole("heading", { name: "My Files", level: 3 })).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();
|
|
});
|
|
}
|
|
});
|