diff --git a/package-lock.json b/package-lock.json index b44b274e9ac..34e3ded2aea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,6 +85,7 @@ "yjs": "^13.6.27" }, "devDependencies": { + "@axe-core/playwright": "^4.11.0", "@nextcloud/babel-config": "^1.3.0", "@nextcloud/browserslist-config": "^3.1.2", "@nextcloud/e2e-test-server": "^0.4.0", @@ -247,6 +248,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@axe-core/playwright": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.0.tgz", + "integrity": "sha512-70vBT/Ylqpm65RQz2iCG2o0JJCEG/WCNyefTr2xcOcr1CoSee60gNQYUMZZ7YukoKkFLv26I/jjlsvwwp532oQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -7571,6 +7585,16 @@ "dev": true, "license": "MIT" }, + "node_modules/axe-core": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", + "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axios": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", @@ -16242,6 +16266,7 @@ "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, diff --git a/package.json b/package.json index b0d619697c0..309127abd2e 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "yjs": "^13.6.27" }, "devDependencies": { + "@axe-core/playwright": "^4.11.0", "@nextcloud/babel-config": "^1.3.0", "@nextcloud/browserslist-config": "^3.1.2", "@nextcloud/e2e-test-server": "^0.4.0", diff --git a/playwright/e2e/accessibility.spec.ts b/playwright/e2e/accessibility.spec.ts new file mode 100644 index 00000000000..e91e3b87773 --- /dev/null +++ b/playwright/e2e/accessibility.spec.ts @@ -0,0 +1,199 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mergeTests } from '@playwright/test' +import { test as accessibilityTest, expect } from '../support/fixtures/accessibility' +import { test as editorTest } from '../support/fixtures/editor' +import { test as uploadFileTest } from '../support/fixtures/upload-file' +import { + formatViolations, + getAccessibilitySummary, + violationFingerprints, +} from '../support/utils/accessibility' + +const test = mergeTests(accessibilityTest, editorTest, uploadFileTest) + +test.describe('Editor Accessibility', () => { + test('should not have automatically detectable accessibility violations on the full page', async ({ + page, + open, + makeAxeBuilder, + }, testInfo) => { + await open() + + // Wait for the editor to be fully loaded + await page.waitForSelector('.text-editor', { state: 'visible' }) + + // Run the accessibility scan + const accessibilityScanResults = await makeAxeBuilder().analyze() + + // Attach the full scan results for debugging + await testInfo.attach('accessibility-scan-results', { + body: JSON.stringify(accessibilityScanResults, null, 2), + contentType: 'application/json', + }) + + // Attach a summary for quick overview + const summary = getAccessibilitySummary(accessibilityScanResults) + await testInfo.attach('accessibility-summary', { + body: JSON.stringify(summary, null, 2), + contentType: 'application/json', + }) + + // Snapshot test for violations to track changes over time + expect( + violationFingerprints(accessibilityScanResults), + formatViolations(accessibilityScanResults), + ).toMatchSnapshot('accessibility-violations.json') + }) + + test('should not have accessibility violations in the editor content area', async ({ + page, + open, + makeAxeBuilder, + editor, + }, testInfo) => { + await open() + await editor.type('Test content') + + // Wait for the editor to be ready + await page.waitForSelector('.text-editor', { state: 'visible' }) + + // Scan only the editor content area + const accessibilityScanResults = await makeAxeBuilder() + .include('.text-editor') + .analyze() + + await testInfo.attach('editor-content-scan-results', { + body: JSON.stringify(accessibilityScanResults, null, 2), + contentType: 'application/json', + }) + + expect( + violationFingerprints(accessibilityScanResults), + formatViolations(accessibilityScanResults), + ).toMatchSnapshot('editor-content-violations.json') + }) + + test('should not have accessibility violations in the menu bar', async ({ + page, + open, + makeAxeBuilder, + }, testInfo) => { + await open() + + // Wait for the menu bar to be visible + await page.waitForSelector('.text-menubar', { state: 'visible' }) + + // Scan only the menu bar + const accessibilityScanResults = await makeAxeBuilder() + .include('.text-menubar') + .analyze() + + await testInfo.attach('menubar-scan-results', { + body: JSON.stringify(accessibilityScanResults, null, 2), + contentType: 'application/json', + }) + + expect( + violationFingerprints(accessibilityScanResults), + formatViolations(accessibilityScanResults), + ).toMatchSnapshot('menubar-violations.json') + }) + + test('should have proper keyboard navigation support', async ({ + page, + open, + makeAxeBuilder, + }, testInfo) => { + await open() + + // Wait for the editor to be fully loaded + await page.waitForSelector('.text-editor', { state: 'visible' }) + + // Check for keyboard-specific accessibility issues + const accessibilityScanResults = await makeAxeBuilder() + // Focus on keyboard accessibility rules (WCAG only, not best-practice) + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + .analyze() + + await testInfo.attach('keyboard-navigation-scan-results', { + body: JSON.stringify(accessibilityScanResults, null, 2), + contentType: 'application/json', + }) + + // Check that interactive elements are keyboard accessible + expect( + violationFingerprints(accessibilityScanResults), + formatViolations(accessibilityScanResults), + ).toMatchSnapshot('keyboard-navigation-violations.json') + }) + + test('should have proper ARIA labels on interactive elements', async ({ + page, + open, + makeAxeBuilder, + editor, + }, testInfo) => { + await open() + + // Open a menu to check its accessibility + const boldButton = editor.getMenu('Bold') + await expect(boldButton).toBeVisible() + + // Wait for all menu items to be rendered + await page.waitForSelector('[role="button"], button', { state: 'attached' }) + + // Scan for ARIA-related issues + const accessibilityScanResults = await makeAxeBuilder() + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + .analyze() + + await testInfo.attach('aria-labels-scan-results', { + body: JSON.stringify(accessibilityScanResults, null, 2), + contentType: 'application/json', + }) + + expect( + violationFingerprints(accessibilityScanResults), + formatViolations(accessibilityScanResults), + ).toMatchSnapshot('aria-labels-violations.json') + }) + + test('should maintain accessibility after text formatting', async ({ + page, + open, + makeAxeBuilder, + editor, + }, testInfo) => { + await open() + + // Type some text and format it + await editor.type('Format me') + const CtrlOrCmd = process.platform === 'darwin' ? 'Meta' : 'Control' + await editor.content.press(CtrlOrCmd + '+a') + await editor.getMenu('Bold').click() + await editor.getMenu('Italic').click() + + // Wait for formatting to be applied + await page.waitForSelector('strong', { state: 'attached' }) + await page.waitForSelector('em', { state: 'attached' }) + + // Scan after formatting operations + const accessibilityScanResults = await makeAxeBuilder() + .include('.text-editor') + .analyze() + + await testInfo.attach('formatted-content-scan-results', { + body: JSON.stringify(accessibilityScanResults, null, 2), + contentType: 'application/json', + }) + + expect( + violationFingerprints(accessibilityScanResults), + formatViolations(accessibilityScanResults), + ).toMatchSnapshot('formatted-content-violations.json') + }) +}) diff --git a/playwright/e2e/accessibility.spec.ts-snapshots/accessibility-violations-chromium-linux.json b/playwright/e2e/accessibility.spec.ts-snapshots/accessibility-violations-chromium-linux.json new file mode 100644 index 00000000000..fee2c24b712 --- /dev/null +++ b/playwright/e2e/accessibility.spec.ts-snapshots/accessibility-violations-chromium-linux.json @@ -0,0 +1,12 @@ +[ + { + "rule": "label", + "description": "Ensure every form element has a label", + "targets": [ + [ + "input[data-cy-upload-picker-input=\"\"]" + ] + ], + "impact": "critical" + } +] \ No newline at end of file diff --git a/playwright/e2e/accessibility.spec.ts-snapshots/aria-labels-violations-chromium-linux.json b/playwright/e2e/accessibility.spec.ts-snapshots/aria-labels-violations-chromium-linux.json new file mode 100644 index 00000000000..fee2c24b712 --- /dev/null +++ b/playwright/e2e/accessibility.spec.ts-snapshots/aria-labels-violations-chromium-linux.json @@ -0,0 +1,12 @@ +[ + { + "rule": "label", + "description": "Ensure every form element has a label", + "targets": [ + [ + "input[data-cy-upload-picker-input=\"\"]" + ] + ], + "impact": "critical" + } +] \ No newline at end of file diff --git a/playwright/e2e/accessibility.spec.ts-snapshots/editor-content-violations-chromium-linux.json b/playwright/e2e/accessibility.spec.ts-snapshots/editor-content-violations-chromium-linux.json new file mode 100644 index 00000000000..0637a088a01 --- /dev/null +++ b/playwright/e2e/accessibility.spec.ts-snapshots/editor-content-violations-chromium-linux.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/playwright/e2e/accessibility.spec.ts-snapshots/formatted-content-violations-chromium-linux.json b/playwright/e2e/accessibility.spec.ts-snapshots/formatted-content-violations-chromium-linux.json new file mode 100644 index 00000000000..0637a088a01 --- /dev/null +++ b/playwright/e2e/accessibility.spec.ts-snapshots/formatted-content-violations-chromium-linux.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/playwright/e2e/accessibility.spec.ts-snapshots/keyboard-navigation-violations-chromium-linux.json b/playwright/e2e/accessibility.spec.ts-snapshots/keyboard-navigation-violations-chromium-linux.json new file mode 100644 index 00000000000..fee2c24b712 --- /dev/null +++ b/playwright/e2e/accessibility.spec.ts-snapshots/keyboard-navigation-violations-chromium-linux.json @@ -0,0 +1,12 @@ +[ + { + "rule": "label", + "description": "Ensure every form element has a label", + "targets": [ + [ + "input[data-cy-upload-picker-input=\"\"]" + ] + ], + "impact": "critical" + } +] \ No newline at end of file diff --git a/playwright/e2e/accessibility.spec.ts-snapshots/menubar-violations-chromium-linux.json b/playwright/e2e/accessibility.spec.ts-snapshots/menubar-violations-chromium-linux.json new file mode 100644 index 00000000000..0637a088a01 --- /dev/null +++ b/playwright/e2e/accessibility.spec.ts-snapshots/menubar-violations-chromium-linux.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/playwright/support/fixtures/accessibility.ts b/playwright/support/fixtures/accessibility.ts new file mode 100644 index 00000000000..2117606b338 --- /dev/null +++ b/playwright/support/fixtures/accessibility.ts @@ -0,0 +1,28 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test as base } from '@playwright/test' +import AxeBuilder from '@axe-core/playwright' + +type AxeFixture = { + makeAxeBuilder: () => AxeBuilder +} + +export const test = base.extend({ + makeAxeBuilder: async ({ page }, use) => { + const makeAxeBuilder = () => new AxeBuilder({ page }) + // Test against WCAG 2.0 and 2.1 Level A and AA + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + + // Use legacy mode for compatibility with custom Playwright fixtures + // See: https://github.com/dequelabs/axe-core-npm/blob/develop/packages/playwright/error-handling.md + .setLegacyMode() + + + await use(makeAxeBuilder) + }, +}) + +export { expect } from '@playwright/test' diff --git a/playwright/support/utils/accessibility.ts b/playwright/support/utils/accessibility.ts new file mode 100644 index 00000000000..d0e5e84375a --- /dev/null +++ b/playwright/support/utils/accessibility.ts @@ -0,0 +1,80 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +// eslint-disable-next-line import/no-named-as-default +import AxeBuilder from '@axe-core/playwright' + +type AxeResults = Awaited> + +/** + * Creates a fingerprint of accessibility violations for snapshot testing. + * This extracts only the essential information (rule ID and CSS selectors) + * to avoid fragile snapshots that break when implementation details change. + * + * @param accessibilityScanResults - Results from AxeBuilder.analyze() + * @return JSON string with violation fingerprints + */ +export function violationFingerprints(accessibilityScanResults: AxeResults): string { + const violationFingerprints = accessibilityScanResults.violations.map( + (violation) => ({ + rule: violation.id, + description: violation.description, + // These are CSS selectors which uniquely identify each element with + // a violation of the rule in question. + targets: violation.nodes.map((node) => node.target), + // Include impact level for prioritization + impact: violation.impact, + }), + ) + + return JSON.stringify(violationFingerprints, null, 2) +} + +/** + * Formats violation results for better readability in test output + * + * @param accessibilityScanResults - Results from AxeBuilder.analyze() + * @return Formatted string describing all violations + */ +export function formatViolations(accessibilityScanResults: AxeResults): string { + if (accessibilityScanResults.violations.length === 0) { + return 'No accessibility violations found' + } + + const violations = accessibilityScanResults.violations + .map((violation) => { + const targets = violation.nodes + .map((node) => ` - ${node.target.join(' ')}`) + .join('\n') + return ` +Rule: ${violation.id} (${violation.impact}) +Description: ${violation.description} +Help: ${violation.help} +Help URL: ${violation.helpUrl} +Affected elements: +${targets} +` + }) + .join('\n---\n') + + return `Found ${accessibilityScanResults.violations.length} accessibility violation(s):\n${violations}` +} + +/** + * Gets a summary of accessibility scan results including passes, violations, and incomplete checks + * + * @param accessibilityScanResults - Results from AxeBuilder.analyze() + * @return Summary object + */ +export function getAccessibilitySummary(accessibilityScanResults: AxeResults) { + return { + violations: accessibilityScanResults.violations.length, + passes: accessibilityScanResults.passes.length, + incomplete: accessibilityScanResults.incomplete.length, + inapplicable: accessibilityScanResults.inapplicable.length, + url: accessibilityScanResults.url, + timestamp: accessibilityScanResults.timestamp, + } +} diff --git a/src/EditorFactory.js b/src/EditorFactory.js index 74235a30862..5e5c4bec8a9 100644 --- a/src/EditorFactory.js +++ b/src/EditorFactory.js @@ -5,6 +5,7 @@ import 'proxy-polyfill' +import { translate as t } from '@nextcloud/l10n' import { Editor } from '@tiptap/core' import hljs from 'highlight.js/lib/core' import { createLowlight } from 'lowlight' @@ -35,6 +36,10 @@ const loadSyntaxHighlight = async (language) => { const editorProps = { scrollMargin: 50, scrollThreshold: 50, + attributes: { + role: 'textbox', + 'aria-label': t('text', 'Text editor'), + }, } const createRichEditor = ({