Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

794 lines
34 KiB
JavaScript

#!/usr/bin/env node
/**
* Keyboard & Accessibility UI Tests
*
* Tests for keyboard navigation, shortcuts, ARIA labels,
* and accessibility features.
*
* Run: node test_keyboard_accessibility_ci.js
*/
const { setupTest, teardownTest, TestResults, log, delay, navigateTo, withTimeout } = require('./test_lib');
// ============================================================================
// Keyboard Navigation Tests
// ============================================================================
const KeyboardNavigationTests = {
async tabNavigationWorks(page, baseUrl) {
// Use settings page — it has many tabbable elements (inputs, toggles, dropdowns)
// The research page only has a textarea which traps tab focus
await navigateTo(page, `${baseUrl}/settings`);
// Wait for page to be fully loaded (all scripts executed) and interactive
await page.waitForFunction(() => document.readyState === 'complete', { timeout: 10000 }).catch(() => {});
await page.waitForSelector('input, select, button', { timeout: 5000 }).catch(() => {});
// Click on the page first to establish keyboard focus in headless Chrome
await page.click('body').catch(() => {});
await delay(500);
// Press Tab multiple times and check focus moves
const focusedElements = [];
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Tab');
await delay(200);
const focused = await page.evaluate(() => {
const el = document.activeElement;
return {
tag: el?.tagName?.toLowerCase(),
type: el?.type,
id: el?.id,
className: el?.className?.substring(0, 30)
};
});
focusedElements.push(focused);
}
const uniqueFocused = new Set(focusedElements.map(e => `${e.tag}-${e.id || e.className}`));
// In headless Chrome, Tab focus can be unreliable — skip instead of fail
if (uniqueFocused.size <= 1 && process.env.CI) {
return {
passed: null,
skipped: true,
message: `Tab navigation unreliable in headless Chrome: ${focusedElements.map(e => e.tag).join(' -> ')}`
};
}
return {
passed: uniqueFocused.size > 1,
message: `Tab navigation: ${uniqueFocused.size} unique elements focused (${focusedElements.map(e => e.tag).join(' -> ')})`
};
},
async escapeKeyFunction(page, baseUrl) {
// Assert Escape closes the open custom dropdown — the dropdown's input
// keydown handler hides the list on Escape. The old test used Bootstrap
// selectors (.dropdown-toggle / [data-toggle]) that don't exist on / in this
// app, so it never opened anything; depending on timing it either skipped or
// crashed with a navigation "Execution context was destroyed". We open the
// real #search_engine dropdown instead.
//
// research.js re-runs the dropdown setup after the search-engine fetch and
// each setup ends by hiding the dropdown, so wait for the network to settle
// before opening (same race guard as the mobile dropdown test). The opened
// list is reparented to <body>, so read it by id (#search-engine-dropdown-list).
await navigateTo(page, `${baseUrl}/`);
await page.waitForSelector('#search_engine', { timeout: 15000 });
await page.waitForNetworkIdle({ idleTime: 500, timeout: 15000 }).catch(() => {});
const listOpen = () => {
const list = document.querySelector('#search-engine-dropdown-list');
return !!list && window.getComputedStyle(list).display === 'block'
&& list.classList.contains('ldr-dropdown-active');
};
// Open the dropdown. #search_engine is the search-engine selector's <input>
// (a custom dropdown); its options load without an LLM, so it is CI-safe.
// Clicking the input both focuses it and opens the list.
await page.click('#search_engine');
await page.waitForFunction(listOpen, { timeout: 5000 }).catch(() => {});
const opened = await page.evaluate(listOpen);
if (!opened) {
return { passed: false, message: 'Could not open #search_engine dropdown to test Escape' };
}
// Press Escape and assert it closes. The click above focused the #search_engine
// <input>, and the Escape->hide handler is attached to that input's keydown
// listener, so the keypress reaches it (verified: this assertion passes locally).
await page.keyboard.press('Escape');
await page.waitForFunction(() => {
const list = document.querySelector('#search-engine-dropdown-list');
return !list || window.getComputedStyle(list).display === 'none'
|| !list.classList.contains('ldr-dropdown-active');
}, { timeout: 5000 }).catch(() => {});
const stillOpen = await page.evaluate(listOpen);
return {
passed: !stillOpen,
message: !stillOpen
? 'Escape closes the open custom dropdown'
: 'Escape did not close the open dropdown'
};
},
async enterKeyOnButtons(page, baseUrl) {
await navigateTo(page, `${baseUrl}/`);
// Tab to first button
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Tab');
const isButton = await page.evaluate(() => {
return document.activeElement?.tagName?.toLowerCase() === 'button' ||
document.activeElement?.tagName?.toLowerCase() === 'a';
});
if (isButton) break;
}
const focusedElement = await page.evaluate(() => ({
tag: document.activeElement?.tagName?.toLowerCase(),
text: document.activeElement?.textContent?.trim()?.substring(0, 30)
}));
if (focusedElement.tag !== 'button' && focusedElement.tag !== 'a') {
return { passed: null, skipped: true, message: 'Could not focus on a button to test Enter key' };
}
return {
passed: true,
message: `Button "${focusedElement.text}" is focusable and can be activated with Enter`
};
},
async arrowKeysInDropdowns(page, baseUrl) {
// Native <select> elements don't consistently respond to arrow keys
// in headless Chrome — this is a known limitation, not an app bug
if (process.env.CI) {
return { passed: null, skipped: true, message: 'Native select arrow keys unreliable in headless Chrome' };
}
await navigateTo(page, `${baseUrl}/settings`);
// Find and focus a select element
const selectInfo = await page.evaluate(() => {
const select = document.querySelector('select');
if (select) {
select.focus();
return {
exists: true,
optionsCount: select.options.length,
selectedIndex: select.selectedIndex
};
}
return { exists: false };
});
if (!selectInfo.exists) {
return { passed: null, skipped: true, message: 'No select element to test arrow keys' };
}
if (selectInfo.optionsCount <= 1) {
return { passed: null, skipped: true, message: 'Select has only one option' };
}
const beforeIndex = selectInfo.selectedIndex;
// If at last item, use ArrowUp; otherwise use ArrowDown
const useArrowUp = beforeIndex >= selectInfo.optionsCount - 1;
const keyToPress = useArrowUp ? 'ArrowUp' : 'ArrowDown';
await page.keyboard.press(keyToPress);
await delay(100);
const afterIndex = await page.evaluate(() => {
return document.activeElement?.selectedIndex;
});
return {
passed: beforeIndex !== afterIndex,
message: `Arrow keys in dropdown: index ${beforeIndex} -> ${afterIndex} (${keyToPress})`
};
}
};
// ============================================================================
// Keyboard Shortcuts Tests
// ============================================================================
const KeyboardShortcutsTests = {
async shortcutsDocumented(page, baseUrl) {
await navigateTo(page, `${baseUrl}/`);
const result = await page.evaluate(() => {
const pageText = document.body.textContent.toLowerCase();
return {
hasCtrl: pageText.includes('ctrl+') || pageText.includes('ctrl +'),
hasAlt: pageText.includes('alt+') || pageText.includes('alt +'),
hasShift: pageText.includes('shift+') || pageText.includes('shift +'),
hasEsc: pageText.includes('esc') || pageText.includes('escape'),
hasEnter: pageText.includes('enter'),
hasShortcutHelp: document.querySelector('.shortcuts, .keyboard-shortcuts, [class*="shortcut"]') !== null
};
});
const hasDocumentation = result.hasCtrl || result.hasAlt || result.hasShift ||
result.hasEsc || result.hasEnter || result.hasShortcutHelp;
if (!hasDocumentation) {
return { passed: null, skipped: true, message: 'No keyboard shortcuts documentation found' };
}
return {
passed: true,
message: `Shortcuts documented: Ctrl=${result.hasCtrl}, Alt=${result.hasAlt}, Esc=${result.hasEsc}, Enter=${result.hasEnter}`
};
},
async ctrlEnterSubmit(page, baseUrl) {
// The Ctrl/Cmd+Enter "submit from anywhere" shortcut is a document-level
// keydown handler (research.js ~687-705) that calls handleResearchSubmit,
// which POSTs to /api/start_research (research modes) or
// /api/chat/sessions (chat mode). We assert the shortcut fires that POST —
// NOT the navigation, which would need an LLM.
//
// Isolation, layered (each guards a confirmed failure mode):
// 1. waitForNetworkIdle after navigateTo: research.js's async init
// (loadSettings) writes '' to #model_hidden once the settings fetch
// settles (llm.model is present-but-empty). Seeding before that lands
// gets clobbered, so the client-side model guard (#5048) returns early
// with no POST. Waiting for idle makes our seed the last write — the
// same guard the sibling escapeKeyFunction test uses.
// 2. setRequestInterception + page.on('request') respond 400 JSON to the
// submit POST so it never reaches the server. Without this the dummy
// model also defeats the server's truthiness-only check, returning
// {status:'success'} and redirecting to /progress/{id} AFTER this test
// returns — destroying the next sibling test's page context. The 400
// instead drives handleResearchSubmit into its self-cleaning error
// branch (removes the overlay, re-enables the button). waitForRequest
// still fires: Puppeteer emits the request before respond() answers it.
// 3. Idempotent cleanup in finally: the page is shared with later tests
// (navigateTo short-circuits on path-equality), so interception is
// disabled and any residual overlay/alert/button state cleared.
//
// We BLUR the textarea before pressing Ctrl+Enter: its keydown handler also
// submits on PLAIN Enter, so pressing Ctrl+Enter there would pass even if the
// Ctrl-specific document handler regressed. From <body>, only the
// document-level handler can fire.
await navigateTo(page, `${baseUrl}/`);
await page.waitForNetworkIdle({ idleTime: 500, timeout: 15000 }).catch(() => {});
const textarea = await page.$('textarea[name="query"], textarea#query');
if (!textarea) {
return { passed: false, message: 'No query textarea found on /' };
}
await textarea.type('Test query for keyboard submission');
// Repo convention for request interception (see test_research_api.js):
// setRequestInterception + page.on('request'). /api/start_research and
// /api/chat/sessions are only POSTed by the submit handler, so this can't
// disturb page init; every other request passes through unchanged.
const intercept = (request) => {
if (request.method() === 'POST'
&& (request.url().includes('/api/start_research')
|| request.url().includes('/api/chat/sessions'))) {
request.respond({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ status: 'error', message: 'Intercepted by UI test' })
});
} else {
request.continue();
}
};
try {
// Satisfy the client-side model-required guard (#5048); read back to
// confirm. If the custom dropdown never created #model_hidden, skip
// rather than misreport a Ctrl+Enter regression.
const modelSeeded = await page.evaluate(() => {
const m = document.querySelector('#model_hidden');
if (!m) return false;
m.value = 'test-model';
return m.value.trim() === 'test-model';
});
if (!modelSeeded) {
return { passed: null, skipped: true, message: '#model_hidden not present; cannot exercise submit path' };
}
// Move focus off the textarea so plain Enter can't submit — isolates Ctrl+Enter.
await page.evaluate(() => document.activeElement?.blur());
// Enable interception only around the keypress window.
await page.setRequestInterception(true);
page.on('request', intercept);
// A submit POST goes to /api/start_research (research mode) or
// /api/chat/sessions (chat mode); either proves the shortcut submitted.
const submitRequest = page.waitForRequest(
(req) => req.method() === 'POST'
&& (req.url().includes('/api/start_research') || req.url().includes('/api/chat/sessions')),
{ timeout: 8000 }
).catch(() => null);
// Modifier up is in a nested finally so a mid-press failure can't leave
// Control held for sibling tests (this page is reused).
try {
await page.keyboard.down('Control');
await page.keyboard.press('Enter');
} finally {
await page.keyboard.up('Control');
}
const req = await submitRequest;
const passed = !!req;
// Let the client's own error-branch (driven by our canned 400) remove
// the overlay + re-enable the button before we return, so the next test
// doesn't inherit them. The intercept makes the response immediate.
await page.waitForFunction(
() => !document.querySelector('.ldr-loading-overlay'),
{ timeout: 5000 }
).catch(() => {});
return {
passed,
message: passed
? `Ctrl+Enter (from body) submits the form (POST ${new URL(req.url()).pathname})`
: 'Ctrl+Enter did not trigger a submit request'
};
} finally {
// Disable interception first (so later requests need no explicit
// continue()), then drop the listener. The page is shared, so this must
// not leak. DOM cleanup is idempotent in case the client branch stalled.
await page.setRequestInterception(false).catch(() => {});
page.off('request', intercept);
await page.evaluate(() => {
document.querySelectorAll('.ldr-loading-overlay').forEach((el) => el.remove());
const btn = document.getElementById('start-research-btn');
if (btn) btn.disabled = false;
const alert = document.getElementById('research-alert');
if (alert) { alert.innerHTML = ''; alert.style.display = 'none'; }
}).catch(() => {});
}
}
};
// ============================================================================
// Focus Management Tests
// ============================================================================
const FocusManagementTests = {
async focusIndicatorVisible(page, baseUrl) {
await navigateTo(page, `${baseUrl}/`);
// Tab to an element
await page.keyboard.press('Tab');
await delay(100);
const result = await page.evaluate(() => {
const focused = document.activeElement;
if (!focused || focused === document.body) return { hasFocusedElement: false };
const style = window.getComputedStyle(focused);
// Use the DISCRETE sub-properties. The old check compared style.outline
// (the shorthand, e.g. "rgb(...) none 1px") to 'none' — never literally
// equal once a colour is set — so it false-passed whenever outline-width
// was non-zero even with outline-style: none (an invisible outline).
const hasOutline = style.outlineStyle !== 'none' && parseFloat(style.outlineWidth) > 0;
const hasBoxShadow = !!style.boxShadow && style.boxShadow !== 'none';
const hasBorderChange = focused.classList.contains('focus') || focused.classList.contains('focused');
// The app draws keyboard focus rings via :focus-visible (it suppresses the
// mouse-focus outline) — a standard a11y pattern — so detect that directly.
let focusVisible = false;
try { focusVisible = focused.matches(':focus-visible'); } catch { /* unsupported engine */ }
return {
hasFocusedElement: true,
tag: focused.tagName.toLowerCase(),
hasOutline,
hasBoxShadow,
hasBorderChange,
focusVisible,
outline: `${style.outlineStyle} ${style.outlineWidth}`
};
});
if (!result.hasFocusedElement) {
return { passed: null, skipped: true, message: 'No element received focus' };
}
// A concrete drawn indicator (real outline / box-shadow / focus class) is a
// trustworthy signal anywhere. The :focus-visible STATE alone means the app
// *should* draw a ring, but the styling + :focus-visible engagement is
// unreliable under headless/automation (the same reason the Tab/arrow/native
// checks in this file skip in CI).
const hasConcreteIndicator = result.hasOutline || result.hasBoxShadow || result.hasBorderChange;
const detail = `<${result.tag}>: outline=${result.hasOutline}, boxShadow=${result.hasBoxShadow}, focus-visible=${result.focusVisible}, outline="${result.outline}"`;
if (hasConcreteIndicator) {
return { passed: true, message: `Focus indicator present ${detail}` };
}
// No drawn ring observed. In CI this is the automation limitation, not a proven
// regression (the app's :focus-visible rings are real but not reliably observable
// here) — skip rather than fail. Locally, accept the :focus-visible state as the
// dev signal.
if (process.env.CI) {
return { passed: null, skipped: true, message: `No concrete focus indicator observable under automation ${detail}` };
}
return {
passed: result.focusVisible,
message: `Focus indicator ${result.focusVisible ? 'via :focus-visible state' : 'NOT found'} ${detail}`
};
},
async focusTrapInModals(page, baseUrl) {
await navigateTo(page, `${baseUrl}/`);
// Try to open a modal
const opened = await page.evaluate(() => {
const modalTrigger = document.querySelector('[data-toggle="modal"], [data-bs-toggle="modal"], .open-modal');
if (modalTrigger) {
modalTrigger.click();
return true;
}
return false;
});
if (!opened) {
return { passed: null, skipped: true, message: 'No modal to test focus trap' };
}
await delay(500);
// Tab multiple times and check if focus stays in modal
const focusedElements = [];
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Tab');
await delay(50);
const inModal = await page.evaluate(() => {
const focused = document.activeElement;
const modal = document.querySelector('.modal.show, .modal[style*="display: block"], [role="dialog"]');
return modal?.contains(focused) ?? false;
});
focusedElements.push(inModal);
}
const allInModal = focusedElements.every(x => x);
return {
passed: allInModal,
message: allInModal
? 'Focus trap works in modal'
: `Focus escaped modal (${focusedElements.filter(x => x).length}/10 in modal)`
};
},
async skipToContentLink(page, baseUrl) {
await navigateTo(page, `${baseUrl}/`);
const result = await page.evaluate(() => {
const skipLink = document.querySelector(
'a[href="#main"], ' +
'a[href="#content"], ' +
'.skip-link, ' +
'.skip-to-content, ' +
'a.visually-hidden, ' +
'a.sr-only'
);
return {
hasSkipLink: !!skipLink,
href: skipLink?.href,
text: skipLink?.textContent?.trim()
};
});
if (!result.hasSkipLink) {
return { passed: null, skipped: true, message: 'No skip to content link found' };
}
return {
passed: true,
message: `Skip link: "${result.text}" -> ${result.href}`
};
}
};
// ============================================================================
// ARIA Labels Tests
// ============================================================================
const AriaLabelsTests = {
async buttonsHaveLabels(page, baseUrl) {
await navigateTo(page, `${baseUrl}/`);
const result = await page.evaluate(() => {
const buttons = document.querySelectorAll('button, [role="button"]');
let labeled = 0;
let unlabeled = 0;
const unlabeledButtons = [];
buttons.forEach(btn => {
const hasText = btn.textContent?.trim().length > 0;
const hasAriaLabel = btn.hasAttribute('aria-label');
const hasAriaLabelledBy = btn.hasAttribute('aria-labelledby');
const hasTitle = btn.hasAttribute('title');
if (hasText || hasAriaLabel || hasAriaLabelledBy || hasTitle) {
labeled++;
} else {
unlabeled++;
unlabeledButtons.push(btn.className.substring(0, 30));
}
});
return {
total: buttons.length,
labeled,
unlabeled,
unlabeledExamples: unlabeledButtons.slice(0, 3)
};
});
const percentLabeled = result.total > 0 ? Math.round((result.labeled / result.total) * 100) : 100;
return {
passed: percentLabeled >= 80,
message: `Buttons labeled: ${result.labeled}/${result.total} (${percentLabeled}%), unlabeled: ${result.unlabeledExamples.join(', ')}`
};
},
async imagesHaveAltText(page, baseUrl) {
await navigateTo(page, `${baseUrl}/`);
const result = await page.evaluate(() => {
const images = document.querySelectorAll('img');
let withAlt = 0;
let withoutAlt = 0;
const missingAlt = [];
images.forEach(img => {
if (img.hasAttribute('alt')) {
withAlt++;
} else {
withoutAlt++;
missingAlt.push(img.src?.split('/').pop()?.substring(0, 20));
}
});
return {
total: images.length,
withAlt,
withoutAlt,
missingExamples: missingAlt.slice(0, 3)
};
});
if (result.total === 0) {
return { passed: null, skipped: true, message: 'No images found on page' };
}
const percentWithAlt = Math.round((result.withAlt / result.total) * 100);
return {
passed: percentWithAlt >= 80,
message: `Images with alt: ${result.withAlt}/${result.total} (${percentWithAlt}%), missing: ${result.missingExamples.join(', ')}`
};
},
async formInputsHaveLabels(page, baseUrl) {
await navigateTo(page, `${baseUrl}/`);
const result = await page.evaluate(() => {
const inputs = document.querySelectorAll('input:not([type="hidden"]):not([type="submit"]), textarea, select');
let labeled = 0;
let unlabeled = 0;
const unlabeledInputs = [];
inputs.forEach(input => {
const id = input.id;
const hasLabelFor = id && document.querySelector(`label[for="${id}"]`);
const hasAriaLabel = input.hasAttribute('aria-label');
const hasAriaLabelledBy = input.hasAttribute('aria-labelledby');
const hasPlaceholder = input.hasAttribute('placeholder');
const wrappedInLabel = input.closest('label');
if (hasLabelFor || hasAriaLabel || hasAriaLabelledBy || wrappedInLabel) {
labeled++;
} else {
unlabeled++;
unlabeledInputs.push(input.name || input.id || input.type);
}
});
return {
total: inputs.length,
labeled,
unlabeled,
unlabeledExamples: unlabeledInputs.slice(0, 3)
};
});
if (result.total === 0) {
return { passed: null, skipped: true, message: 'No form inputs found' };
}
const percentLabeled = Math.round((result.labeled / result.total) * 100);
return {
passed: percentLabeled >= 70,
message: `Inputs labeled: ${result.labeled}/${result.total} (${percentLabeled}%), unlabeled: ${result.unlabeledExamples.join(', ')}`
};
},
async ariaRolesPresent(page, baseUrl) {
await navigateTo(page, `${baseUrl}/`);
const result = await page.evaluate(() => {
const roles = {
main: document.querySelectorAll('[role="main"], main').length,
navigation: document.querySelectorAll('[role="navigation"], nav').length,
button: document.querySelectorAll('[role="button"]').length,
dialog: document.querySelectorAll('[role="dialog"]').length,
alert: document.querySelectorAll('[role="alert"]').length,
tab: document.querySelectorAll('[role="tab"]').length,
tabpanel: document.querySelectorAll('[role="tabpanel"]').length
};
return roles;
});
const totalRoles = Object.values(result).reduce((a, b) => a + b, 0);
const rolesList = Object.entries(result).filter(([_k, v]) => v > 0).map(([k, v]) => `${k}:${v}`);
return {
passed: totalRoles > 0,
message: `ARIA roles: ${rolesList.join(', ') || 'none found'}`
};
}
};
// ============================================================================
// Color and Contrast Tests
// ============================================================================
const ColorContrastTests = {
async semanticColors(page, baseUrl) {
// Semantic states (error / success / warning) must be visually distinguished
// by colour. The old test queried .error/.success/.warning elements then
// returned passed:true unconditionally — it never compared anything. Render an
// alert of each type via the app's own alert function (window.showSafeAlert ->
// #research-alert) and assert all three are PAIRWISE DISTINCT in text /
// background / border colour. Deterministic and LLM-independent.
await navigateTo(page, `${baseUrl}/`);
await page.waitForSelector('#research-alert', { timeout: 15000 });
const hasMechanism = await page.evaluate(() => typeof window.showSafeAlert === 'function');
if (!hasMechanism) {
return { passed: false, message: 'Alert mechanism (window.showSafeAlert) not loaded on /' };
}
const colorFor = (type) => page.evaluate((t) => {
const a = document.querySelector('#research-alert');
if (!a) return null;
a.innerHTML = '';
a.style.display = 'none';
window.showSafeAlert('research-alert', `semantic-${t}`, t);
const el = a.firstElementChild || a;
const cs = window.getComputedStyle(el);
return { color: cs.color, background: cs.backgroundColor, border: cs.borderColor };
}, type);
const err = await colorFor('error');
const ok = await colorFor('success');
const warn = await colorFor('warning');
// Restore the container to its hidden/empty default so we don't leak a visible
// alert into sibling tests that share this page.
await page.evaluate(() => {
const a = document.querySelector('#research-alert');
if (a) { a.innerHTML = ''; a.style.display = 'none'; }
});
if (!err || !ok || !warn) {
return { passed: false, message: 'Could not render semantic alerts to compare colours' };
}
// Distinct if any of text / background / border colour differs.
const differ = (x, y) => x.color !== y.color || x.background !== y.background || x.border !== y.border;
const errVsOk = differ(err, ok);
const errVsWarn = differ(err, warn);
const okVsWarn = differ(ok, warn);
const passed = errVsOk && errVsWarn && okVsWarn;
return {
passed,
message: passed
? `error/success/warning alerts all use distinct colours (bg error=${err.background}, success=${ok.background}, warning=${warn.background})`
: `Semantic colours not all distinct (error≠success=${errVsOk}, error≠warning=${errVsWarn}, success≠warning=${okVsWarn})`
};
}
};
// ============================================================================
// Main Test Runner
// ============================================================================
async function main() {
log.section('Keyboard & Accessibility Tests');
const ctx = await setupTest({ authenticate: true });
const results = new TestResults('Keyboard Accessibility Tests');
const { page } = ctx;
const { baseUrl } = ctx.config;
const subTestTimeout = ctx.config.isCI ? 60000 : 30000;
async function run(category, name, testFn) {
try {
const result = await withTimeout(
testFn(page, baseUrl),
subTestTimeout,
`${category}/${name}`
);
if (result.skipped) {
results.skip(category, name, result.message);
} else {
results.add(category, name, result.passed, result.message);
}
} catch (error) {
results.add(category, name, false, `Error: ${error.message}`);
}
}
try {
// Keyboard Navigation
log.section('Keyboard Navigation');
await run('Keyboard', 'Tab Navigation', (p, u) => KeyboardNavigationTests.tabNavigationWorks(p, u));
await run('Keyboard', 'Escape Key', (p, u) => KeyboardNavigationTests.escapeKeyFunction(p, u));
await run('Keyboard', 'Enter on Buttons', (p, u) => KeyboardNavigationTests.enterKeyOnButtons(p, u));
await run('Keyboard', 'Arrow Keys', (p, u) => KeyboardNavigationTests.arrowKeysInDropdowns(p, u));
// Keyboard Shortcuts
log.section('Keyboard Shortcuts');
await run('Shortcuts', 'Documented', (p, u) => KeyboardShortcutsTests.shortcutsDocumented(p, u));
await run('Shortcuts', 'Ctrl+Enter Submits', (p, u) => KeyboardShortcutsTests.ctrlEnterSubmit(p, u));
// Focus Management
log.section('Focus Management');
await run('Focus', 'Indicator Visible', (p, u) => FocusManagementTests.focusIndicatorVisible(p, u));
await run('Focus', 'Trap in Modals', (p, u) => FocusManagementTests.focusTrapInModals(p, u));
await run('Focus', 'Skip Link', (p, u) => FocusManagementTests.skipToContentLink(p, u));
// ARIA Labels
log.section('ARIA Labels');
await run('ARIA', 'Buttons Labeled', (p, u) => AriaLabelsTests.buttonsHaveLabels(p, u));
await run('ARIA', 'Images Alt Text', (p, u) => AriaLabelsTests.imagesHaveAltText(p, u));
await run('ARIA', 'Inputs Labeled', (p, u) => AriaLabelsTests.formInputsHaveLabels(p, u));
await run('ARIA', 'Roles Present', (p, u) => AriaLabelsTests.ariaRolesPresent(p, u));
// Color Contrast
log.section('Color & Contrast');
await run('Color', 'Semantic Colors', (p, u) => ColorContrastTests.semanticColors(p, u));
} catch (error) {
log.error(`Fatal error: ${error.message}`);
console.error(error.stack);
} finally {
results.print();
results.save();
await teardownTest(ctx);
process.exit(results.exitCode());
}
}
// Run if executed directly
if (require.main === module) {
main().catch(error => {
console.error('Test runner failed:', error);
process.exit(1);
});
}
module.exports = { KeyboardNavigationTests, KeyboardShortcutsTests, FocusManagementTests, AriaLabelsTests, ColorContrastTests };