diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 5c2c309..0252f3d 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -117,6 +117,19 @@ jobs: npm test fi + # ----------------------------------- + # Code coverage report + # ----------------------------------- + - name: Generate code coverage + run: | + if [ "${{ steps.pm.outputs.manager }}" = "pnpm" ]; then + pnpm test:coverage + elif [ "${{ steps.pm.outputs.manager }}" = "yarn" ]; then + yarn test:coverage + else + npm run test:coverage + fi + # ----------------------------------- # Regression tests # ----------------------------------- diff --git a/frontend/package.json b/frontend/package.json index aeb0aed..89ca71a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "start": "next start", "lint": "eslint", "test": "vitest run", + "test:coverage": "vitest run --coverage", "test:security-headers": "node --test security-headers.test.mjs" }, "dependencies": { @@ -47,6 +48,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^9", "eslint-config-next": "16.1.3", "tailwindcss": "^4", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 34d52bc..bedfcec 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.10) + '@vitest/coverage-v8': + specifier: ^4.1.10 + version: 4.1.10(vitest@4.1.10) eslint: specifier: ^9 version: 9.39.2(jiti@2.6.1) @@ -125,7 +128,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@20.19.30)(vite@8.1.5(@types/node@20.19.30)(jiti@2.6.1)) + version: 4.1.10(@types/node@20.19.30)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@20.19.30)(jiti@2.6.1)) packages: @@ -174,10 +177,18 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -191,6 +202,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.28.6': resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} @@ -207,6 +223,14 @@ packages: resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@creit.tech/stellar-wallets-kit@1.9.5': resolution: {integrity: sha512-b9E77r+o6Opow0CttmHdFBPeJpdLhOcLFTx3CbnwMQVivo94niap70y3A03F5cYgwVk9HhM4CJr0aimm0eNwxA==} engines: {node: '>=16'} @@ -2411,6 +2435,15 @@ packages: vue-router: optional: true + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -2614,6 +2647,9 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -3508,6 +3544,9 @@ packages: hmac-drbg@1.0.1: resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html2canvas@1.4.1: resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} engines: {node: '>=8.0.0'} @@ -3714,6 +3753,18 @@ packages: peerDependencies: ws: '*' + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -3733,6 +3784,9 @@ packages: js-sha256@0.9.0: resolution: {integrity: sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4003,6 +4057,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -5373,8 +5434,12 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.28.6': @@ -5386,6 +5451,10 @@ snapshots: dependencies: '@babel/types': 7.28.6 + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + '@babel/runtime@7.28.6': {} '@babel/template@7.28.6': @@ -5411,6 +5480,13 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + '@creit.tech/stellar-wallets-kit@1.9.5(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(@stellar/stellar-base@13.1.0)(@stellar/stellar-sdk@13.3.0)(@trezor/connect@9.6.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(@types/react@19.2.10)(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(near-api-js@5.1.1)(react@19.2.3)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': dependencies: '@albedo-link/intent': 0.12.0 @@ -8000,6 +8076,20 @@ snapshots: next: 16.1.3(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@20.19.30)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@20.19.30)(jiti@2.6.1)) + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -8448,6 +8538,12 @@ snapshots: ast-types-flow@0.0.8: {} + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-function@1.0.0: {} asynckit@0.4.0: {} @@ -9487,6 +9583,8 @@ snapshots: minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 + html-escaper@2.0.2: {} + html2canvas@1.4.1: dependencies: css-line-break: 2.1.0 @@ -9691,6 +9789,19 @@ snapshots: dependencies: ws: 7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10) + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -9724,6 +9835,8 @@ snapshots: js-sha256@0.9.0: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -9962,6 +10075,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.3: + 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.7.3 + math-intrinsics@1.1.0: {} md5.js@1.3.5: @@ -11244,7 +11367,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 - vitest@4.1.10(@types/node@20.19.30)(vite@8.1.5(@types/node@20.19.30)(jiti@2.6.1)): + vitest@4.1.10(@types/node@20.19.30)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@20.19.30)(jiti@2.6.1)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@20.19.30)(jiti@2.6.1)) @@ -11268,6 +11391,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.30 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) transitivePeerDependencies: - msw diff --git a/frontend/src/app/(dashboard)/user/overview/components/ContributorProfileCard.test.tsx b/frontend/src/app/(dashboard)/user/overview/components/ContributorProfileCard.test.tsx new file mode 100644 index 0000000..2a7c2fc --- /dev/null +++ b/frontend/src/app/(dashboard)/user/overview/components/ContributorProfileCard.test.tsx @@ -0,0 +1,93 @@ +/** + * Accessibility tests for ContributorProfileCard. + * + * Uses renderToStaticMarkup pattern (no jsdom needed). + */ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import ContributorProfileCard from "./ContributorProfileCard"; + +vi.mock("@/lib/contributor-profile", () => ({ + calculateContributorProfileCompletion: vi.fn(() => ({ + percentage: 50, + missingFields: [ + { key: "bio", label: "Bio" }, + { key: "location", label: "Location" }, + { key: "website", label: "Website" }, + ], + })), +})); + +describe("ContributorProfileCard — form accessibility", () => { + it("renders a
element with novalidate", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain(" { + const html = renderToStaticMarkup(); + + expect(html).toContain('aria-label="Contributor profile fields"'); + }); + + it("each field has a label with htmlFor matching an input/textarea id", () => { + const html = renderToStaticMarkup(); + + const forValues = [...html.matchAll(/\bfor="([^"]+)"/g)].map((m) => m[1]); + expect(forValues.length).toBeGreaterThanOrEqual(6); + + const idValues = new Set( + [...html.matchAll(/\bid="([^"]+)"/g)].map((m) => m[1]), + ); + + for (const forVal of forValues) { + expect(forVal.trim()).not.toBe(""); + expect( + idValues.has(forVal), + `No element with id="${forVal}" found`, + ).toBe(true); + } + }); + + it("inputs have aria-required and aria-invalid attributes", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('aria-required="false"'); + expect(html).toContain('aria-invalid="true"'); + }); + + it("progress bar has role=progressbar with accessible label", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('role="progressbar"'); + expect(html).toContain('aria-valuenow="50"'); + expect(html).toContain('aria-valuemin="0"'); + expect(html).toContain('aria-valuemax="100"'); + expect(html).toContain('aria-label="Profile 50% complete"'); + }); + + it("suggested next steps use role=alert with aria-live=polite", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('role="alert"'); + expect(html).toContain('aria-live="polite"'); + expect(html).toContain("Suggested next steps"); + }); + + it("completion status icons have aria-label", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('aria-label="Missing"'); + }); + + it("finds profile- prefixed field ids", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('id="profile-name"'); + expect(html).toContain('id="profile-headline"'); + expect(html).toContain('id="profile-bio"'); + }); +}); diff --git a/frontend/src/app/(dashboard)/user/overview/components/ContributorProfileCard.tsx b/frontend/src/app/(dashboard)/user/overview/components/ContributorProfileCard.tsx index 67d4f73..87eb1e5 100644 --- a/frontend/src/app/(dashboard)/user/overview/components/ContributorProfileCard.tsx +++ b/frontend/src/app/(dashboard)/user/overview/components/ContributorProfileCard.tsx @@ -37,6 +37,8 @@ export default function ContributorProfileCard() { setProfile((current) => ({ ...current, [key]: value })); }; + const missingCount = completion.missingFields.length; + return (
-
{completion.percentage}%
+
complete
@@ -65,7 +72,14 @@ export default function ContributorProfileCard() {
-
+
-
+ {PROFILE_FIELDS.map((field) => { const isComplete = Boolean(profile[field.key]?.trim()); const isTextArea = field.key === "bio" || field.key === "skills"; + const fieldId = `profile-${field.key}`; + const statusId = `status-${field.key}`; return (
{isComplete ? ( - + ) : ( - + )}
- - + + {isComplete ? "Filled" : "Missing"}
{isTextArea ? (