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