From dfc974047846347375b819ab3b1b21f2705b4b39 Mon Sep 17 00:00:00 2001 From: Rami Yushuvaev Date: Sat, 2 May 2026 09:36:16 +0300 Subject: [PATCH 01/10] Tooling: install the jsx-a11y plugin --- .github/workflows/lint.yml | 48 ++++ eslint.config.mjs | 29 ++- package-lock.json | 394 ++++++++++++++++++++++++++++++++ package.json | 2 + tests/e2e/accessibility.spec.ts | 48 ++++ 5 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/lint.yml create mode 100644 tests/e2e/accessibility.spec.ts diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..118411fb --- /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: Lint CSS / JS / PHP (eslint + stylelint + phpcbf + 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..1d079417 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.recommended, { plugins: { 'react-hooks': reactHooks }, rules: reactHooks.configs.recommended.rules, @@ -127,7 +129,32 @@ 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. Start as warnings so existing code is surfaced + // without breaking CI; promote to error once the audit backlog is + // cleared. + 'jsx-a11y/alt-text': 'warn', + 'jsx-a11y/anchor-has-content': 'warn', + 'jsx-a11y/anchor-is-valid': 'warn', + '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': 'warn', + 'jsx-a11y/control-has-associated-label': 'warn', + 'jsx-a11y/heading-has-content': 'warn', + 'jsx-a11y/iframe-has-title': 'warn', + 'jsx-a11y/img-redundant-alt': 'warn', + 'jsx-a11y/interactive-supports-focus': 'warn', + 'jsx-a11y/label-has-associated-control': 'warn', + 'jsx-a11y/no-autofocus': 'warn', + 'jsx-a11y/no-noninteractive-element-interactions': 'warn', + 'jsx-a11y/no-noninteractive-tabindex': 'warn', + 'jsx-a11y/no-redundant-roles': 'warn', + 'jsx-a11y/no-static-element-interactions': 'warn', + 'jsx-a11y/role-has-required-aria-props': 'error', + 'jsx-a11y/role-supports-aria-props': 'error', + 'jsx-a11y/tabindex-no-positive': 'warn' } }, { 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..05d03b8e --- /dev/null +++ b/tests/e2e/accessibility.spec.ts @@ -0,0 +1,48 @@ +import AxeBuilder from '@axe-core/playwright' +import { expect, test } from '@playwright/test' +import { 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('Welcome screen has no detectable axe violations', async ({ page }) => { + await page.goto('/wp-admin/admin.php?page=code-snippets-welcome') + await page.waitForLoadState('networkidle') + + const results = await runAxe(page) + expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]) + }) + + 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('Add new snippet form has no detectable axe violations', async ({ page }) => { + await page.goto(URLS.ADD_SNIPPET) + 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('/wp-admin/admin.php?page=code-snippets-import') + await page.waitForLoadState('networkidle') + + const results = await runAxe(page) + expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]) + }) +}) From d77e34b477a11d4e2cde8ba24bbcd9decd375845 Mon Sep 17 00:00:00 2001 From: Rami Yushuvaev Date: Sat, 2 May 2026 09:59:22 +0300 Subject: [PATCH 02/10] Tooling: install the jsx-a11y plugin --- .github/workflows/lint.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 118411fb..cfa72d32 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,4 +1,4 @@ -name: "(Lint): CSS / JS / PHP" +name: "(Lint): CSS, JS, PHP" on: pull_request: @@ -23,7 +23,7 @@ concurrency: jobs: lint: - name: Lint CSS / JS / PHP (eslint + stylelint + phpcbf + phpcs) + name: stylelint, eslint, phpcs runs-on: ubuntu-22.04 steps: - name: Checkout source code From 53df2fda9da066698f2ccf31c10196423c644741 Mon Sep 17 00:00:00 2001 From: Rami Yushuvaev Date: Sat, 2 May 2026 10:05:37 +0300 Subject: [PATCH 03/10] A11y Test: update import screen URL --- tests/e2e/accessibility.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/accessibility.spec.ts b/tests/e2e/accessibility.spec.ts index 05d03b8e..edba1500 100644 --- a/tests/e2e/accessibility.spec.ts +++ b/tests/e2e/accessibility.spec.ts @@ -39,7 +39,7 @@ test.describe('Accessibility (axe-core, WCAG 2.1 AA)', () => { }) test('Import snippets screen has no detectable axe violations', async ({ page }) => { - await page.goto('/wp-admin/admin.php?page=code-snippets-import') + await page.goto('/wp-admin/admin.php?page=import-code-snippets') await page.waitForLoadState('networkidle') const results = await runAxe(page) From 0886e575a4d3bc32203e203a2d38f3792d0f6a50 Mon Sep 17 00:00:00 2001 From: Rami Yushuvaev Date: Mon, 4 May 2026 12:04:49 +0300 Subject: [PATCH 04/10] Accessibility rules --- eslint.config.mjs | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 1d079417..4256239e 100755 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -130,31 +130,29 @@ export default eslintTs.config( 'prefer-template': 'error', 'sort-imports': ['error', { ignoreDeclarationSort: true }], 'yoda': ['error', 'always'], - // Accessibility rules. Start as warnings so existing code is surfaced - // without breaking CI; promote to error once the audit backlog is - // cleared. - 'jsx-a11y/alt-text': 'warn', - 'jsx-a11y/anchor-has-content': 'warn', - 'jsx-a11y/anchor-is-valid': 'warn', + // 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': 'warn', - 'jsx-a11y/control-has-associated-label': 'warn', - 'jsx-a11y/heading-has-content': 'warn', - 'jsx-a11y/iframe-has-title': 'warn', - 'jsx-a11y/img-redundant-alt': 'warn', - 'jsx-a11y/interactive-supports-focus': 'warn', - 'jsx-a11y/label-has-associated-control': 'warn', - 'jsx-a11y/no-autofocus': 'warn', - 'jsx-a11y/no-noninteractive-element-interactions': 'warn', - 'jsx-a11y/no-noninteractive-tabindex': 'warn', - 'jsx-a11y/no-redundant-roles': 'warn', - 'jsx-a11y/no-static-element-interactions': 'warn', + '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': 'warn' + 'jsx-a11y/tabindex-no-positive': 'error' } }, { From a0fec61dc16b293ea0050347bc18082d38574e3c Mon Sep 17 00:00:00 2001 From: Rami Yushuvaev Date: Tue, 5 May 2026 10:06:06 +0300 Subject: [PATCH 05/10] Replace `recommended` with `strict` --- eslint.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 4256239e..d8647050 100755 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -22,7 +22,7 @@ export default eslintTs.config( ...compat.extends('plugin:react-hooks/recommended'), reactPlugin.configs.flat.recommended, importPlugin.flatConfigs.recommended, - jsxA11yPlugin.flatConfigs.recommended, + jsxA11yPlugin.flatConfigs.strict, { plugins: { 'react-hooks': reactHooks }, rules: reactHooks.configs.recommended.rules, From 32cf2f9c2dcfee8ae8992db5eac823bf575fa123 Mon Sep 17 00:00:00 2001 From: Rami Yushuvaev Date: Tue, 5 May 2026 10:27:42 +0300 Subject: [PATCH 06/10] Add more a11y tests --- tests/e2e/accessibility.spec.ts | 62 ++++++++++++++++++++++--- tests/e2e/helpers/SnippetsTestHelper.ts | 2 +- tests/e2e/helpers/constants.ts | 6 ++- 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/tests/e2e/accessibility.spec.ts b/tests/e2e/accessibility.spec.ts index edba1500..df01cea8 100644 --- a/tests/e2e/accessibility.spec.ts +++ b/tests/e2e/accessibility.spec.ts @@ -1,6 +1,6 @@ import AxeBuilder from '@axe-core/playwright' import { expect, test } from '@playwright/test' -import { URLS } from './helpers/constants' +import { SELECTORS, TIMEOUTS, URLS } from './helpers/constants' import type { Page } from '@playwright/test' const A11Y_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] @@ -14,16 +14,16 @@ const runAxe = (page: Page) => .analyze() test.describe('Accessibility (axe-core, WCAG 2.1 AA)', () => { - test('Welcome screen has no detectable axe violations', async ({ page }) => { - await page.goto('/wp-admin/admin.php?page=code-snippets-welcome') + 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('Manage snippets list has no detectable axe violations', async ({ page }) => { - await page.goto(URLS.SNIPPETS_ADMIN) + 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) @@ -31,7 +31,7 @@ test.describe('Accessibility (axe-core, WCAG 2.1 AA)', () => { }) test('Add new snippet form has no detectable axe violations', async ({ page }) => { - await page.goto(URLS.ADD_SNIPPET) + await page.goto(URLS.ADD_SNIPPET_ADMIN) await page.waitForLoadState('networkidle') const results = await runAxe(page) @@ -39,10 +39,58 @@ test.describe('Accessibility (axe-core, WCAG 2.1 AA)', () => { }) test('Import snippets screen has no detectable axe violations', async ({ page }) => { - await page.goto('/wp-admin/admin.php?page=import-code-snippets') + 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: '/' } From bccbd6db02cad81cbb3b181cffdec391aed2f47f Mon Sep 17 00:00:00 2001 From: Rami Yushuvaev Date: Tue, 5 May 2026 21:56:58 +0300 Subject: [PATCH 07/10] Add more accessibility tests --- tests/e2e/accessibility.spec.ts | 325 +++++++++++++++++++++++++++++++- 1 file changed, 322 insertions(+), 3 deletions(-) diff --git a/tests/e2e/accessibility.spec.ts b/tests/e2e/accessibility.spec.ts index df01cea8..54d29e83 100644 --- a/tests/e2e/accessibility.spec.ts +++ b/tests/e2e/accessibility.spec.ts @@ -1,10 +1,13 @@ import AxeBuilder from '@axe-core/playwright' import { expect, test } from '@playwright/test' import { SELECTORS, TIMEOUTS, URLS } from './helpers/constants' +import { SnippetsTestHelper } from './helpers/SnippetsTestHelper' import type { Page } from '@playwright/test' const A11Y_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] +const A11Y_SNIPPET_PREFIX = 'E2E A11y Test' + const runAxe = (page: Page) => new AxeBuilder({ page }) .withTags(A11Y_TAGS) @@ -13,7 +16,7 @@ const runAxe = (page: Page) => .disableRules(['region', 'skip-link']) .analyze() -test.describe('Accessibility (axe-core, WCAG 2.1 AA)', () => { +test.describe('Accessibility - Automated tests (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') @@ -63,7 +66,18 @@ test.describe('Accessibility (axe-core, WCAG 2.1 AA)', () => { }) }) -test.describe('Accessibility (manual checks)', () => { +test.describe('Accessibility — Manage Snippets Screen', () => { + let snippetName: string + + test.beforeAll(async () => { + snippetName = SnippetsTestHelper.makeUniqueSnippetName(A11Y_SNIPPET_PREFIX) + await SnippetsTestHelper.createSnippetViaCli({ name: snippetName, active: true }) + }) + + test.afterAll(async () => { + await SnippetsTestHelper.cleanupSnippetsByPrefix(A11Y_SNIPPET_PREFIX) + }) + 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 }) @@ -76,7 +90,127 @@ test.describe('Accessibility (manual checks)', () => { expect(['ascending', 'descending', null].includes(ariaSort)).toBe(true) }) - test('Snippets edit screen associates Snippet Content label with the code field', async ({ page }) => { + test('Activation toggle has role="switch" and aria-checked', async ({ page }) => { + await page.goto(URLS.SNIPPETS_ADMIN) + await page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT }) + + const toggle = page.locator(`${SELECTORS.SNIPPET_ROW} input[role="switch"]`).first() + await expect(toggle).toBeVisible() + await expect(toggle).toHaveAttribute('role', 'switch') + + const ariaChecked = await toggle.getAttribute('aria-checked') + expect(['true', 'false'].includes(ariaChecked ?? '')).toBe(true) + }) + + test('Activation toggle has an associated screen-reader label', async ({ page }) => { + await page.goto(URLS.SNIPPETS_ADMIN) + await page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT }) + + const toggle = page.locator(`${SELECTORS.SNIPPET_ROW} input[role="switch"]`).first() + const toggleId = await toggle.getAttribute('id') + expect(toggleId).toBeTruthy() + + const label = page.locator(`label[for="${toggleId}"]`) + await expect(label).toHaveClass(/screen-reader-text/) + await expect(label).not.toBeEmpty() + }) + + test('Bulk action select has an accessible label', async ({ page }) => { + await page.goto(URLS.SNIPPETS_ADMIN) + await page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT }) + + const bulkSelect = page.locator('#bulk-action-selector-top') + await expect(bulkSelect).toBeVisible() + + const label = page.locator('label[for="bulk-action-selector-top"]') + await expect(label).toHaveClass(/screen-reader-text/) + await expect(label).not.toBeEmpty() + }) + + test('Search input has a screen-reader label', async ({ page }) => { + await page.goto(URLS.SNIPPETS_ADMIN) + await page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT }) + + const searchInput = page.locator(SELECTORS.SNIPPET_SEARCH_INPUT) + await expect(searchInput).toBeVisible() + + const label = page.locator('label[for="snippets_search"]') + await expect(label).toHaveClass(/screen-reader-text/) + await expect(label).not.toBeEmpty() + }) + + test('Search region has an aria-label', async ({ page }) => { + await page.goto(URLS.SNIPPETS_ADMIN) + await page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT }) + + const searchRegion = page.locator('search[aria-label]') + await expect(searchRegion).toBeVisible() + await expect(searchRegion).toHaveAttribute('aria-label', /\S+/) + }) + + test('Type filter tabs use aria-current="page" for active tab', async ({ page }) => { + await page.goto(URLS.SNIPPETS_ADMIN) + await page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT }) + + const typeNav = page.locator('nav.snippet-type-tabs') + await expect(typeNav).toHaveAttribute('aria-label', /\S+/) + + const activeTab = typeNav.locator('a[aria-current="page"]') + await expect(activeTab).toHaveCount(1) + }) + + test('Status filter links use aria-current="page" for active status', async ({ page }) => { + await page.goto(URLS.SNIPPETS_ADMIN) + await page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT }) + + const activeStatus = page.locator('.subsubsub a.current[aria-current="page"]') + await expect(activeStatus).toHaveCount(1) + }) + + test('Live region announces snippet count', async ({ page }) => { + await page.goto(URLS.SNIPPETS_ADMIN) + await page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT }) + + const liveRegion = page.locator('[role="status"][aria-live="polite"]') + await expect(liveRegion).toBeAttached() + await expect(liveRegion).toHaveText(/\d+ snippets? found/) + }) +}) + +test.describe('Accessibility — Add/Edit Snippet Screen', () => { + test('Title input has an associated label', async ({ page }) => { + await page.goto(URLS.ADD_SNIPPET_ADMIN) + await page.waitForSelector(SELECTORS.TITLE_INPUT, { timeout: TIMEOUTS.DEFAULT }) + + const label = page.locator('label[for="title"]') + await expect(label).toBeAttached() + await expect(label).not.toBeEmpty() + }) + + test('Code editor region has role="application" and aria-label', async ({ page }) => { + await page.goto(URLS.ADD_SNIPPET_ADMIN) + await page.waitForSelector(SELECTORS.TITLE_INPUT, { timeout: TIMEOUTS.DEFAULT }) + + const editorRegion = page.locator('.snippet-editor[role="application"]') + await expect(editorRegion).toBeVisible() + await expect(editorRegion).toHaveAttribute('aria-label', /\S+/) + }) + + test('Code editor has escape-to-tab-out instructions as screen-reader text', async ({ page }) => { + await page.goto(URLS.ADD_SNIPPET_ADMIN) + await page.waitForSelector(SELECTORS.TITLE_INPUT, { timeout: TIMEOUTS.DEFAULT }) + + const editorRegion = page.locator('.snippet-editor[role="application"]') + const describedById = await editorRegion.getAttribute('aria-describedby') + expect(describedById).toBeTruthy() + + const instructions = page.locator(`#${describedById}`) + await expect(instructions).toBeAttached() + await expect(instructions).toHaveClass(/screen-reader-text/) + await expect(instructions).toHaveText(/Escape/) + }) + + test('Snippet content label associates with the code field', async ({ page }) => { await page.goto(URLS.ADD_SNIPPET_ADMIN) await page.waitForSelector(SELECTORS.TITLE_INPUT, { timeout: TIMEOUTS.DEFAULT }) @@ -84,6 +218,62 @@ test.describe('Accessibility (manual checks)', () => { await expect(page.locator('#snippet-code')).toBeVisible() }) + test('Snippet type selector is keyboard-operable', async ({ page }) => { + await page.goto(URLS.ADD_SNIPPET_ADMIN) + await page.waitForSelector(SELECTORS.TITLE_INPUT, { timeout: TIMEOUTS.DEFAULT }) + + const typeSelect = page.locator('#snippet-type-select-input') + await typeSelect.focus() + await expect(typeSelect).toBeFocused() + + await page.keyboard.press('Space') + const menu = page.locator('.code-snippets-select-type .code-snippets-select__menu') + await expect(menu).toBeVisible() + }) + + test('Save button has an accessible name', async ({ page }) => { + await page.goto(URLS.ADD_SNIPPET_ADMIN) + await page.waitForSelector(SELECTORS.TITLE_INPUT, { timeout: TIMEOUTS.DEFAULT }) + + const saveButton = page.locator('role=button[name="Save Snippet"]') + await expect(saveButton).toBeVisible() + await expect(saveButton).toBeEnabled() + }) + + test('Save and Activate button has an accessible name', async ({ page }) => { + await page.goto(URLS.ADD_SNIPPET_ADMIN) + await page.waitForSelector(SELECTORS.TITLE_INPUT, { timeout: TIMEOUTS.DEFAULT }) + + const saveActivateButton = page.locator('role=button[name="Save and Activate"]') + await expect(saveActivateButton).toBeVisible() + await expect(saveActivateButton).toBeEnabled() + }) +}) + +test.describe('Accessibility — Import Screen', () => { + test('Import source tabs are keyboard-navigable buttons', async ({ page }) => { + await page.goto(URLS.IMPORT_SNIPPETS_ADMIN) + await page.waitForSelector('.import-snippets-menu', { timeout: TIMEOUTS.DEFAULT }) + + const nav = page.locator('nav[aria-label="Import sources"]') + await expect(nav).toBeVisible() + + const tabs = nav.locator('button.nav-tab') + const tabCount = await tabs.count() + expect(tabCount).toBeGreaterThanOrEqual(2) + + await tabs.first().focus() + await expect(tabs.first()).toBeFocused() + }) + + test('Active import tab has the nav-tab-active class', async ({ page }) => { + await page.goto(URLS.IMPORT_SNIPPETS_ADMIN) + await page.waitForSelector('.import-snippets-menu', { timeout: TIMEOUTS.DEFAULT }) + + const activeTab = page.locator('nav[aria-label="Import sources"] button.nav-tab-active') + await expect(activeTab).toHaveCount(1) + }) + 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 }) @@ -93,4 +283,133 @@ test.describe('Accessibility (manual checks)', () => { await expect(fileInput).toBeFocused() }) + + test('Upload drop zone label is associated with the file input', async ({ page }) => { + await page.goto(URLS.IMPORT_SNIPPETS_ADMIN) + await page.waitForSelector('.import-snippets-menu', { timeout: TIMEOUTS.DEFAULT }) + + const dropZoneLabel = page.locator('label.upload-drop-zone') + await expect(dropZoneLabel).toBeVisible() + + const forAttribute = await dropZoneLabel.getAttribute('for') + expect(forAttribute).toBeTruthy() + + const fileInput = page.locator(`#${forAttribute}`) + await expect(fileInput).toHaveAttribute('type', 'file') + }) +}) + +test.describe('Accessibility — Settings Screen', () => { + test('Settings tabs are keyboard-navigable', async ({ page }) => { + await page.goto(URLS.SETTINGS_ADMIN) + await page.waitForLoadState('networkidle') + + const tabs = page.locator('.nav-tab-wrapper .nav-tab') + const tabCount = await tabs.count() + expect(tabCount).toBeGreaterThanOrEqual(2) + + await tabs.first().focus() + await expect(tabs.first()).toBeFocused() + + await page.keyboard.press('Tab') + await expect(tabs.nth(1)).toBeFocused() + }) + + test('Active settings tab is visually indicated', async ({ page }) => { + await page.goto(URLS.SETTINGS_ADMIN) + await page.waitForLoadState('networkidle') + + const activeTab = page.locator('.nav-tab-wrapper .nav-tab-active') + await expect(activeTab).toHaveCount(1) + }) +}) + +test.describe('Accessibility — Modal Focus Management', () => { + let snippetName: string + + test.beforeAll(async () => { + snippetName = SnippetsTestHelper.makeUniqueSnippetName(A11Y_SNIPPET_PREFIX) + await SnippetsTestHelper.createSnippetViaCli({ name: snippetName, active: true }) + }) + + test.afterAll(async () => { + await SnippetsTestHelper.cleanupSnippetsByPrefix(A11Y_SNIPPET_PREFIX) + }) + + test('Delete confirmation dialog receives focus when opened', async ({ page }) => { + await page.goto(URLS.SNIPPETS_ADMIN) + await page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT }) + + const snippetRow = page.locator( + `${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))` + ).first() + await snippetRow.hover() + + const trashButton = snippetRow.locator('.row-actions button:has-text("Trash")') + await trashButton.click() + + const modal = page.locator('.components-modal__frame') + await expect(modal).toBeVisible() + + const focusedElement = page.locator(':focus') + const isInsideModal = await focusedElement.evaluate( + (el, modalSelector) => !!el.closest(modalSelector), + '.components-modal__frame' + ) + expect(isInsideModal).toBe(true) + }) + + test('Delete confirmation dialog closes on Escape', async ({ page }) => { + await page.goto(URLS.SNIPPETS_ADMIN) + await page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT }) + + const snippetRow = page.locator( + `${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))` + ).first() + await snippetRow.hover() + + const trashButton = snippetRow.locator('.row-actions button:has-text("Trash")') + await trashButton.click() + + const modal = page.locator('.components-modal__frame') + await expect(modal).toBeVisible() + + await page.keyboard.press('Escape') + await expect(modal).not.toBeVisible() + }) +}) + +test.describe('Accessibility — Toolbar Navigation', () => { + test('Toolbar renders two nav landmarks with distinct aria-label values', async ({ page }) => { + await page.goto(URLS.SNIPPETS_ADMIN) + await page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT }) + + const toolbar = page.locator('.code-snippets-toolbar') + await expect(toolbar).toBeVisible() + + const navs = toolbar.locator('nav[aria-label]') + const count = await navs.count() + expect(count).toBe(2) + + const firstLabel = await navs.nth(0).getAttribute('aria-label') + const secondLabel = await navs.nth(1).getAttribute('aria-label') + + expect(firstLabel).toBeTruthy() + expect(secondLabel).toBeTruthy() + expect(firstLabel).not.toEqual(secondLabel) + }) + + test('Toolbar nav links with external targets have rel="noopener noreferrer"', async ({ page }) => { + await page.goto(URLS.SNIPPETS_ADMIN) + await page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT }) + + const externalLinks = page.locator('.code-snippets-toolbar a[target="_blank"]') + const linkCount = await externalLinks.count() + + for (let i = 0; i < linkCount; i++) { + const rel = await externalLinks.nth(i).getAttribute('rel') + expect(rel).toContain('noopener') + expect(rel).toContain('noreferrer') + } + }) }) From 5a672e5c7e2fbd326ab84a6e00a67c3228cedc4f Mon Sep 17 00:00:00 2001 From: Rami Yushuvaev Date: Tue, 5 May 2026 22:19:40 +0300 Subject: [PATCH 08/10] eslint-disable-next-line --- src/js/components/ImportMenu/MigrateForm/MigrateForm.tsx | 5 ++++- .../UploadForm/SelectSnippets/ImportResultDisplay.tsx | 5 ++++- src/js/components/ImportMenu/UploadForm/UploadForm.tsx | 5 ++++- .../ManageMenu/SnippetsTable/SnippetsListTable.tsx | 5 ++++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/js/components/ImportMenu/MigrateForm/MigrateForm.tsx b/src/js/components/ImportMenu/MigrateForm/MigrateForm.tsx index be660a48..5bbeafa7 100644 --- a/src/js/components/ImportMenu/MigrateForm/MigrateForm.tsx +++ b/src/js/components/ImportMenu/MigrateForm/MigrateForm.tsx @@ -50,7 +50,10 @@ const StatusMessages: React.FC = () => { {createInterpolateElement( __('Selected snippets have been successfully imported to your Code Snippets library.', 'code-snippets'), - { a: } + { + // eslint-disable-next-line jsx-a11y/anchor-has-content, jsx-a11y/control-has-associated-label + a: + } )} )} diff --git a/src/js/components/ImportMenu/UploadForm/SelectSnippets/ImportResultDisplay.tsx b/src/js/components/ImportMenu/UploadForm/SelectSnippets/ImportResultDisplay.tsx index 069f5be2..f7321deb 100644 --- a/src/js/components/ImportMenu/UploadForm/SelectSnippets/ImportResultDisplay.tsx +++ b/src/js/components/ImportMenu/UploadForm/SelectSnippets/ImportResultDisplay.tsx @@ -43,7 +43,10 @@ export const ImportResultDisplay: React.FC = ({ succes

{createInterpolateElement( __('Go to All Snippets to activate your imported snippets.', 'code-snippets'), - { a: } + { + // eslint-disable-next-line jsx-a11y/anchor-has-content, jsx-a11y/control-has-associated-label + a: + } )}

)} diff --git a/src/js/components/ImportMenu/UploadForm/UploadForm.tsx b/src/js/components/ImportMenu/UploadForm/UploadForm.tsx index 7c52fcee..9b11a08e 100644 --- a/src/js/components/ImportMenu/UploadForm/UploadForm.tsx +++ b/src/js/components/ImportMenu/UploadForm/UploadForm.tsx @@ -29,7 +29,10 @@ export const UploadForm: React.FC = () => {

{createInterpolateElement( __('Afterward, you will need to visit the All Snippets page to activate the imported snippets.', 'code-snippets'), - { a: } + { + // eslint-disable-next-line jsx-a11y/anchor-has-content, jsx-a11y/control-has-associated-label + a: + } )}

diff --git a/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx b/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx index 757a3f05..41dc52cd 100644 --- a/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx @@ -243,7 +243,10 @@ const NoItemsMessage = () => { currentType ? __("It looks like you don't have any snippets of this type. Perhaps you would like to add a new one?", 'code-snippets') : __("It looks like you don't have any snippets. Perhaps you would like to add a new one?", 'code-snippets'), - { a: } + { + // eslint-disable-next-line jsx-a11y/anchor-has-content, jsx-a11y/control-has-associated-label + a: + } )} } From b63e74b02c54b8ff32608fa84f09ca98e18acd66 Mon Sep 17 00:00:00 2001 From: Rami Yushuvaev Date: Tue, 5 May 2026 23:41:06 +0300 Subject: [PATCH 09/10] Update jsx-a11y rules --- eslint.config.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index d8647050..d710e7e0 100755 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -139,7 +139,7 @@ export default eslintTs.config( '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/control-has-associated-label': 'warn', 'jsx-a11y/heading-has-content': 'error', 'jsx-a11y/iframe-has-title': 'error', 'jsx-a11y/img-redundant-alt': 'error', @@ -149,7 +149,7 @@ export default eslintTs.config( '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/no-static-element-interactions': 'warn', 'jsx-a11y/role-has-required-aria-props': 'error', 'jsx-a11y/role-supports-aria-props': 'error', 'jsx-a11y/tabindex-no-positive': 'error' From e4b3f30f5244623cf17d15617e55749ad31206ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 20:43:48 +0000 Subject: [PATCH 10/10] fix(ci): add PHP setup and Composer install to lint workflow Agent-Logs-Url: https://github.com/codesnippetspro/code-snippets/sessions/2bb9d8a2-f530-4f0c-b4d1-c50042562680 Co-authored-by: rami-elementor <92088692+rami-elementor@users.noreply.github.com> --- .github/workflows/lint.yml | 40 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index cfa72d32..70029ba9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -38,6 +38,46 @@ jobs: - name: Install npm dependencies run: npm ci + - name: Set up PHP + uses: codesnippetspro/setup-php@v2 + with: + php-version: '8.2' + + - name: Compute dependency hash + id: deps-hash + run: | + set -euo pipefail + tmpfile=$(mktemp) + if [ -f "src/composer.lock" ]; then + cat "src/composer.lock" >> "$tmpfile" + fi + if [ -s "$tmpfile" ]; then + deps_hash=$(shasum -a 1 "$tmpfile" | awk '{print $1}' | cut -c1-8) + else + deps_hash=$(echo "${GITHUB_SHA:-unknown}" | cut -c1-8) + fi + echo "deps_hash=$deps_hash" >> "$GITHUB_OUTPUT" + + - name: Get Composer cache + id: composer-cache + uses: actions/cache/restore@v4 + with: + path: src/vendor + key: ${{ runner.os }}-php-8.2-composer-${{ steps.deps-hash.outputs.deps_hash }} + restore-keys: | + ${{ runner.os }}-php-8.2-composer- + + - name: Install Composer dependencies + if: steps.composer-cache.outputs.cache-hit != 'true' + run: composer install -d src --no-progress --prefer-dist --optimize-autoloader + + - name: Save Composer cache + if: steps.composer-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: src/vendor + key: ${{ runner.os }}-php-8.2-composer-${{ steps.deps-hash.outputs.deps_hash }} + - name: Run Stylelint run: npm run lint:styles