diff --git a/.github/workflows/accessibility.yml b/.github/workflows/accessibility.yml new file mode 100644 index 000000000..a4a6c3610 --- /dev/null +++ b/.github/workflows/accessibility.yml @@ -0,0 +1,33 @@ +name: Accessibility check +on: + push: + branches: + - master + - main + pull_request: + branches: + - master + - main + +jobs: + axe: + name: axe-core (WCAG 2.x A/AA) + runs-on: ubuntu-latest + steps: + - name: Checkout the latest commit + uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - name: Install dependencies + run: npm install + - name: Run accessibility tests + run: | + npx start-server-and-test start http://localhost:3000 "cypress run --spec cypress/e2e/accessibility/accessibility.cy.js" + - name: Upload failure screenshots + if: failure() + uses: actions/upload-artifact@v4 + with: + name: accessibility-failure-screenshots + path: cypress/screenshots/accessibility/accessibility.cy.js + if-no-files-found: ignore diff --git a/cypress.config.js b/cypress.config.js index 03aa59415..f0ffcb2a5 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -8,7 +8,14 @@ module.exports = defineConfig({ supportFile: false, baseUrl: 'http://localhost:3000/', setupNodeEvents(on, config) { - // implement node event listeners here + on('task', { + // Lets cypress-axe violation summaries print to the CI log, since + // cy.log output isn't captured in `cypress run` terminal output. + log(message) { + console.log(message); + return null; + }, + }); }, }, }); diff --git a/cypress/e2e/accessibility/accessibility.cy.js b/cypress/e2e/accessibility/accessibility.cy.js new file mode 100644 index 000000000..699fb2899 --- /dev/null +++ b/cypress/e2e/accessibility/accessibility.cy.js @@ -0,0 +1,50 @@ +import 'cypress-axe'; + +// One representative page per distinct template/component mix, rather than a +// full-site crawl: this keeps the check fast in CI while still exercising +// every reusable pattern (nav + prose, deprecated-content styling, migration +// tables, the interactive release-notes filter/collapse widgets). +const PAGES = [ + { path: '/', label: 'home / docs index' }, + { path: '/dashboard/security/users', label: 'dashboard doc page' }, + { path: '/framework/deprecated/pubsub', label: 'deprecated framework doc page' }, + { path: '/support/migrate-v5-to-v6', label: 'migration guide (tables + admonitions)' }, + { path: '/support/release-notes', label: 'release notes (version filter + collapsible sections)' }, +]; + +// axe-core rule tags to run: WCAG 2.0/2.1/2.2 Level A + AA, which includes +// contrast, ARIA, labeling, heading order, and the 2.2 "target-size" (touch +// target) rule. +const RUN_TAGS = ['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']; + +// Only fail the build for violations a real user would actually hit. +const INCLUDED_IMPACTS = ['critical', 'serious']; + +function logViolations(violations) { + cy.task( + 'log', + `${violations.length} accessibility violation${violations.length === 1 ? '' : 's'} detected:`, + ); + const summary = violations.map(({ id, impact, description, helpUrl, nodes }) => ({ + id, + impact, + description, + helpUrl, + affectedElements: nodes.map((n) => n.target.join(' ')), + })); + cy.task('log', JSON.stringify(summary, null, 2)); +} + +describe('Accessibility (axe-core)', () => { + PAGES.forEach(({ path, label }) => { + it(`has no critical/serious violations on ${label} (${path})`, () => { + cy.visit(path); + cy.injectAxe(); + cy.checkA11y( + null, + { runOnly: { type: 'tag', values: RUN_TAGS }, includedImpacts: INCLUDED_IMPACTS }, + logViolations, + ); + }); + }); +}); diff --git a/package.json b/package.json index 400196e5e..7bfa8e1e4 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", "cy:open": "cypress open", - "cy:run": "cypress run", + "cy:run": "cypress run --spec \"cypress/e2e/*.cy.js\"", "test": "start-server-and-test start http://localhost:3000 cy:run", "index:push": "pwsh scripts/refresh-search-index.ps1", "issue-templates:sync-versions": "pwsh scripts/update-issue-template-versions.ps1" @@ -54,11 +54,13 @@ "@docusaurus/module-type-aliases": "3.10.2", "@docusaurus/plugin-content-docs": "3.10.2", "@docusaurus/types": "3.10.2", + "axe-core": "^4.12.1", "cypress": "^15.13.1", + "cypress-axe": "^1.7.0", "dotenv": "^17.4.2", "start-server-and-test": "^2.1.5" }, "engines": { "node": ">=20.0" } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 873edde99..b33465610 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,9 +69,15 @@ importers: '@docusaurus/types': specifier: 3.10.2 version: 3.10.2(@swc/core@1.15.47)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + axe-core: + specifier: ^4.12.1 + version: 4.13.0 cypress: specifier: ^15.13.1 version: 15.13.1 + cypress-axe: + specifier: ^1.7.0 + version: 1.7.0(axe-core@4.13.0)(cypress@15.13.1) dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -2437,6 +2443,10 @@ packages: aws4@1.13.2: resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} + engines: {node: '>=4'} + axios@1.15.0: resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} @@ -2969,6 +2979,13 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cypress-axe@1.7.0: + resolution: {integrity: sha512-zzJpvAAjauEB3GZl0KYXb8i3w6MztWAt2WM3czYTFyNVC30alDmqCm9E7GwZ4bgkldZJlmHakaVEyu73R5St4w==} + engines: {node: '>=10'} + peerDependencies: + axe-core: ^3 || ^4 + cypress: ^10 || ^11 || ^12 || ^13 || ^14 || ^15 + cypress@15.13.1: resolution: {integrity: sha512-jLkgo75zlwo7PhXp0XJot+zIfFSDzN1SvTml6Xf3ETM1XHRWnH3Q4LAR3orCo/BsnxPnhjG3m5HYSvn9DAtwBg==} engines: {node: ^20.1.0 || ^22.0.0 || >=24.0.0} @@ -9502,6 +9519,8 @@ snapshots: aws4@1.13.2: {} + axe-core@4.13.0: {} + axios@1.15.0(debug@4.4.3): dependencies: follow-redirects: 1.15.11(debug@4.4.3) @@ -10089,6 +10108,11 @@ snapshots: csstype@3.2.3: {} + cypress-axe@1.7.0(axe-core@4.13.0)(cypress@15.13.1): + dependencies: + axe-core: 4.13.0 + cypress: 15.13.1 + cypress@15.13.1: dependencies: '@cypress/request': 3.0.10