From faf88d01f54cab0c06fe2641e28cf0d06575f63e Mon Sep 17 00:00:00 2001 From: Olasunkanmi975 Date: Sat, 27 Jun 2026 19:47:45 +0100 Subject: [PATCH 1/3] chore(test): enforce Vitest coverage thresholds in CI --- .github/workflows/ci.yml | 40 ++++++---- .gitignore | 2 +- CONTRIBUTING.md | 56 ++++++++++++++ package.json | 3 +- pnpm-lock.yaml | 155 +++++++++++++++++++++++++++++++++++++++ vitest.config.ts | 29 ++++++++ 6 files changed, 270 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73807382..a8e929c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,16 +90,30 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Run Tests - shell: bash - run: | - if timeout 30s pnpm run test; then - echo "Tests completed within the 30-second limit." - else - status=$? - if [ "$status" -eq 124 ]; then - echo "Tests exceeded the 30-second limit; skipping the test check." - exit 0 - fi - exit "$status" - fi + - name: Run Tests with Coverage + # Generates: coverage/lcov.info (→ Codecov) + # coverage/coverage-final.json (→ tooling) + # Exits non-zero when any configured threshold is breached. + run: pnpm vitest run --coverage --reporter=json --reporter=lcov + + - name: Upload coverage reports to Codecov + # Requires a repository secret named CODECOV_TOKEN. + # Add it at: GitHub → Settings → Secrets → Actions → New repository secret. + # Obtain the token from https://codecov.io after connecting the repository. + # continue-on-error keeps the workflow green while the secret is absent. + uses: codecov/codecov-action@v4 + continue-on-error: true + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/lcov.info + fail_ci_if_error: false + + - name: Upload coverage artefact + # Preserves the full coverage directory for offline inspection regardless + # of whether Codecov upload succeeds. + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/ + retention-days: 14 + diff --git a/.gitignore b/.gitignore index 6efd534a..a73f5c93 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ node_modules/ .env dist/ build/ -coverage/node_modules/ +coverage/ .env.local package-lock.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4ac9e13..a3f96717 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,3 +63,59 @@ Use the PR template (auto-applied). Ensure it includes: ## Security Do not commit secrets. Use `.env.local` for local environment variables. + +--- + +## Code Coverage + +### Minimum thresholds + +CI enforces the following coverage thresholds (configured in +[`vitest.config.ts`](./vitest.config.ts)): + +| Metric | Minimum | +|------------|---------| +| Lines | 60 % | +| Functions | 60 % | +| Branches | 50 % | +| Statements | 60 % | + +**Do not lower these thresholds** to make a failing build pass. +If new code genuinely cannot be covered, discuss with the team first. + +### Run coverage locally + +```bash +pnpm run test:coverage +``` + +This writes the following files to `./coverage/`: + +| File | Used by | +|-----------------------------|-------------------| +| `coverage/lcov.info` | Codecov / IDE | +| `coverage/coverage-final.json` | Tooling | +| `coverage/index.html` | Local HTML report | + +Open `coverage/index.html` in a browser for a line-by-line breakdown. + +### CI enforcement + +The **Frontend CI** workflow (`ci.yml`) runs `vitest run --coverage` on every +PR targeting `main` or `develop`. The job fails — and blocks merge — when any +threshold is breached. + +### Interpreting a failure + +When thresholds are violated Vitest prints a table like: + +``` +ERROR: Coverage for lines (42.5 %) does not meet global threshold (60 %) +``` + +To resolve: + +1. Add or improve tests for the uncovered code. +2. Re-run `pnpm run test:coverage` locally until the output shows no threshold + errors. +3. Push — CI will re-run automatically. diff --git a/package.json b/package.json index e133d3f0..a2a3a667 100644 --- a/package.json +++ b/package.json @@ -136,7 +136,8 @@ "tsx": "^4.20.3", "typescript": "^5.8.3", "vite": "^5.4.19", - "vitest": "^2.1.9" + "vitest": "^2.1.9", + "@vitest/coverage-v8": "^2.1.9" }, "pnpm": { "overrides": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ff14882..d9c66c77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,6 +260,9 @@ importers: '@vitejs/plugin-react-swc': specifier: ^3.10.2 version: 3.11.0(vite@5.4.21(@types/node@20.19.41)(lightningcss@1.32.0)(terser@5.48.0)) + '@vitest/coverage-v8': + specifier: ^2.1.9 + version: 2.1.9(vitest@2.1.9(@types/node@20.19.41)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0)) eslint: specifier: ^9 version: 9.39.4(jiti@2.7.0) @@ -336,6 +339,10 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@apideck/better-ajv-errors@0.3.7': resolution: {integrity: sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==} engines: {node: '>=10'} @@ -1598,6 +1605,10 @@ packages: cpu: [x64] os: [win32] + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@isaacs/cliui@9.0.0': resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} @@ -1847,6 +1858,10 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@playwright/test@1.60.0': resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} engines: {node: '>=18'} @@ -3297,6 +3312,15 @@ packages: peerDependencies: vite: ^4 || ^5 || ^6 || ^7 + '@vitest/coverage-v8@2.1.9': + resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} + peerDependencies: + '@vitest/browser': 2.1.9 + vitest: 2.1.9 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -4374,6 +4398,9 @@ packages: duplexify@4.1.3: resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -4924,6 +4951,11 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} @@ -5300,6 +5332,10 @@ packages: resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} engines: {node: '>=10'} + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + istanbul-reports@3.2.0: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} @@ -5308,6 +5344,9 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.2.3: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} @@ -5728,6 +5767,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -5808,6 +5850,10 @@ packages: resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -6103,6 +6149,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -6965,6 +7015,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -7153,6 +7207,10 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} @@ -7785,6 +7843,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -7987,6 +8049,11 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@apideck/better-ajv-errors@0.3.7(ajv@8.20.0)': dependencies: ajv: 8.20.0 @@ -9248,6 +9315,15 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/cliui@9.0.0': {} '@istanbuljs/load-nyc-config@1.1.0': @@ -9557,6 +9633,9 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@playwright/test@1.60.0': dependencies: playwright: 1.60.0 @@ -11339,6 +11418,24 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@20.19.41)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 1.2.0 + vitest: 2.1.9(@types/node@20.19.41)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0) + transitivePeerDependencies: + - supports-color + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -12779,6 +12876,8 @@ snapshots: readable-stream: 3.6.2 stream-shift: 1.0.3 + eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -13536,6 +13635,15 @@ snapshots: glob-to-regexp@0.4.1: {} + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@11.1.0: dependencies: foreground-child: 3.3.1 @@ -13922,6 +14030,14 @@ snapshots: transitivePeerDependencies: - supports-color + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + istanbul-reports@3.2.0: dependencies: html-escaper: 2.0.2 @@ -13936,6 +14052,12 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jackspeak@4.2.3: dependencies: '@isaacs/cliui': 9.0.0 @@ -14528,6 +14650,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + make-dir@4.0.0: dependencies: semver: 7.8.1 @@ -14595,6 +14723,10 @@ snapshots: dependencies: brace-expansion: 2.1.1 + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.1 + minimist@1.2.8: {} minipass@7.1.3: {} @@ -14927,6 +15059,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-scurry@2.0.2: dependencies: lru-cache: 11.5.1 @@ -16024,6 +16161,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -16214,6 +16357,12 @@ snapshots: glob: 7.2.3 minimatch: 3.1.5 + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 10.5.0 + minimatch: 10.2.5 + text-decoder@1.2.7: dependencies: b4a: 1.8.1 @@ -17017,6 +17166,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 diff --git a/vitest.config.ts b/vitest.config.ts index 7f1cb220..530b66f4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,6 +15,35 @@ export default defineConfig({ setupFiles: ['./src/testing/test-setup.ts'], globals: true, exclude: ['**/node_modules/**', '**/.next/**', 'e2e/**'], + coverage: { + // Use the V8 coverage provider (built into Node — no extra instrumentation). + provider: 'v8', + + // Only collect coverage for source files inside src/. + include: ['src/**/*.{ts,tsx}'], + + // Exclude test files, type-only files, and generated/config files. + exclude: [ + 'src/**/*.test.{ts,tsx}', + 'src/**/*.spec.{ts,tsx}', + 'src/**/*.d.ts', + 'src/testing/**', + ], + + // Output formats consumed by CI (LCOV → Codecov, JSON → tooling). + reporter: ['text', 'lcov', 'json'], + + // Where coverage artefacts are written. + reportsDirectory: './coverage', + + // Minimum acceptable coverage — CI fails when any threshold is breached. + thresholds: { + lines: 60, + functions: 60, + branches: 50, + statements: 60, + }, + }, }, resolve: { alias: { From d59710f49e7c045cba3f82f0be229f09dda1c5b1 Mon Sep 17 00:00:00 2001 From: Olasunkanmi975 Date: Sat, 27 Jun 2026 19:51:15 +0100 Subject: [PATCH 2/3] chore(test): enforce Vitest coverage thresholds in CI --- pnpm-lock.yaml | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9c66c77..3a38d9ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -259,7 +259,7 @@ importers: version: 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@vitejs/plugin-react-swc': specifier: ^3.10.2 - version: 3.11.0(vite@5.4.21(@types/node@20.19.41)(lightningcss@1.32.0)(terser@5.48.0)) + version: 3.11.0(@swc/helpers@0.5.15)(vite@5.4.21(@types/node@20.19.41)(lightningcss@1.32.0)(terser@5.48.0)) '@vitest/coverage-v8': specifier: ^2.1.9 version: 2.1.9(vitest@2.1.9(@types/node@20.19.41)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0)) @@ -2352,7 +2352,6 @@ packages: resolution: {integrity: sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-gnu@4.60.4': resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} @@ -2364,7 +2363,6 @@ packages: resolution: {integrity: sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-musl@4.60.4': resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} @@ -2418,7 +2416,6 @@ packages: resolution: {integrity: sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.4': resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} @@ -2430,7 +2427,6 @@ packages: resolution: {integrity: sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-linux-x64-musl@4.60.4': resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} @@ -10733,7 +10729,7 @@ snapshots: '@swc/core-win32-x64-msvc@1.15.40': optional: true - '@swc/core@1.15.40': + '@swc/core@1.15.40(@swc/helpers@0.5.15)': dependencies: '@swc/counter': 0.1.3 '@swc/types': 0.1.26 @@ -10750,6 +10746,7 @@ snapshots: '@swc/core-win32-arm64-msvc': 1.15.40 '@swc/core-win32-ia32-msvc': 1.15.40 '@swc/core-win32-x64-msvc': 1.15.40 + '@swc/helpers': 0.5.15 '@swc/counter@0.1.3': {} @@ -11410,10 +11407,10 @@ snapshots: global: 4.4.0 is-function: 1.0.2 - '@vitejs/plugin-react-swc@3.11.0(vite@5.4.21(@types/node@20.19.41)(lightningcss@1.32.0)(terser@5.48.0))': + '@vitejs/plugin-react-swc@3.11.0(@swc/helpers@0.5.15)(vite@5.4.21(@types/node@20.19.41)(lightningcss@1.32.0)(terser@5.48.0))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.27 - '@swc/core': 1.15.40 + '@swc/core': 1.15.40(@swc/helpers@0.5.15) vite: 5.4.21(@types/node@20.19.41)(lightningcss@1.32.0)(terser@5.48.0) transitivePeerDependencies: - '@swc/helpers' @@ -13189,7 +13186,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: @@ -13211,7 +13208,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 From 97be4d7feb90c1df8a3341379117ad66d920e1ad Mon Sep 17 00:00:00 2001 From: Olasunkanmi975 Date: Sat, 27 Jun 2026 20:07:17 +0100 Subject: [PATCH 3/3] fix(settings): validate language against SUPPORTED_LANGUAGES allowlist --- .../settings/__tests__/store.language.test.ts | 130 ++++++++++++++++++ src/lib/settings/store.ts | 10 +- src/lib/settings/types.ts | 14 +- 3 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 src/lib/settings/__tests__/store.language.test.ts diff --git a/src/lib/settings/__tests__/store.language.test.ts b/src/lib/settings/__tests__/store.language.test.ts new file mode 100644 index 00000000..c23c50a3 --- /dev/null +++ b/src/lib/settings/__tests__/store.language.test.ts @@ -0,0 +1,130 @@ +/** + * @file store.language.test.ts + * + * Tests for language-setting validation in `useSettingsStore.patchSettings`. + * + * Covered behaviour: + * - Supported locales are stored unchanged. + * - Unsupported locales fall back to DEFAULT_LANGUAGE. + * - The old 24-character length clamp no longer applies to valid locales. + * - All locales currently listed in SUPPORTED_LANGUAGES continue to work. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useSettingsStore } from '../store'; +import { createDefaultSettings } from '../types'; +import { SUPPORTED_LANGUAGES, DEFAULT_LANGUAGE } from '@/locales/config'; + +// --------------------------------------------------------------------------- +// Setup — mock localStorage used by the persist middleware +// --------------------------------------------------------------------------- + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { store = {}; }, + }; +})(); + +// @ts-ignore +global.localStorage = localStorageMock; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Reset Zustand store to defaults between tests to avoid cross-test pollution. */ +function resetStore() { + localStorageMock.clear(); + useSettingsStore.setState({ + settings: createDefaultSettings(), + updatedAt: Date.now(), + lastSyncedAt: null, + }); +} + +function patchLanguage(lang: string) { + useSettingsStore.getState().patchSettings({ language: lang }); + return useSettingsStore.getState().settings.language; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useSettingsStore — language validation', () => { + beforeEach(() => { + resetStore(); + }); + + // -- Supported locales ---------------------------------------------------- + + it('stores a valid supported locale unchanged', () => { + expect(patchLanguage('fr')).toBe('fr'); + }); + + it('stores "en" (DEFAULT_LANGUAGE) unchanged', () => { + expect(patchLanguage('en')).toBe('en'); + }); + + it('stores every locale listed in SUPPORTED_LANGUAGES without modification', () => { + const supported = Object.keys(SUPPORTED_LANGUAGES); + for (const locale of supported) { + resetStore(); + expect(patchLanguage(locale)).toBe(locale); + } + }); + + // -- Unsupported locales -------------------------------------------------- + + it('falls back to DEFAULT_LANGUAGE for a completely unknown locale', () => { + expect(patchLanguage('xx')).toBe(DEFAULT_LANGUAGE); + }); + + it('falls back to DEFAULT_LANGUAGE for an empty string', () => { + expect(patchLanguage('')).toBe(DEFAULT_LANGUAGE); + }); + + it('falls back to DEFAULT_LANGUAGE for a locale-like string not in the allowlist', () => { + // 'en-GB' looks valid but is not a key in SUPPORTED_LANGUAGES + expect(patchLanguage('en-GB')).toBe(DEFAULT_LANGUAGE); + }); + + it('falls back to DEFAULT_LANGUAGE for a numeric string', () => { + expect(patchLanguage('1234')).toBe(DEFAULT_LANGUAGE); + }); + + // -- Old 24-char clamp removed ------------------------------------------- + + it('does not clamp or store locales longer than 24 characters — they fall back instead', () => { + // Under the old scheme a 25-char string would be sliced to 24 chars and stored. + // Under the new scheme it is not in SUPPORTED_LANGUAGES, so it falls back. + const longLocale = 'a'.repeat(25); + expect(patchLanguage(longLocale)).toBe(DEFAULT_LANGUAGE); + }); + + it('does not trim or modify a valid locale with surrounding whitespace — it falls back', () => { + // The old implementation called .trim(); the new one does not — + // ' en ' is not a key in SUPPORTED_LANGUAGES. + expect(patchLanguage(' en ')).toBe(DEFAULT_LANGUAGE); + }); + + // -- Store state integrity ------------------------------------------------ + + it('does not mutate other settings when language is patched', () => { + const before = useSettingsStore.getState().settings; + patchLanguage('es'); + const after = useSettingsStore.getState().settings; + expect(after.theme).toBe(before.theme); + expect(after.notificationsEnabled).toBe(before.notificationsEnabled); + }); + + it('updates updatedAt when a valid language is patched', () => { + const before = useSettingsStore.getState().updatedAt; + patchLanguage('ja'); + const after = useSettingsStore.getState().updatedAt; + expect(after).toBeGreaterThanOrEqual(before); + }); +}); diff --git a/src/lib/settings/store.ts b/src/lib/settings/store.ts index 0a931e50..8803f608 100644 --- a/src/lib/settings/store.ts +++ b/src/lib/settings/store.ts @@ -43,12 +43,13 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { SETTINGS_SCHEMA_VERSION, SETTINGS_STORAGE_KEY } from './constants'; import { type AppSettings, appSettingsSchema, createDefaultSettings } from './types'; +import { SUPPORTED_LANGUAGES, DEFAULT_LANGUAGE } from '@/locales/config'; interface SettingsStoreActions { /** * Merge a partial update into the current settings. * Validates the merged result against `appSettingsSchema`; silently ignores invalid patches. - * `language` is trimmed and clamped to 24 chars; empty strings fall back to `'en'`. + * `language` must be a key in `SUPPORTED_LANGUAGES`; unsupported values fall back to `DEFAULT_LANGUAGE`. * Automatically updates `updatedAt` to `Date.now()`. */ patchSettings: (partial: Partial) => void; @@ -112,7 +113,12 @@ export const useSettingsStore = create()( version: SETTINGS_SCHEMA_VERSION, ...(partial.language !== undefined ? { - language: partial.language.trim().slice(0, 24) || 'en', + // Validate against the supported locale allowlist. + // Unsupported values fall back to DEFAULT_LANGUAGE instead of being + // stored — the allowlist is the single source of truth. + language: partial.language in SUPPORTED_LANGUAGES + ? partial.language + : DEFAULT_LANGUAGE, } : {}), }; diff --git a/src/lib/settings/types.ts b/src/lib/settings/types.ts index 54e8771d..2bea756f 100644 --- a/src/lib/settings/types.ts +++ b/src/lib/settings/types.ts @@ -1,5 +1,9 @@ +/** + * Validated schema for all user-configurable application settings. + */ import { z } from 'zod'; import { SETTINGS_SCHEMA_VERSION, SETTINGS_DOCUMENTATION_VERSION } from './constants'; +import { SUPPORTED_LANGUAGES, DEFAULT_LANGUAGE } from '@/locales/config'; /** User-selectable colour scheme. `'system'` follows the OS preference. */ export const themePreferenceSchema = z.enum(['light', 'dark', 'system']); @@ -14,7 +18,7 @@ export type VirtualBackgroundType = z.infer; * Fields: * - `version` — Schema version; bumped when new fields are added (see `SETTINGS_SCHEMA_VERSION`). * - `theme` — Colour scheme: `'light'`, `'dark'`, or `'system'` (follows OS preference). - * - `language` — BCP-47 locale tag (e.g. `'en'`, `'fr-CA'`), max 24 chars; defaults to `navigator.language`. + * - `language` — A key from `SUPPORTED_LANGUAGES` (e.g. `'en'`, `'fr'`); defaults to `DEFAULT_LANGUAGE`. * - `notificationsEnabled` — Master toggle for in-app push/toast notifications. * - `emailNotifications` — Whether transactional and digest emails should be sent. * - `prefetchingEnabled` — Pre-fetches linked pages on hover for faster navigation; disable on slow connections. @@ -30,7 +34,9 @@ export type VirtualBackgroundType = z.infer; export const appSettingsSchema = z.object({ version: z.literal(SETTINGS_SCHEMA_VERSION), theme: themePreferenceSchema, - language: z.string().max(24), + language: z.enum( + Object.keys(SUPPORTED_LANGUAGES) as [string, ...string[]] + ), notificationsEnabled: z.boolean(), emailNotifications: z.boolean(), prefetchingEnabled: z.boolean(), @@ -87,7 +93,9 @@ export function createDefaultSettings(): AppSettings { return { version: SETTINGS_SCHEMA_VERSION, theme: 'system', - language: typeof navigator !== 'undefined' ? (navigator.language || 'en').slice(0, 24) : 'en', + language: typeof navigator !== 'undefined' && navigator.language in SUPPORTED_LANGUAGES + ? navigator.language + : DEFAULT_LANGUAGE, notificationsEnabled: true, emailNotifications: true, prefetchingEnabled: true,