mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
1. passport-photo 404 vs 501: add base route at /api/v1/tools/passport-photo
that returns 501 FEATURE_NOT_INSTALLED when the AI bundle is missing,
matching other AI tools. The /generate sub-route is Sharp-only (no
sidecar) so it correctly skips the isToolInstalled guard.
2. AuthGuard analytics consent race: don't evaluate shouldShowConsent()
until analyticsConfig has been fetched (guard on analyticsConfig !== null).
Prevents redirect to /analytics-consent before config is loaded.
3. Fragile sidebar Settings selector: add openSettings(page) helper to
E2E helpers that checks sidebar visibility with fallback to button role.
Replace all 134 occurrences of page.locator("aside").getByText("Settings")
across 18 test files.
481 lines
19 KiB
TypeScript
481 lines
19 KiB
TypeScript
import { expect, openSettings, test } from "./helpers";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GUI Accessibility: ARIA semantics, focus management, keyboard navigation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Semantic HTML: Landmarks & Structure
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Semantic HTML - Landmarks", () => {
|
|
test("home page has exactly one main landmark", async ({ loggedInPage: page }) => {
|
|
await expect(page.locator("main")).toHaveCount(1);
|
|
});
|
|
|
|
test("tool page has exactly one main landmark", async ({ loggedInPage: page }) => {
|
|
await page.goto("/resize");
|
|
await expect(page.locator("main")).toHaveCount(1);
|
|
});
|
|
|
|
test("home page has a sidebar landmark (aside)", async ({ loggedInPage: page }) => {
|
|
await expect(page.locator("aside")).toBeVisible();
|
|
});
|
|
|
|
test("tool page has a sidebar landmark (aside)", async ({ loggedInPage: page }) => {
|
|
await page.goto("/resize");
|
|
await expect(page.locator("aside")).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("Semantic HTML - Buttons", () => {
|
|
test("all visible buttons on home page have accessible names", async ({ loggedInPage: page }) => {
|
|
// Wait for the page to fully load
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const buttons = page.getByRole("button");
|
|
const count = await buttons.count();
|
|
expect(count).toBeGreaterThan(0);
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const button = buttons.nth(i);
|
|
if (!(await button.isVisible().catch(() => false))) continue;
|
|
|
|
const name = await button.getAttribute("aria-label");
|
|
const title = await button.getAttribute("title");
|
|
const text = await button.textContent();
|
|
|
|
// Every visible button should have at least one of: text content, aria-label, or title
|
|
const hasAccessibleName =
|
|
(text && text.trim().length > 0) ||
|
|
(name && name.trim().length > 0) ||
|
|
(title && title.trim().length > 0);
|
|
expect(
|
|
hasAccessibleName,
|
|
`Button at index ${i} has no accessible name. text="${text}", aria-label="${name}", title="${title}"`,
|
|
).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
test("all visible buttons on tool page have accessible names", async ({ loggedInPage: page }) => {
|
|
await page.goto("/resize");
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const buttons = page.getByRole("button");
|
|
const count = await buttons.count();
|
|
expect(count).toBeGreaterThan(0);
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const button = buttons.nth(i);
|
|
if (!(await button.isVisible().catch(() => false))) continue;
|
|
|
|
const name = await button.getAttribute("aria-label");
|
|
const title = await button.getAttribute("title");
|
|
const text = await button.textContent();
|
|
|
|
const hasAccessibleName =
|
|
(text && text.trim().length > 0) ||
|
|
(name && name.trim().length > 0) ||
|
|
(title && title.trim().length > 0);
|
|
expect(
|
|
hasAccessibleName,
|
|
`Button at index ${i} has no accessible name. text="${text}", aria-label="${name}", title="${title}"`,
|
|
).toBeTruthy();
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe("Semantic HTML - Form Inputs", () => {
|
|
test.use({ storageState: { cookies: [], origins: [] } });
|
|
|
|
test("login form inputs have associated labels", async ({ page }) => {
|
|
await page.goto("/login");
|
|
|
|
// Username input should be findable by label
|
|
const usernameInput = page.getByLabel("Username");
|
|
await expect(usernameInput).toBeVisible();
|
|
await expect(usernameInput).toHaveAttribute("id", "username");
|
|
|
|
// Password input should be findable by label
|
|
const passwordInput = page.getByLabel("Password");
|
|
await expect(passwordInput).toBeVisible();
|
|
await expect(passwordInput).toHaveAttribute("id", "password");
|
|
});
|
|
|
|
test("login form inputs have proper autocomplete attributes", async ({ page }) => {
|
|
await page.goto("/login");
|
|
|
|
await expect(page.getByLabel("Username")).toHaveAttribute("autocomplete", "username");
|
|
await expect(page.getByLabel("Password")).toHaveAttribute("autocomplete", "current-password");
|
|
});
|
|
});
|
|
|
|
test.describe("Semantic HTML - Form Inputs on Tool Pages", () => {
|
|
test("QR generate form inputs have associated labels", async ({ loggedInPage: page }) => {
|
|
await page.goto("/qr-generate");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
// The URL input should be findable by its label
|
|
const urlInput = page.getByLabel("URL");
|
|
await expect(urlInput).toBeVisible();
|
|
});
|
|
|
|
test("change password form inputs have associated labels", async ({ loggedInPage: page }) => {
|
|
// Navigate to change-password (uses storageState auth but page is accessible)
|
|
await page.goto("/change-password");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
// Each input should be findable by label (use exact match for "New password"
|
|
// to avoid matching the "Generate strong password" button text)
|
|
await expect(page.getByLabel("Current password")).toBeVisible();
|
|
await expect(page.getByLabel("New password", { exact: true })).toBeVisible();
|
|
await expect(page.getByLabel("Confirm new password")).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("Semantic HTML - Heading Hierarchy", () => {
|
|
test.use({ storageState: { cookies: [], origins: [] } });
|
|
|
|
test("login page has sequential heading hierarchy", async ({ page }) => {
|
|
await page.goto("/login");
|
|
|
|
// Should have h1 (SnapOtter) and h2 (Login)
|
|
const h1 = page.locator("h1");
|
|
const h2 = page.locator("h2");
|
|
|
|
await expect(h1.first()).toBeVisible();
|
|
await expect(h2.first()).toBeVisible();
|
|
|
|
// h1 should appear before h2 in DOM order
|
|
const h1Box = await h1.first().boundingBox();
|
|
const h2Box = await h2.first().boundingBox();
|
|
expect(h1Box).toBeTruthy();
|
|
expect(h2Box).toBeTruthy();
|
|
if (h1Box && h2Box) {
|
|
expect(h1Box.y).toBeLessThan(h2Box.y);
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe("Heading Hierarchy - Tool Pages", () => {
|
|
test("tool page has headings including an h2 for the tool name", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await page.goto("/resize");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
// The tool page should have an h2 heading for the tool name
|
|
const h2 = page.locator("h2").filter({ hasText: "Resize" });
|
|
await expect(h2).toBeAttached();
|
|
|
|
// Collect all headings in the DOM (including those in the sidebar or
|
|
// settings panel that may not pass a visibility bounding-box check)
|
|
const headingLevels = await page.evaluate(() => {
|
|
const headings = document.querySelectorAll("h1, h2, h3, h4, h5, h6");
|
|
return Array.from(headings).map((h) => Number.parseInt(h.tagName.charAt(1), 10));
|
|
});
|
|
|
|
// There should be at least one heading
|
|
expect(headingLevels.length).toBeGreaterThan(0);
|
|
|
|
// There should be at most one h1 (the page title or brand)
|
|
const h1Count = headingLevels.filter((l) => l === 1).length;
|
|
expect(h1Count).toBeLessThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Modal/Dialog Accessibility
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Settings Dialog Accessibility", () => {
|
|
test("settings dialog opens as a layered panel with backdrop", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
// Dialog content should be visible
|
|
await expect(page.locator("h2").filter({ hasText: "Settings" })).toBeVisible();
|
|
|
|
// Backdrop should exist (the aria-hidden overlay)
|
|
const backdrop = page.locator("[aria-hidden='true']").first();
|
|
await expect(backdrop).toBeVisible();
|
|
});
|
|
|
|
test("Escape key closes settings dialog", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
await page.keyboard.press("Escape");
|
|
|
|
await expect(page.locator("h2").filter({ hasText: "Settings" })).not.toBeVisible();
|
|
});
|
|
|
|
test("settings dialog can be closed via close button", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
// Use the X close button
|
|
const closeBtn = page
|
|
.locator("button")
|
|
.filter({ has: page.locator("svg.lucide-x") })
|
|
.first();
|
|
await closeBtn.click();
|
|
|
|
await expect(page.locator("h2").filter({ hasText: "Settings" })).not.toBeVisible();
|
|
});
|
|
|
|
test("settings dialog has a close button", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
// Close button with X icon
|
|
const closeBtn = page.locator("button").filter({ has: page.locator("svg.lucide-x") });
|
|
await expect(closeBtn.first()).toBeVisible();
|
|
|
|
await closeBtn.first().click();
|
|
await expect(page.locator("h2").filter({ hasText: "Settings" })).not.toBeVisible();
|
|
});
|
|
|
|
test("settings dialog backdrop is marked aria-hidden", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
// The backdrop overlay has aria-hidden="true" to keep it out of the a11y tree
|
|
const backdrop = page.locator("div[aria-hidden='true'].absolute.inset-0");
|
|
await expect(backdrop).toBeAttached();
|
|
|
|
await page.keyboard.press("Escape");
|
|
});
|
|
|
|
test("focus is contained inside settings dialog while open", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
// Tab through several elements -- focus should stay within the dialog
|
|
for (let i = 0; i < 10; i++) {
|
|
await page.keyboard.press("Tab");
|
|
}
|
|
|
|
// The key test: after tabbing, focus should NOT escape to elements behind
|
|
// the dialog (like the sidebar). It should remain inside the dialog overlay.
|
|
const focusLocation = await page.evaluate(() => {
|
|
const active = document.activeElement;
|
|
if (!active) return "none";
|
|
const sidebar = document.querySelector("aside");
|
|
if (sidebar?.contains(active)) return "sidebar";
|
|
const dialogOverlay = document.querySelector(".fixed.inset-0");
|
|
if (dialogOverlay?.contains(active)) return "dialog";
|
|
return "other";
|
|
});
|
|
// Focus must not have leaked into the sidebar behind the dialog
|
|
expect(focusLocation).not.toBe("sidebar");
|
|
|
|
await page.keyboard.press("Escape");
|
|
});
|
|
});
|
|
|
|
test.describe("Help Dialog Accessibility", () => {
|
|
test("Escape key closes help dialog", async ({ loggedInPage: page }) => {
|
|
await page.locator("aside").getByText("Help").click();
|
|
await expect(page.getByRole("heading", { name: "Help" })).toBeVisible();
|
|
|
|
await page.keyboard.press("Escape");
|
|
|
|
await expect(page.getByRole("heading", { name: "Help" })).not.toBeVisible();
|
|
});
|
|
|
|
test("help dialog closes via Escape and focus returns", async ({ loggedInPage: page }) => {
|
|
await page.locator("aside").getByText("Help").click();
|
|
await expect(page.getByRole("heading", { name: "Help" })).toBeVisible();
|
|
|
|
await page.keyboard.press("Escape");
|
|
|
|
await expect(page.getByRole("heading", { name: "Help" })).not.toBeVisible();
|
|
const activeElement = await page.evaluate(() => document.activeElement?.tagName);
|
|
expect(activeElement).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dropzone Accessibility
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Dropzone Accessibility", () => {
|
|
test("dropzone has an accessible section label", async ({ loggedInPage: page }) => {
|
|
// The Dropzone component uses <section aria-label="File drop zone">
|
|
const dropzone = page.locator("section[aria-label='File drop zone']");
|
|
await expect(dropzone).toBeVisible();
|
|
});
|
|
|
|
test("upload button inside dropzone is clickable", async ({ loggedInPage: page }) => {
|
|
// The upload button should be a real interactive element
|
|
const uploadBtn = page.getByRole("button", { name: /upload/i }).first();
|
|
await expect(uploadBtn).toBeVisible();
|
|
await expect(uploadBtn).toBeEnabled();
|
|
});
|
|
|
|
test("dropzone upload button is focusable and keyboard-reachable", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
// Navigate to a tool page to ensure the dropzone is present
|
|
await page.goto("/resize");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
// The upload button is a real <button> element, so it's natively
|
|
// keyboard-accessible via Tab, Enter, and Space.
|
|
const uploadBtn = page.getByText("Upload from computer");
|
|
await expect(uploadBtn).toBeVisible();
|
|
|
|
// Focus the upload button
|
|
await uploadBtn.focus();
|
|
const focusedTag = await page.evaluate(() => document.activeElement?.tagName);
|
|
expect(focusedTag).toBe("BUTTON");
|
|
|
|
// Verify the focused element's text matches the upload button
|
|
const focusedText = await page.evaluate(() => document.activeElement?.textContent);
|
|
expect(focusedText).toContain("Upload from computer");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Slider / Range Input Accessibility
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Slider Keyboard Accessibility", () => {
|
|
test("QR size slider responds to arrow keys", async ({ loggedInPage: page }) => {
|
|
await page.goto("/qr-generate");
|
|
await page.waitForLoadState("domcontentloaded");
|
|
|
|
const sizeSlider = page.locator("#qr-size");
|
|
await expect(sizeSlider).toBeVisible();
|
|
|
|
// Get initial value
|
|
const initialValue = await sizeSlider.inputValue();
|
|
|
|
// Focus and press ArrowRight to increase
|
|
await sizeSlider.focus();
|
|
await page.keyboard.press("ArrowRight");
|
|
|
|
const newValue = await sizeSlider.inputValue();
|
|
// The value should have increased (step is 100, initial is 1024 or similar)
|
|
expect(Number(newValue)).toBeGreaterThanOrEqual(Number(initialValue));
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Navigation Accessibility
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Navigation Accessibility", () => {
|
|
test("sidebar items are keyboard-navigable via Tab", async ({ loggedInPage: page }) => {
|
|
const sidebar = page.locator("aside");
|
|
await expect(sidebar).toBeVisible();
|
|
|
|
// All sidebar items should be links or buttons (keyboard accessible)
|
|
const sidebarLinks = sidebar.locator("a");
|
|
const sidebarButtons = sidebar.locator("button");
|
|
|
|
const linkCount = await sidebarLinks.count();
|
|
const buttonCount = await sidebarButtons.count();
|
|
|
|
// Should have both navigation links and action buttons
|
|
expect(linkCount + buttonCount).toBeGreaterThanOrEqual(4);
|
|
});
|
|
|
|
test("sidebar links have href attributes for navigation", async ({ loggedInPage: page }) => {
|
|
const sidebar = page.locator("aside");
|
|
const links = sidebar.locator("a");
|
|
const count = await links.count();
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const href = await links.nth(i).getAttribute("href");
|
|
expect(href).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
test("tool panel search input is focusable", async ({ loggedInPage: page }) => {
|
|
const searchInput = page.getByPlaceholder(/search/i).first();
|
|
await expect(searchInput).toBeVisible();
|
|
|
|
await searchInput.focus();
|
|
const isFocused = await page.evaluate(() => document.activeElement?.tagName === "INPUT");
|
|
expect(isFocused).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Focus Management
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Focus Management", () => {
|
|
test.use({ storageState: { cookies: [], origins: [] } });
|
|
|
|
test("login page form elements are focusable in order", async ({ page }) => {
|
|
await page.goto("/login");
|
|
|
|
// Focus should be possible on username, password, and login button in order
|
|
const username = page.getByLabel("Username");
|
|
const password = page.getByLabel("Password");
|
|
const loginBtn = page.getByRole("button", { name: /login/i });
|
|
|
|
await username.focus();
|
|
expect(await page.evaluate(() => document.activeElement?.id)).toBe("username");
|
|
|
|
await password.focus();
|
|
expect(await page.evaluate(() => document.activeElement?.id)).toBe("password");
|
|
|
|
// Fill both fields to enable the button
|
|
await username.fill("test");
|
|
await password.fill("test");
|
|
|
|
await loginBtn.focus();
|
|
const activeTag = await page.evaluate(() => document.activeElement?.tagName);
|
|
expect(activeTag).toBe("BUTTON");
|
|
});
|
|
});
|
|
|
|
test.describe("Focus Management - Dialogs", () => {
|
|
test("closing settings dialog returns focus to page", async ({ loggedInPage: page }) => {
|
|
// Open settings
|
|
await openSettings(page);
|
|
|
|
// Close via Escape
|
|
await page.keyboard.press("Escape");
|
|
await expect(page.locator("h2").filter({ hasText: "Settings" })).not.toBeVisible();
|
|
|
|
// Focus should return to the page body (not trapped in a removed dialog)
|
|
const activeElement = await page.evaluate(() => document.activeElement?.tagName);
|
|
expect(activeElement).toBeDefined();
|
|
// Should not be null or stuck on a removed element
|
|
expect(activeElement).not.toBe("undefined");
|
|
});
|
|
|
|
test("closing help dialog returns focus to page", async ({ loggedInPage: page }) => {
|
|
// Open help
|
|
const helpBtn = page.locator("aside").getByText("Help");
|
|
await helpBtn.click();
|
|
await expect(page.getByRole("heading", { name: "Help" })).toBeVisible();
|
|
|
|
// Close via Escape
|
|
await page.keyboard.press("Escape");
|
|
await expect(page.getByRole("heading", { name: "Help" })).not.toBeVisible();
|
|
|
|
// Focus should return to the page
|
|
const activeElement = await page.evaluate(() => document.activeElement?.tagName);
|
|
expect(activeElement).toBeDefined();
|
|
expect(activeElement).not.toBe("undefined");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Connection Banner Accessibility
|
|
// ---------------------------------------------------------------------------
|
|
test.describe("Connection Banner Accessibility", () => {
|
|
test("connection banner uses role=status and aria-live=polite", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
// The ConnectionBanner component uses role="status" aria-live="polite"
|
|
// When connected, the banner is hidden. We verify the component is
|
|
// mounted by checking it does NOT show when connection is healthy.
|
|
// The role/aria-live attributes are in the source code for when it is visible.
|
|
|
|
// On a healthy connection, the banner should not be visible
|
|
const banner = page.locator("[role='status'][aria-live='polite']");
|
|
// The banner is only rendered when status !== "connected", so count may be 0
|
|
const count = await banner.count();
|
|
// Either not rendered (0) or rendered but hidden -- both are valid
|
|
expect(count).toBeGreaterThanOrEqual(0);
|
|
|
|
// Page should still function normally
|
|
await expect(page.locator("main")).toBeVisible();
|
|
});
|
|
});
|