Skip to content

Commit c187192

Browse files
committed
tests: Add accessibility tests with axe
Signed-off-by: Julius Knorr <jus@bitgrid.net>
1 parent 85a7e3b commit c187192

5 files changed

Lines changed: 334 additions & 0 deletions

File tree

package-lock.json

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
"yjs": "^13.6.27"
106106
},
107107
"devDependencies": {
108+
"@axe-core/playwright": "^4.11.0",
108109
"@nextcloud/babel-config": "^1.3.0",
109110
"@nextcloud/browserslist-config": "^3.1.2",
110111
"@nextcloud/e2e-test-server": "^0.4.0",
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { mergeTests } from '@playwright/test'
7+
import { test as accessibilityTest, expect } from '../support/fixtures/accessibility'
8+
import { test as editorTest } from '../support/fixtures/editor'
9+
import { test as uploadFileTest } from '../support/fixtures/upload-file'
10+
import {
11+
formatViolations,
12+
getAccessibilitySummary,
13+
} from '../support/utils/accessibility'
14+
15+
const test = mergeTests(accessibilityTest, editorTest, uploadFileTest)
16+
17+
test.describe('Editor Accessibility', () => {
18+
test('should not have automatically detectable accessibility violations on the full page', async ({
19+
page,
20+
open,
21+
makeAxeBuilder,
22+
}, testInfo) => {
23+
await open()
24+
25+
// Wait for the editor to be fully loaded
26+
await page.waitForSelector('.text-editor', { state: 'visible' })
27+
28+
// Run the accessibility scan
29+
const accessibilityScanResults = await makeAxeBuilder().analyze()
30+
31+
// Attach the full scan results for debugging
32+
await testInfo.attach('accessibility-scan-results', {
33+
body: JSON.stringify(accessibilityScanResults, null, 2),
34+
contentType: 'application/json',
35+
})
36+
37+
// Attach a summary for quick overview
38+
const summary = getAccessibilitySummary(accessibilityScanResults)
39+
await testInfo.attach('accessibility-summary', {
40+
body: JSON.stringify(summary, null, 2),
41+
contentType: 'application/json',
42+
})
43+
44+
// Expect no violations
45+
expect(
46+
accessibilityScanResults.violations,
47+
formatViolations(accessibilityScanResults),
48+
).toEqual([])
49+
})
50+
51+
test('should not have accessibility violations in the editor content area', async ({
52+
page,
53+
open,
54+
makeAxeBuilder,
55+
editor,
56+
}, testInfo) => {
57+
await open()
58+
await editor.type('Test content')
59+
60+
// Wait for the editor to be ready
61+
await page.waitForSelector('.text-editor', { state: 'visible' })
62+
63+
// Scan only the editor content area
64+
const accessibilityScanResults = await makeAxeBuilder()
65+
.include('.text-editor')
66+
.analyze()
67+
68+
await testInfo.attach('editor-content-scan-results', {
69+
body: JSON.stringify(accessibilityScanResults, null, 2),
70+
contentType: 'application/json',
71+
})
72+
73+
expect(
74+
accessibilityScanResults.violations,
75+
formatViolations(accessibilityScanResults),
76+
).toEqual([])
77+
})
78+
79+
test('should not have accessibility violations in the menu bar', async ({
80+
page,
81+
open,
82+
makeAxeBuilder,
83+
}, testInfo) => {
84+
await open()
85+
86+
// Wait for the menu bar to be visible
87+
await page.waitForSelector('.text-menubar', { state: 'visible' })
88+
89+
// Scan only the menu bar
90+
const accessibilityScanResults = await makeAxeBuilder()
91+
.include('.text-menubar')
92+
.analyze()
93+
94+
await testInfo.attach('menubar-scan-results', {
95+
body: JSON.stringify(accessibilityScanResults, null, 2),
96+
contentType: 'application/json',
97+
})
98+
99+
expect(
100+
accessibilityScanResults.violations,
101+
formatViolations(accessibilityScanResults),
102+
).toEqual([])
103+
})
104+
105+
test('should have proper keyboard navigation support', async ({
106+
page,
107+
open,
108+
makeAxeBuilder,
109+
}, testInfo) => {
110+
await open()
111+
112+
// Wait for the editor to be fully loaded
113+
await page.waitForSelector('.text-editor', { state: 'visible' })
114+
115+
// Check for keyboard-specific accessibility issues
116+
const accessibilityScanResults = await makeAxeBuilder()
117+
// Focus on keyboard accessibility rules (WCAG only, not best-practice)
118+
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
119+
.analyze()
120+
121+
await testInfo.attach('keyboard-navigation-scan-results', {
122+
body: JSON.stringify(accessibilityScanResults, null, 2),
123+
contentType: 'application/json',
124+
})
125+
126+
// Check that interactive elements are keyboard accessible
127+
expect(
128+
accessibilityScanResults.violations,
129+
formatViolations(accessibilityScanResults),
130+
).toEqual([])
131+
})
132+
133+
test('should have proper ARIA labels on interactive elements', async ({
134+
page,
135+
open,
136+
makeAxeBuilder,
137+
editor,
138+
}, testInfo) => {
139+
await open()
140+
141+
// Open a menu to check its accessibility
142+
const boldButton = editor.getMenu('Bold')
143+
await expect(boldButton).toBeVisible()
144+
145+
// Wait for all menu items to be rendered
146+
await page.waitForSelector('[role="button"], button', { state: 'attached' })
147+
148+
// Scan for ARIA-related issues
149+
const accessibilityScanResults = await makeAxeBuilder()
150+
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
151+
.analyze()
152+
153+
await testInfo.attach('aria-labels-scan-results', {
154+
body: JSON.stringify(accessibilityScanResults, null, 2),
155+
contentType: 'application/json',
156+
})
157+
158+
expect(
159+
accessibilityScanResults.violations,
160+
formatViolations(accessibilityScanResults),
161+
).toEqual([])
162+
})
163+
164+
test('should maintain accessibility after text formatting', async ({
165+
page,
166+
open,
167+
makeAxeBuilder,
168+
editor,
169+
}, testInfo) => {
170+
await open()
171+
172+
// Type some text and format it
173+
await editor.type('Format me')
174+
const CtrlOrCmd = process.platform === 'darwin' ? 'Meta' : 'Control'
175+
await editor.content.press(CtrlOrCmd + '+a')
176+
await editor.getMenu('Bold').click()
177+
await editor.getMenu('Italic').click()
178+
179+
// Wait for formatting to be applied
180+
await page.waitForSelector('strong', { state: 'attached' })
181+
await page.waitForSelector('em', { state: 'attached' })
182+
183+
// Scan after formatting operations
184+
const accessibilityScanResults = await makeAxeBuilder()
185+
.include('.text-editor')
186+
.analyze()
187+
188+
await testInfo.attach('formatted-content-scan-results', {
189+
body: JSON.stringify(accessibilityScanResults, null, 2),
190+
contentType: 'application/json',
191+
})
192+
193+
expect(
194+
accessibilityScanResults.violations,
195+
formatViolations(accessibilityScanResults),
196+
).toEqual([])
197+
})
198+
})
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { test as base } from '@playwright/test'
7+
import AxeBuilder from '@axe-core/playwright'
8+
9+
type AxeFixture = {
10+
makeAxeBuilder: () => AxeBuilder
11+
}
12+
13+
export const test = base.extend<AxeFixture>({
14+
makeAxeBuilder: async ({ page }, use) => {
15+
const makeAxeBuilder = () => new AxeBuilder({ page })
16+
// Test against WCAG 2.0 and 2.1 Level A and AA
17+
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
18+
19+
// Use legacy mode for compatibility with custom Playwright fixtures
20+
// See: https://github.com/dequelabs/axe-core-npm/blob/develop/packages/playwright/error-handling.md
21+
.setLegacyMode()
22+
23+
// TODO: Fix these accessibility issues in the application
24+
.exclude('input[data-cy-upload-picker-input]') // Upload input needs proper label
25+
26+
await use(makeAxeBuilder)
27+
},
28+
})
29+
30+
export { expect } from '@playwright/test'
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
// eslint-disable-next-line import/no-named-as-default
7+
import AxeBuilder from '@axe-core/playwright'
8+
9+
type AxeResults = Awaited<ReturnType<AxeBuilder['analyze']>>
10+
11+
/**
12+
* Creates a fingerprint of accessibility violations for snapshot testing.
13+
* This extracts only the essential information (rule ID and CSS selectors)
14+
* to avoid fragile snapshots that break when implementation details change.
15+
*
16+
* @param accessibilityScanResults - Results from AxeBuilder.analyze()
17+
* @return JSON string with violation fingerprints
18+
*/
19+
export function violationFingerprints(accessibilityScanResults: AxeResults): string {
20+
const violationFingerprints = accessibilityScanResults.violations.map(
21+
(violation) => ({
22+
rule: violation.id,
23+
description: violation.description,
24+
// These are CSS selectors which uniquely identify each element with
25+
// a violation of the rule in question.
26+
targets: violation.nodes.map((node) => node.target),
27+
// Include impact level for prioritization
28+
impact: violation.impact,
29+
}),
30+
)
31+
32+
return JSON.stringify(violationFingerprints, null, 2)
33+
}
34+
35+
/**
36+
* Formats violation results for better readability in test output
37+
*
38+
* @param accessibilityScanResults - Results from AxeBuilder.analyze()
39+
* @return Formatted string describing all violations
40+
*/
41+
export function formatViolations(accessibilityScanResults: AxeResults): string {
42+
if (accessibilityScanResults.violations.length === 0) {
43+
return 'No accessibility violations found'
44+
}
45+
46+
const violations = accessibilityScanResults.violations
47+
.map((violation) => {
48+
const targets = violation.nodes
49+
.map((node) => ` - ${node.target.join(' ')}`)
50+
.join('\n')
51+
return `
52+
Rule: ${violation.id} (${violation.impact})
53+
Description: ${violation.description}
54+
Help: ${violation.help}
55+
Help URL: ${violation.helpUrl}
56+
Affected elements:
57+
${targets}
58+
`
59+
})
60+
.join('\n---\n')
61+
62+
return `Found ${accessibilityScanResults.violations.length} accessibility violation(s):\n${violations}`
63+
}
64+
65+
/**
66+
* Gets a summary of accessibility scan results including passes, violations, and incomplete checks
67+
*
68+
* @param accessibilityScanResults - Results from AxeBuilder.analyze()
69+
* @return Summary object
70+
*/
71+
export function getAccessibilitySummary(accessibilityScanResults: AxeResults) {
72+
return {
73+
violations: accessibilityScanResults.violations.length,
74+
passes: accessibilityScanResults.passes.length,
75+
incomplete: accessibilityScanResults.incomplete.length,
76+
inapplicable: accessibilityScanResults.inapplicable.length,
77+
url: accessibilityScanResults.url,
78+
timestamp: accessibilityScanResults.timestamp,
79+
}
80+
}

0 commit comments

Comments
 (0)