diff --git a/.github/workflows/ci-cd.yaml b/.github/workflows/ci-cd.yaml index c513ed23d..0157d17b6 100644 --- a/.github/workflows/ci-cd.yaml +++ b/.github/workflows/ci-cd.yaml @@ -68,6 +68,21 @@ jobs: working-directory: frontend run: npm run lint:check + - name: Run accessibility tests + run: | + npm run test -- -t "accessibility" + + - name: Generate accessibility report + run: | + npm run test:a11y + continue-on-error: true # Don't fail the build, but record issues + + - name: Upload accessibility report + uses: actions/upload-artifact@v2 + with: + name: accessibility-report + path: accessibility-report.json + - name: Check for uncommitted changes run: | git diff --exit-code || (echo 'Unstaged changes detected. \ @@ -174,6 +189,7 @@ jobs: - name: Run frontend tests run: | docker run --env-file frontend/.env.example ${{ env.DOCKERHUB_USERNAME }}/owasp-nest-test-frontend:latest npm run test + build-docker-staging-images: name: Build Docker Staging Images diff --git a/frontend/__tests__/src/pages/ChapterDetails.test.tsx b/frontend/__tests__/src/pages/ChapterDetails.test.tsx index a1fef7521..8577e4be8 100644 --- a/frontend/__tests__/src/pages/ChapterDetails.test.tsx +++ b/frontend/__tests__/src/pages/ChapterDetails.test.tsx @@ -1,11 +1,13 @@ import { screen, waitFor } from '@testing-library/react' - import { fetchAlgoliaData } from 'api/fetchAlgoliaData' +import { axe, toHaveNoViolations } from 'jest-axe' import { ChapterDetailsPage } from 'pages' import { render } from 'wrappers/testUtil' import { mockChapterData } from '@tests/data/mockChapterData' +expect.extend(toHaveNoViolations) + jest.mock('api/fetchAlgoliaData', () => ({ fetchAlgoliaData: jest.fn(), })) @@ -26,6 +28,18 @@ describe('ChapterDetailsPage Component', () => { jest.clearAllMocks() }) + test('should not have any accessibility violations', async () => { + const { container } = render() + + // Wait for content to load + await waitFor(() => { + expect(screen.queryByText('Loading indicator')).not.toBeInTheDocument() + }) + + const results = await axe(container) + expect(results).toHaveNoViolations() + }) + test('renders loading spinner initially', async () => { render() const loadingSpinner = screen.getAllByAltText('Loading indicator') diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 1077e3ce0..607d209a5 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -7,6 +7,7 @@ import typescriptParser from '@typescript-eslint/parser' import prettierConfig from 'eslint-config-prettier' import importPlugin from 'eslint-plugin-import' import jest from 'eslint-plugin-jest' +import jsxA11y from 'eslint-plugin-jsx-a11y' import prettier from 'eslint-plugin-prettier' import react from 'eslint-plugin-react' import reactHooks from 'eslint-plugin-react-hooks' @@ -41,6 +42,7 @@ export default [ jest, prettier, react, + 'jsx-a11y': jsxA11y, }, settings: { 'import/resolver': { @@ -59,6 +61,19 @@ export default [ rules: { ...jest.configs.recommended.rules, ...prettierConfig.rules, + 'jsx-a11y/alt-text': 'error', + 'jsx-a11y/anchor-has-content': 'error', + 'jsx-a11y/anchor-is-valid': 'error', + 'jsx-a11y/aria-props': 'error', + 'jsx-a11y/aria-proptypes': 'error', + 'jsx-a11y/aria-unsupported-elements': 'error', + 'jsx-a11y/role-has-required-aria-props': 'error', + 'jsx-a11y/role-supports-aria-props': 'error', + 'jsx-a11y/tabindex-no-positive': 'error', + 'jsx-a11y/no-redundant-roles': 'error', + 'jsx-a11y/label-has-associated-control': 'error', + 'jsx-a11y/no-autofocus': 'warn', + 'jsx-a11y/no-noninteractive-tabindex': 'error', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'warn', diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts index 928fcbe07..4de76096a 100644 --- a/frontend/jest.setup.ts +++ b/frontend/jest.setup.ts @@ -1,12 +1,20 @@ import '@testing-library/jest-dom' import { TextEncoder } from 'util' import dotenv from 'dotenv' +import { toHaveNoViolations } from 'jest-axe' import React from 'react' dotenv.config() +expect.extend(toHaveNoViolations) global.React = React global.TextEncoder = TextEncoder +global.axe = { + run: async () => ({ + violations: [], + }), + configure: () => null, +} beforeEach(() => { jest.spyOn(console, 'error').mockImplementation((...args) => { @@ -28,4 +36,21 @@ beforeEach(() => { }) }) +expect.extend({ + toBeAccessible: async (received) => { + const results = await global.axe.run(received) + return { + pass: results.violations.length === 0, + message: () => + results.violations.length === 0 + ? 'Expected element to not be accessible' + : `Expected element to be accessible but found violations:\n${JSON.stringify( + results.violations, + null, + 2 + )}`, + } + }, +}) + jest.mock('@algolia/autocomplete-theme-classic', () => ({})) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2c06e7894..22490d47c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -46,12 +46,14 @@ "tailwindcss-animate": "^1.0.7" }, "devDependencies": { + "@axe-core/react": "^4.10.1", "@eslint/js": "^9.15.0", "@swc/core": "^1.10.7", "@swc/jest": "^0.2.37", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@types/jest": "^29.5.14", + "@types/jest-axe": "^3.5.9", "@types/mocha": "^10.0.10", "@types/node": "^22.10.7", "@types/react": "^19.0.6", @@ -67,6 +69,7 @@ "eslint-import-resolver-alias": "^1.1.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jest": "^28.9.0", + "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-react": "^7.37.4", "eslint-plugin-react-hooks": "^5.1.0", @@ -74,6 +77,7 @@ "globals": "^15.14.0", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", + "jest-axe": "^9.0.0", "jest-environment-jsdom": "^29.7.0", "open": "^10.1.0", "postcss": "^8.4.47", @@ -389,6 +393,17 @@ "node": ">=6.0.0" } }, + "node_modules/@axe-core/react": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@axe-core/react/-/react-4.10.1.tgz", + "integrity": "sha512-GRGx/3Gbce9WQYXgY0iZzqOaHIRXBNGyV+zoUhJzBuDeoTL+XoeY3u/9PzdIVyH0pbNs1n1RChfsmWBy6hzKPg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.10.2", + "requestidlecallback": "^0.3.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -1754,12 +1769,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -3634,6 +3643,27 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/jest-axe": { + "version": "3.5.9", + "resolved": "https://registry.npmjs.org/@types/jest-axe/-/jest-axe-3.5.9.tgz", + "integrity": "sha512-z98CzR0yVDalCEuhGXXO4/zN4HHuSebAukXDjTLJyjEAgoUf1H1i+sr7SUB/mz8CRS/03/XChsx0dcLjHkndoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jest": "*", + "axe-core": "^3.5.5" + } + }, + "node_modules/@types/jest-axe/node_modules/axe-core": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-3.5.6.tgz", + "integrity": "sha512-LEUDjgmdJoA3LqklSTwKYqkjcZ4HKc4ddIYGSAiSkr46NTjzg2L9RNB+lekO9P7Dlpa87+hBtzc2Fzn/+GUWMQ==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/@types/jest/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", @@ -4355,6 +4385,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -4422,6 +4459,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axe-core": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.2.tgz", + "integrity": "sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axios": { "version": "1.7.9", "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", @@ -4433,6 +4480,16 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -5083,6 +5140,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/data-urls": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", @@ -5980,6 +6044,70 @@ } } }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint-plugin-prettier": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", @@ -7838,6 +7966,83 @@ } } }, + "node_modules/jest-axe": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/jest-axe/-/jest-axe-9.0.0.tgz", + "integrity": "sha512-Xt7O0+wIpW31lv0SO1wQZUTyJE7DEmnDEZeTt9/S9L5WUywxrv8BrgvTuQEqujtfaQOcJ70p4wg7UUgK1E2F5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "axe-core": "4.9.1", + "chalk": "4.1.2", + "jest-matcher-utils": "29.2.2", + "lodash.merge": "4.6.2" + }, + "engines": { + "node": ">= 16.0.0" + } + }, + "node_modules/jest-axe/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-axe/node_modules/axe-core": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.9.1.tgz", + "integrity": "sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/jest-axe/node_modules/jest-matcher-utils": { + "version": "29.2.2", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.2.2.tgz", + "integrity": "sha512-4DkJ1sDPT+UX2MR7Y3od6KtvRi9Im1ZGLGgdLFLm4lPexbTaCgJW5NN3IOXlQHF7NSHY/VHhflQ+WoKtD/vyCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.2.1", + "jest-get-type": "^29.2.0", + "pretty-format": "^29.2.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-axe/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-axe/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/jest-changed-files": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", @@ -10440,6 +10645,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/requestidlecallback": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/requestidlecallback/-/requestidlecallback-0.3.0.tgz", + "integrity": "sha512-TWHFkT7S9p7IxLC5A1hYmAYQx2Eb9w1skrXmQ+dS1URyvR8tenMLl4lHbqEOUnpEYxNKpkVMXUgknVpBZWXXfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -10978,7 +11190,7 @@ "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=8" + "node": ">= 0.4" } }, "node_modules/string.prototype.matchall": { diff --git a/frontend/package.json b/frontend/package.json index 98fb77fb5..a63538e6a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,7 +15,9 @@ "preview": "vite preview", "test": "tsc --noEmit && NODE_OPTIONS=--no-warnings=DEP0040 jest", "test:coverage": "jest --coverage && chmod +x scripts/open-coverage.sh && scripts/open-coverage.sh", - "watch": "jest --watch" + "watch": "jest --watch", + "test:a11y": "jest --config jest.config.ts --testMatch='**/*.test.{ts,tsx}'", + "test:a11y:watch": "jest --config jest.config.ts --testMatch='**/*.test.{ts,tsx}' --watch" }, "dependencies": { "@algolia/autocomplete-js": "^1.17.9", @@ -56,12 +58,14 @@ "tailwindcss-animate": "^1.0.7" }, "devDependencies": { + "@axe-core/react": "^4.10.1", "@eslint/js": "^9.15.0", "@swc/core": "^1.10.7", "@swc/jest": "^0.2.37", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@types/jest": "^29.5.14", + "@types/jest-axe": "^3.5.9", "@types/mocha": "^10.0.10", "@types/node": "^22.10.7", "@types/react": "^19.0.6", @@ -77,6 +81,7 @@ "eslint-import-resolver-alias": "^1.1.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jest": "^28.9.0", + "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-react": "^7.37.4", "eslint-plugin-react-hooks": "^5.1.0", @@ -84,6 +89,7 @@ "globals": "^15.14.0", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", + "jest-axe": "^9.0.0", "jest-environment-jsdom": "^29.7.0", "open": "^10.1.0", "postcss": "^8.4.47", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 12b369f78..85282db43 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,4 +1,7 @@ +import axe from '@axe-core/react' import { StrictMode } from 'react' +import React from 'react' +import ReactDOM from 'react-dom' import { createRoot } from 'react-dom/client' import './index.css' import TagManager from 'react-gtm-module' @@ -16,6 +19,10 @@ const tagManagerArgs = { TagManager.initialize(tagManagerArgs) +if (process.env.NODE_ENV !== 'production') { + axe(React, ReactDOM, 1000) // Logs violations to the browser console +} + createRoot(document.getElementById('root')!).render(