Skip to content

Commit 9868396

Browse files
committed
feat: add comprehensive accessibility test suite
- Add accessibility tests for heading hierarchy - Test ARIA attributes on navigation elements - Validate image alt attributes - Check link accessibility - Test form element labeling - Verify proper document structure - Test color contrast requirements - Validate keyboard navigation - Check focus indicators - Test screen reader compatibility Found issues: - Homepage missing h1 elements (accessibility violation) - Tests ready to validate accessibility improvements
1 parent a02e680 commit 9868396

File tree

1 file changed

+334
-0
lines changed

1 file changed

+334
-0
lines changed

tests/accessibility.spec.js

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
// @ts-check
2+
const { test, expect } = require('@playwright/test');
3+
4+
test.describe('Accessibility Tests', () => {
5+
test('homepage has proper heading hierarchy @accessibility', async ({ page }) => {
6+
await page.goto('/');
7+
8+
// Check that we have proper heading hierarchy (h1 -> h2 -> h3, etc.)
9+
const headings = await page.locator('h1, h2, h3, h4, h5, h6').all();
10+
11+
expect(headings.length).toBeGreaterThan(0);
12+
13+
// Check for h1 (should only be one)
14+
const h1Elements = await page.locator('h1').count();
15+
expect(h1Elements).toBeGreaterThanOrEqual(1);
16+
expect(h1Elements).toBeLessThanOrEqual(2); // Allow for hidden h1s
17+
});
18+
19+
test('navigation has proper ARIA attributes @accessibility', async ({ page }) => {
20+
await page.goto('/');
21+
22+
// Check mobile menu toggle has proper ARIA
23+
const mobileToggle = page.locator('.mobile-menu-toggle');
24+
if (await mobileToggle.isVisible()) {
25+
await expect(mobileToggle).toHaveAttribute('aria-expanded');
26+
await expect(mobileToggle).toHaveAttribute('aria-controls');
27+
}
28+
29+
// Check theme toggle has proper ARIA
30+
const themeToggle = page.locator('.theme-toggle-button');
31+
await expect(themeToggle).toHaveAttribute('aria-pressed');
32+
33+
// Check mobile navigation has proper ARIA
34+
const mobileNav = page.locator('.mobile-navigation');
35+
if (await mobileNav.isVisible()) {
36+
await expect(mobileNav).toHaveAttribute('aria-hidden');
37+
}
38+
});
39+
40+
test('images have proper alt attributes @accessibility', async ({ page }) => {
41+
await page.goto('/');
42+
43+
// Get all images
44+
const images = await page.locator('img').all();
45+
46+
for (const img of images) {
47+
const src = await img.getAttribute('src');
48+
const alt = await img.getAttribute('alt');
49+
50+
// All images should have alt attributes (even if empty for decorative images)
51+
expect(alt).not.toBeNull();
52+
53+
// If it's a content image, it should have meaningful alt text
54+
if (src && !src.includes('icon') && !src.includes('favicon')) {
55+
expect(alt?.length).toBeGreaterThan(0);
56+
}
57+
}
58+
});
59+
60+
test('links have accessible names @accessibility', async ({ page }) => {
61+
await page.goto('/');
62+
63+
// Get all links
64+
const links = await page.locator('a').all();
65+
66+
for (const link of links) {
67+
const href = await link.getAttribute('href');
68+
const text = await link.textContent();
69+
const ariaLabel = await link.getAttribute('aria-label');
70+
const title = await link.getAttribute('title');
71+
72+
// Skip empty or javascript: links
73+
if (!href || href === '#' || href.startsWith('javascript:')) {
74+
continue;
75+
}
76+
77+
// Link should have accessible text (visible text, aria-label, or title)
78+
const hasAccessibleName = (text && text.trim().length > 0) ||
79+
(ariaLabel && ariaLabel.length > 0) ||
80+
(title && title.length > 0);
81+
82+
expect(hasAccessibleName).toBeTruthy();
83+
}
84+
});
85+
86+
test('form elements have proper labels @accessibility', async ({ page }) => {
87+
await page.goto('/contact/');
88+
89+
// Get all form inputs
90+
const inputs = await page.locator('input, select, textarea').all();
91+
92+
for (const input of inputs) {
93+
const id = await input.getAttribute('id');
94+
const ariaLabel = await input.getAttribute('aria-label');
95+
const ariaLabelledby = await input.getAttribute('aria-labelledby');
96+
const placeholder = await input.getAttribute('placeholder');
97+
98+
// Input should have some form of labeling
99+
let hasLabel = false;
100+
101+
if (id) {
102+
// Check for associated label
103+
const label = page.locator(`label[for="${id}"]`);
104+
if (await label.count() > 0) {
105+
hasLabel = true;
106+
}
107+
}
108+
109+
if (ariaLabel || ariaLabelledby || placeholder) {
110+
hasLabel = true;
111+
}
112+
113+
// Allow some exceptions for hidden inputs
114+
const type = await input.getAttribute('type');
115+
if (type === 'hidden') {
116+
continue;
117+
}
118+
119+
expect(hasLabel).toBeTruthy();
120+
}
121+
});
122+
123+
test('page has proper document structure @accessibility', async ({ page }) => {
124+
await page.goto('/');
125+
126+
// Check for main landmark
127+
const main = page.locator('main, [role="main"]');
128+
const mainCount = await main.count();
129+
expect(mainCount).toBeGreaterThanOrEqual(1);
130+
131+
// Check for navigation landmark
132+
const nav = page.locator('nav, [role="navigation"]');
133+
const navCount = await nav.count();
134+
expect(navCount).toBeGreaterThanOrEqual(1);
135+
136+
// Check page has a title
137+
const title = await page.title();
138+
expect(title.length).toBeGreaterThan(0);
139+
expect(title).not.toBe('Document'); // Default title
140+
});
141+
142+
test('color contrast is sufficient @accessibility', async ({ page }) => {
143+
await page.goto('/');
144+
145+
// Test both themes
146+
const themes = ['dark', 'light'];
147+
148+
for (const theme of themes) {
149+
if (theme === 'light') {
150+
const themeToggle = page.locator('.theme-toggle-button');
151+
await themeToggle.click();
152+
await page.waitForTimeout(500);
153+
}
154+
155+
// Check main text elements have sufficient contrast
156+
const textElements = [
157+
'body',
158+
'.intro-text',
159+
'.terminal-content',
160+
'p',
161+
'h1, h2, h3, h4, h5, h6'
162+
];
163+
164+
for (const selector of textElements) {
165+
const element = page.locator(selector).first();
166+
if (await element.isVisible()) {
167+
const styles = await element.evaluate(el => {
168+
const computed = window.getComputedStyle(el);
169+
return {
170+
color: computed.color,
171+
backgroundColor: computed.backgroundColor
172+
};
173+
});
174+
175+
// Basic check that text is not transparent or same as background
176+
expect(styles.color).not.toBe('rgba(0, 0, 0, 0)');
177+
expect(styles.color).not.toBe('transparent');
178+
expect(styles.color).not.toBe(styles.backgroundColor);
179+
}
180+
}
181+
}
182+
});
183+
184+
test('keyboard navigation works @accessibility', async ({ page }) => {
185+
await page.goto('/');
186+
187+
// Test tab navigation
188+
await page.keyboard.press('Tab');
189+
let focusedElement = page.locator(':focus');
190+
await expect(focusedElement).toBeVisible();
191+
192+
// Continue tabbing through interactive elements
193+
for (let i = 0; i < 5; i++) {
194+
await page.keyboard.press('Tab');
195+
focusedElement = page.locator(':focus');
196+
197+
// Check that focused element is visible and interactive
198+
if (await focusedElement.count() > 0) {
199+
await expect(focusedElement).toBeVisible();
200+
201+
const tagName = await focusedElement.evaluate(el => el.tagName.toLowerCase());
202+
const role = await focusedElement.getAttribute('role');
203+
const tabindex = await focusedElement.getAttribute('tabindex');
204+
205+
// Should be an interactive element
206+
const isInteractive = ['a', 'button', 'input', 'select', 'textarea'].includes(tagName) ||
207+
role === 'button' ||
208+
role === 'link' ||
209+
tabindex === '0';
210+
211+
if (isInteractive) {
212+
expect(true).toBeTruthy(); // Valid interactive element
213+
}
214+
}
215+
}
216+
});
217+
218+
test('focus indicators are visible @accessibility', async ({ page }) => {
219+
await page.goto('/');
220+
221+
// Test focus on various interactive elements
222+
const interactiveSelectors = [
223+
'.theme-toggle-button',
224+
'.nav-link',
225+
'a[href]'
226+
];
227+
228+
for (const selector of interactiveSelectors) {
229+
const element = page.locator(selector).first();
230+
if (await element.isVisible()) {
231+
await element.focus();
232+
233+
// Check that focused element has visible focus indicator
234+
const styles = await element.evaluate(el => {
235+
const computed = window.getComputedStyle(el);
236+
return {
237+
outline: computed.outline,
238+
outlineWidth: computed.outlineWidth,
239+
outlineStyle: computed.outlineStyle,
240+
outlineColor: computed.outlineColor,
241+
boxShadow: computed.boxShadow
242+
};
243+
});
244+
245+
// Should have some form of focus indicator
246+
const hasFocusIndicator = styles.outline !== 'none' ||
247+
styles.outlineWidth !== '0px' ||
248+
styles.boxShadow !== 'none';
249+
250+
expect(hasFocusIndicator).toBeTruthy();
251+
}
252+
}
253+
});
254+
255+
test('mobile navigation is keyboard accessible @accessibility @mobile', async ({ page }) => {
256+
await page.goto('/');
257+
258+
// Focus on mobile menu toggle
259+
const mobileToggle = page.locator('.mobile-menu-toggle');
260+
if (await mobileToggle.isVisible()) {
261+
await mobileToggle.focus();
262+
263+
// Should be able to activate with Enter or Space
264+
await page.keyboard.press('Enter');
265+
await page.waitForTimeout(500);
266+
267+
const mobileNav = page.locator('.mobile-navigation');
268+
await expect(mobileNav).toBeVisible();
269+
270+
// Should be able to close with Escape
271+
await page.keyboard.press('Escape');
272+
await page.waitForTimeout(500);
273+
274+
await expect(mobileNav).toBeHidden();
275+
}
276+
});
277+
278+
test('theme toggle is keyboard accessible @accessibility', async ({ page }) => {
279+
await page.goto('/');
280+
281+
const themeToggle = page.locator('.theme-toggle-button');
282+
await themeToggle.focus();
283+
284+
// Get initial theme
285+
const initialTheme = await page.locator('html').getAttribute('data-theme');
286+
287+
// Should be able to toggle with Enter or Space
288+
await page.keyboard.press('Enter');
289+
await page.waitForTimeout(500);
290+
291+
const newTheme = await page.locator('html').getAttribute('data-theme');
292+
expect(newTheme).not.toBe(initialTheme);
293+
294+
// Test Space key as well
295+
await page.keyboard.press('Space');
296+
await page.waitForTimeout(500);
297+
298+
const finalTheme = await page.locator('html').getAttribute('data-theme');
299+
expect(finalTheme).toBe(initialTheme);
300+
});
301+
302+
test('screen reader announcements work @accessibility', async ({ page }) => {
303+
await page.goto('/');
304+
305+
// Check for proper live regions or announcements
306+
const liveRegions = page.locator('[aria-live], [aria-atomic], [role="status"], [role="alert"]');
307+
308+
// At minimum, check that ARIA attributes are used where appropriate
309+
const ariaElements = page.locator('[aria-label], [aria-labelledby], [aria-describedby], [aria-expanded], [aria-pressed], [aria-hidden]');
310+
const ariaCount = await ariaElements.count();
311+
312+
expect(ariaCount).toBeGreaterThan(0);
313+
});
314+
315+
test('content is readable without CSS @accessibility', async ({ page }) => {
316+
await page.goto('/');
317+
318+
// Disable CSS
319+
await page.addStyleTag({ content: '* { all: unset !important; }' });
320+
321+
// Check that main content is still visible and readable
322+
const headings = page.locator('h1, h2, h3, h4, h5, h6');
323+
const headingCount = await headings.count();
324+
expect(headingCount).toBeGreaterThan(0);
325+
326+
const paragraphs = page.locator('p');
327+
const paragraphCount = await paragraphs.count();
328+
expect(paragraphCount).toBeGreaterThan(0);
329+
330+
const links = page.locator('a[href]');
331+
const linkCount = await links.count();
332+
expect(linkCount).toBeGreaterThan(0);
333+
});
334+
});

0 commit comments

Comments
 (0)