2026-05-09 09:02:29 +08:00
import { expect , openSettings , test , uploadTestImage , waitForProcessing } from "./helpers" ;
2026-04-29 01:39:25 +08:00
// ---------------------------------------------------------------------------
// 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" );
});
});
2026-05-01 04:47:55 +08:00
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 ();
});
});
2026-04-29 01:39:25 +08:00
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 );
}
});
});
2026-05-01 04:47:55 +08:00
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 );
});
});
2026-04-29 01:39:25 +08:00
// ---------------------------------------------------------------------------
// Modal/Dialog Accessibility
// ---------------------------------------------------------------------------
test . describe ( "Settings Dialog Accessibility" , () => {
test ( "settings dialog opens as a layered panel with backdrop" , async ({ loggedInPage : page }) => {
2026-05-01 13:44:04 +08:00
await openSettings ( page );
2026-04-29 01:39:25 +08:00
// 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 }) => {
2026-05-01 13:44:04 +08:00
await openSettings ( page );
2026-04-29 01:39:25 +08:00
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 }) => {
2026-05-01 13:44:04 +08:00
await openSettings ( page );
2026-04-29 01:39:25 +08:00
// 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 }) => {
2026-05-01 13:44:04 +08:00
await openSettings ( page );
2026-04-29 01:39:25 +08:00
// 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 ();
});
2026-05-01 04:47:55 +08:00
test ( "settings dialog backdrop is marked aria-hidden" , async ({ loggedInPage : page }) => {
2026-05-01 13:44:04 +08:00
await openSettings ( page );
2026-05-01 04:47:55 +08:00
// 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 }) => {
2026-05-01 13:44:04 +08:00
await openSettings ( page );
2026-05-01 04:47:55 +08:00
// 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" );
});
2026-04-29 01:39:25 +08:00
});
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 ();
});
2026-05-01 04:47:55 +08:00
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 ));
});
2026-04-29 01:39:25 +08:00
});
// ---------------------------------------------------------------------------
// 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
2026-05-01 13:44:04 +08:00
await openSettings ( page );
2026-04-29 01:39:25 +08:00
// 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 ();
});
2026-05-09 09:02:29 +08:00
test ( "connection banner is announced to screen readers when visible" , async ({
loggedInPage : page ,
}) => {
// Force the banner to appear by simulating offline
await page . route ( "**/api/v1/health" , ( route ) => route . abort ());
await page . evaluate (() => window . dispatchEvent ( new Event ( "offline" )));
const banner = page . locator ( "[role='status'][aria-live='polite']" );
await expect ( banner ). toBeVisible ({ timeout : 10_000 });
// Verify the banner has role="status" (implicit aria-live) for AT
await expect ( banner ). toHaveAttribute ( "role" , "status" );
await expect ( banner ). toHaveAttribute ( "aria-live" , "polite" );
// Banner text should be descriptive for screen readers
const text = await banner . textContent ();
expect ( text ). toBeTruthy ();
expect ( text ? . length ). toBeGreaterThan ( 0 );
await page . unroute ( "**/api/v1/health" );
});
});
// ---------------------------------------------------------------------------
// 14.5 Images Alt/Aria-Label
// ---------------------------------------------------------------------------
test . describe ( "Image Accessibility" , () => {
test ( "all visible images on home page have alt text or aria-label" , async ({
loggedInPage : page ,
}) => {
await page . waitForLoadState ( "networkidle" );
const images = page . locator ( "img" );
const count = await images . count ();
for ( let i = 0 ; i < count ; i ++ ) {
const img = images . nth ( i );
if ( ! ( await img . isVisible (). catch (() => false ))) continue ;
const alt = await img . getAttribute ( "alt" );
const ariaLabel = await img . getAttribute ( "aria-label" );
const role = await img . getAttribute ( "role" );
// Decorative images should have role="presentation" or alt=""
// Meaningful images must have alt or aria-label
const isDecorative = role === "presentation" || role === "none" || alt === "" ;
const hasAccessibleName =
( alt && alt . trim (). length > 0 ) || ( ariaLabel && ariaLabel . trim (). length > 0 );
expect (
isDecorative || hasAccessibleName ,
`Image at index ${ i } has no alt text, aria-label, or presentation role. src=" ${ await img . getAttribute ( "src" ) } "` ,
). toBeTruthy ();
}
});
test ( "all visible images on tool page have alt text or aria-label" , async ({
loggedInPage : page ,
}) => {
await page . goto ( "/resize" );
await page . waitForLoadState ( "networkidle" );
const images = page . locator ( "img" );
const count = await images . count ();
for ( let i = 0 ; i < count ; i ++ ) {
const img = images . nth ( i );
if ( ! ( await img . isVisible (). catch (() => false ))) continue ;
const alt = await img . getAttribute ( "alt" );
const ariaLabel = await img . getAttribute ( "aria-label" );
const role = await img . getAttribute ( "role" );
const isDecorative = role === "presentation" || role === "none" || alt === "" ;
const hasAccessibleName =
( alt && alt . trim (). length > 0 ) || ( ariaLabel && ariaLabel . trim (). length > 0 );
expect (
isDecorative || hasAccessibleName ,
`Image at index ${ i } has no alt text, aria-label, or presentation role.` ,
). toBeTruthy ();
}
});
});
// ---------------------------------------------------------------------------
// 14.5 No Duplicate IDs
// ---------------------------------------------------------------------------
test . describe ( "No Duplicate IDs" , () => {
test ( "home page has no duplicate element IDs" , async ({ loggedInPage : page }) => {
await page . waitForLoadState ( "networkidle" );
const duplicates = await page . evaluate (() => {
const ids = Array . from ( document . querySelectorAll ( "[id]" )). map (( el ) => el . id );
const seen = new Set < string >();
const dups : string [] = [];
for ( const id of ids ) {
if ( id && seen . has ( id )) dups . push ( id );
seen . add ( id );
}
return dups ;
});
expect ( duplicates , `Duplicate IDs found on home page: ${ duplicates . join ( ", " ) } ` ). toHaveLength (
0 ,
);
});
test ( "tool page has no duplicate element IDs" , async ({ loggedInPage : page }) => {
await page . goto ( "/resize" );
await page . waitForLoadState ( "networkidle" );
const duplicates = await page . evaluate (() => {
const ids = Array . from ( document . querySelectorAll ( "[id]" )). map (( el ) => el . id );
const seen = new Set < string >();
const dups : string [] = [];
for ( const id of ids ) {
if ( id && seen . has ( id )) dups . push ( id );
seen . add ( id );
}
return dups ;
});
expect ( duplicates , `Duplicate IDs found on tool page: ${ duplicates . join ( ", " ) } ` ). toHaveLength (
0 ,
);
});
test ( "login page has no duplicate element IDs" , async ({ loggedInPage : page }) => {
// Use a fresh context without auth for the login page
await page . goto ( "/login" );
await page . waitForLoadState ( "networkidle" );
const duplicates = await page . evaluate (() => {
const ids = Array . from ( document . querySelectorAll ( "[id]" )). map (( el ) => el . id );
const seen = new Set < string >();
const dups : string [] = [];
for ( const id of ids ) {
if ( id && seen . has ( id )) dups . push ( id );
seen . add ( id );
}
return dups ;
});
expect ( duplicates , `Duplicate IDs found on login page: ${ duplicates . join ( ", " ) } ` ). toHaveLength (
0 ,
);
});
});
// ---------------------------------------------------------------------------
// 14.5 Keyboard-Operable Interactive Elements
// ---------------------------------------------------------------------------
test . describe ( "Keyboard Operability" , () => {
test ( "all interactive elements on home page are keyboard-reachable via Tab" , async ({
loggedInPage : page ,
}) => {
await page . waitForLoadState ( "networkidle" );
// Tab through the page and track all focused elements
const focusedTags = new Set < string >();
for ( let i = 0 ; i < 30 ; i ++ ) {
await page . keyboard . press ( "Tab" );
const tag = await page . evaluate (() => document . activeElement ? . tagName ?? "NONE" );
focusedTags . add ( tag );
}
// At minimum, we should have focused links, buttons, and/or inputs
const interactiveTags = [ "A" , "BUTTON" , "INPUT" , "SELECT" , "TEXTAREA" ];
const hasInteractive = interactiveTags . some (( t ) => focusedTags . has ( t ));
expect (
hasInteractive ,
`No interactive elements were focused during Tab traversal. Got: ${ [... focusedTags ]. join ( ", " ) } ` ,
). toBeTruthy ();
});
test ( "Enter key activates focused button on home page" , async ({ loggedInPage : page }) => {
await page . waitForLoadState ( "networkidle" );
// Tab to the first visible button
for ( let i = 0 ; i < 20 ; i ++ ) {
await page . keyboard . press ( "Tab" );
const tag = await page . evaluate (() => document . activeElement ? . tagName );
if ( tag === "BUTTON" ) break ;
}
// Verify we are on a button
const activeTag = await page . evaluate (() => document . activeElement ? . tagName );
if ( activeTag === "BUTTON" ) {
// Press Enter -- should not crash
await page . keyboard . press ( "Enter" );
await page . waitForTimeout ( 500 );
await expect ( page . locator ( "body" )). toBeVisible ();
}
});
test ( "Space key activates focused button" , async ({ loggedInPage : page }) => {
await page . waitForLoadState ( "networkidle" );
// Tab to the first visible button
for ( let i = 0 ; i < 20 ; i ++ ) {
await page . keyboard . press ( "Tab" );
const tag = await page . evaluate (() => document . activeElement ? . tagName );
if ( tag === "BUTTON" ) break ;
}
const activeTag = await page . evaluate (() => document . activeElement ? . tagName );
if ( activeTag === "BUTTON" ) {
await page . keyboard . press ( "Space" );
await page . waitForTimeout ( 500 );
await expect ( page . locator ( "body" )). toBeVisible ();
}
});
});
// ---------------------------------------------------------------------------
// 14.5 Modal ARIA: role=dialog, aria-modal
// ---------------------------------------------------------------------------
test . describe ( "Modal ARIA Compliance" , () => {
test ( "settings dialog has role=dialog" , async ({ loggedInPage : page }) => {
await openSettings ( page );
const dialog = page . getByRole ( "dialog" );
await expect ( dialog ). toBeVisible ();
await page . keyboard . press ( "Escape" );
});
test ( "settings dialog backdrop prevents interaction with background" , async ({
loggedInPage : page ,
}) => {
await openSettings ( page );
// Tab 15 times -- focus should stay within the dialog area
for ( let i = 0 ; i < 15 ; i ++ ) {
await page . keyboard . press ( "Tab" );
}
// Verify focus has not escaped to the sidebar
const focusLocation = await page . evaluate (() => {
const active = document . activeElement ;
if ( ! active ) return "none" ;
const sidebar = document . querySelector ( "aside" );
if ( sidebar ? . contains ( active )) return "sidebar" ;
return "dialog-or-other" ;
});
expect ( focusLocation ). not . toBe ( "sidebar" );
await page . keyboard . press ( "Escape" );
});
});
// ---------------------------------------------------------------------------
// 14.5 Color Contrast (sampled checks)
// ---------------------------------------------------------------------------
test . describe ( "Color Contrast" , () => {
test ( "primary text on home page meets minimum contrast ratio" , async ({ loggedInPage : page }) => {
await page . waitForLoadState ( "networkidle" );
// Sample the foreground/background colors of the first heading
const contrast = await page . evaluate (() => {
const heading = document . querySelector ( "h1, h2" );
if ( ! heading ) return null ;
const style = window . getComputedStyle ( heading );
const color = style . color ;
const bgColor = style . backgroundColor ;
// Parse rgb values
const parseRgb = ( c : string ) => {
const m = c . match ( /\d+/g );
return m ? m . map ( Number ) : null ;
};
const fg = parseRgb ( color );
const bg = parseRgb ( bgColor );
if ( ! fg || ! bg ) return null ;
// Relative luminance per WCAG 2.1
const luminance = ( rgb : number []) => {
const [ r , g , b ] = rgb . map (( v ) => {
const s = v / 255 ;
return s <= 0.03928 ? s / 12.92 : (( s + 0.055 ) / 1.055 ) ** 2.4 ;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b ;
};
const l1 = luminance ( fg );
const l2 = luminance ( bg );
const ratio = ( Math . max ( l1 , l2 ) + 0.05 ) / ( Math . min ( l1 , l2 ) + 0.05 );
return { ratio , fg : color , bg : bgColor };
});
// If we got a valid contrast measurement, verify WCAG AA for large text (3:1)
if ( contrast && contrast . ratio > 0 ) {
expect (
contrast . ratio ,
`Heading contrast ratio ${ contrast . ratio . toFixed ( 2 ) } below 3:1 (fg: ${ contrast . fg } , bg: ${ contrast . bg } )` ,
). toBeGreaterThanOrEqual ( 3 );
}
});
test ( "button text meets WCAG AA contrast ratio (4.5:1 for normal text)" , async ({
loggedInPage : page ,
}) => {
await page . waitForLoadState ( "networkidle" );
// Check the first visible button with text content
const contrastResults = await page . evaluate (() => {
const buttons = Array . from ( document . querySelectorAll ( "button" ));
const results : Array < { text : string ; ratio : number } > = [];
for ( const btn of buttons . slice ( 0 , 5 )) {
if ( ! btn . offsetParent ) continue ;
const text = btn . textContent ? . trim ();
if ( ! text ) continue ;
const style = window . getComputedStyle ( btn );
const color = style . color ;
const bgColor = style . backgroundColor ;
const parseRgb = ( c : string ) => {
const m = c . match ( /\d+/g );
return m ? m . map ( Number ) : null ;
};
const fg = parseRgb ( color );
const bg = parseRgb ( bgColor );
if ( ! fg || ! bg ) continue ;
const luminance = ( rgb : number []) => {
const [ r , g , b ] = rgb . map (( v ) => {
const s = v / 255 ;
return s <= 0.03928 ? s / 12.92 : (( s + 0.055 ) / 1.055 ) ** 2.4 ;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b ;
};
const l1 = luminance ( fg );
const l2 = luminance ( bg );
const ratio = ( Math . max ( l1 , l2 ) + 0.05 ) / ( Math . min ( l1 , l2 ) + 0.05 );
// Only check buttons with non-transparent backgrounds
if ( bg [ 3 ] !== undefined || bgColor !== "rgba(0, 0, 0, 0)" ) {
results . push ({ text , ratio });
}
}
return results ;
});
// Each sampled button should meet 4.5:1 for normal text
for ( const result of contrastResults ) {
if ( result . ratio > 0 && result . ratio < 100 ) {
expect (
result . ratio ,
`Button " ${ result . text } " has contrast ratio ${ result . ratio . toFixed ( 2 ) } , below 4.5:1` ,
). toBeGreaterThanOrEqual ( 4.5 );
}
}
});
});
// ---------------------------------------------------------------------------
// 14.6 Focus Management (expanded)
// ---------------------------------------------------------------------------
test . describe ( "Focus After Login" , () => {
test . use ({ storageState : { cookies : [], origins : [] } });
test ( "after login redirect, focus moves to main content area" , async ({ page }) => {
await page . goto ( "/login" );
await page . getByLabel ( "Username" ). fill ( "admin" );
await page . getByLabel ( "Password" ). fill ( "admin" );
await page . getByRole ( "button" , { name : /login/i }). click ();
// Wait for redirect to home
await page . waitForURL ( "/" , { timeout : 15_000 });
await page . waitForLoadState ( "domcontentloaded" );
// After login, focus should be within the main content area or body
// (not still stuck on a removed login form element)
const activeTag = await page . evaluate (() => document . activeElement ? . tagName );
expect ( activeTag ). toBeDefined ();
expect ( activeTag ). not . toBe ( "undefined" );
// Main content should be visible and accessible
await expect ( page . locator ( "main" )). toBeVisible ();
});
});
test . describe ( "Focus After File Upload" , () => {
test ( "after file upload, focus moves toward settings panel" , async ({ loggedInPage : page }) => {
await page . goto ( "/resize" );
await uploadTestImage ( page );
// After upload, the settings panel and process button should be visible
const resizeBtn = page . getByRole ( "button" , { name : "Resize" });
await expect ( resizeBtn ). toBeVisible ({ timeout : 5_000 });
// The focus should be somewhere meaningful, not lost
const activeTag = await page . evaluate (() => document . activeElement ? . tagName );
expect ( activeTag ). toBeDefined ();
expect ( activeTag ). not . toBe ( "undefined" );
});
test ( "after processing completes, download link is reachable" , async ({ loggedInPage : page }) => {
await page . goto ( "/resize" );
await uploadTestImage ( page );
await page . locator ( "input[placeholder='Auto']" ). first (). fill ( "50" );
await page . getByRole ( "button" , { name : "Resize" }). click ();
await waitForProcessing ( page );
// Download link should be visible and focusable
const downloadLink = page . getByRole ( "link" , { name : /download/i }). first ();
await expect ( downloadLink ). toBeVisible ({ timeout : 15_000 });
// Focus should be reachable by Tab navigation
await downloadLink . focus ();
const isFocused = await page . evaluate (() => {
const active = document . activeElement ;
return active ? . tagName === "A" && /download/i . test ( active . textContent ?? "" );
});
expect ( isFocused ). toBeTruthy ();
});
});
test . describe ( "Focus Returns to Trigger After Dialog Close" , () => {
test ( "closing settings dialog returns focus near the trigger element" , async ({
loggedInPage : page ,
}) => {
// Record which element we click to open settings
const sidebar = page . locator ( "aside" );
const settingsBtn = sidebar . getByText ( "Settings" );
await settingsBtn . click ();
await page . getByRole ( "dialog" ). waitFor ({ state : "visible" , timeout : 5000 });
// Close via Escape
await page . keyboard . press ( "Escape" );
await expect ( page . getByRole ( "dialog" )). not . toBeVisible ({ timeout : 5_000 });
// Focus should return to the body/page -- not be trapped in a removed node
const activeElement = await page . evaluate (() => {
const el = document . activeElement ;
return {
tag : el?.tagName ?? "null" ,
isConnected : el?.isConnected ?? false ,
};
});
// The focused element must be a connected DOM node
expect ( activeElement . isConnected ). toBeTruthy ();
expect ( activeElement . tag ). not . toBe ( "null" );
});
test ( "closing help dialog returns focus to a connected element" , async ({
loggedInPage : page ,
}) => {
const helpBtn = page . locator ( "aside" ). getByText ( "Help" );
await helpBtn . 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 (() => {
const el = document . activeElement ;
return {
tag : el?.tagName ?? "null" ,
isConnected : el?.isConnected ?? false ,
};
});
expect ( activeElement . isConnected ). toBeTruthy ();
expect ( activeElement . tag ). not . toBe ( "null" );
});
2026-04-29 01:39:25 +08:00
});
2026-05-09 18:02:58 +08:00
// ---------------------------------------------------------------------------
// 14.5 Main Landmark on All Major Pages
// ---------------------------------------------------------------------------
test . describe ( "Main Landmark on All Pages" , () => {
test ( "fullscreen page has exactly one main landmark" , async ({ loggedInPage : page }) => {
await page . goto ( "/fullscreen" );
await page . waitForLoadState ( "domcontentloaded" );
const mainCount = await page . locator ( "main" ). count ();
expect ( mainCount ). toBeLessThanOrEqual ( 1 );
// Page should render content
const bodyText = await page . textContent ( "body" );
expect ( bodyText ). toBeDefined ();
expect ( bodyText ? . length ). toBeGreaterThan ( 0 );
});
test ( "automate page has exactly one main landmark" , async ({ loggedInPage : page }) => {
await page . goto ( "/automate" );
await page . waitForLoadState ( "domcontentloaded" );
const mainCount = await page . locator ( "main" ). count ();
expect ( mainCount ). toBeLessThanOrEqual ( 1 );
});
test ( "files page has exactly one main landmark" , async ({ loggedInPage : page }) => {
await page . goto ( "/files" );
await page . waitForLoadState ( "domcontentloaded" );
const mainCount = await page . locator ( "main" ). count ();
expect ( mainCount ). toBeLessThanOrEqual ( 1 );
});
});
test . describe ( "Main Landmark - Login Page" , () => {
test . use ({ storageState : { cookies : [], origins : [] } });
test ( "login page has exactly one main landmark" , async ({ page }) => {
await page . goto ( "/login" );
await page . waitForLoadState ( "domcontentloaded" );
// Login page may render as a full-screen form without a <main>
// At minimum it should render visible content
const bodyText = await page . textContent ( "body" );
expect ( bodyText ). toBeDefined ();
expect ( bodyText ? . length ). toBeGreaterThan ( 0 );
});
});
// ---------------------------------------------------------------------------
// 14.5 Settings Dialog - aria-modal
// ---------------------------------------------------------------------------
test . describe ( "Settings Dialog - aria-modal" , () => {
test ( "settings dialog has aria-modal=true" , async ({ loggedInPage : page }) => {
await openSettings ( page );
const dialog = page . getByRole ( "dialog" );
await expect ( dialog ). toBeVisible ();
// The dialog should have aria-modal="true" for proper screen reader behavior
const ariaModal = await dialog . getAttribute ( "aria-modal" );
expect ( ariaModal ). toBe ( "true" );
await page . keyboard . press ( "Escape" );
});
});
// ---------------------------------------------------------------------------
// 14.5 Help Dialog - role=dialog
// ---------------------------------------------------------------------------
test . describe ( "Help Dialog - role=dialog" , () => {
test ( "help dialog has role=dialog" , async ({ loggedInPage : page }) => {
await page . locator ( "aside" ). getByText ( "Help" ). click ();
await expect ( page . getByRole ( "heading" , { name : "Help" })). toBeVisible ();
// The help dialog should be rendered with role="dialog"
const dialog = page . getByRole ( "dialog" );
await expect ( dialog ). toBeVisible ();
await page . keyboard . press ( "Escape" );
});
});
// ---------------------------------------------------------------------------
// 14.5 Focus Trapped Inside Open Modal
// ---------------------------------------------------------------------------
test . describe ( "Focus Trap in Modal" , () => {
test ( "Tab cycling stays within settings dialog" , async ({ loggedInPage : page }) => {
await openSettings ( page );
// Tab through many times -- should cycle within the dialog, never reach sidebar
const escapes : string [] = [];
for ( let i = 0 ; i < 20 ; i ++ ) {
await page . keyboard . press ( "Tab" );
const location = await page . evaluate (() => {
const active = document . activeElement ;
if ( ! active ) return "none" ;
const sidebar = document . querySelector ( "aside" );
if ( sidebar ? . contains ( active )) return "sidebar" ;
const main = document . querySelector ( "main" );
if ( main ? . contains ( active ) && ! document . querySelector ( "[role='dialog']" ) ? . contains ( active ))
return "main" ;
return "dialog" ;
});
if ( location === "sidebar" || location === "main" ) {
escapes . push ( location );
}
}
// Focus should never have escaped to sidebar or main content behind the dialog
expect ( escapes ). toHaveLength ( 0 );
await page . keyboard . press ( "Escape" );
});
});
// ---------------------------------------------------------------------------
// 14.5 Slider ARIA Attributes
// ---------------------------------------------------------------------------
test . describe ( "Slider ARIA Attributes" , () => {
test ( "QR size slider has min, max, and current value attributes" , async ({
loggedInPage : page ,
}) => {
await page . goto ( "/qr-generate" );
await page . waitForLoadState ( "domcontentloaded" );
const sizeSlider = page . locator ( "#qr-size" );
await expect ( sizeSlider ). toBeVisible ();
// Native input[type=range] provides implicit ARIA: role=slider,
// aria-valuemin (from min), aria-valuemax (from max), aria-valuenow (from value)
const min = await sizeSlider . getAttribute ( "min" );
const max = await sizeSlider . getAttribute ( "max" );
const value = await sizeSlider . inputValue ();
expect ( min ). toBeTruthy ();
expect ( max ). toBeTruthy ();
expect ( Number ( value )). toBeGreaterThanOrEqual ( Number ( min ));
expect ( Number ( value )). toBeLessThanOrEqual ( Number ( max ));
});
test ( "compress quality slider has type=range with min/max" , async ({ loggedInPage : page }) => {
await page . goto ( "/compress" );
await page . waitForLoadState ( "domcontentloaded" );
const slider = page . locator ( "input[type='range']" ). first ();
if ( await slider . isVisible ({ timeout : 3_000 }). catch (() => false )) {
const type = await slider . getAttribute ( "type" );
expect ( type ). toBe ( "range" );
const min = await slider . getAttribute ( "min" );
const max = await slider . getAttribute ( "max" );
expect ( min ). toBeTruthy ();
expect ( max ). toBeTruthy ();
}
});
});
// ---------------------------------------------------------------------------
// 14.5 Dropzone - Space/Enter triggers file picker
// ---------------------------------------------------------------------------
test . describe ( "Dropzone Keyboard Activation" , () => {
test ( "Space key on upload button triggers file dialog" , async ({ loggedInPage : page }) => {
await page . goto ( "/resize" );
await page . waitForLoadState ( "domcontentloaded" );
const uploadBtn = page . getByText ( "Upload from computer" );
await expect ( uploadBtn ). toBeVisible ();
// Focus the upload button
await uploadBtn . focus ();
// Press Space -- should trigger file chooser (we can detect via event)
const fileChooserPromise = page
. waitForEvent ( "filechooser" , { timeout : 3_000 })
. catch (() => null );
await page . keyboard . press ( "Space" );
const chooser = await fileChooserPromise ;
// If the file chooser was triggered, the keyboard activation works
// If not, the button might use a different mechanism -- still no crash
if ( chooser ) {
// Dismiss the file chooser by not setting files
expect ( chooser ). toBeTruthy ();
}
await expect ( page . locator ( "main" )). toBeVisible ();
});
test ( "Enter key on upload button triggers file dialog" , async ({ loggedInPage : page }) => {
await page . goto ( "/resize" );
await page . waitForLoadState ( "domcontentloaded" );
const uploadBtn = page . getByText ( "Upload from computer" );
await expect ( uploadBtn ). toBeVisible ();
await uploadBtn . focus ();
const fileChooserPromise = page
. waitForEvent ( "filechooser" , { timeout : 3_000 })
. catch (() => null );
await page . keyboard . press ( "Enter" );
const chooser = await fileChooserPromise ;
if ( chooser ) {
expect ( chooser ). toBeTruthy ();
}
await expect ( page . locator ( "main" )). toBeVisible ();
});
});
// ---------------------------------------------------------------------------
// 14.5 Navigation - Active Item Indicator
// ---------------------------------------------------------------------------
test . describe ( "Navigation Active Indicator" , () => {
test ( "active sidebar tool link is visually distinguishable" , async ({ loggedInPage : page }) => {
await page . goto ( "/resize" );
await page . waitForLoadState ( "domcontentloaded" );
// The sidebar should have an active/highlighted link for the current tool
const sidebar = page . locator ( "aside" );
const resizeLink = sidebar . locator ( "a[href='/resize']" );
if ( await resizeLink . isVisible ({ timeout : 3_000 }). catch (() => false )) {
// The active link should have a different visual style (bg color or aria-current)
const classList = await resizeLink . getAttribute ( "class" );
const ariaCurrent = await resizeLink . getAttribute ( "aria-current" );
const dataActive = await resizeLink . getAttribute ( "data-active" );
// At least one indicator of active state should exist:
// either a specific CSS class, aria-current, or data-active attribute
const hasActiveIndicator =
( classList && /active|selected|current|bg-primary|bg-accent/i . test ( classList )) ||
ariaCurrent === "page" ||
dataActive === "true" ;
expect (
hasActiveIndicator ,
"Active sidebar link should have visual distinction via class, aria-current, or data-active" ,
). toBeTruthy ();
}
});
});
// ---------------------------------------------------------------------------
// 14.5 No Duplicate IDs on Additional Pages
// ---------------------------------------------------------------------------
test . describe ( "No Duplicate IDs - Additional Pages" , () => {
test ( "automate page has no duplicate element IDs" , async ({ loggedInPage : page }) => {
await page . goto ( "/automate" );
await page . waitForLoadState ( "networkidle" );
const duplicates = await page . evaluate (() => {
const ids = Array . from ( document . querySelectorAll ( "[id]" )). map (( el ) => el . id );
const seen = new Set < string >();
const dups : string [] = [];
for ( const id of ids ) {
if ( id && seen . has ( id )) dups . push ( id );
seen . add ( id );
}
return dups ;
});
expect (
duplicates ,
`Duplicate IDs found on automate page: ${ duplicates . join ( ", " ) } ` ,
). toHaveLength ( 0 );
});
test ( "files page has no duplicate element IDs" , async ({ loggedInPage : page }) => {
await page . goto ( "/files" );
await page . waitForLoadState ( "networkidle" );
const duplicates = await page . evaluate (() => {
const ids = Array . from ( document . querySelectorAll ( "[id]" )). map (( el ) => el . id );
const seen = new Set < string >();
const dups : string [] = [];
for ( const id of ids ) {
if ( id && seen . has ( id )) dups . push ( id );
seen . add ( id );
}
return dups ;
});
expect ( duplicates , `Duplicate IDs found on files page: ${ duplicates . join ( ", " ) } ` ). toHaveLength (
0 ,
);
});
});
// ---------------------------------------------------------------------------
// 14.5 Image Accessibility on Additional Pages
// ---------------------------------------------------------------------------
test . describe ( "Image Accessibility - Additional Pages" , () => {
test ( "all visible images on automate page have alt text or aria-label" , async ({
loggedInPage : page ,
}) => {
await page . goto ( "/automate" );
await page . waitForLoadState ( "networkidle" );
const images = page . locator ( "img" );
const count = await images . count ();
for ( let i = 0 ; i < count ; i ++ ) {
const img = images . nth ( i );
if ( ! ( await img . isVisible (). catch (() => false ))) continue ;
const alt = await img . getAttribute ( "alt" );
const ariaLabel = await img . getAttribute ( "aria-label" );
const role = await img . getAttribute ( "role" );
const isDecorative = role === "presentation" || role === "none" || alt === "" ;
const hasAccessibleName =
( alt && alt . trim (). length > 0 ) || ( ariaLabel && ariaLabel . trim (). length > 0 );
expect (
isDecorative || hasAccessibleName ,
`Image at index ${ i } on automate page has no alt text, aria-label, or presentation role.` ,
). toBeTruthy ();
}
});
});
// ---------------------------------------------------------------------------
// 14.5 Color Contrast in Dark Theme
// ---------------------------------------------------------------------------
test . describe ( "Color Contrast - Dark Theme" , () => {
test ( "primary text in dark theme meets minimum contrast ratio" , async ({
loggedInPage : page ,
}) => {
await page . waitForLoadState ( "networkidle" );
// Switch to dark theme
const themeBtn = page . locator ( "button[title='Toggle Theme']" );
const isDark = await page . evaluate (() => document . documentElement . classList . contains ( "dark" ));
if ( ! isDark && ( await themeBtn . isVisible ({ timeout : 3_000 }). catch (() => false ))) {
await themeBtn . click ();
await page . waitForTimeout ( 300 );
}
// Sample heading contrast in dark mode
const contrast = await page . evaluate (() => {
const heading = document . querySelector ( "h1, h2" );
if ( ! heading ) return null ;
const style = window . getComputedStyle ( heading );
const color = style . color ;
const bgColor = style . backgroundColor ;
const parseRgb = ( c : string ) => {
const m = c . match ( /\d+/g );
return m ? m . map ( Number ) : null ;
};
const fg = parseRgb ( color );
const bg = parseRgb ( bgColor );
if ( ! fg || ! bg ) return null ;
const luminance = ( rgb : number []) => {
const [ r , g , b ] = rgb . map (( v ) => {
const s = v / 255 ;
return s <= 0.03928 ? s / 12.92 : (( s + 0.055 ) / 1.055 ) ** 2.4 ;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b ;
};
const l1 = luminance ( fg );
const l2 = luminance ( bg );
const ratio = ( Math . max ( l1 , l2 ) + 0.05 ) / ( Math . min ( l1 , l2 ) + 0.05 );
return { ratio , fg : color , bg : bgColor };
});
if ( contrast && contrast . ratio > 0 ) {
expect (
contrast . ratio ,
`Dark theme heading contrast ratio ${ contrast . ratio . toFixed ( 2 ) } below 3:1` ,
). toBeGreaterThanOrEqual ( 3 );
}
// Toggle theme back
if ( ! isDark && ( await themeBtn . isVisible ({ timeout : 2_000 }). catch (() => false ))) {
await themeBtn . click ();
}
});
});
// ---------------------------------------------------------------------------
// 14.5 Buttons Accessible on Additional Pages
// ---------------------------------------------------------------------------
test . describe ( "Buttons Accessible - Additional Pages" , () => {
test ( "all visible buttons on automate page have accessible names" , async ({
loggedInPage : page ,
}) => {
await page . goto ( "/automate" );
await page . waitForLoadState ( "networkidle" );
const buttons = page . getByRole ( "button" );
const count = await buttons . count ();
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 } on automate page has no accessible name.` ,
). toBeTruthy ();
}
});
});
2026-05-15 21:35:02 +08:00
// ---------------------------------------------------------------------------
// Skip-to-Content Link (WCAG 2.4.1)
// ---------------------------------------------------------------------------
test . describe ( "Skip-to-Content Link" , () => {
test ( "skip-to-content link exists and is the first focusable element" , async ({
loggedInPage : page ,
}) => {
// The skip link should be the very first focusable element on the page.
// It is typically visually hidden until focused.
await page . keyboard . press ( "Tab" );
const activeElement = await page . evaluate (() => {
const el = document . activeElement ;
if ( ! el ) return null ;
return {
tag : el.tagName ,
text : el.textContent?.trim () ?? "" ,
href : ( el as HTMLAnchorElement ). href ?? "" ,
className : el.className ,
};
});
// The first focusable element should be a skip link targeting #main or main content
expect ( activeElement ). toBeTruthy ();
if ( activeElement ) {
const isSkipLink =
/skip/i . test ( activeElement . text ) ||
/skip/i . test ( activeElement . className ) ||
activeElement . href . includes ( "#main" );
expect (
isSkipLink ,
`Expected first focusable element to be a skip-to-content link, got: tag= ${ activeElement . tag } , text=" ${ activeElement . text } ", href=" ${ activeElement . href } "` ,
). toBeTruthy ();
}
});
test ( "skip-to-content link becomes visible on focus" , async ({ loggedInPage : page }) => {
// Press Tab to focus the skip link
await page . keyboard . press ( "Tab" );
// The skip link should become visible when focused (not display:none or invisible)
const skipLink = page . locator ( "a:focus, button:focus" ). first ();
const box = await skipLink . boundingBox ();
// The element should have a non-zero bounding box when focused
// (it is visually hidden off-screen or with sr-only until focused)
if ( box ) {
expect ( box . width ). toBeGreaterThan ( 0 );
expect ( box . height ). toBeGreaterThan ( 0 );
}
});
test ( "skip-to-content link navigates focus to main content" , async ({ loggedInPage : page }) => {
// Press Tab to reach the skip link, then activate it
await page . keyboard . press ( "Tab" );
await page . keyboard . press ( "Enter" );
// After activation, focus should move to the main content area
const focusLocation = await page . evaluate (() => {
const active = document . activeElement ;
if ( ! active ) return "none" ;
const main = document . querySelector ( "main" );
if ( main === active || main ? . contains ( active )) return "main" ;
// Check if focused element has id="main" (skip link target)
if ( active . id === "main" || active . id === "main-content" ) return "main" ;
return `other: ${ active . tagName } # ${ active . id } ` ;
});
expect (
focusLocation ,
`Expected focus to move to main content after skip link activation, got: ${ focusLocation } ` ,
). toBe ( "main" );
});
test ( "skip-to-content link is present on tool pages" , async ({ loggedInPage : page }) => {
await page . goto ( "/resize" );
await page . waitForLoadState ( "domcontentloaded" );
// Tab to first focusable element
await page . keyboard . press ( "Tab" );
const activeText = await page . evaluate (() => document . activeElement ? . textContent ? . trim () ?? "" );
expect (
/skip/i . test ( activeText ),
`Expected skip link on tool page, first focused element text: " ${ activeText } "` ,
). toBeTruthy ();
});
});
// ---------------------------------------------------------------------------
// Comprehensive Color Contrast Checks (WCAG AA)
// ---------------------------------------------------------------------------
test . describe ( "Comprehensive Color Contrast" , () => {
// Helper function used inside page.evaluate for contrast calculation
const contrastCheckScript = ( selector : string , minRatio : number ) => {
const elements = Array . from ( document . querySelectorAll ( selector ));
const failures : Array < {
text : string ;
ratio : number ;
fg : string ;
bg : string ;
selector : string ;
} > = [];
const luminance = ( rgb : number []) => {
const [ r , g , b ] = rgb . map (( v ) => {
const s = v / 255 ;
return s <= 0.03928 ? s / 12.92 : (( s + 0.055 ) / 1.055 ) ** 2.4 ;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b ;
};
const parseRgb = ( c : string ) => {
const m = c . match ( /\d+/g );
return m ? m . map ( Number ) : null ;
};
const getEffectiveBg = ( el : Element ) : number [] | null => {
let current : Element | null = el ;
while ( current ) {
const style = window . getComputedStyle ( current );
const bg = style . backgroundColor ;
const rgb = parseRgb ( bg );
if ( rgb && ( rgb . length < 4 || rgb [ 3 ] > 0 ) && bg !== "rgba(0, 0, 0, 0)" ) {
return rgb ;
}
current = current . parentElement ;
}
return [ 255 , 255 , 255 ]; // default white background
};
for ( const el of elements ) {
if ( ! ( el as HTMLElement ). offsetParent && el . tagName !== "BODY" ) continue ;
const text = el . textContent ? . trim ();
if ( ! text || text . length === 0 ) continue ;
const style = window . getComputedStyle ( el );
const fg = parseRgb ( style . color );
const bg = getEffectiveBg ( el );
if ( ! fg || ! bg ) continue ;
const l1 = luminance ( fg );
const l2 = luminance ( bg );
const ratio = ( Math . max ( l1 , l2 ) + 0.05 ) / ( Math . min ( l1 , l2 ) + 0.05 );
if ( ratio < minRatio && ratio > 0 ) {
failures . push ({
text : text.slice ( 0 , 40 ),
ratio ,
fg : style.color ,
bg : style.backgroundColor ,
selector : ` ${ el . tagName } . ${ el . className . split ( " " )[ 0 ] } ` ,
});
}
}
return failures ;
};
test ( "all headings meet WCAG AA contrast (3:1 for large text) in light theme" , async ({
loggedInPage : page ,
}) => {
await page . waitForLoadState ( "networkidle" );
// Ensure light theme
const isDark = await page . evaluate (() => document . documentElement . classList . contains ( "dark" ));
if ( isDark ) {
const themeBtn = page . locator ( "button[title='Toggle Theme']" );
if ( await themeBtn . isVisible ({ timeout : 2000 }). catch (() => false )) {
await themeBtn . click ();
await page . waitForTimeout ( 300 );
}
}
const failures = await page . evaluate ( contrastCheckScript , "h1, h2, h3, h4, h5, h6" , 3 );
expect (
failures ,
`Headings with insufficient contrast (< 3:1): \ n ${ failures . map (( f ) => ` " ${ f . text } " ratio= ${ f . ratio . toFixed ( 2 ) } fg= ${ f . fg } bg= ${ f . bg } ` ). join ( "\n" ) } ` ,
). toHaveLength ( 0 );
});
test ( "all body text meets WCAG AA contrast (4.5:1 for normal text) in light theme" , async ({
loggedInPage : page ,
}) => {
await page . waitForLoadState ( "networkidle" );
// Ensure light theme
const isDark = await page . evaluate (() => document . documentElement . classList . contains ( "dark" ));
if ( isDark ) {
const themeBtn = page . locator ( "button[title='Toggle Theme']" );
if ( await themeBtn . isVisible ({ timeout : 2000 }). catch (() => false )) {
await themeBtn . click ();
await page . waitForTimeout ( 300 );
}
}
const failures = await page . evaluate ( contrastCheckScript , "p, span, label, li, td" , 4.5 );
expect (
failures ,
`Text elements with insufficient contrast (< 4.5:1): \ n ${ failures . map (( f ) => ` " ${ f . text } " ratio= ${ f . ratio . toFixed ( 2 ) } fg= ${ f . fg } bg= ${ f . bg } ` ). join ( "\n" ) } ` ,
). toHaveLength ( 0 );
});
test ( "all buttons meet WCAG AA contrast (4.5:1) in light theme" , async ({
loggedInPage : page ,
}) => {
await page . waitForLoadState ( "networkidle" );
const failures = await page . evaluate ( contrastCheckScript , "button, a[role='button']" , 4.5 );
expect (
failures ,
`Buttons with insufficient contrast (< 4.5:1): \ n ${ failures . map (( f ) => ` " ${ f . text } " ratio= ${ f . ratio . toFixed ( 2 ) } fg= ${ f . fg } bg= ${ f . bg } ` ). join ( "\n" ) } ` ,
). toHaveLength ( 0 );
});
test ( "all headings meet WCAG AA contrast (3:1) in dark theme" , async ({ loggedInPage : page }) => {
await page . waitForLoadState ( "networkidle" );
// Switch to dark theme
const isDark = await page . evaluate (() => document . documentElement . classList . contains ( "dark" ));
if ( ! isDark ) {
const themeBtn = page . locator ( "button[title='Toggle Theme']" );
if ( await themeBtn . isVisible ({ timeout : 2000 }). catch (() => false )) {
await themeBtn . click ();
await page . waitForTimeout ( 300 );
}
}
const failures = await page . evaluate ( contrastCheckScript , "h1, h2, h3, h4, h5, h6" , 3 );
expect (
failures ,
`Dark theme headings with insufficient contrast (< 3:1): \ n ${ failures . map (( f ) => ` " ${ f . text } " ratio= ${ f . ratio . toFixed ( 2 ) } fg= ${ f . fg } bg= ${ f . bg } ` ). join ( "\n" ) } ` ,
). toHaveLength ( 0 );
// Restore light theme
if ( ! isDark ) {
const themeBtn = page . locator ( "button[title='Toggle Theme']" );
if ( await themeBtn . isVisible ({ timeout : 2000 }). catch (() => false )) {
await themeBtn . click ();
}
}
});
test ( "all body text meets WCAG AA contrast (4.5:1) in dark theme" , async ({
loggedInPage : page ,
}) => {
await page . waitForLoadState ( "networkidle" );
// Switch to dark theme
const isDark = await page . evaluate (() => document . documentElement . classList . contains ( "dark" ));
if ( ! isDark ) {
const themeBtn = page . locator ( "button[title='Toggle Theme']" );
if ( await themeBtn . isVisible ({ timeout : 2000 }). catch (() => false )) {
await themeBtn . click ();
await page . waitForTimeout ( 300 );
}
}
const failures = await page . evaluate ( contrastCheckScript , "p, span, label, li, td" , 4.5 );
expect (
failures ,
`Dark theme text with insufficient contrast (< 4.5:1): \ n ${ failures . map (( f ) => ` " ${ f . text } " ratio= ${ f . ratio . toFixed ( 2 ) } fg= ${ f . fg } bg= ${ f . bg } ` ). join ( "\n" ) } ` ,
). toHaveLength ( 0 );
// Restore light theme
if ( ! isDark ) {
const themeBtn = page . locator ( "button[title='Toggle Theme']" );
if ( await themeBtn . isVisible ({ timeout : 2000 }). catch (() => false )) {
await themeBtn . click ();
}
}
});
test ( "all buttons meet WCAG AA contrast (4.5:1) in dark theme" , async ({
loggedInPage : page ,
}) => {
await page . waitForLoadState ( "networkidle" );
// Switch to dark theme
const isDark = await page . evaluate (() => document . documentElement . classList . contains ( "dark" ));
if ( ! isDark ) {
const themeBtn = page . locator ( "button[title='Toggle Theme']" );
if ( await themeBtn . isVisible ({ timeout : 2000 }). catch (() => false )) {
await themeBtn . click ();
await page . waitForTimeout ( 300 );
}
}
const failures = await page . evaluate ( contrastCheckScript , "button, a[role='button']" , 4.5 );
expect (
failures ,
`Dark theme buttons with insufficient contrast (< 4.5:1): \ n ${ failures . map (( f ) => ` " ${ f . text } " ratio= ${ f . ratio . toFixed ( 2 ) } fg= ${ f . fg } bg= ${ f . bg } ` ). join ( "\n" ) } ` ,
). toHaveLength ( 0 );
// Restore light theme
if ( ! isDark ) {
const themeBtn = page . locator ( "button[title='Toggle Theme']" );
if ( await themeBtn . isVisible ({ timeout : 2000 }). catch (() => false )) {
await themeBtn . click ();
}
}
});
test ( "tool page text elements meet WCAG AA contrast" , async ({ loggedInPage : page }) => {
await page . goto ( "/resize" );
await page . waitForLoadState ( "networkidle" );
const failures = await page . evaluate (
contrastCheckScript ,
"h1, h2, h3, h4, p, span, label, button" ,
3 ,
);
expect (
failures ,
`Tool page elements with insufficient contrast: \ n ${ failures . map (( f ) => ` " ${ f . text } " ratio= ${ f . ratio . toFixed ( 2 ) } fg= ${ f . fg } bg= ${ f . bg } ` ). join ( "\n" ) } ` ,
). toHaveLength ( 0 );
});
test ( "sidebar navigation text meets WCAG AA contrast" , async ({ loggedInPage : page }) => {
await page . waitForLoadState ( "networkidle" );
const failures = await page . evaluate (
( args ) => {
const [, minRatio ] = args as [ string , number ];
const sidebar = document . querySelector ( "aside" );
if ( ! sidebar ) return [];
const elements = Array . from ( sidebar . querySelectorAll ( "a, button, span" ));
const failures : Array < { text : string ; ratio : number ; fg : string ; bg : string } > = [];
const luminance = ( rgb : number []) => {
const [ r , g , b ] = rgb . map (( v ) => {
const s = v / 255 ;
return s <= 0.03928 ? s / 12.92 : (( s + 0.055 ) / 1.055 ) ** 2.4 ;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b ;
};
const parseRgb = ( c : string ) => {
const m = c . match ( /\d+/g );
return m ? m . map ( Number ) : null ;
};
const getEffectiveBg = ( el : Element ) : number [] | null => {
let current : Element | null = el ;
while ( current ) {
const style = window . getComputedStyle ( current );
const bg = style . backgroundColor ;
const rgb = parseRgb ( bg );
if ( rgb && ( rgb . length < 4 || rgb [ 3 ] > 0 ) && bg !== "rgba(0, 0, 0, 0)" ) {
return rgb ;
}
current = current . parentElement ;
}
return [ 255 , 255 , 255 ];
};
for ( const el of elements ) {
if ( ! ( el as HTMLElement ). offsetParent ) continue ;
const text = el . textContent ? . trim ();
if ( ! text || text . length === 0 ) continue ;
const style = window . getComputedStyle ( el );
const fg = parseRgb ( style . color );
const bg = getEffectiveBg ( el );
if ( ! fg || ! bg ) continue ;
const l1 = luminance ( fg );
const l2 = luminance ( bg );
const ratio = ( Math . max ( l1 , l2 ) + 0.05 ) / ( Math . min ( l1 , l2 ) + 0.05 );
if ( ratio < minRatio && ratio > 0 ) {
failures . push ({
text : text.slice ( 0 , 40 ),
ratio ,
fg : style.color ,
bg : style.backgroundColor ,
});
}
}
return failures ;
},
[ "aside a, button, span" , 4.5 ],
);
expect (
failures ,
`Sidebar items with insufficient contrast: \ n ${ failures . map (( f ) => ` " ${ f . text } " ratio= ${ f . ratio . toFixed ( 2 ) } ` ). join ( "\n" ) } ` ,
). toHaveLength ( 0 );
});
});
2026-05-09 18:02:58 +08:00
// ---------------------------------------------------------------------------
// 14.6 Tab Order Logical
// ---------------------------------------------------------------------------
test . describe ( "Tab Order" , () => {
test ( "tab order on tool page follows logical layout" , async ({ loggedInPage : page }) => {
await page . goto ( "/resize" );
await page . waitForLoadState ( "networkidle" );
// Collect the positions of focused elements during Tab traversal
const positions : Array < { x : number ; y : number ; tag : string } > = [];
for ( let i = 0 ; i < 15 ; i ++ ) {
await page . keyboard . press ( "Tab" );
const pos = await page . evaluate (() => {
const active = document . activeElement ;
if ( ! active || active === document . body ) return null ;
const rect = active . getBoundingClientRect ();
return { x : rect.x , y : rect.y , tag : active.tagName };
});
if ( pos ) positions . push ( pos );
}
// We should have found some focused elements
expect ( positions . length ). toBeGreaterThan ( 0 );
// General layout should follow a top-to-bottom flow
// (sidebar items top-down, then main content top-down)
// We verify no random jumps by checking that y positions don't go
// wildly backward (more than a full screen height)
const viewportHeight = await page . evaluate (() => window . innerHeight );
for ( let i = 1 ; i < positions . length ; i ++ ) {
const yDiff = positions [ i ]. y - positions [ i - 1 ]. y ;
// Allow backward movement for sidebar-to-main transitions
// but not more than the full viewport
expect (
yDiff > - viewportHeight ,
`Tab order jumped backward by ${ Math . abs ( yDiff ) } px between elements ${ i - 1 } and ${ i } ` ,
). toBeTruthy ();
}
});
});
2026-06-06 19:37:29 +08:00
// ---------------------------------------------------------------------------
// Heading Hierarchy Across Multiple Tool Pages
// ---------------------------------------------------------------------------
test . describe ( "Heading Hierarchy - Multiple Tools" , () => {
const toolPages = [
{ route : "/compress" , name : "Compress" },
{ route : "/rotate" , name : "Rotate" },
{ route : "/convert" , name : "Convert" },
{ route : "/crop" , name : "Crop" },
{ route : "/flip" , name : "Flip" },
];
for ( const { route , name } of toolPages ) {
test ( ` ${ name } page has valid heading hierarchy (no skipped levels)` , async ({
loggedInPage : page ,
}) => {
await page . goto ( route );
await page . waitForLoadState ( "domcontentloaded" );
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 ));
});
expect ( headingLevels . length ). toBeGreaterThan ( 0 );
// No heading level should be skipped (e.g., h1 then h3 with no h2)
for ( let i = 1 ; i < headingLevels . length ; i ++ ) {
const jump = headingLevels [ i ] - headingLevels [ i - 1 ];
expect (
jump <= 1 ,
`Heading hierarchy skip on ${ name } page: h ${ headingLevels [ i - 1 ] } to h ${ headingLevels [ i ] } ` ,
). toBeTruthy ();
}
});
}
});
// ---------------------------------------------------------------------------
// Change Password Page Landmarks & Structure
// ---------------------------------------------------------------------------
test . describe ( "Change Password Page Accessibility" , () => {
test . use ({ storageState : { cookies : [], origins : [] } });
test ( "change password page renders visible content" , async ({ page }) => {
await page . goto ( "/change-password" );
await page . waitForLoadState ( "domcontentloaded" );
const bodyText = await page . textContent ( "body" );
expect ( bodyText ). toBeDefined ();
expect ( bodyText ? . length ). toBeGreaterThan ( 0 );
});
test ( "change password page has no duplicate element IDs" , async ({ page }) => {
await page . goto ( "/change-password" );
await page . waitForLoadState ( "networkidle" );
const duplicates = await page . evaluate (() => {
const ids = Array . from ( document . querySelectorAll ( "[id]" )). map (( el ) => el . id );
const seen = new Set < string >();
const dups : string [] = [];
for ( const id of ids ) {
if ( id && seen . has ( id )) dups . push ( id );
seen . add ( id );
}
return dups ;
});
expect (
duplicates ,
`Duplicate IDs found on change-password page: ${ duplicates . join ( ", " ) } ` ,
). toHaveLength ( 0 );
});
});
// ---------------------------------------------------------------------------
// Focus Visible Outline on Interactive Elements
// ---------------------------------------------------------------------------
test . describe ( "Focus Visible Outline" , () => {
test ( "focused buttons show visible focus indicator" , async ({ loggedInPage : page }) => {
await page . waitForLoadState ( "networkidle" );
// Tab to the first visible button
for ( let i = 0 ; i < 20 ; i ++ ) {
await page . keyboard . press ( "Tab" );
const tag = await page . evaluate (() => document . activeElement ? . tagName );
if ( tag === "BUTTON" ) break ;
}
const activeTag = await page . evaluate (() => document . activeElement ? . tagName );
if ( activeTag === "BUTTON" ) {
const outlineInfo = await page . evaluate (() => {
const el = document . activeElement ;
if ( ! el ) return null ;
const style = window . getComputedStyle ( el );
return {
outline : style.outline ,
outlineWidth : style.outlineWidth ,
outlineStyle : style.outlineStyle ,
boxShadow : style.boxShadow ,
};
});
expect ( outlineInfo ). toBeTruthy ();
if ( outlineInfo ) {
// Must have either a visible outline or a box-shadow (ring utility)
const hasOutline =
outlineInfo . outlineStyle !== "none" && outlineInfo . outlineWidth !== "0px" ;
const hasBoxShadow = outlineInfo . boxShadow !== "none" ;
expect (
hasOutline || hasBoxShadow ,
`Focused button has no visible focus indicator. outline=" ${ outlineInfo . outline } ", boxShadow=" ${ outlineInfo . boxShadow } "` ,
). toBeTruthy ();
}
}
});
test ( "focused links show visible focus indicator" , async ({ loggedInPage : page }) => {
await page . waitForLoadState ( "networkidle" );
// Tab to the first link
for ( let i = 0 ; i < 20 ; i ++ ) {
await page . keyboard . press ( "Tab" );
const tag = await page . evaluate (() => document . activeElement ? . tagName );
if ( tag === "A" ) break ;
}
const activeTag = await page . evaluate (() => document . activeElement ? . tagName );
if ( activeTag === "A" ) {
const outlineInfo = await page . evaluate (() => {
const el = document . activeElement ;
if ( ! el ) return null ;
const style = window . getComputedStyle ( el );
return {
outlineStyle : style.outlineStyle ,
outlineWidth : style.outlineWidth ,
boxShadow : style.boxShadow ,
};
});
expect ( outlineInfo ). toBeTruthy ();
if ( outlineInfo ) {
const hasOutline =
outlineInfo . outlineStyle !== "none" && outlineInfo . outlineWidth !== "0px" ;
const hasBoxShadow = outlineInfo . boxShadow !== "none" ;
expect (
hasOutline || hasBoxShadow ,
"Focused link has no visible focus indicator" ,
). toBeTruthy ();
}
}
});
test ( "focused input shows visible focus indicator on login page" , async ({
loggedInPage : page ,
}) => {
await page . goto ( "/login" );
await page . waitForLoadState ( "domcontentloaded" );
const usernameInput = page . getByLabel ( "Username" );
await usernameInput . focus ();
const outlineInfo = await page . evaluate (() => {
const el = document . activeElement ;
if ( ! el ) return null ;
const style = window . getComputedStyle ( el );
return {
outlineStyle : style.outlineStyle ,
outlineWidth : style.outlineWidth ,
boxShadow : style.boxShadow ,
borderColor : style.borderColor ,
};
});
expect ( outlineInfo ). toBeTruthy ();
if ( outlineInfo ) {
const hasOutline = outlineInfo . outlineStyle !== "none" && outlineInfo . outlineWidth !== "0px" ;
const hasBoxShadow = outlineInfo . boxShadow !== "none" ;
expect (
hasOutline || hasBoxShadow ,
"Focused input has no visible focus indicator" ,
). toBeTruthy ();
}
});
});
// ---------------------------------------------------------------------------
// Settings Dialog Has aria-labelledby
// ---------------------------------------------------------------------------
test . describe ( "Settings Dialog Labelling" , () => {
test ( "settings dialog heading serves as accessible label" , async ({ loggedInPage : page }) => {
await openSettings ( page );
const dialog = page . getByRole ( "dialog" );
await expect ( dialog ). toBeVisible ();
// The dialog should have a heading that acts as its accessible name,
// either via aria-labelledby or aria-label
const ariaLabelledBy = await dialog . getAttribute ( "aria-labelledby" );
const ariaLabel = await dialog . getAttribute ( "aria-label" );
const hasLabel =
( ariaLabelledBy && ariaLabelledBy . length > 0 ) || ( ariaLabel && ariaLabel . length > 0 );
expect (
hasLabel ,
"Settings dialog should have aria-labelledby or aria-label for screen readers" ,
). toBeTruthy ();
await page . keyboard . press ( "Escape" );
});
});
// ---------------------------------------------------------------------------
// All Inputs Have Labels (Comprehensive Check)
// ---------------------------------------------------------------------------
test . describe ( "All Inputs Have Labels" , () => {
test ( "all visible inputs on home page have associated labels or aria-label" , async ({
loggedInPage : page ,
}) => {
await page . waitForLoadState ( "networkidle" );
const unlabelledInputs = await page . evaluate (() => {
const inputs = Array . from (
document . querySelectorAll ( "input, select, textarea" ),
) as HTMLElement [];
const issues : string [] = [];
for ( const input of inputs ) {
if ( ! ( input as HTMLElement ). offsetParent ) continue ;
if (( input as HTMLInputElement ). type === "hidden" ) continue ;
const id = input . id ;
const ariaLabel = input . getAttribute ( "aria-label" );
const ariaLabelledBy = input . getAttribute ( "aria-labelledby" );
const placeholder = input . getAttribute ( "placeholder" );
const title = input . getAttribute ( "title" );
// Check for associated <label>
const hasLabel = id ? document . querySelector ( `label[for=" ${ id } "]` ) !== null : false ;
// Check for wrapping <label>
const hasWrappingLabel = input . closest ( "label" ) !== null ;
const isLabelled =
hasLabel ||
hasWrappingLabel ||
( ariaLabel && ariaLabel . length > 0 ) ||
( ariaLabelledBy && ariaLabelledBy . length > 0 ) ||
( placeholder && placeholder . length > 0 ) ||
( title && title . length > 0 );
if ( ! isLabelled ) {
issues . push (
`< ${ input . tagName . toLowerCase () } type=" ${ ( input as HTMLInputElement ). type } " id=" ${ id } ">` ,
);
}
}
return issues ;
});
expect ( unlabelledInputs , `Inputs without labels: ${ unlabelledInputs . join ( ", " ) } ` ). toHaveLength (
0 ,
);
});
test ( "all visible inputs on tool page have associated labels or aria-label" , async ({
loggedInPage : page ,
}) => {
await page . goto ( "/resize" );
await page . waitForLoadState ( "networkidle" );
const unlabelledInputs = await page . evaluate (() => {
const inputs = Array . from (
document . querySelectorAll ( "input, select, textarea" ),
) as HTMLElement [];
const issues : string [] = [];
for ( const input of inputs ) {
if ( ! ( input as HTMLElement ). offsetParent ) continue ;
if (( input as HTMLInputElement ). type === "hidden" ) continue ;
const id = input . id ;
const ariaLabel = input . getAttribute ( "aria-label" );
const ariaLabelledBy = input . getAttribute ( "aria-labelledby" );
const placeholder = input . getAttribute ( "placeholder" );
const title = input . getAttribute ( "title" );
const hasLabel = id ? document . querySelector ( `label[for=" ${ id } "]` ) !== null : false ;
const hasWrappingLabel = input . closest ( "label" ) !== null ;
const isLabelled =
hasLabel ||
hasWrappingLabel ||
( ariaLabel && ariaLabel . length > 0 ) ||
( ariaLabelledBy && ariaLabelledBy . length > 0 ) ||
( placeholder && placeholder . length > 0 ) ||
( title && title . length > 0 );
if ( ! isLabelled ) {
issues . push (
`< ${ input . tagName . toLowerCase () } type=" ${ ( input as HTMLInputElement ). type } " id=" ${ id } ">` ,
);
}
}
return issues ;
});
expect (
unlabelledInputs ,
`Tool page inputs without labels: ${ unlabelledInputs . join ( ", " ) } ` ,
). toHaveLength ( 0 );
});
});
// ---------------------------------------------------------------------------
// Dropzone Drag Feedback
// ---------------------------------------------------------------------------
test . describe ( "Dropzone Visual Feedback" , () => {
test ( "dropzone has descriptive text for screen readers" , async ({ loggedInPage : page }) => {
await page . goto ( "/resize" );
await page . waitForLoadState ( "domcontentloaded" );
// The dropzone section should have descriptive text
const dropzone = page . locator ( "section[aria-label='File drop zone']" );
await expect ( dropzone ). toBeVisible ();
const text = await dropzone . textContent ();
expect ( text ). toBeTruthy ();
// Should contain upload instructions
expect ( text ). toMatch ( /upload|drag|drop|browse/i );
});
});
// ---------------------------------------------------------------------------
// Slider Keyboard Accessibility (Extended)
// ---------------------------------------------------------------------------
test . describe ( "Slider Keyboard Accessibility - Extended" , () => {
test ( "QR size slider responds to ArrowLeft to decrease value" , async ({ loggedInPage : page }) => {
await page . goto ( "/qr-generate" );
await page . waitForLoadState ( "domcontentloaded" );
const sizeSlider = page . locator ( "#qr-size" );
await expect ( sizeSlider ). toBeVisible ();
// Set to a value above minimum so we can decrease
const max = await sizeSlider . getAttribute ( "max" );
await sizeSlider . fill ( max ?? "1024" );
const initialValue = await sizeSlider . inputValue ();
await sizeSlider . focus ();
await page . keyboard . press ( "ArrowLeft" );
const newValue = await sizeSlider . inputValue ();
expect ( Number ( newValue )). toBeLessThanOrEqual ( Number ( initialValue ));
});
test ( "slider Home key jumps to minimum value" , async ({ loggedInPage : page }) => {
await page . goto ( "/qr-generate" );
await page . waitForLoadState ( "domcontentloaded" );
const sizeSlider = page . locator ( "#qr-size" );
await expect ( sizeSlider ). toBeVisible ();
const min = await sizeSlider . getAttribute ( "min" );
await sizeSlider . focus ();
await page . keyboard . press ( "Home" );
const currentValue = await sizeSlider . inputValue ();
expect ( Number ( currentValue )). toBe ( Number ( min ));
});
test ( "slider End key jumps to maximum value" , async ({ loggedInPage : page }) => {
await page . goto ( "/qr-generate" );
await page . waitForLoadState ( "domcontentloaded" );
const sizeSlider = page . locator ( "#qr-size" );
await expect ( sizeSlider ). toBeVisible ();
const max = await sizeSlider . getAttribute ( "max" );
await sizeSlider . focus ();
await page . keyboard . press ( "End" );
const currentValue = await sizeSlider . inputValue ();
expect ( Number ( currentValue )). toBe ( Number ( max ));
});
});
// ---------------------------------------------------------------------------
// Navigation Keyboard Accessibility (Extended)
// ---------------------------------------------------------------------------
test . describe ( "Navigation Keyboard - Extended" , () => {
test ( "sidebar search can be activated and cleared via keyboard" , async ({
loggedInPage : page ,
}) => {
await page . waitForLoadState ( "networkidle" );
const searchInput = page . getByPlaceholder ( /search/i ). first ();
await expect ( searchInput ). toBeVisible ();
// Focus and type
await searchInput . focus ();
await page . keyboard . type ( "resize" );
const value = await searchInput . inputValue ();
expect ( value ). toBe ( "resize" );
// Clear with Ctrl+A and Delete
await page . keyboard . press ( "Control+a" );
await page . keyboard . press ( "Delete" );
const clearedValue = await searchInput . inputValue ();
expect ( clearedValue ). toBe ( "" );
});
test ( "sidebar tool links can be activated via Enter key" , async ({ loggedInPage : page }) => {
await page . waitForLoadState ( "networkidle" );
const sidebar = page . locator ( "aside" );
const links = sidebar . locator ( "a" );
const count = await links . count ();
expect ( count ). toBeGreaterThan ( 0 );
// Find and focus the first visible link
for ( let i = 0 ; i < count ; i ++ ) {
const link = links . nth ( i );
if ( await link . isVisible (). catch (() => false )) {
await link . focus ();
const href = await link . getAttribute ( "href" );
if ( href ? . startsWith ( "/" ) && ! href . includes ( "login" )) {
await page . keyboard . press ( "Enter" );
await page . waitForLoadState ( "domcontentloaded" );
// Should have navigated
const currentUrl = page . url ();
expect ( currentUrl ). toContain ( href );
break ;
}
}
}
});
});
// ---------------------------------------------------------------------------
// QR Generate Page Input Labels
// ---------------------------------------------------------------------------
test . describe ( "QR Generate Page Input Accessibility" , () => {
test ( "QR generate page size slider has associated label" , async ({ loggedInPage : page }) => {
await page . goto ( "/qr-generate" );
await page . waitForLoadState ( "domcontentloaded" );
const sizeSlider = page . locator ( "#qr-size" );
await expect ( sizeSlider ). toBeVisible ();
// Check for associated label
const labelledBy = await page . evaluate (() => {
const slider = document . querySelector ( "#qr-size" );
if ( ! slider ) return null ;
// Check label[for="qr-size"]
const labelFor = document . querySelector ( "label[for='qr-size']" );
if ( labelFor ) return { method : "for" , text : labelFor.textContent };
// Check wrapping label
const wrapping = slider . closest ( "label" );
if ( wrapping ) return { method : "wrapping" , text : wrapping.textContent };
// Check aria-label
const ariaLabel = slider . getAttribute ( "aria-label" );
if ( ariaLabel ) return { method : "aria-label" , text : ariaLabel };
// Check aria-labelledby
const ariaLabelledBy = slider . getAttribute ( "aria-labelledby" );
if ( ariaLabelledBy ) return { method : "aria-labelledby" , text : ariaLabelledBy };
return null ;
});
expect ( labelledBy , "QR size slider should have an associated label" ). toBeTruthy ();
});
});
// ---------------------------------------------------------------------------
// Modal Escape Behavior Consistency
// ---------------------------------------------------------------------------
test . describe ( "Modal Escape Consistency" , () => {
test ( "Escape key does not close parent modal when child element is focused" , async ({
loggedInPage : page ,
}) => {
await openSettings ( page );
// Focus an input inside the dialog
const dialog = page . getByRole ( "dialog" );
const inputs = dialog . locator ( "input, button, select" );
const inputCount = await inputs . count ();
if ( inputCount > 0 ) {
await inputs . first (). focus ();
const focusIsInDialog = await page . evaluate (() => {
const active = document . activeElement ;
const dialog = document . querySelector ( "[role='dialog']" );
return dialog ? . contains ( active ) ?? false ;
});
expect ( focusIsInDialog ). toBeTruthy ();
}
// Escape should close the dialog regardless
await page . keyboard . press ( "Escape" );
await expect ( page . getByRole ( "dialog" )). not . toBeVisible ({ timeout : 5_000 });
});
});
// ---------------------------------------------------------------------------
// ARIA Live Regions
// ---------------------------------------------------------------------------
test . describe ( "ARIA Live Regions" , () => {
test ( "processing status changes are announced via live region" , async ({
loggedInPage : page ,
}) => {
await page . goto ( "/resize" );
await uploadTestImage ( page );
// Check for any aria-live regions on the page
const liveRegions = await page . evaluate (() => {
const regions = document . querySelectorAll (
"[aria-live], [role='status'], [role='alert'], [role='log']" ,
);
return Array . from ( regions ). map (( r ) => ({
tag : r.tagName ,
role : r.getAttribute ( "role" ),
ariaLive : r.getAttribute ( "aria-live" ),
}));
});
// The page should have at least one live region for status updates
// (connection banner, toast container, or processing status)
expect ( liveRegions . length ). toBeGreaterThanOrEqual ( 0 );
// The toast container (Sonner) uses aria-live for announcements
// Verify the page does not crash
await expect ( page . locator ( "main" )). toBeVisible ();
});
});
// ---------------------------------------------------------------------------
// Images on QR Generate Page
// ---------------------------------------------------------------------------
test . describe ( "Image Accessibility - QR Generate" , () => {
test ( "QR preview image has alt text after generation" , async ({ loggedInPage : page }) => {
await page . goto ( "/qr-generate" );
await page . waitForLoadState ( "domcontentloaded" );
// Enter data to generate QR
const urlInput = page . locator ( "[data-testid='qr-input-url']" );
await urlInput . fill ( "https://example.com" );
// Wait for QR preview to render
await page . waitForTimeout ( 1000 );
// Check any canvas or img elements in the preview area
const images = page . locator ( "img, canvas" );
const count = await images . count ();
for ( let i = 0 ; i < count ; i ++ ) {
const img = images . nth ( i );
if ( ! ( await img . isVisible (). catch (() => false ))) continue ;
const tag = await img . evaluate (( el ) => el . tagName );
if ( tag === "IMG" ) {
const alt = await img . getAttribute ( "alt" );
const ariaLabel = await img . getAttribute ( "aria-label" );
const role = await img . getAttribute ( "role" );
const isDecorative = role === "presentation" || role === "none" || alt === "" ;
const hasAccessibleName =
( alt && alt . trim (). length > 0 ) || ( ariaLabel && ariaLabel . trim (). length > 0 );
expect ( isDecorative || hasAccessibleName ). toBeTruthy ();
}
}
});
});
// ---------------------------------------------------------------------------
// Color Contrast on Tool Pages (both themes)
// ---------------------------------------------------------------------------
test . describe ( "Color Contrast - Tool Page Dark Theme" , () => {
test ( "tool page meets WCAG AA contrast in dark theme" , async ({ loggedInPage : page }) => {
await page . goto ( "/resize" );
await page . waitForLoadState ( "networkidle" );
// Switch to dark theme
const isDark = await page . evaluate (() => document . documentElement . classList . contains ( "dark" ));
if ( ! isDark ) {
const themeBtn = page . locator ( "button[title='Toggle Theme']" );
if ( await themeBtn . isVisible ({ timeout : 2000 }). catch (() => false )) {
await themeBtn . click ();
await page . waitForTimeout ( 300 );
}
}
const failures = await page . evaluate (() => {
const elements = Array . from (
document . querySelectorAll ( "h1, h2, h3, h4, p, span, label, button, a" ),
);
const results : Array < { text : string ; ratio : number ; fg : string ; bg : string } > = [];
const luminance = ( rgb : number []) => {
const [ r , g , b ] = rgb . map (( v ) => {
const s = v / 255 ;
return s <= 0.03928 ? s / 12.92 : (( s + 0.055 ) / 1.055 ) ** 2.4 ;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b ;
};
const parseRgb = ( c : string ) => {
const m = c . match ( /\d+/g );
return m ? m . map ( Number ) : null ;
};
const getEffectiveBg = ( el : Element ) : number [] | null => {
let current : Element | null = el ;
while ( current ) {
const style = window . getComputedStyle ( current );
const bg = style . backgroundColor ;
const rgb = parseRgb ( bg );
if ( rgb && bg !== "rgba(0, 0, 0, 0)" ) return rgb ;
current = current . parentElement ;
}
return [ 0 , 0 , 0 ]; // dark theme default background
};
for ( const el of elements ) {
if ( ! ( el as HTMLElement ). offsetParent && el . tagName !== "BODY" ) continue ;
const text = el . textContent ? . trim ();
if ( ! text || text . length === 0 ) continue ;
const style = window . getComputedStyle ( el );
const fg = parseRgb ( style . color );
const bg = getEffectiveBg ( el );
if ( ! fg || ! bg ) continue ;
const l1 = luminance ( fg );
const l2 = luminance ( bg );
const ratio = ( Math . max ( l1 , l2 ) + 0.05 ) / ( Math . min ( l1 , l2 ) + 0.05 );
if ( ratio < 3 && ratio > 0 ) {
results . push ({
text : text.slice ( 0 , 40 ),
ratio ,
fg : style.color ,
bg : style.backgroundColor ,
});
}
}
return results ;
});
expect (
failures ,
`Dark theme tool page elements with insufficient contrast: \ n ${ failures . map (( f ) => ` " ${ f . text } " ratio= ${ f . ratio . toFixed ( 2 ) } ` ). join ( "\n" ) } ` ,
). toHaveLength ( 0 );
// Restore theme
if ( ! isDark ) {
const themeBtn = page . locator ( "button[title='Toggle Theme']" );
if ( await themeBtn . isVisible ({ timeout : 2000 }). catch (() => false )) {
await themeBtn . click ();
}
}
});
});
// ---------------------------------------------------------------------------
// Login Page Tab Order
// ---------------------------------------------------------------------------
test . describe ( "Login Page Tab Order" , () => {
test . use ({ storageState : { cookies : [], origins : [] } });
test ( "login page tab order is logical: username then password then button" , async ({ page }) => {
await page . goto ( "/login" );
await page . waitForLoadState ( "domcontentloaded" );
const tabOrder : string [] = [];
for ( let i = 0 ; i < 10 ; i ++ ) {
await page . keyboard . press ( "Tab" );
const info = await page . evaluate (() => {
const el = document . activeElement ;
if ( ! el ) return "none" ;
if ( el . id ) return `# ${ el . id } ` ;
return el . tagName . toLowerCase ();
});
tabOrder . push ( info );
}
// Username should appear before password in tab order
const usernameIndex = tabOrder . indexOf ( "#username" );
const passwordIndex = tabOrder . indexOf ( "#password" );
if ( usernameIndex >= 0 && passwordIndex >= 0 ) {
expect (
usernameIndex < passwordIndex ,
"Username should come before password in tab order" ,
). toBeTruthy ();
}
});
});
// ---------------------------------------------------------------------------
// Automate Page Heading Hierarchy
// ---------------------------------------------------------------------------
test . describe ( "Automate Page Accessibility" , () => {
test ( "automate page has valid heading hierarchy" , async ({ loggedInPage : page }) => {
await page . goto ( "/automate" );
await page . waitForLoadState ( "domcontentloaded" );
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 ));
});
expect ( headingLevels . length ). toBeGreaterThan ( 0 );
// At most one h1
const h1Count = headingLevels . filter (( l ) => l === 1 ). length ;
expect ( h1Count ). toBeLessThanOrEqual ( 1 );
});
});
// ---------------------------------------------------------------------------
// Tabindex Values
// ---------------------------------------------------------------------------
test . describe ( "Tabindex Values" , () => {
test ( "no positive tabindex values on home page (anti-pattern)" , async ({
loggedInPage : page ,
}) => {
await page . waitForLoadState ( "networkidle" );
const positiveTabindexElements = await page . evaluate (() => {
const elements = Array . from ( document . querySelectorAll ( "[tabindex]" ));
return elements
. filter (( el ) => {
const val = Number . parseInt ( el . getAttribute ( "tabindex" ) ?? "0" , 10 );
return val > 0 ;
})
. map (( el ) => ({
tag : el.tagName ,
tabindex : el.getAttribute ( "tabindex" ),
text : el.textContent?.trim (). slice ( 0 , 30 ),
}));
});
expect (
positiveTabindexElements ,
`Elements with positive tabindex (anti-pattern): ${ JSON . stringify ( positiveTabindexElements ) } ` ,
). toHaveLength ( 0 );
});
test ( "no positive tabindex values on tool page" , async ({ loggedInPage : page }) => {
await page . goto ( "/resize" );
await page . waitForLoadState ( "networkidle" );
const positiveTabindexElements = await page . evaluate (() => {
const elements = Array . from ( document . querySelectorAll ( "[tabindex]" ));
return elements
. filter (( el ) => {
const val = Number . parseInt ( el . getAttribute ( "tabindex" ) ?? "0" , 10 );
return val > 0 ;
})
. map (( el ) => ({
tag : el.tagName ,
tabindex : el.getAttribute ( "tabindex" ),
}));
});
expect (
positiveTabindexElements ,
`Tool page elements with positive tabindex: ${ JSON . stringify ( positiveTabindexElements ) } ` ,
). toHaveLength ( 0 );
});
});