- Add comprehensive health check system with multiple endpoints - Add Prometheus metrics endpoint - Add production logging configurations (5 strategies) - Add complete deployment documentation suite: * QUICKSTART.md - 30-minute deployment guide * DEPLOYMENT_CHECKLIST.md - Printable verification checklist * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference * production-logging.md - Logging configuration guide * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation * README.md - Navigation hub * DEPLOYMENT_SUMMARY.md - Executive summary - Add deployment scripts and automation - Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment - Update README with production-ready features All production infrastructure is now complete and ready for deployment.
407 lines
13 KiB
JavaScript
407 lines
13 KiB
JavaScript
/**
|
|
* Accessibility Manager Tests
|
|
*
|
|
* Tests for ARIA live regions, focus management, and screen reader support.
|
|
*/
|
|
|
|
import { AccessibilityManager } from '../../../../resources/js/modules/livecomponent/AccessibilityManager.js';
|
|
|
|
describe('AccessibilityManager', () => {
|
|
let manager;
|
|
let container;
|
|
|
|
beforeEach(() => {
|
|
manager = new AccessibilityManager();
|
|
container = document.createElement('div');
|
|
container.dataset.liveComponent = 'test-component:1';
|
|
document.body.appendChild(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = '';
|
|
});
|
|
|
|
describe('initialize', () => {
|
|
it('creates global live region', () => {
|
|
manager.initialize();
|
|
|
|
expect(manager.liveRegion).not.toBeNull();
|
|
expect(manager.liveRegion.id).toBe('livecomponent-announcer');
|
|
expect(manager.liveRegion.getAttribute('role')).toBe('status');
|
|
expect(manager.liveRegion.getAttribute('aria-live')).toBe('polite');
|
|
});
|
|
|
|
it('applies screen reader only styles', () => {
|
|
manager.initialize();
|
|
|
|
const region = manager.liveRegion;
|
|
expect(region.style.position).toBe('absolute');
|
|
expect(region.style.left).toBe('-10000px');
|
|
expect(region.style.width).toBe('1px');
|
|
expect(region.style.height).toBe('1px');
|
|
});
|
|
});
|
|
|
|
describe('createComponentLiveRegion', () => {
|
|
beforeEach(() => {
|
|
manager.initialize();
|
|
});
|
|
|
|
it('creates component-specific live region', () => {
|
|
const region = manager.createComponentLiveRegion('test:1', container);
|
|
|
|
expect(region).not.toBeNull();
|
|
expect(region.id).toBe('livecomponent-test:1-announcer');
|
|
expect(region.getAttribute('aria-live')).toBe('polite');
|
|
expect(container.contains(region)).toBe(true);
|
|
});
|
|
|
|
it('reuses existing component live region', () => {
|
|
const region1 = manager.createComponentLiveRegion('test:1', container);
|
|
const region2 = manager.createComponentLiveRegion('test:1', container);
|
|
|
|
expect(region1).toBe(region2);
|
|
expect(container.querySelectorAll('[role="status"]')).toHaveLength(1);
|
|
});
|
|
|
|
it('supports assertive politeness level', () => {
|
|
const region = manager.createComponentLiveRegion('test:1', container, 'assertive');
|
|
|
|
expect(region.getAttribute('aria-live')).toBe('assertive');
|
|
});
|
|
});
|
|
|
|
describe('announce', () => {
|
|
beforeEach(() => {
|
|
manager.initialize();
|
|
jest.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it('queues announcement', () => {
|
|
manager.announce('Test message');
|
|
|
|
expect(manager.announcementQueue).toHaveLength(1);
|
|
expect(manager.announcementQueue[0].message).toBe('Test message');
|
|
});
|
|
|
|
it('throttles announcements', () => {
|
|
manager.announce('Message 1');
|
|
manager.announce('Message 2');
|
|
manager.announce('Message 3');
|
|
|
|
// Should only keep most recent
|
|
jest.advanceTimersByTime(500);
|
|
|
|
expect(manager.liveRegion.textContent).toBe('Message 3');
|
|
expect(manager.announcementQueue).toHaveLength(0);
|
|
});
|
|
|
|
it('uses component-specific live region when provided', () => {
|
|
manager.createComponentLiveRegion('test:1', container);
|
|
|
|
manager.announce('Component message', 'polite', 'test:1');
|
|
jest.advanceTimersByTime(600);
|
|
|
|
const componentRegion = manager.componentLiveRegions.get('test:1');
|
|
expect(componentRegion.textContent).toBe('Component message');
|
|
});
|
|
|
|
it('updates politeness for assertive announcements', () => {
|
|
manager.announce('Urgent message', 'assertive');
|
|
jest.advanceTimersByTime(500);
|
|
|
|
expect(manager.liveRegion.getAttribute('aria-live')).toBe('assertive');
|
|
});
|
|
});
|
|
|
|
describe('captureFocusState', () => {
|
|
it('captures focus state for focused element', () => {
|
|
const input = document.createElement('input');
|
|
input.type = 'text';
|
|
input.id = 'test-input';
|
|
input.value = 'test value';
|
|
container.appendChild(input);
|
|
|
|
input.focus();
|
|
input.selectionStart = 2;
|
|
input.selectionEnd = 5;
|
|
|
|
const focusState = manager.captureFocusState('test:1', container);
|
|
|
|
expect(focusState).not.toBeNull();
|
|
expect(focusState.id).toBe('test-input');
|
|
expect(focusState.selectionStart).toBe(2);
|
|
expect(focusState.selectionEnd).toBe(5);
|
|
});
|
|
|
|
it('captures data-lc-keep-focus attribute', () => {
|
|
const button = document.createElement('button');
|
|
button.setAttribute('data-lc-keep-focus', '');
|
|
container.appendChild(button);
|
|
|
|
button.focus();
|
|
|
|
const focusState = manager.captureFocusState('test:1', container);
|
|
|
|
expect(focusState.keepFocus).toBe(true);
|
|
});
|
|
|
|
it('returns null when focus is outside container', () => {
|
|
const externalInput = document.createElement('input');
|
|
document.body.appendChild(externalInput);
|
|
externalInput.focus();
|
|
|
|
const focusState = manager.captureFocusState('test:1', container);
|
|
|
|
expect(focusState).toBeNull();
|
|
});
|
|
|
|
it('captures scroll position', () => {
|
|
const textarea = document.createElement('textarea');
|
|
textarea.style.height = '50px';
|
|
textarea.style.overflow = 'scroll';
|
|
textarea.value = 'Line 1\n'.repeat(20);
|
|
container.appendChild(textarea);
|
|
|
|
textarea.focus();
|
|
textarea.scrollTop = 100;
|
|
|
|
const focusState = manager.captureFocusState('test:1', container);
|
|
|
|
expect(focusState.scrollTop).toBe(100);
|
|
});
|
|
});
|
|
|
|
describe('restoreFocus', () => {
|
|
it('restores focus to element with data-lc-keep-focus', () => {
|
|
const input1 = document.createElement('input');
|
|
input1.id = 'input1';
|
|
container.appendChild(input1);
|
|
|
|
const input2 = document.createElement('input');
|
|
input2.id = 'input2';
|
|
input2.setAttribute('data-lc-keep-focus', '');
|
|
container.appendChild(input2);
|
|
|
|
// Capture focus on input1
|
|
input1.focus();
|
|
manager.captureFocusState('test:1', container);
|
|
|
|
// Restore should prefer data-lc-keep-focus
|
|
const restored = manager.restoreFocus('test:1', container);
|
|
|
|
expect(restored).toBe(true);
|
|
expect(document.activeElement).toBe(input2);
|
|
});
|
|
|
|
it('restores focus by ID', () => {
|
|
const input = document.createElement('input');
|
|
input.id = 'test-input';
|
|
container.appendChild(input);
|
|
|
|
input.focus();
|
|
manager.captureFocusState('test:1', container);
|
|
|
|
// Blur and restore
|
|
input.blur();
|
|
const restored = manager.restoreFocus('test:1', container);
|
|
|
|
expect(restored).toBe(true);
|
|
expect(document.activeElement).toBe(input);
|
|
});
|
|
|
|
it('restores selection for input elements', () => {
|
|
const input = document.createElement('input');
|
|
input.type = 'text';
|
|
input.id = 'test-input';
|
|
input.value = 'test value';
|
|
container.appendChild(input);
|
|
|
|
input.focus();
|
|
input.selectionStart = 2;
|
|
input.selectionEnd = 5;
|
|
|
|
manager.captureFocusState('test:1', container);
|
|
|
|
// Change selection
|
|
input.selectionStart = 0;
|
|
input.selectionEnd = 0;
|
|
|
|
manager.restoreFocus('test:1', container);
|
|
|
|
expect(input.selectionStart).toBe(2);
|
|
expect(input.selectionEnd).toBe(5);
|
|
});
|
|
|
|
it('restores scroll position', () => {
|
|
const textarea = document.createElement('textarea');
|
|
textarea.id = 'test-textarea';
|
|
textarea.style.height = '50px';
|
|
textarea.style.overflow = 'scroll';
|
|
textarea.value = 'Line 1\n'.repeat(20);
|
|
container.appendChild(textarea);
|
|
|
|
textarea.focus();
|
|
textarea.scrollTop = 100;
|
|
|
|
manager.captureFocusState('test:1', container);
|
|
|
|
textarea.scrollTop = 0;
|
|
|
|
manager.restoreFocus('test:1', container);
|
|
|
|
expect(textarea.scrollTop).toBe(100);
|
|
});
|
|
|
|
it('returns false when no focus state captured', () => {
|
|
const restored = manager.restoreFocus('test:1', container);
|
|
|
|
expect(restored).toBe(false);
|
|
});
|
|
|
|
it('clears focus state after restore', () => {
|
|
const input = document.createElement('input');
|
|
input.id = 'test-input';
|
|
container.appendChild(input);
|
|
|
|
input.focus();
|
|
manager.captureFocusState('test:1', container);
|
|
manager.restoreFocus('test:1', container);
|
|
|
|
// Second restore should fail (state cleared)
|
|
const restored2 = manager.restoreFocus('test:1', container);
|
|
expect(restored2).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('announceUpdate', () => {
|
|
beforeEach(() => {
|
|
manager.initialize();
|
|
jest.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it('announces fragment update', () => {
|
|
manager.announceUpdate('test:1', 'fragment', { fragmentName: 'header' });
|
|
|
|
jest.advanceTimersByTime(500);
|
|
|
|
expect(manager.liveRegion.textContent).toBe('Updated header');
|
|
});
|
|
|
|
it('announces full component update', () => {
|
|
manager.announceUpdate('test:1', 'full');
|
|
|
|
jest.advanceTimersByTime(500);
|
|
|
|
expect(manager.liveRegion.textContent).toBe('Component updated');
|
|
});
|
|
|
|
it('announces action completion', () => {
|
|
manager.announceUpdate('test:1', 'action', { actionMessage: 'Form submitted' });
|
|
|
|
jest.advanceTimersByTime(500);
|
|
|
|
expect(manager.liveRegion.textContent).toBe('Form submitted');
|
|
});
|
|
});
|
|
|
|
describe('cleanup', () => {
|
|
beforeEach(() => {
|
|
manager.initialize();
|
|
});
|
|
|
|
it('removes component live region', () => {
|
|
manager.createComponentLiveRegion('test:1', container);
|
|
|
|
const region = manager.componentLiveRegions.get('test:1');
|
|
expect(container.contains(region)).toBe(true);
|
|
|
|
manager.cleanup('test:1');
|
|
|
|
expect(container.contains(region)).toBe(false);
|
|
expect(manager.componentLiveRegions.has('test:1')).toBe(false);
|
|
});
|
|
|
|
it('clears focus state', () => {
|
|
const input = document.createElement('input');
|
|
input.id = 'test-input';
|
|
container.appendChild(input);
|
|
|
|
input.focus();
|
|
manager.captureFocusState('test:1', container);
|
|
|
|
manager.cleanup('test:1');
|
|
|
|
expect(manager.focusStates.has('test:1')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getStats', () => {
|
|
beforeEach(() => {
|
|
manager.initialize();
|
|
});
|
|
|
|
it('returns accurate statistics', () => {
|
|
manager.createComponentLiveRegion('test:1', container);
|
|
manager.createComponentLiveRegion('test:2', container);
|
|
|
|
const input = document.createElement('input');
|
|
container.appendChild(input);
|
|
input.focus();
|
|
manager.captureFocusState('test:1', container);
|
|
|
|
manager.announce('Test message');
|
|
|
|
const stats = manager.getStats();
|
|
|
|
expect(stats.has_global_live_region).toBe(true);
|
|
expect(stats.component_live_regions).toBe(2);
|
|
expect(stats.tracked_focus_states).toBe(1);
|
|
expect(stats.pending_announcements).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('shouldPreserveKeyboardNav', () => {
|
|
it('returns true for interactive elements', () => {
|
|
const button = document.createElement('button');
|
|
expect(manager.shouldPreserveKeyboardNav(button)).toBe(true);
|
|
|
|
const input = document.createElement('input');
|
|
expect(manager.shouldPreserveKeyboardNav(input)).toBe(true);
|
|
|
|
const link = document.createElement('a');
|
|
expect(manager.shouldPreserveKeyboardNav(link)).toBe(true);
|
|
});
|
|
|
|
it('returns true for elements with tabindex', () => {
|
|
const div = document.createElement('div');
|
|
div.setAttribute('tabindex', '0');
|
|
|
|
expect(manager.shouldPreserveKeyboardNav(div)).toBe(true);
|
|
});
|
|
|
|
it('returns true for elements with interactive roles', () => {
|
|
const div = document.createElement('div');
|
|
div.setAttribute('role', 'button');
|
|
|
|
expect(manager.shouldPreserveKeyboardNav(div)).toBe(true);
|
|
});
|
|
|
|
it('returns false for non-interactive elements', () => {
|
|
const div = document.createElement('div');
|
|
expect(manager.shouldPreserveKeyboardNav(div)).toBe(false);
|
|
|
|
const span = document.createElement('span');
|
|
expect(manager.shouldPreserveKeyboardNav(span)).toBe(false);
|
|
});
|
|
});
|
|
});
|