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
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