diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..cfa72d32 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,48 @@ +name: "(Lint): CSS, JS, PHP" + +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + push: + branches: + - 'core' + - 'pro' + paths-ignore: + - '**.md' + - '**.txt' + - '.gitignore' + - 'docs/**' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: lint-${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: stylelint, eslint, phpcs + runs-on: ubuntu-22.04 + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: 'npm' + + - name: Install npm dependencies + run: npm ci + + - name: Run Stylelint + run: npm run lint:styles + + - name: Run ESLint + run: npm run lint:js + + - name: Run PHP CS + run: npm run lint:php diff --git a/eslint.config.mjs b/eslint.config.mjs index 63e623ca..d8647050 100755 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,6 +6,7 @@ import eslintTs from 'typescript-eslint' import stylistic from '@stylistic/eslint-plugin' import reactHooks from 'eslint-plugin-react-hooks' import importPlugin from 'eslint-plugin-import' +import jsxA11yPlugin from 'eslint-plugin-jsx-a11y' import reactPlugin from 'eslint-plugin-react' import { FlatCompat } from '@eslint/eslintrc' @@ -21,6 +22,7 @@ export default eslintTs.config( ...compat.extends('plugin:react-hooks/recommended'), reactPlugin.configs.flat.recommended, importPlugin.flatConfigs.recommended, + jsxA11yPlugin.flatConfigs.strict, { plugins: { 'react-hooks': reactHooks }, rules: reactHooks.configs.recommended.rules, @@ -127,7 +129,30 @@ export default eslintTs.config( 'prefer-named-capture-group': 'error', 'prefer-template': 'error', 'sort-imports': ['error', { ignoreDeclarationSort: true }], - 'yoda': ['error', 'always'] + 'yoda': ['error', 'always'], + // Accessibility 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-role': 'error', + 'jsx-a11y/aria-unsupported-elements': 'error', + 'jsx-a11y/click-events-have-key-events': 'error', + 'jsx-a11y/control-has-associated-label': 'error', + 'jsx-a11y/heading-has-content': 'error', + 'jsx-a11y/iframe-has-title': 'error', + 'jsx-a11y/img-redundant-alt': 'error', + 'jsx-a11y/interactive-supports-focus': 'error', + 'jsx-a11y/label-has-associated-control': 'error', + 'jsx-a11y/no-autofocus': 'error', + 'jsx-a11y/no-noninteractive-element-interactions': 'error', + 'jsx-a11y/no-noninteractive-tabindex': 'error', + 'jsx-a11y/no-redundant-roles': 'error', + 'jsx-a11y/no-static-element-interactions': 'error', + 'jsx-a11y/role-has-required-aria-props': 'error', + 'jsx-a11y/role-supports-aria-props': 'error', + 'jsx-a11y/tabindex-no-positive': 'error' } }, { diff --git a/package-lock.json b/package-lock.json index 10d1aa31..9c17ed43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "react-select": "^5.10.0" }, "devDependencies": { + "@axe-core/playwright": "^4.11.2", "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.20.0", "@playwright/test": "^1.48.0", @@ -54,6 +55,7 @@ "eslint": "^9.20.1", "eslint-import-resolver-typescript": "^3.7.0", "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.4", "eslint-plugin-react-hooks": "^5.1.0", "eslint-webpack-plugin": "^4.2.0", @@ -129,6 +131,19 @@ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@axe-core/playwright": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.3.tgz", + "integrity": "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.4" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.26.2", "license": "MIT", @@ -2615,6 +2630,27 @@ "@parcel/watcher-win32-x64": "2.5.0" } }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz", + "integrity": "sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@parcel/watcher-darwin-arm64": { "version": "2.5.0", "cpu": [ @@ -2634,6 +2670,255 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.0.tgz", + "integrity": "sha512-9rhlwd78saKf18fT869/poydQK8YqlU26TMiNg7AIu7eBp9adqbJZqmdFOsbZ5cnLp5XvRo9wcFmNHgHdWaGYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.0.tgz", + "integrity": "sha512-syvfhZzyM8kErg3VF0xpV8dixJ+RzbUaaGaeb7uDuz0D3FK97/mZ5AJQ3XNnDsXX7KkFNtyQyFrXZzQIcN49Tw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.0.tgz", + "integrity": "sha512-0VQY1K35DQET3dVYWpOaPFecqOT9dbuCfzjxoQyif1Wc574t3kOSkKevULddcR9znz1TcklCE7Ht6NIxjvTqLA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.0.tgz", + "integrity": "sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.0.tgz", + "integrity": "sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.0.tgz", + "integrity": "sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.0.tgz", + "integrity": "sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.0.tgz", + "integrity": "sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.0.tgz", + "integrity": "sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.0.tgz", + "integrity": "sha512-+rgpsNRKwo8A53elqbbHXdOMtY/tAtTzManTWShB5Kk54N8Q9mzNWV7tV+IbGueCbcj826MfWGU3mprWtuf1TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.0.tgz", + "integrity": "sha512-lPrxve92zEHdgeff3aiu4gDOIt4u7sJYha6wbdEZDCDUhtjTsOMiaJzG5lMY4GkWH8p0fMmO2Ppq5G5XXG+DQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "dev": true, @@ -4764,6 +5049,16 @@ "dev": true, "license": "Python-2.0" }, + "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/array-buffer-byte-length": { "version": "1.0.2", "dev": true, @@ -4922,6 +5217,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/astral-regex": { "version": "2.0.0", "dev": true, @@ -4999,6 +5301,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axe-core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axios": { "version": "1.13.5", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", @@ -5010,6 +5322,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/b4a": { "version": "1.6.6", "dev": true, @@ -6221,6 +6543,13 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "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-view-buffer": { "version": "1.0.2", "dev": true, @@ -7028,6 +7357,36 @@ "strip-bom": "^3.0.0" } }, + "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-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", @@ -9051,6 +9410,26 @@ "dev": true, "license": "MIT" }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/lazystream": { "version": "1.0.1", "dev": true, @@ -12068,6 +12447,21 @@ "dev": true, "license": "MIT" }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "dev": true, diff --git a/package.json b/package.json index ca44ad34..4dca5023 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "react-select": "^5.10.0" }, "devDependencies": { + "@axe-core/playwright": "^4.11.2", "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.20.0", "@playwright/test": "^1.48.0", @@ -96,6 +97,7 @@ "eslint": "^9.20.1", "eslint-import-resolver-typescript": "^3.7.0", "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.4", "eslint-plugin-react-hooks": "^5.1.0", "eslint-webpack-plugin": "^4.2.0", diff --git a/tests/e2e/accessibility.spec.ts b/tests/e2e/accessibility.spec.ts new file mode 100644 index 00000000..df01cea8 --- /dev/null +++ b/tests/e2e/accessibility.spec.ts @@ -0,0 +1,96 @@ +import AxeBuilder from '@axe-core/playwright' +import { expect, test } from '@playwright/test' +import { SELECTORS, TIMEOUTS, URLS } from './helpers/constants' +import type { Page } from '@playwright/test' + +const A11Y_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] + +const runAxe = (page: Page) => + new AxeBuilder({ page }) + .withTags(A11Y_TAGS) + // Skip rules that target areas owned by WordPress core admin chrome + // rather than this plugin (skip-link target, default landmarks). + .disableRules(['region', 'skip-link']) + .analyze() + +test.describe('Accessibility (axe-core, WCAG 2.1 AA)', () => { + test('Manage snippets list has no detectable axe violations', async ({ page }) => { + await page.goto(URLS.SNIPPETS_ADMIN) + await page.waitForLoadState('networkidle') + + const results = await runAxe(page) + expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]) + }) + + test('Community cloud screen has no detectable axe violations', async ({ page }) => { + await page.goto(URLS.COMMUNITY_CLOUD_ADMIN) + await page.waitForLoadState('networkidle') + + const results = await runAxe(page) + expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]) + }) + + test('Add new snippet form has no detectable axe violations', async ({ page }) => { + await page.goto(URLS.ADD_SNIPPET_ADMIN) + await page.waitForLoadState('networkidle') + + const results = await runAxe(page) + expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]) + }) + + test('Import snippets screen has no detectable axe violations', async ({ page }) => { + await page.goto(URLS.IMPORT_SNIPPETS_ADMIN) + await page.waitForLoadState('networkidle') + + const results = await runAxe(page) + expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]) + }) + + test('Snippets settings screen has no detectable axe violations', async ({ page }) => { + await page.goto(URLS.SETTINGS_ADMIN) + await page.waitForLoadState('networkidle') + + const results = await runAxe(page) + expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]) + }) + + test('Welcome screen has no detectable axe violations', async ({ page }) => { + await page.goto(URLS.WELCOME_SCREEN_ADMIN) + await page.waitForLoadState('networkidle') + + const results = await runAxe(page) + expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]) + }) +}) + +test.describe('Accessibility (manual checks)', () => { + test('Snippets table with sortable column use buttons with aria-sort', async ({ page }) => { + await page.goto(URLS.SNIPPETS_ADMIN) + await page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT }) + + const nameSortButton = page.locator('th.column-name .list-table-sort-button').first() + await expect(nameSortButton).toBeVisible() + + const nameHeader = page.locator('th.column-name').first() + const ariaSort = await nameHeader.getAttribute('aria-sort') + expect(['ascending', 'descending', null].includes(ariaSort)).toBe(true) + }) + + test('Snippets edit screen associates Snippet Content label with the code field', async ({ page }) => { + await page.goto(URLS.ADD_SNIPPET_ADMIN) + await page.waitForSelector(SELECTORS.TITLE_INPUT, { timeout: TIMEOUTS.DEFAULT }) + + await expect(page.locator('label[for="snippet-code"]')).toBeVisible() + await expect(page.locator('#snippet-code')).toBeVisible() + }) + + test('Snippets import screen has a keyboard-focusable upload file control', async ({ page }) => { + await page.goto(URLS.IMPORT_SNIPPETS_ADMIN) + await page.waitForSelector('.import-snippets-menu', { timeout: TIMEOUTS.DEFAULT }) + + const fileInput = page.locator('.upload-drop-zone-file-input') + await fileInput.focus() + + await expect(fileInput).toBeFocused() + }) +}) diff --git a/tests/e2e/helpers/SnippetsTestHelper.ts b/tests/e2e/helpers/SnippetsTestHelper.ts index 7daae5ff..a7eb29e9 100644 --- a/tests/e2e/helpers/SnippetsTestHelper.ts +++ b/tests/e2e/helpers/SnippetsTestHelper.ts @@ -249,7 +249,7 @@ export class SnippetsTestHelper { * Click the "Add New" button to start creating a snippet */ async clickAddNewSnippet(): Promise { - await this.page.goto(URLS.ADD_SNIPPET) + await this.page.goto(URLS.ADD_SNIPPET_ADMIN) await this.page.waitForSelector(SELECTORS.TITLE_INPUT, { timeout: TIMEOUTS.DEFAULT }) } diff --git a/tests/e2e/helpers/constants.ts b/tests/e2e/helpers/constants.ts index 604a5a17..38efa6a4 100644 --- a/tests/e2e/helpers/constants.ts +++ b/tests/e2e/helpers/constants.ts @@ -26,7 +26,11 @@ export const TIMEOUTS = { export const URLS = { SNIPPETS_ADMIN: '/wp-admin/admin.php?page=snippets', - ADD_SNIPPET: '/wp-admin/admin.php?page=add-snippet', + COMMUNITY_CLOUD_ADMIN: '/wp-admin/admin.php?page=snippets&subpage=cloud-community', + ADD_SNIPPET_ADMIN: '/wp-admin/admin.php?page=add-snippet', + IMPORT_SNIPPETS_ADMIN: '/wp-admin/admin.php?page=import-code-snippets', + SETTINGS_ADMIN: '/wp-admin/admin.php?page=snippets-settings', + WELCOME_SCREEN_ADMIN: '/wp-admin/admin.php?page=code-snippets-welcome', FRONTEND: '/' }