diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..52b0cef --- /dev/null +++ b/.ignore @@ -0,0 +1,2 @@ +# Prevents cargo watch from restarting the server when frontend files change +frontend/ diff --git a/.story_kit/README.md b/.story_kit/README.md index 80df039..e9de9c0 100644 --- a/.story_kit/README.md +++ b/.story_kit/README.md @@ -67,6 +67,7 @@ When the user asks for a feature, follow this 4-step loop strictly: ### Step 3: The Implementation (Code) * **Action:** Write the code to satisfy the approved tests and Acceptance Criteria. * **Constraint:** adhere strictly to `specs/tech/STACK.md` (e.g., if it says "No `unwrap()`", you must not use `unwrap()`). +* **Full-Stack Completion:** Every story must be completed across all components of the stack. If a feature touches the backend, frontend, and API layer, all three must be fully implemented and working end-to-end before the story can be accepted. Partial implementations (e.g., backend logic with no frontend wiring, or UI scaffolding with no real data) do not satisfy acceptance criteria. ### Step 4: Verification (Close) * **Action:** For each Acceptance Criterion in the story, write a failing test (red), mark the criterion as tested, make the test pass (green), and refactor if needed. Keep only one failing test at a time. diff --git a/.story_kit/stories/archived/.gitkeep b/.story_kit/stories/archived/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.story_kit/stories/archived/27_protect_tests_and_coverage.md b/.story_kit/stories/archived/27_protect_tests_and_coverage.md new file mode 100644 index 0000000..15c790e --- /dev/null +++ b/.story_kit/stories/archived/27_protect_tests_and_coverage.md @@ -0,0 +1,37 @@ +--- +name: Coverage Tracking +test_plan: approved +--- + +# Story 27: Coverage Tracking + +## User Story +As a user, I want the workflow to track test coverage and block acceptance when coverage regresses, so quality guardrails cannot be weakened silently. + +## Acceptance Criteria +- [x] The workflow fails if coverage drops below the defined threshold. +- [x] Coverage regression is reported clearly before acceptance. + +## Test Plan (Approved) + +### Backend (Rust) — Unit + +**AC1: Workflow fails if coverage drops below threshold** +- `workflow::check_coverage_threshold()` fails when coverage % < configured threshold +- Passes when coverage >= threshold + +**AC2: Coverage regression reported clearly before acceptance** +- `workflow::evaluate_acceptance_with_coverage()` includes coverage delta when coverage dropped +- `AcceptanceDecision` extended with `coverage_report` field +- Acceptance blocked with clear message when baseline exists and current < baseline + +### Frontend (Vitest + Playwright) + +**AC1:** Gate panel shows "Coverage below threshold (X% < Y%)" and coverage display +**AC2:** Review/gate panels display coverage regression text and summary +**E2E:** Blocked acceptance displays coverage reasons; green coverage when above threshold + +## Out of Scope +- Introducing new test frameworks beyond those listed in `specs/tech/STACK.md`. +- Large refactors solely to improve coverage. +- Path-based test file detection (not reliable for languages with inline tests like Rust). diff --git a/.story_kit/stories/upcoming/.gitkeep b/.story_kit/stories/upcoming/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.story_kit/stories/upcoming/27_protect_tests_and_coverage.md b/.story_kit/stories/upcoming/27_protect_tests_and_coverage.md deleted file mode 100644 index 3dbc23f..0000000 --- a/.story_kit/stories/upcoming/27_protect_tests_and_coverage.md +++ /dev/null @@ -1,14 +0,0 @@ -# Story 27: Protect Tests and Coverage - -## User Story -As a user, I want explicit safeguards around test deletion and coverage regression, so quality guardrails cannot be weakened silently. - -## Acceptance Criteria -- Any deletion of test files requires explicit user approval. -- Any change that disables or neuters a test (e.g., commenting out assertions) requires explicit user approval. -- The workflow fails if coverage drops below the defined threshold. -- Coverage regression is reported clearly before acceptance. - -## Out of Scope -- Introducing new test frameworks beyond those listed in `specs/tech/STACK.md`. -- Large refactors solely to improve coverage. \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index b82c32b..9028bf7 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -10,4 +10,5 @@ lerna-debug.log* node_modules dist dist-ssr +coverage *.local diff --git a/frontend/package.json b/frontend/package.json index fac3ed8..cbbdbca 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,8 @@ "server": "cargo run --manifest-path server/Cargo.toml", "test": "vitest run", "test:unit": "vitest run", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@types/react-syntax-highlighter": "^15.5.13", @@ -25,10 +26,11 @@ "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", + "@types/node": "^25.0.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", - "@types/node": "^25.0.0", "@vitejs/plugin-react": "^4.6.0", + "@vitest/coverage-v8": "^2.1.9", "jest": "^29.0.0", "jsdom": "^28.1.0", "ts-jest": "^29.0.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index e5c87f8..870d832 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.6.0 version: 4.7.0(vite@5.4.21(@types/node@25.0.3)) + '@vitest/coverage-v8': + specifier: ^2.1.9 + version: 2.1.9(vitest@2.1.9(@types/node@25.0.3)(jsdom@28.1.0)) jest: specifier: ^29.0.0 version: 29.7.0(@types/node@25.0.3) @@ -78,6 +81,10 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@4.1.2': resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==} @@ -146,6 +153,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-syntax-async-generators@7.8.4': resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} peerDependencies: @@ -265,6 +277,10 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -503,6 +519,10 @@ packages: '@noble/hashes': optional: true + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@istanbuljs/load-nyc-config@1.1.0': resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -593,6 +613,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@playwright/test@1.58.2': resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} engines: {node: '>=18'} @@ -832,6 +856,15 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@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==} @@ -873,6 +906,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -881,6 +918,10 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -948,6 +989,9 @@ packages: brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1158,6 +1202,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -1168,6 +1215,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -1257,6 +1307,10 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -1304,6 +1358,11 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + 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@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -1538,10 +1597,17 @@ 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'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jest-changed-files@29.7.0: resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1727,6 +1793,9 @@ packages: lowlight@1.20.0: resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.6: resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} @@ -1741,6 +1810,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'} @@ -1863,9 +1935,17 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1933,6 +2013,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -1958,6 +2041,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'} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -2160,6 +2247,10 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -2206,6 +2297,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'} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -2213,6 +2308,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + strip-bom@4.0.0: resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} engines: {node: '>=8'} @@ -2254,6 +2353,10 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2499,6 +2602,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'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -2541,6 +2648,11 @@ snapshots: '@adobe/css-tools@4.4.4': {} + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@asamuzakjp/css-color@4.1.2': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) @@ -2638,6 +2750,10 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -2758,6 +2874,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@0.2.3': {} '@biomejs/biome@2.4.2': @@ -2892,6 +3013,15 @@ snapshots: '@exodus/bytes@1.14.1': {} + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@istanbuljs/load-nyc-config@1.1.0': dependencies: camelcase: 5.3.1 @@ -3083,6 +3213,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@pkgjs/parseargs@0.11.0': + optional: true + '@playwright/test@1.58.2': dependencies: playwright: 1.58.2 @@ -3304,6 +3437,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@25.0.3)(jsdom@28.1.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.1 + tinyrainbow: 1.2.0 + vitest: 2.1.9(@types/node@25.0.3)(jsdom@28.1.0) + transitivePeerDependencies: + - supports-color + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -3352,12 +3503,16 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -3454,6 +3609,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -3669,12 +3828,16 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + electron-to-chromium@1.5.267: {} emittery@0.13.1: {} emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + entities@6.0.1: {} error-ex@1.3.4: @@ -3790,6 +3953,11 @@ snapshots: dependencies: is-callable: 1.2.7 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + format@0.2.2: {} fs.realpath@1.0.0: {} @@ -3830,6 +3998,15 @@ snapshots: get-stream@6.0.1: {} + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -4094,11 +4271,25 @@ 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 istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jest-changed-files@29.7.0: dependencies: execa: 5.1.1 @@ -4468,6 +4659,8 @@ snapshots: fault: 1.0.4 highlight.js: 10.7.3 + lru-cache@10.4.3: {} + lru-cache@11.2.6: {} lru-cache@5.1.1: @@ -4480,6 +4673,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + make-dir@4.0.0: dependencies: semver: 7.7.3 @@ -4731,8 +4930,14 @@ snapshots: dependencies: brace-expansion: 1.1.12 + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + minimist@1.2.8: {} + minipass@7.1.3: {} + ms@2.1.3: {} nanoid@3.3.11: {} @@ -4791,6 +4996,8 @@ snapshots: p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -4820,6 +5027,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + pathe@1.1.2: {} pathval@2.0.1: {} @@ -5070,6 +5282,8 @@ snapshots: signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + sisteransi@1.0.5: {} slash@3.0.0: {} @@ -5111,6 +5325,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.1.2 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -5120,6 +5340,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + strip-bom@4.0.0: {} strip-final-newline@2.0.0: {} @@ -5156,6 +5380,12 @@ snapshots: glob: 7.2.3 minimatch: 3.1.2 + test-exclude@7.0.1: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.5.0 + minimatch: 9.0.5 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -5405,6 +5635,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.1.2 + wrappy@1.0.2: {} write-file-atomic@4.0.2: diff --git a/frontend/src/api/workflow.ts b/frontend/src/api/workflow.ts index 5a565d0..e2d3f33 100644 --- a/frontend/src/api/workflow.ts +++ b/frontend/src/api/workflow.ts @@ -22,12 +22,19 @@ export interface TestRunSummaryResponse { failed: number; } +export interface CoverageReportResponse { + current_percent: number; + threshold_percent: number; + baseline_percent?: number | null; +} + export interface AcceptanceResponse { can_accept: boolean; reasons: string[]; warning?: string | null; summary: TestRunSummaryResponse; missing_categories: string[]; + coverage_report?: CoverageReportResponse | null; } export interface ReviewStory { @@ -37,6 +44,18 @@ export interface ReviewStory { warning?: string | null; summary: TestRunSummaryResponse; missing_categories: string[]; + coverage_report?: CoverageReportResponse | null; +} + +export interface RecordCoveragePayload { + story_id: string; + current_percent: number; + threshold_percent?: number | null; +} + +export interface CollectCoverageRequest { + story_id: string; + threshold_percent?: number | null; } export interface ReviewListResponse { @@ -71,6 +90,20 @@ async function requestJson( } export const workflowApi = { + collectCoverage(payload: CollectCoverageRequest, baseUrl?: string) { + return requestJson( + "/workflow/coverage/collect", + { method: "POST", body: JSON.stringify(payload) }, + baseUrl, + ); + }, + recordCoverage(payload: RecordCoveragePayload, baseUrl?: string) { + return requestJson( + "/workflow/coverage/record", + { method: "POST", body: JSON.stringify(payload) }, + baseUrl, + ); + }, recordTests(payload: RecordTestsPayload, baseUrl?: string) { return requestJson( "/workflow/tests/record", diff --git a/frontend/src/components/Chat.test.tsx b/frontend/src/components/Chat.test.tsx index fbd2d0d..ddcb145 100644 --- a/frontend/src/components/Chat.test.tsx +++ b/frontend/src/components/Chat.test.tsx @@ -33,6 +33,8 @@ vi.mock("../api/workflow", () => { getReviewQueue: vi.fn(), getReviewQueueAll: vi.fn(), ensureAcceptance: vi.fn(), + recordCoverage: vi.fn(), + collectCoverage: vi.fn(), }, }; }); @@ -390,4 +392,122 @@ describe("Chat review panel", () => { expect(await screen.findByText("Ready to accept")).toBeInTheDocument(); }); + + it("shows coverage below threshold in gate panel (AC3)", async () => { + mockedWorkflow.getAcceptance.mockResolvedValueOnce({ + can_accept: false, + reasons: ["Coverage below threshold (55.0% < 80.0%)."], + warning: null, + summary: { total: 3, passed: 3, failed: 0 }, + missing_categories: [], + coverage_report: { + current_percent: 55.0, + threshold_percent: 80.0, + baseline_percent: null, + }, + }); + + render(); + + expect(await screen.findByText("Blocked")).toBeInTheDocument(); + expect(await screen.findByText(/Coverage: 55\.0%/)).toBeInTheDocument(); + expect(await screen.findByText(/threshold: 80\.0%/)).toBeInTheDocument(); + expect( + await screen.findByText("Coverage below threshold (55.0% < 80.0%)."), + ).toBeInTheDocument(); + }); + + it("shows coverage regression in review panel (AC4)", async () => { + const story: ReviewStory = { + story_id: "27_protect_tests_and_coverage", + can_accept: false, + reasons: ["Coverage regression: 90.0% → 82.0% (threshold: 80.0%)."], + warning: null, + summary: { total: 4, passed: 4, failed: 0 }, + missing_categories: [], + coverage_report: { + current_percent: 82.0, + threshold_percent: 80.0, + baseline_percent: 90.0, + }, + }; + + mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({ + stories: [story], + }); + + render(); + + expect( + await screen.findByText( + "Coverage regression: 90.0% → 82.0% (threshold: 80.0%).", + ), + ).toBeInTheDocument(); + expect(await screen.findByText(/Coverage: 82\.0%/)).toBeInTheDocument(); + }); + + it("shows green coverage when above threshold (AC3)", async () => { + mockedWorkflow.getAcceptance.mockResolvedValueOnce({ + can_accept: true, + reasons: [], + warning: null, + summary: { total: 5, passed: 5, failed: 0 }, + missing_categories: [], + coverage_report: { + current_percent: 92.0, + threshold_percent: 80.0, + baseline_percent: 90.0, + }, + }); + + render(); + + expect(await screen.findByText("Ready to accept")).toBeInTheDocument(); + expect(await screen.findByText(/Coverage: 92\.0%/)).toBeInTheDocument(); + }); + + it("collect coverage button triggers collection and refreshes gate", async () => { + const mockedCollectCoverage = vi.mocked(workflowApi.collectCoverage); + mockedCollectCoverage.mockResolvedValueOnce({ + current_percent: 85.0, + threshold_percent: 80.0, + baseline_percent: null, + }); + + mockedWorkflow.getAcceptance + .mockResolvedValueOnce({ + can_accept: false, + reasons: ["No test results recorded for the story."], + warning: null, + summary: { total: 0, passed: 0, failed: 0 }, + missing_categories: ["unit", "integration"], + }) + .mockResolvedValueOnce({ + can_accept: true, + reasons: [], + warning: null, + summary: { total: 5, passed: 5, failed: 0 }, + missing_categories: [], + coverage_report: { + current_percent: 85.0, + threshold_percent: 80.0, + baseline_percent: null, + }, + }); + + render(); + + const collectButton = await screen.findByRole("button", { + name: "Collect Coverage", + }); + await userEvent.click(collectButton); + + await waitFor(() => { + expect(mockedCollectCoverage).toHaveBeenCalledWith({ + story_id: "26_establish_tdd_workflow_and_gates", + }); + }); + + expect(await screen.findByText(/Coverage: 85\.0%/)).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 9442348..7807862 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -27,6 +27,11 @@ interface GateState { failed: number; }; missingCategories: string[]; + coverageReport: { + currentPercent: number; + thresholdPercent: number; + baselinePercent: number | null; + } | null; } export function Chat({ projectPath, onCloseProject }: ChatProps) { @@ -54,6 +59,8 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { const [proceedSuccess, setProceedSuccess] = useState(null); const [lastReviewRefresh, setLastReviewRefresh] = useState(null); const [lastGateRefresh, setLastGateRefresh] = useState(null); + const [isCollectingCoverage, setIsCollectingCoverage] = useState(false); + const [coverageError, setCoverageError] = useState(null); const storyId = "26_establish_tdd_workflow_and_gates"; const gateStatusColor = isGateLoading @@ -185,6 +192,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { warning: response.warning ?? null, summary: response.summary, missingCategories: response.missing_categories, + coverageReport: response.coverage_report + ? { + currentPercent: response.coverage_report.current_percent, + thresholdPercent: response.coverage_report.threshold_percent, + baselinePercent: + response.coverage_report.baseline_percent ?? null, + } + : null, }); setLastGateRefresh(new Date()); }) @@ -254,6 +269,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { warning: response.warning ?? null, summary: response.summary, missingCategories: response.missing_categories, + coverageReport: response.coverage_report + ? { + currentPercent: response.coverage_report.current_percent, + thresholdPercent: response.coverage_report.threshold_percent, + baselinePercent: + response.coverage_report.baseline_percent ?? null, + } + : null, }); setLastGateRefresh(new Date()); } catch (error) { @@ -268,6 +291,21 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { } }; + const handleCollectCoverage = async () => { + setIsCollectingCoverage(true); + setCoverageError(null); + try { + await workflowApi.collectCoverage({ story_id: storyId }); + await refreshGateState(storyId); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to collect coverage."; + setCoverageError(message); + } finally { + setIsCollectingCoverage(false); + } + }; + const refreshReviewQueue = async () => { setIsReviewLoading(true); setReviewError(null); @@ -549,8 +587,11 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { gateStatusColor={gateStatusColor} isGateLoading={isGateLoading} gateError={gateError} + coverageError={coverageError} lastGateRefresh={lastGateRefresh} onRefresh={() => refreshGateState(storyId)} + onCollectCoverage={handleCollectCoverage} + isCollectingCoverage={isCollectingCoverage} /> diff --git a/frontend/src/components/GatePanel.tsx b/frontend/src/components/GatePanel.tsx index 0255d1f..930929b 100644 --- a/frontend/src/components/GatePanel.tsx +++ b/frontend/src/components/GatePanel.tsx @@ -1,3 +1,9 @@ +interface CoverageReport { + currentPercent: number; + thresholdPercent: number; + baselinePercent: number | null; +} + interface GateState { canAccept: boolean; reasons: string[]; @@ -8,6 +14,7 @@ interface GateState { failed: number; }; missingCategories: string[]; + coverageReport: CoverageReport | null; } interface GatePanelProps { @@ -16,8 +23,11 @@ interface GatePanelProps { gateStatusColor: string; isGateLoading: boolean; gateError: string | null; + coverageError: string | null; lastGateRefresh: Date | null; onRefresh: () => void; + onCollectCoverage: () => void; + isCollectingCoverage: boolean; } const formatTimestamp = (value: Date | null): string => { @@ -35,8 +45,11 @@ export function GatePanel({ gateStatusColor, isGateLoading, gateError, + coverageError, lastGateRefresh, onRefresh, + onCollectCoverage, + isCollectingCoverage, }: GatePanelProps) { return (
Refresh +
+ {gateState.coverageReport && ( +
+ Coverage: {gateState.coverageReport.currentPercent.toFixed(1)}% + (threshold: {gateState.coverageReport.thresholdPercent.toFixed(1)} + %) +
+ )} + {coverageError && ( +
+ Coverage error: {coverageError} +
+ )} {gateState.missingCategories.length > 0 && (
Missing: {gateState.missingCategories.join(", ")} diff --git a/frontend/src/components/ReviewPanel.tsx b/frontend/src/components/ReviewPanel.tsx index 6c9ca37..228ece5 100644 --- a/frontend/src/components/ReviewPanel.tsx +++ b/frontend/src/components/ReviewPanel.tsx @@ -278,6 +278,22 @@ export function ReviewPanel({ Summary: {story.summary.passed}/{story.summary.total} passing,{" "} {` ${story.summary.failed}`} failing
+ {story.coverage_report && ( +
+ Coverage: {story.coverage_report.current_percent.toFixed(1)}% + (threshold:{" "} + {story.coverage_report.threshold_percent.toFixed(1)}%) +
+ )} {story.missing_categories.length > 0 && (
Missing: {story.missing_categories.join(", ")} diff --git a/frontend/tests/e2e/review-panel.spec.ts b/frontend/tests/e2e/review-panel.spec.ts index 9d3636f..6eec013 100644 --- a/frontend/tests/e2e/review-panel.spec.ts +++ b/frontend/tests/e2e/review-panel.spec.ts @@ -1,13 +1,13 @@ import { expect, test } from "@playwright/test"; test.describe("App boot smoke test", () => { - test("renders the project selection screen", async ({ page }) => { - await page.goto("/"); + test("renders the project selection screen", async ({ page }) => { + await page.goto("/"); - await expect(page.getByText("StorkIt")).toBeVisible(); - await expect(page.getByPlaceholder("/path/to/project")).toBeVisible(); - await expect( - page.getByRole("button", { name: "Open Project" }), - ).toBeVisible(); - }); + await expect(page.getByText("StorkIt")).toBeVisible(); + await expect(page.getByPlaceholder("/path/to/project")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Open Project" }), + ).toBeVisible(); + }); }); diff --git a/frontend/tests/e2e/test-protection.spec.ts b/frontend/tests/e2e/test-protection.spec.ts new file mode 100644 index 0000000..c4c9101 --- /dev/null +++ b/frontend/tests/e2e/test-protection.spec.ts @@ -0,0 +1,247 @@ +import { expect, test } from "@playwright/test"; +import type { + AcceptanceResponse, + ReviewListResponse, +} from "../../src/api/workflow"; + +/** + * Helper: mock all API routes needed to render the Chat view. + */ +function mockChatApis( + page: import("@playwright/test").Page, + overrides: { + acceptance?: AcceptanceResponse; + reviewQueue?: ReviewListResponse; + } = {}, +) { + const acceptance: AcceptanceResponse = overrides.acceptance ?? { + can_accept: false, + reasons: ["No test results recorded for the story."], + warning: null, + summary: { total: 0, passed: 0, failed: 0 }, + missing_categories: ["unit", "integration"], + }; + + const reviewQueue: ReviewListResponse = overrides.reviewQueue ?? { + stories: [], + }; + + return Promise.all([ + page.route("**/api/projects", (route) => + route.fulfill({ json: ["/tmp/test-project"] }), + ), + page.route("**/api/io/fs/home", (route) => route.fulfill({ json: "/tmp" })), + page.route("**/api/project", (route) => { + if (route.request().method() === "POST") { + return route.fulfill({ json: "/tmp/test-project" }); + } + if (route.request().method() === "DELETE") { + return route.fulfill({ json: true }); + } + return route.fulfill({ json: null }); + }), + page.route("**/api/ollama/models**", (route) => + route.fulfill({ json: ["llama3.1"] }), + ), + page.route("**/api/anthropic/key/exists", (route) => + route.fulfill({ json: false }), + ), + page.route("**/api/anthropic/models", (route) => + route.fulfill({ json: [] }), + ), + page.route("**/api/model", (route) => { + if (route.request().method() === "POST") { + return route.fulfill({ json: true }); + } + return route.fulfill({ json: null }); + }), + page.route("**/api/workflow/acceptance", (route) => { + if (route.request().url().includes("/ensure")) return route.fallback(); + return route.fulfill({ json: acceptance }); + }), + page.route("**/api/workflow/review/all", (route) => + route.fulfill({ json: reviewQueue }), + ), + page.route("**/api/workflow/acceptance/ensure", (route) => + route.fulfill({ json: true }), + ), + page.route("**/api/io/fs/list/absolute**", (route) => + route.fulfill({ json: [] }), + ), + ]); +} + +async function openProject(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.getByPlaceholder("/path/to/project").fill("/tmp/test-project"); + await page.getByRole("button", { name: "Open Project" }).click(); + await expect(page.getByText("Workflow Gates", { exact: true })).toBeVisible(); +} + +test.describe("Coverage threshold (AC1)", () => { + test("shows blocked status when coverage is below threshold", async ({ + page, + }) => { + await mockChatApis(page, { + acceptance: { + can_accept: false, + reasons: ["Coverage below threshold (55.0% < 80.0%)."], + warning: null, + summary: { total: 3, passed: 3, failed: 0 }, + missing_categories: [], + coverage_report: { + current_percent: 55.0, + threshold_percent: 80.0, + baseline_percent: null, + }, + }, + }); + + await openProject(page); + + await expect(page.getByText("Blocked").first()).toBeVisible(); + await expect(page.getByText(/Coverage: 55\.0%/)).toBeVisible(); + await expect(page.getByText(/threshold: 80\.0%/)).toBeVisible(); + await expect( + page.getByText("Coverage below threshold (55.0% < 80.0%)."), + ).toBeVisible(); + }); + + test("shows green coverage when above threshold", async ({ page }) => { + await mockChatApis(page, { + acceptance: { + can_accept: true, + reasons: [], + warning: null, + summary: { total: 5, passed: 5, failed: 0 }, + missing_categories: [], + coverage_report: { + current_percent: 92.0, + threshold_percent: 80.0, + baseline_percent: 90.0, + }, + }, + }); + + await openProject(page); + + await expect(page.getByText("Ready to accept")).toBeVisible(); + await expect(page.getByText(/Coverage: 92\.0%/)).toBeVisible(); + }); +}); + +test.describe("Coverage regression reporting (AC2)", () => { + test("review panel shows coverage regression reason", async ({ page }) => { + await mockChatApis(page, { + reviewQueue: { + stories: [ + { + story_id: "27_protect_tests_and_coverage", + can_accept: false, + reasons: ["Coverage regression: 90.0% → 82.0% (threshold: 80.0%)."], + warning: null, + summary: { total: 4, passed: 4, failed: 0 }, + missing_categories: [], + coverage_report: { + current_percent: 82.0, + threshold_percent: 80.0, + baseline_percent: 90.0, + }, + }, + ], + }, + }); + + await openProject(page); + + await expect( + page.getByText(/Coverage regression.*90\.0%.*82\.0%/), + ).toBeVisible(); + await expect(page.getByText(/Coverage: 82\.0%/)).toBeVisible(); + + const blockedButton = page.getByRole("button", { name: "Blocked" }); + await expect(blockedButton).toBeDisabled(); + }); + + test("blocked acceptance with coverage and test failures combined", async ({ + page, + }) => { + await mockChatApis(page, { + acceptance: { + can_accept: false, + reasons: [ + "1 test(s) are failing; acceptance is blocked.", + "Coverage below threshold (65.0% < 80.0%).", + ], + warning: null, + summary: { total: 4, passed: 3, failed: 1 }, + missing_categories: [], + coverage_report: { + current_percent: 65.0, + threshold_percent: 80.0, + baseline_percent: null, + }, + }, + }); + + await openProject(page); + + await expect(page.getByText("Blocked").first()).toBeVisible(); + await expect( + page.getByText("Coverage below threshold (65.0% < 80.0%)."), + ).toBeVisible(); + await expect(page.getByText(/Coverage: 65\.0%/)).toBeVisible(); + await expect(page.getByText(/1 test\(s\) are failing/)).toBeVisible(); + }); +}); + +test.describe("Coverage collection (E2E)", () => { + test("collect coverage button triggers collection and displays result", async ({ + page, + }) => { + await mockChatApis(page); + + // Mock the collect coverage endpoint + await page.route("**/api/workflow/coverage/collect", (route) => + route.fulfill({ + json: { + current_percent: 85.0, + threshold_percent: 80.0, + baseline_percent: null, + }, + }), + ); + + await openProject(page); + + // Click the Collect Coverage button + const collectButton = page.getByRole("button", { + name: "Collect Coverage", + }); + await expect(collectButton).toBeVisible(); + + // Override acceptance to return coverage data after collection + await page.unroute("**/api/workflow/acceptance"); + await page.route("**/api/workflow/acceptance", (route) => { + if (route.request().url().includes("/ensure")) return route.fallback(); + return route.fulfill({ + json: { + can_accept: true, + reasons: [], + warning: null, + summary: { total: 5, passed: 5, failed: 0 }, + missing_categories: [], + coverage_report: { + current_percent: 85.0, + threshold_percent: 80.0, + baseline_percent: null, + }, + }, + }); + }); + + await collectButton.click(); + + await expect(page.getByText(/Coverage: 85\.0%/)).toBeVisible(); + }); +}); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3cd5c35..1f1ab59 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,7 +6,10 @@ export default defineConfig(() => ({ plugins: [react()], server: { proxy: { - "/api": "http://127.0.0.1:3001", + "/api": { + target: "http://127.0.0.1:3001", + timeout: 120000, + }, }, }, build: { diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 0b9a89e..9b04907 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -9,5 +9,10 @@ export default defineConfig({ setupFiles: ["./src/setupTests.ts"], css: true, exclude: ["tests/e2e/**", "node_modules/**"], + coverage: { + provider: "v8", + reporter: ["json-summary"], + reportsDirectory: "./coverage", + }, }, }); diff --git a/server/src/http/workflow.rs b/server/src/http/workflow.rs index b3b9e92..507e4f4 100644 --- a/server/src/http/workflow.rs +++ b/server/src/http/workflow.rs @@ -1,7 +1,8 @@ use crate::http::context::{AppContext, OpenApiResult, bad_request}; use crate::io::story_metadata::{StoryMetadata, parse_front_matter}; use crate::workflow::{ - StoryTestResults, TestCaseResult, TestStatus, evaluate_acceptance, summarize_results, + CoverageReport, StoryTestResults, TestCaseResult, TestStatus, + evaluate_acceptance_with_coverage, parse_coverage_json, summarize_results, }; use poem_openapi::{Object, OpenApi, Tags, payload::Json}; use serde::Deserialize; @@ -40,6 +41,13 @@ struct TestRunSummaryResponse { pub failed: usize, } +#[derive(Object)] +struct CoverageReportResponse { + pub current_percent: f64, + pub threshold_percent: f64, + pub baseline_percent: Option, +} + #[derive(Object)] struct AcceptanceResponse { pub can_accept: bool, @@ -47,6 +55,7 @@ struct AcceptanceResponse { pub warning: Option, pub summary: TestRunSummaryResponse, pub missing_categories: Vec, + pub coverage_report: Option, } #[derive(Object)] @@ -57,6 +66,20 @@ struct ReviewStory { pub warning: Option, pub summary: TestRunSummaryResponse, pub missing_categories: Vec, + pub coverage_report: Option, +} + +#[derive(Deserialize, Object)] +struct RecordCoveragePayload { + pub story_id: String, + pub current_percent: f64, + pub threshold_percent: Option, +} + +#[derive(Deserialize, Object)] +struct CollectCoverageRequest { + pub story_id: String, + pub threshold_percent: Option, } #[derive(Object)] @@ -96,8 +119,12 @@ fn load_current_story_metadata(ctx: &AppContext) -> Result ReviewStory { - let decision = evaluate_acceptance(results); +fn to_review_story( + story_id: &str, + results: &StoryTestResults, + coverage: Option<&CoverageReport>, +) -> ReviewStory { + let decision = evaluate_acceptance_with_coverage(results, coverage); let summary = summarize_results(results); let mut missing_categories = Vec::new(); @@ -114,6 +141,12 @@ fn to_review_story(story_id: &str, results: &StoryTestResults) -> ReviewStory { let can_accept = decision.can_accept && missing_categories.is_empty(); + let coverage_report = coverage.map(|c| CoverageReportResponse { + current_percent: c.current_percent, + threshold_percent: c.threshold_percent, + baseline_percent: c.baseline_percent, + }); + ReviewStory { story_id: story_id.to_string(), can_accept, @@ -125,6 +158,7 @@ fn to_review_story(story_id: &str, results: &StoryTestResults) -> ReviewStory { failed: summary.failed, }, missing_categories, + coverage_report, } } @@ -170,20 +204,23 @@ impl WorkflowApi { &self, payload: Json, ) -> OpenApiResult> { - let results = { + let (results, coverage) = { let workflow = self .ctx .workflow .lock() .map_err(|e| bad_request(e.to_string()))?; - workflow + let results = workflow .results .get(&payload.0.story_id) .cloned() - .unwrap_or_default() + .unwrap_or_default(); + let coverage = workflow.coverage.get(&payload.0.story_id).cloned(); + (results, coverage) }; - let decision = evaluate_acceptance(&results); + let decision = + evaluate_acceptance_with_coverage(&results, coverage.as_ref()); let summary = summarize_results(&results); let mut missing_categories = Vec::new(); @@ -200,6 +237,12 @@ impl WorkflowApi { let can_accept = decision.can_accept && missing_categories.is_empty(); + let coverage_report = coverage.map(|c| CoverageReportResponse { + current_percent: c.current_percent, + threshold_percent: c.threshold_percent, + baseline_percent: c.baseline_percent, + }); + Ok(Json(AcceptanceResponse { can_accept, reasons, @@ -210,6 +253,7 @@ impl WorkflowApi { failed: summary.failed, }, missing_categories, + coverage_report, })) } @@ -225,7 +269,10 @@ impl WorkflowApi { workflow .results .iter() - .map(|(story_id, results)| to_review_story(story_id, results)) + .map(|(story_id, results)| { + let coverage = workflow.coverage.get(story_id); + to_review_story(story_id, results, coverage) + }) .filter(|story| story.can_accept) .collect::>() }; @@ -262,7 +309,8 @@ impl WorkflowApi { .into_iter() .map(|story_id| { let results = workflow.results.get(&story_id).cloned().unwrap_or_default(); - to_review_story(&story_id, &results) + let coverage = workflow.coverage.get(&story_id); + to_review_story(&story_id, &results, coverage) }) .collect::>() }; @@ -270,6 +318,91 @@ impl WorkflowApi { Ok(Json(ReviewListResponse { stories })) } + /// Record coverage data for a story. + #[oai(path = "/workflow/coverage/record", method = "post")] + async fn record_coverage( + &self, + payload: Json, + ) -> OpenApiResult> { + let mut workflow = self + .ctx + .workflow + .lock() + .map_err(|e| bad_request(e.to_string()))?; + workflow.record_coverage( + payload.0.story_id, + payload.0.current_percent, + payload.0.threshold_percent, + ); + Ok(Json(true)) + } + + /// Run coverage collection: execute test:coverage, parse output, record result. + #[oai(path = "/workflow/coverage/collect", method = "post")] + async fn collect_coverage( + &self, + payload: Json, + ) -> OpenApiResult> { + let root = self + .ctx + .state + .get_project_root() + .map_err(bad_request)?; + + let frontend_dir = root.join("frontend"); + + // Run pnpm run test:coverage in the frontend directory + let output = tokio::task::spawn_blocking(move || { + std::process::Command::new("pnpm") + .args(["run", "test:coverage"]) + .current_dir(&frontend_dir) + .output() + }) + .await + .map_err(|e| bad_request(format!("Task join error: {e}")))? + .map_err(|e| bad_request(format!("Failed to run coverage command: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(bad_request(format!("Coverage command failed: {stderr}"))); + } + + // Read the coverage summary JSON + let summary_path = root + .join("frontend") + .join("coverage") + .join("coverage-summary.json"); + let json_str = fs::read_to_string(&summary_path) + .map_err(|e| bad_request(format!("Failed to read coverage summary: {e}")))?; + + let current_percent = parse_coverage_json(&json_str).map_err(bad_request)?; + + // Record coverage in workflow state + let coverage_report = { + let mut workflow = self + .ctx + .workflow + .lock() + .map_err(|e| bad_request(e.to_string()))?; + workflow.record_coverage( + payload.0.story_id.clone(), + current_percent, + payload.0.threshold_percent, + ); + workflow + .coverage + .get(&payload.0.story_id) + .cloned() + .expect("just inserted") + }; + + Ok(Json(CoverageReportResponse { + current_percent: coverage_report.current_percent, + threshold_percent: coverage_report.threshold_percent, + baseline_percent: coverage_report.baseline_percent, + })) + } + /// Ensure a story can be accepted; returns an error when gates fail. #[oai(path = "/workflow/acceptance/ensure", method = "post")] async fn ensure_acceptance( diff --git a/server/src/workflow.rs b/server/src/workflow.rs index aad5a55..f30c027 100644 --- a/server/src/workflow.rs +++ b/server/src/workflow.rs @@ -49,6 +49,7 @@ pub struct StoryTestResults { pub struct WorkflowState { pub stories: HashMap, pub results: HashMap, + pub coverage: HashMap, } #[allow(dead_code)] @@ -105,6 +106,29 @@ impl WorkflowState { Ok(()) } + + pub fn record_coverage( + &mut self, + story_id: String, + current_percent: f64, + threshold_percent: Option, + ) { + let threshold = threshold_percent.unwrap_or(80.0); + + let baseline = self + .coverage + .get(&story_id) + .map(|existing| existing.baseline_percent.unwrap_or(existing.current_percent)); + + self.coverage.insert( + story_id, + CoverageReport { + current_percent, + threshold_percent: threshold, + baseline_percent: baseline, + }, + ); + } } #[allow(dead_code)] @@ -179,10 +203,250 @@ pub fn evaluate_acceptance(results: &StoryTestResults) -> AcceptanceDecision { } } +/// Coverage report for a story. +#[derive(Debug, Clone, PartialEq)] +pub struct CoverageReport { + pub current_percent: f64, + pub threshold_percent: f64, + pub baseline_percent: Option, +} + +/// Parse coverage percentage from a vitest coverage-summary.json string. +/// Expects JSON with `{"total": {"lines": {"pct": }}}`. +pub fn parse_coverage_json(json_str: &str) -> Result { + let value: serde_json::Value = + serde_json::from_str(json_str).map_err(|e| format!("Invalid coverage JSON: {e}"))?; + + value + .get("total") + .and_then(|t| t.get("lines")) + .and_then(|l| l.get("pct")) + .and_then(|p| p.as_f64()) + .ok_or_else(|| "Missing total.lines.pct in coverage JSON.".to_string()) +} + +/// Check whether coverage meets the threshold. +#[allow(dead_code)] +pub fn check_coverage_threshold(current: f64, threshold: f64) -> Result<(), String> { + if current >= threshold { + Ok(()) + } else { + Err(format!( + "Coverage below threshold ({current:.1}% < {threshold:.1}%)." + )) + } +} + +/// Evaluate acceptance with optional coverage data. +pub fn evaluate_acceptance_with_coverage( + results: &StoryTestResults, + coverage: Option<&CoverageReport>, +) -> AcceptanceDecision { + let mut decision = evaluate_acceptance(results); + + if let Some(report) = coverage { + if report.current_percent < report.threshold_percent { + decision.can_accept = false; + decision.reasons.push(format!( + "Coverage below threshold ({:.1}% < {:.1}%).", + report.current_percent, report.threshold_percent + )); + } + if let Some(baseline) = report.baseline_percent + && report.current_percent < baseline { + decision.can_accept = false; + decision.reasons.push(format!( + "Coverage regression: {:.1}% → {:.1}% (threshold: {:.1}%).", + baseline, report.current_percent, report.threshold_percent + )); + } + } + + decision +} + #[cfg(test)] mod tests { use super::*; + // === parse_coverage_json === + + #[test] + fn parses_valid_coverage_json() { + let json = r#"{"total":{"lines":{"total":100,"covered":85,"pct":85.0},"statements":{"pct":85.0}}}"#; + assert_eq!(parse_coverage_json(json).unwrap(), 85.0); + } + + #[test] + fn rejects_invalid_coverage_json() { + assert!(parse_coverage_json("not json").is_err()); + } + + #[test] + fn rejects_missing_total_lines_pct() { + let json = r#"{"total":{"branches":{"pct":90.0}}}"#; + assert!(parse_coverage_json(json).is_err()); + } + + // === AC1: check_coverage_threshold === + + #[test] + fn coverage_threshold_passes_when_met() { + assert!(check_coverage_threshold(80.0, 80.0).is_ok()); + assert!(check_coverage_threshold(95.5, 80.0).is_ok()); + } + + #[test] + fn coverage_threshold_fails_when_below() { + let result = check_coverage_threshold(72.3, 80.0); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("72.3%")); + assert!(err.contains("80.0%")); + } + + // === AC2: evaluate_acceptance_with_coverage === + + #[test] + fn acceptance_blocked_by_coverage_below_threshold() { + let results = StoryTestResults { + unit: vec![TestCaseResult { + name: "unit-1".to_string(), + status: TestStatus::Pass, + details: None, + }], + integration: vec![TestCaseResult { + name: "int-1".to_string(), + status: TestStatus::Pass, + details: None, + }], + }; + + let coverage = CoverageReport { + current_percent: 55.0, + threshold_percent: 80.0, + baseline_percent: None, + }; + + let decision = evaluate_acceptance_with_coverage(&results, Some(&coverage)); + assert!(!decision.can_accept); + assert!(decision.reasons.iter().any(|r| r.contains("Coverage below threshold"))); + } + + #[test] + fn acceptance_blocked_by_coverage_regression() { + let results = StoryTestResults { + unit: vec![TestCaseResult { + name: "unit-1".to_string(), + status: TestStatus::Pass, + details: None, + }], + integration: vec![TestCaseResult { + name: "int-1".to_string(), + status: TestStatus::Pass, + details: None, + }], + }; + + let coverage = CoverageReport { + current_percent: 82.0, + threshold_percent: 80.0, + baseline_percent: Some(90.0), + }; + + let decision = evaluate_acceptance_with_coverage(&results, Some(&coverage)); + assert!(!decision.can_accept); + assert!(decision.reasons.iter().any(|r| r.contains("Coverage regression"))); + } + + #[test] + fn acceptance_passes_with_good_coverage() { + let results = StoryTestResults { + unit: vec![TestCaseResult { + name: "unit-1".to_string(), + status: TestStatus::Pass, + details: None, + }], + integration: vec![TestCaseResult { + name: "int-1".to_string(), + status: TestStatus::Pass, + details: None, + }], + }; + + let coverage = CoverageReport { + current_percent: 92.0, + threshold_percent: 80.0, + baseline_percent: Some(90.0), + }; + + let decision = evaluate_acceptance_with_coverage(&results, Some(&coverage)); + assert!(decision.can_accept); + } + + #[test] + fn acceptance_works_without_coverage_data() { + let results = StoryTestResults { + unit: vec![TestCaseResult { + name: "unit-1".to_string(), + status: TestStatus::Pass, + details: None, + }], + integration: vec![TestCaseResult { + name: "int-1".to_string(), + status: TestStatus::Pass, + details: None, + }], + }; + + let decision = evaluate_acceptance_with_coverage(&results, None); + assert!(decision.can_accept); + } + + // === record_coverage === + + #[test] + fn record_coverage_first_time_has_no_baseline() { + let mut state = WorkflowState::default(); + state.record_coverage("story-27".to_string(), 85.0, Some(80.0)); + + let report = state.coverage.get("story-27").unwrap(); + assert_eq!(report.current_percent, 85.0); + assert_eq!(report.threshold_percent, 80.0); + assert_eq!(report.baseline_percent, None); + } + + #[test] + fn record_coverage_subsequent_sets_baseline() { + let mut state = WorkflowState::default(); + state.record_coverage("story-27".to_string(), 85.0, Some(80.0)); + state.record_coverage("story-27".to_string(), 78.0, Some(80.0)); + + let report = state.coverage.get("story-27").unwrap(); + assert_eq!(report.current_percent, 78.0); + assert_eq!(report.baseline_percent, Some(85.0)); + } + + #[test] + fn record_coverage_default_threshold() { + let mut state = WorkflowState::default(); + state.record_coverage("story-27".to_string(), 90.0, None); + + let report = state.coverage.get("story-27").unwrap(); + assert_eq!(report.threshold_percent, 80.0); + } + + #[test] + fn record_coverage_custom_threshold() { + let mut state = WorkflowState::default(); + state.record_coverage("story-27".to_string(), 90.0, Some(95.0)); + + let report = state.coverage.get("story-27").unwrap(); + assert_eq!(report.threshold_percent, 95.0); + } + + // === Existing tests === + #[test] fn warns_when_multiple_tests_fail() { let results = StoryTestResults {