mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
A release-readiness QA pass over the whole product. The commits split into defects a user would hit and gates that were reporting green while measuring nothing. ## Fixes that change behaviour Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so request.ip came from a client-set header and a forged X-Forwarded-For got past the login limiter. The default is now a private-network trust list. A transient Postgres outage stranded in-flight jobs, leaving finished output on disk with no row pointing at it. A reconciler now resolves those rows and adopts the bytes rather than dropping the work. A Redis connection that moved to a new address wedged every read-blocked consumer, so completions stopped signalling while health still answered 200. Socket timeouts plus subscriber pings recover it. Installing more than one AI bundle left the shared venv multi-versioned and silently broke three tools. The installer now reconciles distributions to one version each. Converting an image to JXL at quality 1 through 4 returned a 500, because libjxl 0.7 rejects the distance those values compute. The quality is floored at what the encoder honours. A missing ffmpeg was also reported to the user as a corrupt upload; it now says the engine is unavailable. RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at 0.22.2, and the release scan was split so it can fail on an unfixed critical instead of hiding it behind ignore-unfixed. ## Gates that could not fail Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs build; coverage discarded its whole report on any failing test; the lint gate skipped root tests, scripts, and two workspaces; and several generated matrices counted a host missing ffmpeg as a passing tool. Each now measures what it claims. Full evidence and the outstanding release items are tracked locally and are not part of this branch.
1555 lines
58 KiB
TypeScript
1555 lines
58 KiB
TypeScript
import {
|
|
expect,
|
|
getE2eRunRoot,
|
|
openSettings,
|
|
test,
|
|
uploadTestImage,
|
|
waitForProcessing,
|
|
} from "./helpers";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GUI Resilience: Error handling, form validation, state reset, stability,
|
|
// connection banner, toast behaviour, disconnection recovery
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 14.1 Connection Banner & Disconnection
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Connection Banner & Disconnection", () => {
|
|
test("connection banner appears when API is unreachable", async ({ loggedInPage: page }) => {
|
|
// Block all health-check requests so the monitor thinks the server is down
|
|
await page.route("**/api/v1/health", (route) => route.abort());
|
|
|
|
try {
|
|
// Remount the connection monitor while health requests are blocked. This
|
|
// avoids racing an in-flight successful health request from initial load.
|
|
await page.reload();
|
|
|
|
// The RouteAnnouncer also renders role="status", so match the connection
|
|
// banner by its exact failure-state message.
|
|
await expect(
|
|
page.getByRole("status").filter({ hasText: /reconnecting to server/i }),
|
|
).toBeVisible({ timeout: 10_000 });
|
|
} finally {
|
|
await page.unroute("**/api/v1/health");
|
|
}
|
|
});
|
|
|
|
test("UI remains interactive while disconnected (banner and main visible)", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
|
|
await page.context().setOffline(true);
|
|
try {
|
|
await expect(page.getByRole("status").filter({ hasText: /you're offline/i })).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
// Top-nav banner and main content should still be visible and interactive
|
|
await expect(page.getByRole("banner")).toBeVisible();
|
|
await expect(page.locator("main")).toBeVisible();
|
|
|
|
// Client-side filtering remains interactive without a network connection.
|
|
const searchInput = page.getByPlaceholder(/search/i).first();
|
|
if (await searchInput.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await searchInput.fill("resize");
|
|
await expect(page.getByText("Resize").first()).toBeVisible({ timeout: 5_000 });
|
|
}
|
|
} finally {
|
|
await page.context().setOffline(false);
|
|
}
|
|
});
|
|
|
|
test("connection banner disappears when API reconnects", async ({ loggedInPage: page }) => {
|
|
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
|
|
await page.context().setOffline(true);
|
|
await expect(page.getByRole("status").filter({ hasText: /you're offline/i })).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
await page.context().setOffline(false);
|
|
|
|
// Banner should eventually disappear (after "reconnected" state clears)
|
|
await expect(
|
|
page.getByRole("status").filter({ hasText: /offline|reconnecting|connected/i }),
|
|
).not.toBeVisible({ timeout: 15_000 });
|
|
});
|
|
|
|
test("toast appears when processing fails due to disconnection", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
// Block all tool API requests to simulate disconnection during processing
|
|
await page.route("**/api/v1/tools/**", (route) => route.abort());
|
|
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
|
|
// Wait for error state to propagate
|
|
await page.waitForTimeout(3000);
|
|
|
|
// An error indication should be visible (toast or inline error)
|
|
// Sonner renders toasts in [data-sonner-toaster] or there is inline error text
|
|
const toaster = page.locator("[data-sonner-toaster]");
|
|
const inlineError = page.locator("text=/error|failed|unable|interrupted|reconnect/i");
|
|
const hasToast = await toaster.isVisible({ timeout: 3000 }).catch(() => false);
|
|
const hasInlineError = await inlineError
|
|
.first()
|
|
.isVisible({ timeout: 1000 })
|
|
.catch(() => false);
|
|
|
|
// At least one error indicator should be present
|
|
expect(hasToast || hasInlineError).toBeTruthy();
|
|
|
|
// Page should not have crashed
|
|
await expect(page.locator("main")).toBeVisible();
|
|
|
|
await page.unroute("**/api/v1/tools/**");
|
|
});
|
|
|
|
test("no crash when attempting to process while disconnected", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
// Block all API tool endpoints to simulate disconnection
|
|
await page.route("**/api/v1/tools/**", (route) => route.abort());
|
|
|
|
// Set width and click process -- should fail gracefully
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
|
|
// Wait briefly for the error to propagate
|
|
await page.waitForTimeout(3000);
|
|
|
|
// The page should not crash -- sidebar, main, and dropzone remain
|
|
await expect(page.locator("main")).toBeVisible();
|
|
await expect(page.getByRole("banner")).toBeVisible();
|
|
|
|
// An error message should be displayed somewhere (error text or toast)
|
|
const bodyText = await page.textContent("body");
|
|
expect(bodyText).toBeDefined();
|
|
expect(bodyText?.length).toBeGreaterThan(0);
|
|
|
|
await page.unroute("**/api/v1/tools/**");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Error Boundaries & 404 Handling
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Error Boundaries", () => {
|
|
test("navigating to nonexistent tool shows error state, not white screen", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
// A two-segment /<section>/<toolId> path with an unknown toolId hits
|
|
// ToolPage's "Tool not found" guard. A single-segment path would render the
|
|
// standalone 404 page ("Page not found") instead.
|
|
await page.goto("/image/nonexistent-tool-xyz");
|
|
|
|
// The ToolPage component renders "Tool not found" inside AppLayout
|
|
await expect(page.getByText("Tool not found")).toBeVisible({ timeout: 10_000 });
|
|
|
|
// The top-nav banner should still be visible (not a white screen)
|
|
await expect(page.getByRole("banner")).toBeVisible();
|
|
});
|
|
|
|
test("error boundary fallback has Go Home button that navigates to /", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
// Trigger the REAL ErrorBoundary (App.tsx) rather than fabricating its
|
|
// markup. Abort the Automate page's lazy chunk so its dynamic import()
|
|
// rejects; React.lazy turns that rejection into a thrown error during
|
|
// render, which propagates to the nearest error boundary.
|
|
await page.route("**/automate-page-*.js", (route) => route.abort());
|
|
|
|
await page.goto("/");
|
|
await expect(page.getByRole("banner")).toBeVisible({ timeout: 10_000 });
|
|
await page
|
|
.getByRole("link", { name: /automate/i })
|
|
.first()
|
|
.click();
|
|
|
|
// The real fallback renders its "Go Home" button (exactly one element).
|
|
const goHomeBtn = page.getByRole("button", { name: "Go Home" });
|
|
await expect(goHomeBtn).toBeVisible({ timeout: 10_000 });
|
|
|
|
// The home chunk is not blocked, so Go Home navigates back successfully.
|
|
await goHomeBtn.click();
|
|
await page.waitForURL("/", { timeout: 10_000 });
|
|
await page.unroute("**/automate-page-*.js");
|
|
});
|
|
|
|
test("no white screen of death on any error path", async ({ loggedInPage: page }) => {
|
|
// Navigate to an invalid tool route -- should show "Tool not found", not blank
|
|
await page.goto("/image/this-tool-does-not-exist");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
// Body must have visible content (not a white screen)
|
|
const bodyHtml = await page.evaluate(() => document.body.innerHTML.trim());
|
|
expect(bodyHtml.length).toBeGreaterThan(0);
|
|
|
|
// The page should show either "Tool not found" or the top-nav banner at
|
|
// minimum. locator.or() waits for whichever resolves first -- isVisible()
|
|
// does not auto-wait, so it would race React's first render.
|
|
const errorState = page.getByText("Tool not found").or(page.getByRole("banner"));
|
|
await expect(errorState.first()).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test("multiple invalid tool routes all show error state consistently", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
// Two-segment tool routes with unknown toolIds hit ToolPage's
|
|
// "Tool not found" guard (single-segment paths render the standalone 404).
|
|
const invalidRoutes = [
|
|
"/image/nonexistent-tool-xyz",
|
|
"/image/fake-tool-abc",
|
|
"/image/definitely-not-a-tool",
|
|
];
|
|
|
|
for (const route of invalidRoutes) {
|
|
await page.goto(route);
|
|
await expect(page.getByText("Tool not found")).toBeVisible({ timeout: 10_000 });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Server Error Handling: File Upload Validation
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("File Upload Validation", () => {
|
|
test("non-image file upload (.txt) is rejected or ignored gracefully", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await page.goto("/image/resize");
|
|
|
|
// Create a .txt file via the file chooser
|
|
const fileChooserPromise = page.waitForEvent("filechooser");
|
|
const dropzone = page.locator("[class*='border-dashed']").first();
|
|
await dropzone.click();
|
|
const fileChooser = await fileChooserPromise;
|
|
|
|
// The accept filter on the input is "image/*" so the browser may reject
|
|
// the file, or the app may ignore it. Either way, no crash should occur.
|
|
const fs = await import("node:fs");
|
|
const path = await import("node:path");
|
|
const tmpDir = getE2eRunRoot();
|
|
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
|
const txtPath = path.join(tmpDir, "not-an-image.txt");
|
|
fs.writeFileSync(txtPath, "This is not an image file.");
|
|
|
|
await fileChooser.setFiles(txtPath);
|
|
await page.waitForTimeout(1000);
|
|
|
|
// The page should not crash. Either a dropzone remains or an error is shown.
|
|
const pageContent = await page.textContent("body");
|
|
expect(pageContent).toBeDefined();
|
|
// No uncaught exception should have crashed the app
|
|
await expect(page.locator("body")).not.toHaveText(/undefined|null.*error/i);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Form Validation States: Login Page
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Login Form Validation", () => {
|
|
test.use({ storageState: { cookies: [], origins: [] } });
|
|
|
|
test("login button disabled when username is empty", async ({ page }) => {
|
|
await page.goto("/login");
|
|
const loginBtn = page.getByRole("button", { name: /login/i });
|
|
|
|
// Only fill password
|
|
await page.getByLabel("Password").fill("somepassword");
|
|
await expect(loginBtn).toBeDisabled();
|
|
});
|
|
|
|
test("login button disabled when password is empty", async ({ page }) => {
|
|
await page.goto("/login");
|
|
const loginBtn = page.getByRole("button", { name: /login/i });
|
|
|
|
// Only fill username
|
|
await page.getByLabel("Username").fill("someuser");
|
|
await expect(loginBtn).toBeDisabled();
|
|
});
|
|
|
|
test("login button disabled when both fields are empty", async ({ page }) => {
|
|
await page.goto("/login");
|
|
const loginBtn = page.getByRole("button", { name: /login/i });
|
|
|
|
await expect(loginBtn).toBeDisabled();
|
|
});
|
|
|
|
test("wrong credentials show error message", async ({ page }) => {
|
|
await page.goto("/login");
|
|
|
|
await page.getByLabel("Username").fill("wrong-user");
|
|
await page.getByLabel("Password").fill("wrong-password");
|
|
await page.getByRole("button", { name: /login/i }).click();
|
|
|
|
// Error message should appear (text-destructive class)
|
|
await expect(page.getByText(/invalid|incorrect|error/i)).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Should stay on login page
|
|
await expect(page).toHaveURL(/\/login/);
|
|
});
|
|
|
|
test("error message clears on next submission attempt", async ({ page }) => {
|
|
await page.goto("/login");
|
|
|
|
// Trigger error
|
|
await page.getByLabel("Username").fill("bad-user");
|
|
await page.getByLabel("Password").fill("bad-pass");
|
|
await page.getByRole("button", { name: /login/i }).click();
|
|
await expect(page.getByText(/invalid|incorrect|error/i)).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Modify fields and resubmit
|
|
await page.getByLabel("Username").fill("another-bad-user");
|
|
await page.getByLabel("Password").fill("another-bad-pass");
|
|
await page.getByRole("button", { name: /login/i }).click();
|
|
|
|
// The button should show "Logging in..." briefly (loading state works)
|
|
// And eventually show a new error (no crash)
|
|
await expect(page.getByText(/invalid|incorrect|error|logging/i)).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Form Validation: Change Password Page
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Change Password Form Validation", () => {
|
|
test.use({ storageState: { cookies: [], origins: [] } });
|
|
|
|
test("change password button disabled when fields are empty", async ({ page }) => {
|
|
await page.goto("/change-password");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
const submitBtn = page.getByRole("button", { name: /change password/i });
|
|
await expect(submitBtn).toBeDisabled();
|
|
});
|
|
|
|
test("mismatched passwords show error", async ({ page }) => {
|
|
await page.goto("/change-password");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
// Use exact label match for "New password" to avoid matching
|
|
// the "Generate strong password" button text
|
|
await page.getByLabel("Current password").fill("admin");
|
|
await page.getByLabel("New password", { exact: true }).fill("NewPass123");
|
|
await page.getByLabel("Confirm new password").fill("DifferentPass456");
|
|
|
|
await page.getByRole("button", { name: /change password/i }).click();
|
|
|
|
// The client-side validation catches mismatch before the API call
|
|
await expect(page.getByText(/do not match/i)).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Form Validation: Add Member (People settings section)
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Add Member Form Validation", () => {
|
|
test("adding a duplicate username shows error", async ({ loggedInPage: page }) => {
|
|
// Open settings dialog and navigate to People section
|
|
await openSettings(page);
|
|
|
|
// Navigate to People section
|
|
await page.getByRole("button", { name: /people/i }).click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Click "Add Members" to show the form
|
|
const addBtn = page.getByRole("button", { name: /add members/i });
|
|
await addBtn.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Fill in a username that already exists ("admin" is the default user)
|
|
await page.locator("input[placeholder='Username']").fill("admin");
|
|
await page.locator("input[placeholder='Password']").fill("StrongPass123");
|
|
|
|
// Submit the form
|
|
await page.getByRole("button", { name: /create/i }).click();
|
|
|
|
// The add-user form surfaces the failure in its role="alert" region. The API
|
|
// returns "Username already exists" (409) for an existing username.
|
|
await expect(page.getByRole("alert")).toContainText(
|
|
/already exists|duplicate|conflict|taken|failed/i,
|
|
{ timeout: 10_000 },
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Form Validation: QR Generate (no-file tool)
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("QR Generate Form Validation", () => {
|
|
test("download button disabled when text input is empty", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/qr-generate");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
// The download button should be disabled when no data is entered
|
|
const downloadBtn = page.locator("[data-testid='qr-generate-download']");
|
|
await expect(downloadBtn).toBeVisible({ timeout: 5_000 });
|
|
await expect(downloadBtn).toBeDisabled();
|
|
});
|
|
|
|
test("download button enabled after entering text", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/qr-generate");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
// Enter data in the URL field (default content type)
|
|
const urlInput = page.locator("[data-testid='qr-input-url']");
|
|
await urlInput.fill("https://example.com");
|
|
|
|
// Now the download button should be enabled
|
|
const downloadBtn = page.locator("[data-testid='qr-generate-download']");
|
|
await expect(downloadBtn).toBeEnabled({ timeout: 5_000 });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Form Validation: Pipeline Save (Automate page)
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Pipeline Save Form Validation", () => {
|
|
test("pipeline save button disabled when name is empty", async ({ loggedInPage: page }) => {
|
|
await page.goto("/automate");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
// Add a step to the pipeline so the Save button appears
|
|
// Find a tool in the palette and click it
|
|
const resizeTool = page.locator("text=Resize").first();
|
|
if (await resizeTool.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
|
await resizeTool.click();
|
|
await page.waitForTimeout(500);
|
|
}
|
|
|
|
// Click the Save button to reveal the save form
|
|
const saveBtn = page.getByRole("button", { name: /^Save$/ });
|
|
if (await saveBtn.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
|
await saveBtn.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// The save submit button should be disabled when name field is empty
|
|
const submitBtn = page
|
|
.locator("button")
|
|
.filter({ hasText: /^Save$|^\.\.\.$/ })
|
|
.last();
|
|
// The pipeline name input should be empty
|
|
const nameInput = page.locator("input[placeholder='Pipeline name']");
|
|
if (await nameInput.isVisible({ timeout: 2_000 }).catch(() => false)) {
|
|
await expect(nameInput).toHaveValue("");
|
|
// The save/submit button should be disabled with empty name
|
|
await expect(submitBtn).toBeDisabled();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Form Validation: Compress Quality > 100
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Compress Quality Clamping", () => {
|
|
test("compress quality slider clamps to max value", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/compress");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
// Find a quality slider/range input
|
|
const slider = page.locator("input[type='range']").first();
|
|
if (await slider.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
|
// Try to set a value beyond max via JS
|
|
const maxVal = await slider.getAttribute("max");
|
|
const max = maxVal ? Number(maxVal) : 100;
|
|
|
|
await slider.evaluate((el: HTMLInputElement) => {
|
|
el.value = "150";
|
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
});
|
|
|
|
// Read back -- should be clamped to max
|
|
const currentVal = await slider.inputValue();
|
|
expect(Number(currentVal)).toBeLessThanOrEqual(max);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tool Form Validation: Process Button State
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Tool Form Validation", () => {
|
|
test("resize process button requires a file to be uploaded", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
|
|
// Before uploading, the resize button should be visible but the dropzone
|
|
// should be shown instead of the process area
|
|
const resizeBtn = page.getByRole("button", { name: "Resize" });
|
|
const dropzone = page.locator("[class*='border-dashed']").first();
|
|
|
|
// Dropzone should be visible (no file uploaded yet)
|
|
await expect(dropzone).toBeVisible();
|
|
|
|
// The Resize button is in the settings panel. Check if it is disabled
|
|
// or if processing is blocked by requiring a file selection first.
|
|
if (await resizeBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
await expect(resizeBtn).toBeDisabled();
|
|
}
|
|
});
|
|
|
|
test("compress process button requires a file to be uploaded", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/compress");
|
|
|
|
const compressBtn = page.getByRole("button", { name: "Compress" });
|
|
const dropzone = page.locator("[class*='border-dashed']").first();
|
|
|
|
await expect(dropzone).toBeVisible();
|
|
|
|
if (await compressBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
await expect(compressBtn).toBeDisabled();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Toast Behavior
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Toast Behavior", () => {
|
|
test("toasts do not block main UI interaction", async ({ loggedInPage: page }) => {
|
|
// Sonner's Toaster component lazily renders its container on first toast,
|
|
// so we can't rely on a DOM element existing before any toast fires.
|
|
// Instead, verify the main content area is fully interactive.
|
|
await page.waitForLoadState("domcontentloaded");
|
|
await expect(page.locator("main")).toBeVisible({ timeout: 10_000 });
|
|
|
|
// The body should have content (page loaded correctly)
|
|
const content = await page.textContent("body");
|
|
expect(content).toBeDefined();
|
|
expect(content?.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("success toast after processing auto-dismisses", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
// Set a width and process
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
await waitForProcessing(page);
|
|
|
|
// Wait for the download link to appear (processing complete)
|
|
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
// The page should still be interactive after processing
|
|
// (toasts don't block interaction)
|
|
await expect(page.locator("main")).toBeVisible();
|
|
await expect(page.getByRole("banner")).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State Reset: Upload -> Navigate Away -> Come Back
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("State Reset on Navigation", () => {
|
|
test("upload, process, navigate away, come back: state is clean", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
// Upload and process in resize
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
await waitForProcessing(page);
|
|
|
|
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
// Navigate away to home
|
|
await page.goto("/");
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// Come back to resize
|
|
await page.goto("/image/resize");
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// State should be clean: dropzone visible, no download link
|
|
await expect(page.getByText("Upload from computer")).toBeVisible({ timeout: 5_000 });
|
|
await expect(page.getByRole("link", { name: /download/i })).not.toBeVisible();
|
|
});
|
|
|
|
test("upload, clear, upload again: no orphaned state", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
|
|
// First upload
|
|
await uploadTestImage(page);
|
|
await expect(page.getByText(/test-image/i).first()).toBeVisible();
|
|
|
|
// Clear files
|
|
const clearBtn = page.getByText("Clear all");
|
|
if (await clearBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await clearBtn.click();
|
|
await page.waitForTimeout(500);
|
|
}
|
|
|
|
// Dropzone should reappear
|
|
await expect(page.getByText("Upload from computer")).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Upload again
|
|
await uploadTestImage(page);
|
|
await expect(page.getByText(/test-image/i).first()).toBeVisible();
|
|
|
|
// No blob images from the first upload should remain in an orphaned state
|
|
// (only the current upload's blob should exist)
|
|
const blobImages = page.locator("img[src^='blob:']");
|
|
const count = await blobImages.count();
|
|
expect(count).toBeLessThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Memory/Stability: Rapid Navigation
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Memory and Stability", () => {
|
|
test("rapid navigation between 10 tool pages renders each without errors", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
const toolRoutes = [
|
|
"/resize",
|
|
"/crop",
|
|
"/rotate",
|
|
"/convert",
|
|
"/compress",
|
|
"/sharpening",
|
|
"/image/adjust-colors",
|
|
"/strip-metadata",
|
|
"/bulk-rename",
|
|
"/favicon",
|
|
];
|
|
|
|
for (const route of toolRoutes) {
|
|
await page.goto(route);
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
// Each tool page should render its name or show the tool layout
|
|
// (settings panel or no-dropzone panel)
|
|
const body = page.locator("body");
|
|
await expect(body).toBeVisible();
|
|
|
|
// No JavaScript error should have crashed the page
|
|
const content = await page.textContent("body");
|
|
expect(content).toBeDefined();
|
|
expect(content?.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
test("open and close Settings dialog 20 times without slowdown", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
const timings: number[] = [];
|
|
|
|
for (let i = 0; i < 20; i++) {
|
|
const start = Date.now();
|
|
|
|
// Open settings
|
|
await openSettings(page);
|
|
|
|
const openTime = Date.now() - start;
|
|
timings.push(openTime);
|
|
|
|
// Close settings via Escape
|
|
await page.keyboard.press("Escape");
|
|
await expect(page.locator("h2").filter({ hasText: "Settings" })).not.toBeVisible({
|
|
timeout: 5_000,
|
|
});
|
|
}
|
|
|
|
// The last open should not be significantly slower than the first
|
|
// Allow 3x tolerance for CI variability
|
|
const firstOpen = timings[0];
|
|
const lastOpen = timings[timings.length - 1];
|
|
expect(lastOpen).toBeLessThan(Math.max(firstOpen * 3, 2000));
|
|
});
|
|
|
|
test("10x upload/clear cycle without crash or leak", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
// Upload
|
|
await uploadTestImage(page);
|
|
await expect(page.getByText(/test-image/i).first()).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Clear files
|
|
const clearBtn = page.getByText("Clear all");
|
|
if (await clearBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await clearBtn.click();
|
|
await page.waitForTimeout(300);
|
|
}
|
|
|
|
// Dropzone should reappear
|
|
await expect(page.getByText("Upload from computer")).toBeVisible({ timeout: 5_000 });
|
|
}
|
|
|
|
// After 10 cycles, the page should still be responsive
|
|
await expect(page.locator("main")).toBeVisible();
|
|
const content = await page.textContent("body");
|
|
expect(content).toBeDefined();
|
|
expect(content?.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("navigate 15 different tool pages rapidly without crash or state bleed", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
const routes = [
|
|
"/resize",
|
|
"/crop",
|
|
"/rotate",
|
|
"/convert",
|
|
"/compress",
|
|
"/sharpening",
|
|
"/image/adjust-colors",
|
|
"/strip-metadata",
|
|
"/bulk-rename",
|
|
"/favicon",
|
|
"/watermark",
|
|
"/border",
|
|
"/flip",
|
|
"/qr-generate",
|
|
"/image-to-pdf",
|
|
];
|
|
|
|
const errors: string[] = [];
|
|
|
|
page.on("pageerror", (err) => {
|
|
errors.push(err.message);
|
|
});
|
|
|
|
for (const route of routes) {
|
|
await page.goto(route);
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
// Each page should render a body with content
|
|
const content = await page.textContent("body");
|
|
expect(content).toBeDefined();
|
|
expect(content?.length).toBeGreaterThan(0);
|
|
}
|
|
|
|
// No uncaught JS errors should have occurred during rapid navigation
|
|
expect(errors).toHaveLength(0);
|
|
});
|
|
|
|
test("10x upload/clear cycle does not leak blob URLs", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
await uploadTestImage(page);
|
|
await expect(page.getByText(/test-image/i).first()).toBeVisible({ timeout: 5_000 });
|
|
|
|
const clearBtn = page.getByText("Clear all");
|
|
if (await clearBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await clearBtn.click();
|
|
await page.waitForTimeout(300);
|
|
}
|
|
await expect(page.getByText("Upload from computer")).toBeVisible({ timeout: 5_000 });
|
|
}
|
|
|
|
// After clearing all files, no blob URLs should remain in the DOM
|
|
const blobImages = page.locator("img[src^='blob:']");
|
|
await expect(blobImages).toHaveCount(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 14.3 Server Error Handling (via route interception)
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Server Error Handling", () => {
|
|
test("oversized file beyond limit shows clear error, not crash", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
// Intercept to return 413 (payload too large)
|
|
await page.route("**/api/v1/tools/image/resize", (route) =>
|
|
route.fulfill({
|
|
status: 413,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ error: "File too large. Maximum size is 50MB." }),
|
|
}),
|
|
);
|
|
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
|
|
// Wait for error to appear
|
|
await page.waitForTimeout(3000);
|
|
|
|
// Page should not crash
|
|
await expect(page.locator("main")).toBeVisible();
|
|
await expect(page.getByRole("banner")).toBeVisible();
|
|
|
|
// Some error indication should be visible (toast or inline)
|
|
const bodyText = await page.textContent("body");
|
|
expect(bodyText).toBeDefined();
|
|
expect(bodyText?.length).toBeGreaterThan(0);
|
|
|
|
await page.unroute("**/api/v1/tools/image/resize");
|
|
});
|
|
|
|
test("server 500 response shows error, not crash", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
// Intercept the tool API endpoint to return 500
|
|
await page.route("**/api/v1/tools/image/resize", (route) =>
|
|
route.fulfill({
|
|
status: 500,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ error: "Internal Server Error" }),
|
|
}),
|
|
);
|
|
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
|
|
// Wait for error state to propagate
|
|
await page.waitForTimeout(3000);
|
|
|
|
// The page should remain functional -- no white screen
|
|
await expect(page.locator("main")).toBeVisible();
|
|
await expect(page.getByRole("banner")).toBeVisible();
|
|
|
|
// An error indication should be visible (inline error text or toast)
|
|
const bodyText = await page.textContent("body");
|
|
expect(bodyText).toBeDefined();
|
|
expect(bodyText?.length).toBeGreaterThan(0);
|
|
|
|
await page.unroute("**/api/v1/tools/image/resize");
|
|
});
|
|
|
|
test("empty file upload (0 bytes) is handled gracefully", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
|
|
// Create a 0-byte file
|
|
const fs = await import("node:fs");
|
|
const path = await import("node:path");
|
|
const tmpDir = getE2eRunRoot();
|
|
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
|
const emptyPath = path.join(tmpDir, "empty.png");
|
|
fs.writeFileSync(emptyPath, "");
|
|
|
|
const fileChooserPromise = page.waitForEvent("filechooser");
|
|
const dropzone = page.locator("[class*='border-dashed']").first();
|
|
await dropzone.click();
|
|
const fileChooser = await fileChooserPromise;
|
|
await fileChooser.setFiles(emptyPath);
|
|
|
|
await page.waitForTimeout(1000);
|
|
|
|
// No crash -- page remains interactive
|
|
const pageContent = await page.textContent("body");
|
|
expect(pageContent).toBeDefined();
|
|
await expect(page.locator("main")).toBeVisible();
|
|
});
|
|
|
|
test("server 400 response shows validation error clearly", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
// Intercept to return 400 with a validation message
|
|
await page.route("**/api/v1/tools/image/resize", (route) =>
|
|
route.fulfill({
|
|
status: 400,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ error: "Invalid dimensions: width must be > 0" }),
|
|
}),
|
|
);
|
|
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
|
|
// Wait for error to display
|
|
await page.waitForTimeout(3000);
|
|
|
|
// Page should not crash
|
|
await expect(page.locator("main")).toBeVisible();
|
|
await expect(page.getByRole("banner")).toBeVisible();
|
|
|
|
// Body should contain meaningful content (not blank)
|
|
const bodyText = await page.textContent("body");
|
|
expect(bodyText).toBeDefined();
|
|
expect(bodyText?.length).toBeGreaterThan(0);
|
|
|
|
await page.unroute("**/api/v1/tools/image/resize");
|
|
});
|
|
|
|
test("auth expiry (401) redirects to login or shows re-auth prompt", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
// Intercept to return 401 (session expired)
|
|
await page.route("**/api/v1/tools/image/resize", (route) =>
|
|
route.fulfill({
|
|
status: 401,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ error: "Session expired" }),
|
|
}),
|
|
);
|
|
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
|
|
// Wait for error/redirect
|
|
await page.waitForTimeout(3000);
|
|
|
|
// Should either redirect to /login or show an auth error -- not crash
|
|
const url = page.url();
|
|
const bodyText = await page.textContent("body");
|
|
const redirectedToLogin = url.includes("/login");
|
|
const showsAuthError = /session|expired|unauthorized|login/i.test(bodyText ?? "");
|
|
|
|
expect(
|
|
redirectedToLogin || showsAuthError,
|
|
"Expected redirect to login or auth error message after 401",
|
|
).toBeTruthy();
|
|
|
|
// Page should not be a white screen
|
|
const content = await page.textContent("body");
|
|
expect(content).toBeDefined();
|
|
expect(content?.length).toBeGreaterThan(0);
|
|
|
|
await page.unroute("**/api/v1/tools/image/resize");
|
|
});
|
|
|
|
test("rate limit (429) shows throttle message, not crash", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
// Intercept to return 429 (rate limited)
|
|
await page.route("**/api/v1/tools/image/resize", (route) =>
|
|
route.fulfill({
|
|
status: 429,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ error: "Rate limit exceeded. Try again later." }),
|
|
}),
|
|
);
|
|
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
|
|
// Wait for error to appear
|
|
await page.waitForTimeout(3000);
|
|
|
|
// Page should not crash
|
|
await expect(page.locator("main")).toBeVisible();
|
|
await expect(page.getByRole("banner")).toBeVisible();
|
|
|
|
// Body should contain meaningful content (not blank)
|
|
const bodyText = await page.textContent("body");
|
|
expect(bodyText).toBeDefined();
|
|
expect(bodyText?.length).toBeGreaterThan(0);
|
|
|
|
await page.unroute("**/api/v1/tools/image/resize");
|
|
});
|
|
|
|
test("network timeout shows error, not infinite spinner", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
// Intercept and never respond -- simulates a timeout/hang
|
|
await page.route("**/api/v1/tools/image/resize", async (route) => {
|
|
// Just hold the request indefinitely (abort after test timeout)
|
|
await new Promise(() => {});
|
|
void route;
|
|
});
|
|
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
|
|
// After a reasonable wait, the page should still be interactive
|
|
await page.waitForTimeout(5000);
|
|
await expect(page.locator("main")).toBeVisible();
|
|
await expect(page.getByRole("banner")).toBeVisible();
|
|
|
|
// Body content should exist (not a blank/crashed page)
|
|
const bodyText = await page.textContent("body");
|
|
expect(bodyText).toBeDefined();
|
|
|
|
await page.unroute("**/api/v1/tools/image/resize");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 14.4 Additional Form Validation States
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Tool-Specific Form Validation", () => {
|
|
test("resize with width = 0 does not crash or submit invalid request", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
// Set width to 0
|
|
await page.locator("input[placeholder='Auto']").first().fill("0");
|
|
|
|
// The Resize button should either be disabled or clicking should show
|
|
// a validation error -- either way, no crash
|
|
const resizeBtn = page.getByRole("button", { name: "Resize" });
|
|
|
|
if (await resizeBtn.isDisabled().catch(() => false)) {
|
|
// Button is disabled for invalid input -- correct behavior
|
|
await expect(resizeBtn).toBeDisabled();
|
|
} else {
|
|
// Button is enabled -- click it and verify no crash
|
|
await resizeBtn.click();
|
|
await page.waitForTimeout(2000);
|
|
|
|
// Should show an error or remain on the page without crashing
|
|
await expect(page.locator("main")).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test("resize with negative width does not crash", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
await page.locator("input[placeholder='Auto']").first().fill("-100");
|
|
|
|
const resizeBtn = page.getByRole("button", { name: "Resize" });
|
|
if (await resizeBtn.isDisabled().catch(() => false)) {
|
|
await expect(resizeBtn).toBeDisabled();
|
|
} else {
|
|
await resizeBtn.click();
|
|
await page.waitForTimeout(2000);
|
|
await expect(page.locator("main")).toBeVisible();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 14.10 Toast Notifications (expanded)
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Toast Notifications", () => {
|
|
test("Toaster is positioned at bottom-right", async ({ loggedInPage: page }) => {
|
|
// Sonner's Toaster is rendered with position="bottom-right" in App.tsx.
|
|
// Verify by checking the Toaster container's data attribute when it renders.
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
// Trigger a toast by processing an image
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
await waitForProcessing(page);
|
|
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
// Check that Sonner's container exists with bottom-right positioning
|
|
// Sonner renders an <ol> with data-sonner-toaster and data-y-position="bottom"
|
|
const toaster = page.locator("[data-sonner-toaster]");
|
|
if (await toaster.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
const yPos = await toaster.getAttribute("data-y-position");
|
|
const xPos = await toaster.getAttribute("data-x-position");
|
|
expect(yPos).toBe("bottom");
|
|
expect(xPos).toBe("right");
|
|
}
|
|
});
|
|
|
|
test("error toast appears on processing failure", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
// Intercept to cause a failure
|
|
await page.route("**/api/v1/tools/image/resize", (route) =>
|
|
route.fulfill({
|
|
status: 500,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ error: "Simulated server failure" }),
|
|
}),
|
|
);
|
|
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
|
|
// Wait for error state
|
|
await page.waitForTimeout(3000);
|
|
|
|
// Page should not crash -- error is shown either inline or via toast
|
|
await expect(page.locator("main")).toBeVisible();
|
|
const bodyText = await page.textContent("body");
|
|
expect(bodyText).toBeDefined();
|
|
expect(bodyText?.length).toBeGreaterThan(0);
|
|
|
|
await page.unroute("**/api/v1/tools/image/resize");
|
|
});
|
|
|
|
test("toast does not block interactive elements beneath it", async ({ loggedInPage: page }) => {
|
|
// Process to trigger a toast
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
await waitForProcessing(page);
|
|
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
// If a toast appeared, verify the sidebar is still clickable
|
|
await expect(page.getByRole("banner")).toBeVisible();
|
|
const searchInput = page.getByPlaceholder(/search/i).first();
|
|
if (await searchInput.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await searchInput.focus();
|
|
const isFocused = await page.evaluate(() => document.activeElement?.tagName === "INPUT");
|
|
expect(isFocused).toBeTruthy();
|
|
}
|
|
|
|
// The Sonner toaster uses pointer-events: auto only on the toast itself,
|
|
// not a full-page overlay, so underlying elements remain interactive.
|
|
// Verify the main area is still clickable
|
|
await expect(page.locator("main")).toBeVisible();
|
|
});
|
|
|
|
test("success toast auto-dismisses within reasonable time", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
await waitForProcessing(page);
|
|
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
// If a toast appeared, it should auto-dismiss within 10 seconds
|
|
const toaster = page.locator("[data-sonner-toaster]");
|
|
if (await toaster.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
const toastItems = toaster.locator("[data-sonner-toast]");
|
|
const initialCount = await toastItems.count();
|
|
|
|
if (initialCount > 0) {
|
|
// Wait for auto-dismiss (Sonner default is ~4s, give generous 10s)
|
|
await page.waitForTimeout(10_000);
|
|
const afterCount = await toastItems.count();
|
|
// At least one toast should have been dismissed
|
|
expect(afterCount).toBeLessThanOrEqual(initialCount);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("multiple toasts stack without overlapping", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
// Process twice quickly to generate multiple toasts
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
await waitForProcessing(page);
|
|
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
// Process again with a different width
|
|
await page.locator("input[placeholder='Auto']").first().fill("75");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
await waitForProcessing(page);
|
|
|
|
// Check that Sonner handles stacking properly
|
|
const toaster = page.locator("[data-sonner-toaster]");
|
|
if (await toaster.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
const toasts = toaster.locator("[data-sonner-toast]");
|
|
const count = await toasts.count();
|
|
|
|
if (count >= 2) {
|
|
// Verify toasts do not overlap (each should have a distinct vertical position)
|
|
const boxes = [];
|
|
for (let i = 0; i < count; i++) {
|
|
const box = await toasts.nth(i).boundingBox();
|
|
if (box) boxes.push(box);
|
|
}
|
|
|
|
// If multiple visible toasts exist, their y positions should differ
|
|
if (boxes.length >= 2) {
|
|
const yPositions = boxes.map((b) => Math.round(b.y));
|
|
const uniqueY = new Set(yPositions);
|
|
expect(uniqueY.size).toBeGreaterThanOrEqual(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Page should remain functional
|
|
await expect(page.locator("main")).toBeVisible();
|
|
});
|
|
|
|
test("toast text is readable (has sufficient size)", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
await waitForProcessing(page);
|
|
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
const toaster = page.locator("[data-sonner-toaster]");
|
|
if (await toaster.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
const toastItems = toaster.locator("[data-sonner-toast]");
|
|
const count = await toastItems.count();
|
|
|
|
if (count > 0) {
|
|
// Check font size is at least 12px (readable)
|
|
const fontSize = await toastItems.first().evaluate((el) => {
|
|
const style = window.getComputedStyle(el);
|
|
return Number.parseFloat(style.fontSize);
|
|
});
|
|
expect(fontSize).toBeGreaterThanOrEqual(12);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Server Error Handling: 503 Service Unavailable
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Server Error Handling - Service Unavailable", () => {
|
|
test("server 503 response shows maintenance/retry message, not crash", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
await page.route("**/api/v1/tools/image/resize", (route) =>
|
|
route.fulfill({
|
|
status: 503,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ error: "Service temporarily unavailable" }),
|
|
}),
|
|
);
|
|
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
await expect(page.locator("main")).toBeVisible();
|
|
await expect(page.getByRole("banner")).toBeVisible();
|
|
|
|
const bodyText = await page.textContent("body");
|
|
expect(bodyText).toBeDefined();
|
|
expect(bodyText?.length).toBeGreaterThan(0);
|
|
|
|
await page.unroute("**/api/v1/tools/image/resize");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Form Validation: Resize with Extremely Large Dimensions
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Resize Extreme Dimensions", () => {
|
|
test("resize with extremely large width does not crash browser", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
await page.locator("input[placeholder='Auto']").first().fill("999999");
|
|
|
|
const resizeBtn = page.getByRole("button", { name: "Resize" });
|
|
if (await resizeBtn.isDisabled().catch(() => false)) {
|
|
await expect(resizeBtn).toBeDisabled();
|
|
} else {
|
|
await resizeBtn.click();
|
|
await page.waitForTimeout(3000);
|
|
await expect(page.locator("main")).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test("resize with fractional width does not crash", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
await page.locator("input[placeholder='Auto']").first().fill("50.5");
|
|
|
|
const resizeBtn = page.getByRole("button", { name: "Resize" });
|
|
await resizeBtn.click();
|
|
await page.waitForTimeout(3000);
|
|
|
|
await expect(page.locator("main")).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Double-Click Prevention on Process Button
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Double-Click Prevention", () => {
|
|
test("rapid double-click on process button does not cause duplicate requests", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
|
|
const requestCount: string[] = [];
|
|
page.on("request", (req) => {
|
|
if (req.url().includes("/api/v1/tools/image/resize") && req.method() === "POST") {
|
|
requestCount.push(req.url());
|
|
}
|
|
});
|
|
|
|
const resizeBtn = page.getByRole("button", { name: "Resize" });
|
|
await resizeBtn.dblclick();
|
|
await waitForProcessing(page);
|
|
|
|
// After processing, page should still be functional
|
|
await expect(page.locator("main")).toBeVisible();
|
|
|
|
// At most 2 requests (one may have been cancelled by the second)
|
|
expect(requestCount.length).toBeLessThanOrEqual(2);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Browser Back/Forward Navigation Stability
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Browser History Navigation", () => {
|
|
test("browser back button from tool page does not crash", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
await page.goto("/image/compress");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
await page.goBack();
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
await expect(page.locator("main")).toBeVisible();
|
|
await expect(page.getByRole("banner")).toBeVisible();
|
|
|
|
const content = await page.textContent("body");
|
|
expect(content).toBeDefined();
|
|
expect(content?.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("browser forward button after back does not crash", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
await page.goto("/image/compress");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
await page.goBack();
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
await page.goForward();
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
await expect(page.locator("main")).toBeVisible();
|
|
await expect(page.getByRole("banner")).toBeVisible();
|
|
});
|
|
|
|
test("rapid back/forward navigation through 5 pages does not crash", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
const routes = [
|
|
"/image/resize",
|
|
"/image/compress",
|
|
"/image/rotate",
|
|
"/image/convert",
|
|
"/image/crop",
|
|
];
|
|
for (const route of routes) {
|
|
await page.goto(route);
|
|
await page.waitForLoadState("domcontentloaded");
|
|
}
|
|
|
|
// Go back through all pages
|
|
for (let i = 0; i < 4; i++) {
|
|
await page.goBack();
|
|
await page.waitForLoadState("domcontentloaded");
|
|
}
|
|
|
|
// Go forward through all pages
|
|
for (let i = 0; i < 4; i++) {
|
|
await page.goForward();
|
|
await page.waitForLoadState("domcontentloaded");
|
|
}
|
|
|
|
await expect(page.locator("main")).toBeVisible();
|
|
const content = await page.textContent("body");
|
|
expect(content).toBeDefined();
|
|
expect(content?.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Concurrent Error States
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Concurrent Error Recovery", () => {
|
|
test("multiple tool endpoints failing simultaneously does not crash", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
// Block multiple endpoints
|
|
await page.route("**/api/v1/tools/**", (route) =>
|
|
route.fulfill({
|
|
status: 500,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ error: "Simulated failure" }),
|
|
}),
|
|
);
|
|
|
|
// Also block health to simulate full outage
|
|
await page.route("**/api/v1/health", (route) => route.abort());
|
|
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
// App should still be alive
|
|
await expect(page.locator("main")).toBeVisible();
|
|
await expect(page.getByRole("banner")).toBeVisible();
|
|
|
|
await page.unroute("**/api/v1/tools/**");
|
|
await page.unroute("**/api/v1/health");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// File Processing State Cleanup
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Processing State Cleanup", () => {
|
|
test("failed processing does not leave orphaned loading state", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
await page.route("**/api/v1/tools/image/resize", (route) =>
|
|
route.fulfill({
|
|
status: 500,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ error: "Processing failed" }),
|
|
}),
|
|
);
|
|
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
// After failure, the process button should be clickable again
|
|
// (not stuck in a loading/disabled state)
|
|
const resizeBtn = page.getByRole("button", { name: "Resize" });
|
|
await expect(resizeBtn).toBeVisible();
|
|
await expect(resizeBtn).toBeEnabled({ timeout: 5_000 });
|
|
|
|
await page.unroute("**/api/v1/tools/image/resize");
|
|
});
|
|
|
|
test("successful processing followed by clear resets fully", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
await page.locator("input[placeholder='Auto']").first().fill("50");
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
await waitForProcessing(page);
|
|
|
|
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
// Clear files
|
|
const clearBtn = page.getByText("Clear all");
|
|
if (await clearBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await clearBtn.click();
|
|
await page.waitForTimeout(500);
|
|
}
|
|
|
|
// Verify full reset: dropzone visible, no download, no spinner
|
|
await expect(page.getByText("Upload from computer")).toBeVisible({ timeout: 5_000 });
|
|
await expect(page.getByRole("link", { name: /download/i })).not.toBeVisible();
|
|
const spinners = page.locator("[class*='animate-spin']");
|
|
await expect(spinners).toHaveCount(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Form Validation: Resize Lock Aspect Ratio
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Resize Aspect Ratio Lock", () => {
|
|
test("setting only width with lock enabled does not crash", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
// Fill only width, leave height as Auto
|
|
await page.locator("input[placeholder='Auto']").first().fill("200");
|
|
|
|
const resizeBtn = page.getByRole("button", { name: "Resize" });
|
|
await resizeBtn.click();
|
|
await waitForProcessing(page);
|
|
|
|
// Should succeed or show error, but not crash
|
|
await expect(page.locator("main")).toBeVisible();
|
|
});
|
|
|
|
test("setting only height with lock enabled does not crash", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
|
|
// Fill only height (second Auto input)
|
|
const heightInput = page.locator("input[placeholder='Auto']").nth(1);
|
|
if (await heightInput.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await heightInput.fill("200");
|
|
}
|
|
|
|
const resizeBtn = page.getByRole("button", { name: "Resize" });
|
|
await resizeBtn.click();
|
|
await waitForProcessing(page);
|
|
|
|
await expect(page.locator("main")).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Settings Persistence
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Settings Persistence", () => {
|
|
test("theme selection persists across page navigations", async ({ loggedInPage: page }) => {
|
|
// The toggle lives in the top-nav, which renders only after the lazy app
|
|
// shell mounts. Wait for it explicitly -- locator.isVisible() does NOT
|
|
// auto-wait, so a bare isVisible() check races the first render.
|
|
const themeBtn = page.locator("button[title='Toggle theme']");
|
|
await expect(themeBtn).toBeVisible({ timeout: 15_000 });
|
|
|
|
const isDark = () => page.evaluate(() => document.documentElement.classList.contains("dark"));
|
|
const isDarkBefore = await isDark();
|
|
|
|
await themeBtn.click();
|
|
// The `dark` class flips synchronously in the store's setTheme, but assert
|
|
// on the resulting state rather than a fixed wait.
|
|
await expect.poll(isDark, { timeout: 5_000 }).toBe(!isDarkBefore);
|
|
const isDarkAfterToggle = await isDark();
|
|
|
|
// Navigate away and back; the choice persists via localStorage.
|
|
await page.goto("/image/resize");
|
|
await expect(themeBtn).toBeVisible({ timeout: 15_000 });
|
|
|
|
const isDarkAfterNav = await isDark();
|
|
expect(isDarkAfterNav).toBe(isDarkAfterToggle);
|
|
|
|
// Toggle back to the original theme.
|
|
await themeBtn.click();
|
|
await expect.poll(isDark, { timeout: 5_000 }).toBe(isDarkBefore);
|
|
});
|
|
});
|