Story 27: Coverage tracking (full-stack)

Add end-to-end coverage tracking: backend collects vitest coverage,
records metrics with threshold/baseline tracking, and blocks acceptance
on regression. Frontend displays coverage in gate/review panels with
a "Collect Coverage" button. Includes 20 Rust tests, 17 Vitest tests,
and 14 Playwright E2E tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-19 14:45:57 +00:00
parent 8f0bc971bf
commit 8f684a6ca4
20 changed files with 1216 additions and 34 deletions

2
.ignore Normal file
View File

@@ -0,0 +1,2 @@
# Prevents cargo watch from restarting the server when frontend files change
frontend/

View File

@@ -67,6 +67,7 @@ When the user asks for a feature, follow this 4-step loop strictly:
### Step 3: The Implementation (Code) ### Step 3: The Implementation (Code)
* **Action:** Write the code to satisfy the approved tests and Acceptance Criteria. * **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()`). * **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) ### 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. * **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.

View File

View File

@@ -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).

View File

View File

@@ -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.

1
frontend/.gitignore vendored
View File

@@ -10,4 +10,5 @@ lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
coverage
*.local *.local

View File

@@ -10,7 +10,8 @@
"server": "cargo run --manifest-path server/Cargo.toml", "server": "cargo run --manifest-path server/Cargo.toml",
"test": "vitest run", "test": "vitest run",
"test:unit": "vitest run", "test:unit": "vitest run",
"test:e2e": "playwright test" "test:e2e": "playwright test",
"test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
@@ -25,10 +26,11 @@
"@testing-library/jest-dom": "^6.0.0", "@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0", "@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3", "@testing-library/user-event": "^14.4.3",
"@types/node": "^25.0.0",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@types/node": "^25.0.0",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"@vitest/coverage-v8": "^2.1.9",
"jest": "^29.0.0", "jest": "^29.0.0",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"ts-jest": "^29.0.0", "ts-jest": "^29.0.0",

236
frontend/pnpm-lock.yaml generated
View File

@@ -51,6 +51,9 @@ importers:
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.6.0 specifier: ^4.6.0
version: 4.7.0(vite@5.4.21(@types/node@25.0.3)) 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: jest:
specifier: ^29.0.0 specifier: ^29.0.0
version: 29.7.0(@types/node@25.0.3) version: 29.7.0(@types/node@25.0.3)
@@ -78,6 +81,10 @@ packages:
'@adobe/css-tools@4.4.4': '@adobe/css-tools@4.4.4':
resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} 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': '@asamuzakjp/css-color@4.1.2':
resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==} resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==}
@@ -146,6 +153,11 @@ packages:
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true 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': '@babel/plugin-syntax-async-generators@7.8.4':
resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==}
peerDependencies: peerDependencies:
@@ -265,6 +277,10 @@ packages:
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
engines: {node: '>=6.9.0'} 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': '@bcoe/v8-coverage@0.2.3':
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
@@ -503,6 +519,10 @@ packages:
'@noble/hashes': '@noble/hashes':
optional: true optional: true
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@istanbuljs/load-nyc-config@1.1.0': '@istanbuljs/load-nyc-config@1.1.0':
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -593,6 +613,10 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} 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': '@playwright/test@1.58.2':
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -832,6 +856,15 @@ packages:
peerDependencies: peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 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': '@vitest/expect@2.1.9':
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
@@ -873,6 +906,10 @@ packages:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@4.3.0: ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -881,6 +918,10 @@ packages:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'} engines: {node: '>=10'}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
anymatch@3.1.3: anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -948,6 +989,9 @@ packages:
brace-expansion@1.1.12: brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
braces@3.0.3: braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1158,6 +1202,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
electron-to-chromium@1.5.267: electron-to-chromium@1.5.267:
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
@@ -1168,6 +1215,9 @@ packages:
emoji-regex@8.0.0: emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
entities@6.0.1: entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
@@ -1257,6 +1307,10 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
format@0.2.2: format@0.2.2:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'} engines: {node: '>=0.4.x'}
@@ -1304,6 +1358,11 @@ packages:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'} 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: glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported deprecated: Glob versions prior to v9 are no longer supported
@@ -1538,10 +1597,17 @@ packages:
resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==}
engines: {node: '>=10'} 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: istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'} engines: {node: '>=8'}
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
jest-changed-files@29.7.0: jest-changed-files@29.7.0:
resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -1727,6 +1793,9 @@ packages:
lowlight@1.20.0: lowlight@1.20.0:
resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==} 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: lru-cache@11.2.6:
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@@ -1741,6 +1810,9 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magicast@0.3.5:
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
make-dir@4.0.0: make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1863,9 +1935,17 @@ packages:
minimatch@3.1.2: minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} 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: minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@7.1.3:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1933,6 +2013,9 @@ packages:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
parse-entities@4.0.2: parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
@@ -1958,6 +2041,10 @@ packages:
path-parse@1.0.7: path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} 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: pathe@1.1.2:
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
@@ -2160,6 +2247,10 @@ packages:
signal-exit@3.0.7: signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} 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: sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
@@ -2206,6 +2297,10 @@ packages:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'} engines: {node: '>=8'}
string-width@5.1.2:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'}
stringify-entities@4.0.4: stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@@ -2213,6 +2308,10 @@ packages:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'} engines: {node: '>=8'}
strip-ansi@7.1.2:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'}
strip-bom@4.0.0: strip-bom@4.0.0:
resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2254,6 +2353,10 @@ packages:
resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==}
engines: {node: '>=8'} engines: {node: '>=8'}
test-exclude@7.0.1:
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
engines: {node: '>=18'}
tinybench@2.9.0: tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -2499,6 +2602,10 @@ packages:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
wrap-ansi@8.1.0:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
wrappy@1.0.2: wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -2541,6 +2648,11 @@ snapshots:
'@adobe/css-tools@4.4.4': {} '@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': '@asamuzakjp/css-color@4.1.2':
dependencies: 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) '@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: dependencies:
'@babel/types': 7.28.5 '@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)': '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.5)':
dependencies: dependencies:
'@babel/core': 7.28.5 '@babel/core': 7.28.5
@@ -2758,6 +2874,11 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@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': {} '@bcoe/v8-coverage@0.2.3': {}
'@biomejs/biome@2.4.2': '@biomejs/biome@2.4.2':
@@ -2892,6 +3013,15 @@ snapshots:
'@exodus/bytes@1.14.1': {} '@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': '@istanbuljs/load-nyc-config@1.1.0':
dependencies: dependencies:
camelcase: 5.3.1 camelcase: 5.3.1
@@ -3083,6 +3213,9 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@pkgjs/parseargs@0.11.0':
optional: true
'@playwright/test@1.58.2': '@playwright/test@1.58.2':
dependencies: dependencies:
playwright: 1.58.2 playwright: 1.58.2
@@ -3304,6 +3437,24 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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': '@vitest/expect@2.1.9':
dependencies: dependencies:
'@vitest/spy': 2.1.9 '@vitest/spy': 2.1.9
@@ -3352,12 +3503,16 @@ snapshots:
ansi-regex@5.0.1: {} ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
ansi-styles@4.3.0: ansi-styles@4.3.0:
dependencies: dependencies:
color-convert: 2.0.1 color-convert: 2.0.1
ansi-styles@5.2.0: {} ansi-styles@5.2.0: {}
ansi-styles@6.2.3: {}
anymatch@3.1.3: anymatch@3.1.3:
dependencies: dependencies:
normalize-path: 3.0.0 normalize-path: 3.0.0
@@ -3454,6 +3609,10 @@ snapshots:
balanced-match: 1.0.2 balanced-match: 1.0.2
concat-map: 0.0.1 concat-map: 0.0.1
brace-expansion@2.0.2:
dependencies:
balanced-match: 1.0.2
braces@3.0.3: braces@3.0.3:
dependencies: dependencies:
fill-range: 7.1.1 fill-range: 7.1.1
@@ -3669,12 +3828,16 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
gopd: 1.2.0 gopd: 1.2.0
eastasianwidth@0.2.0: {}
electron-to-chromium@1.5.267: {} electron-to-chromium@1.5.267: {}
emittery@0.13.1: {} emittery@0.13.1: {}
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
entities@6.0.1: {} entities@6.0.1: {}
error-ex@1.3.4: error-ex@1.3.4:
@@ -3790,6 +3953,11 @@ snapshots:
dependencies: dependencies:
is-callable: 1.2.7 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: {} format@0.2.2: {}
fs.realpath@1.0.0: {} fs.realpath@1.0.0: {}
@@ -3830,6 +3998,15 @@ snapshots:
get-stream@6.0.1: {} 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: glob@7.2.3:
dependencies: dependencies:
fs.realpath: 1.0.0 fs.realpath: 1.0.0
@@ -4094,11 +4271,25 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: istanbul-reports@3.2.0:
dependencies: dependencies:
html-escaper: 2.0.2 html-escaper: 2.0.2
istanbul-lib-report: 3.0.1 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: jest-changed-files@29.7.0:
dependencies: dependencies:
execa: 5.1.1 execa: 5.1.1
@@ -4468,6 +4659,8 @@ snapshots:
fault: 1.0.4 fault: 1.0.4
highlight.js: 10.7.3 highlight.js: 10.7.3
lru-cache@10.4.3: {}
lru-cache@11.2.6: {} lru-cache@11.2.6: {}
lru-cache@5.1.1: lru-cache@5.1.1:
@@ -4480,6 +4673,12 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@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: make-dir@4.0.0:
dependencies: dependencies:
semver: 7.7.3 semver: 7.7.3
@@ -4731,8 +4930,14 @@ snapshots:
dependencies: dependencies:
brace-expansion: 1.1.12 brace-expansion: 1.1.12
minimatch@9.0.5:
dependencies:
brace-expansion: 2.0.2
minimist@1.2.8: {} minimist@1.2.8: {}
minipass@7.1.3: {}
ms@2.1.3: {} ms@2.1.3: {}
nanoid@3.3.11: {} nanoid@3.3.11: {}
@@ -4791,6 +4996,8 @@ snapshots:
p-try@2.2.0: {} p-try@2.2.0: {}
package-json-from-dist@1.0.1: {}
parse-entities@4.0.2: parse-entities@4.0.2:
dependencies: dependencies:
'@types/unist': 2.0.11 '@types/unist': 2.0.11
@@ -4820,6 +5027,11 @@ snapshots:
path-parse@1.0.7: {} path-parse@1.0.7: {}
path-scurry@1.11.1:
dependencies:
lru-cache: 10.4.3
minipass: 7.1.3
pathe@1.1.2: {} pathe@1.1.2: {}
pathval@2.0.1: {} pathval@2.0.1: {}
@@ -5070,6 +5282,8 @@ snapshots:
signal-exit@3.0.7: {} signal-exit@3.0.7: {}
signal-exit@4.1.0: {}
sisteransi@1.0.5: {} sisteransi@1.0.5: {}
slash@3.0.0: {} slash@3.0.0: {}
@@ -5111,6 +5325,12 @@ snapshots:
is-fullwidth-code-point: 3.0.0 is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1 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: stringify-entities@4.0.4:
dependencies: dependencies:
character-entities-html4: 2.1.0 character-entities-html4: 2.1.0
@@ -5120,6 +5340,10 @@ snapshots:
dependencies: dependencies:
ansi-regex: 5.0.1 ansi-regex: 5.0.1
strip-ansi@7.1.2:
dependencies:
ansi-regex: 6.2.2
strip-bom@4.0.0: {} strip-bom@4.0.0: {}
strip-final-newline@2.0.0: {} strip-final-newline@2.0.0: {}
@@ -5156,6 +5380,12 @@ snapshots:
glob: 7.2.3 glob: 7.2.3
minimatch: 3.1.2 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: {} tinybench@2.9.0: {}
tinyexec@0.3.2: {} tinyexec@0.3.2: {}
@@ -5405,6 +5635,12 @@ snapshots:
string-width: 4.2.3 string-width: 4.2.3
strip-ansi: 6.0.1 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: {} wrappy@1.0.2: {}
write-file-atomic@4.0.2: write-file-atomic@4.0.2:

View File

@@ -22,12 +22,19 @@ export interface TestRunSummaryResponse {
failed: number; failed: number;
} }
export interface CoverageReportResponse {
current_percent: number;
threshold_percent: number;
baseline_percent?: number | null;
}
export interface AcceptanceResponse { export interface AcceptanceResponse {
can_accept: boolean; can_accept: boolean;
reasons: string[]; reasons: string[];
warning?: string | null; warning?: string | null;
summary: TestRunSummaryResponse; summary: TestRunSummaryResponse;
missing_categories: string[]; missing_categories: string[];
coverage_report?: CoverageReportResponse | null;
} }
export interface ReviewStory { export interface ReviewStory {
@@ -37,6 +44,18 @@ export interface ReviewStory {
warning?: string | null; warning?: string | null;
summary: TestRunSummaryResponse; summary: TestRunSummaryResponse;
missing_categories: string[]; 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 { export interface ReviewListResponse {
@@ -71,6 +90,20 @@ async function requestJson<T>(
} }
export const workflowApi = { export const workflowApi = {
collectCoverage(payload: CollectCoverageRequest, baseUrl?: string) {
return requestJson<CoverageReportResponse>(
"/workflow/coverage/collect",
{ method: "POST", body: JSON.stringify(payload) },
baseUrl,
);
},
recordCoverage(payload: RecordCoveragePayload, baseUrl?: string) {
return requestJson<boolean>(
"/workflow/coverage/record",
{ method: "POST", body: JSON.stringify(payload) },
baseUrl,
);
},
recordTests(payload: RecordTestsPayload, baseUrl?: string) { recordTests(payload: RecordTestsPayload, baseUrl?: string) {
return requestJson<boolean>( return requestJson<boolean>(
"/workflow/tests/record", "/workflow/tests/record",

View File

@@ -33,6 +33,8 @@ vi.mock("../api/workflow", () => {
getReviewQueue: vi.fn(), getReviewQueue: vi.fn(),
getReviewQueueAll: vi.fn(), getReviewQueueAll: vi.fn(),
ensureAcceptance: 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(); 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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
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();
});
}); });

View File

@@ -27,6 +27,11 @@ interface GateState {
failed: number; failed: number;
}; };
missingCategories: string[]; missingCategories: string[];
coverageReport: {
currentPercent: number;
thresholdPercent: number;
baselinePercent: number | null;
} | null;
} }
export function Chat({ projectPath, onCloseProject }: ChatProps) { export function Chat({ projectPath, onCloseProject }: ChatProps) {
@@ -54,6 +59,8 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
const [proceedSuccess, setProceedSuccess] = useState<string | null>(null); const [proceedSuccess, setProceedSuccess] = useState<string | null>(null);
const [lastReviewRefresh, setLastReviewRefresh] = useState<Date | null>(null); const [lastReviewRefresh, setLastReviewRefresh] = useState<Date | null>(null);
const [lastGateRefresh, setLastGateRefresh] = useState<Date | null>(null); const [lastGateRefresh, setLastGateRefresh] = useState<Date | null>(null);
const [isCollectingCoverage, setIsCollectingCoverage] = useState(false);
const [coverageError, setCoverageError] = useState<string | null>(null);
const storyId = "26_establish_tdd_workflow_and_gates"; const storyId = "26_establish_tdd_workflow_and_gates";
const gateStatusColor = isGateLoading const gateStatusColor = isGateLoading
@@ -185,6 +192,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
warning: response.warning ?? null, warning: response.warning ?? null,
summary: response.summary, summary: response.summary,
missingCategories: response.missing_categories, 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()); setLastGateRefresh(new Date());
}) })
@@ -254,6 +269,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
warning: response.warning ?? null, warning: response.warning ?? null,
summary: response.summary, summary: response.summary,
missingCategories: response.missing_categories, 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()); setLastGateRefresh(new Date());
} catch (error) { } 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 () => { const refreshReviewQueue = async () => {
setIsReviewLoading(true); setIsReviewLoading(true);
setReviewError(null); setReviewError(null);
@@ -549,8 +587,11 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
gateStatusColor={gateStatusColor} gateStatusColor={gateStatusColor}
isGateLoading={isGateLoading} isGateLoading={isGateLoading}
gateError={gateError} gateError={gateError}
coverageError={coverageError}
lastGateRefresh={lastGateRefresh} lastGateRefresh={lastGateRefresh}
onRefresh={() => refreshGateState(storyId)} onRefresh={() => refreshGateState(storyId)}
onCollectCoverage={handleCollectCoverage}
isCollectingCoverage={isCollectingCoverage}
/> />
</div> </div>
</div> </div>

View File

@@ -1,3 +1,9 @@
interface CoverageReport {
currentPercent: number;
thresholdPercent: number;
baselinePercent: number | null;
}
interface GateState { interface GateState {
canAccept: boolean; canAccept: boolean;
reasons: string[]; reasons: string[];
@@ -8,6 +14,7 @@ interface GateState {
failed: number; failed: number;
}; };
missingCategories: string[]; missingCategories: string[];
coverageReport: CoverageReport | null;
} }
interface GatePanelProps { interface GatePanelProps {
@@ -16,8 +23,11 @@ interface GatePanelProps {
gateStatusColor: string; gateStatusColor: string;
isGateLoading: boolean; isGateLoading: boolean;
gateError: string | null; gateError: string | null;
coverageError: string | null;
lastGateRefresh: Date | null; lastGateRefresh: Date | null;
onRefresh: () => void; onRefresh: () => void;
onCollectCoverage: () => void;
isCollectingCoverage: boolean;
} }
const formatTimestamp = (value: Date | null): string => { const formatTimestamp = (value: Date | null): string => {
@@ -35,8 +45,11 @@ export function GatePanel({
gateStatusColor, gateStatusColor,
isGateLoading, isGateLoading,
gateError, gateError,
coverageError,
lastGateRefresh, lastGateRefresh,
onRefresh, onRefresh,
onCollectCoverage,
isCollectingCoverage,
}: GatePanelProps) { }: GatePanelProps) {
return ( return (
<div <div
@@ -83,6 +96,27 @@ export function GatePanel({
> >
Refresh Refresh
</button> </button>
<button
type="button"
onClick={onCollectCoverage}
disabled={isCollectingCoverage || isGateLoading}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #333",
background:
isCollectingCoverage || isGateLoading ? "#2a2a2a" : "#2f2f2f",
color: isCollectingCoverage || isGateLoading ? "#777" : "#aaa",
cursor:
isCollectingCoverage || isGateLoading
? "not-allowed"
: "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
{isCollectingCoverage ? "Collecting..." : "Collect Coverage"}
</button>
</div> </div>
<div <div
style={{ style={{
@@ -147,6 +181,27 @@ export function GatePanel({
Summary: {gateState.summary.passed}/{gateState.summary.total}{" "} Summary: {gateState.summary.passed}/{gateState.summary.total}{" "}
passing, {gateState.summary.failed} failing passing, {gateState.summary.failed} failing
</div> </div>
{gateState.coverageReport && (
<div
style={{
fontSize: "0.85em",
color:
gateState.coverageReport.currentPercent <
gateState.coverageReport.thresholdPercent
? "#ff7b72"
: "#7ee787",
}}
>
Coverage: {gateState.coverageReport.currentPercent.toFixed(1)}%
(threshold: {gateState.coverageReport.thresholdPercent.toFixed(1)}
%)
</div>
)}
{coverageError && (
<div style={{ fontSize: "0.85em", color: "#ff7b72" }}>
Coverage error: {coverageError}
</div>
)}
{gateState.missingCategories.length > 0 && ( {gateState.missingCategories.length > 0 && (
<div style={{ fontSize: "0.85em", color: "#ffb86c" }}> <div style={{ fontSize: "0.85em", color: "#ffb86c" }}>
Missing: {gateState.missingCategories.join(", ")} Missing: {gateState.missingCategories.join(", ")}

View File

@@ -278,6 +278,22 @@ export function ReviewPanel({
Summary: {story.summary.passed}/{story.summary.total} passing,{" "} Summary: {story.summary.passed}/{story.summary.total} passing,{" "}
{` ${story.summary.failed}`} failing {` ${story.summary.failed}`} failing
</div> </div>
{story.coverage_report && (
<div
style={{
fontSize: "0.85em",
color:
story.coverage_report.current_percent <
story.coverage_report.threshold_percent
? "#ff7b72"
: "#7ee787",
}}
>
Coverage: {story.coverage_report.current_percent.toFixed(1)}%
(threshold:{" "}
{story.coverage_report.threshold_percent.toFixed(1)}%)
</div>
)}
{story.missing_categories.length > 0 && ( {story.missing_categories.length > 0 && (
<div style={{ fontSize: "0.85em", color: "#ffb86c" }}> <div style={{ fontSize: "0.85em", color: "#ffb86c" }}>
Missing: {story.missing_categories.join(", ")} Missing: {story.missing_categories.join(", ")}

View File

@@ -1,13 +1,13 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
test.describe("App boot smoke test", () => { test.describe("App boot smoke test", () => {
test("renders the project selection screen", async ({ page }) => { test("renders the project selection screen", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await expect(page.getByText("StorkIt")).toBeVisible(); await expect(page.getByText("StorkIt")).toBeVisible();
await expect(page.getByPlaceholder("/path/to/project")).toBeVisible(); await expect(page.getByPlaceholder("/path/to/project")).toBeVisible();
await expect( await expect(
page.getByRole("button", { name: "Open Project" }), page.getByRole("button", { name: "Open Project" }),
).toBeVisible(); ).toBeVisible();
}); });
}); });

View File

@@ -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();
});
});

View File

@@ -6,7 +6,10 @@ export default defineConfig(() => ({
plugins: [react()], plugins: [react()],
server: { server: {
proxy: { proxy: {
"/api": "http://127.0.0.1:3001", "/api": {
target: "http://127.0.0.1:3001",
timeout: 120000,
},
}, },
}, },
build: { build: {

View File

@@ -9,5 +9,10 @@ export default defineConfig({
setupFiles: ["./src/setupTests.ts"], setupFiles: ["./src/setupTests.ts"],
css: true, css: true,
exclude: ["tests/e2e/**", "node_modules/**"], exclude: ["tests/e2e/**", "node_modules/**"],
coverage: {
provider: "v8",
reporter: ["json-summary"],
reportsDirectory: "./coverage",
},
}, },
}); });

View File

@@ -1,7 +1,8 @@
use crate::http::context::{AppContext, OpenApiResult, bad_request}; use crate::http::context::{AppContext, OpenApiResult, bad_request};
use crate::io::story_metadata::{StoryMetadata, parse_front_matter}; use crate::io::story_metadata::{StoryMetadata, parse_front_matter};
use crate::workflow::{ 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 poem_openapi::{Object, OpenApi, Tags, payload::Json};
use serde::Deserialize; use serde::Deserialize;
@@ -40,6 +41,13 @@ struct TestRunSummaryResponse {
pub failed: usize, pub failed: usize,
} }
#[derive(Object)]
struct CoverageReportResponse {
pub current_percent: f64,
pub threshold_percent: f64,
pub baseline_percent: Option<f64>,
}
#[derive(Object)] #[derive(Object)]
struct AcceptanceResponse { struct AcceptanceResponse {
pub can_accept: bool, pub can_accept: bool,
@@ -47,6 +55,7 @@ struct AcceptanceResponse {
pub warning: Option<String>, pub warning: Option<String>,
pub summary: TestRunSummaryResponse, pub summary: TestRunSummaryResponse,
pub missing_categories: Vec<String>, pub missing_categories: Vec<String>,
pub coverage_report: Option<CoverageReportResponse>,
} }
#[derive(Object)] #[derive(Object)]
@@ -57,6 +66,20 @@ struct ReviewStory {
pub warning: Option<String>, pub warning: Option<String>,
pub summary: TestRunSummaryResponse, pub summary: TestRunSummaryResponse,
pub missing_categories: Vec<String>, pub missing_categories: Vec<String>,
pub coverage_report: Option<CoverageReportResponse>,
}
#[derive(Deserialize, Object)]
struct RecordCoveragePayload {
pub story_id: String,
pub current_percent: f64,
pub threshold_percent: Option<f64>,
}
#[derive(Deserialize, Object)]
struct CollectCoverageRequest {
pub story_id: String,
pub threshold_percent: Option<f64>,
} }
#[derive(Object)] #[derive(Object)]
@@ -96,8 +119,12 @@ fn load_current_story_metadata(ctx: &AppContext) -> Result<Vec<(String, StoryMet
Ok(stories) Ok(stories)
} }
fn to_review_story(story_id: &str, results: &StoryTestResults) -> ReviewStory { fn to_review_story(
let decision = evaluate_acceptance(results); story_id: &str,
results: &StoryTestResults,
coverage: Option<&CoverageReport>,
) -> ReviewStory {
let decision = evaluate_acceptance_with_coverage(results, coverage);
let summary = summarize_results(results); let summary = summarize_results(results);
let mut missing_categories = Vec::new(); 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 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 { ReviewStory {
story_id: story_id.to_string(), story_id: story_id.to_string(),
can_accept, can_accept,
@@ -125,6 +158,7 @@ fn to_review_story(story_id: &str, results: &StoryTestResults) -> ReviewStory {
failed: summary.failed, failed: summary.failed,
}, },
missing_categories, missing_categories,
coverage_report,
} }
} }
@@ -170,20 +204,23 @@ impl WorkflowApi {
&self, &self,
payload: Json<AcceptanceRequest>, payload: Json<AcceptanceRequest>,
) -> OpenApiResult<Json<AcceptanceResponse>> { ) -> OpenApiResult<Json<AcceptanceResponse>> {
let results = { let (results, coverage) = {
let workflow = self let workflow = self
.ctx .ctx
.workflow .workflow
.lock() .lock()
.map_err(|e| bad_request(e.to_string()))?; .map_err(|e| bad_request(e.to_string()))?;
workflow let results = workflow
.results .results
.get(&payload.0.story_id) .get(&payload.0.story_id)
.cloned() .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 summary = summarize_results(&results);
let mut missing_categories = Vec::new(); let mut missing_categories = Vec::new();
@@ -200,6 +237,12 @@ impl WorkflowApi {
let can_accept = decision.can_accept && missing_categories.is_empty(); 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 { Ok(Json(AcceptanceResponse {
can_accept, can_accept,
reasons, reasons,
@@ -210,6 +253,7 @@ impl WorkflowApi {
failed: summary.failed, failed: summary.failed,
}, },
missing_categories, missing_categories,
coverage_report,
})) }))
} }
@@ -225,7 +269,10 @@ impl WorkflowApi {
workflow workflow
.results .results
.iter() .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) .filter(|story| story.can_accept)
.collect::<Vec<_>>() .collect::<Vec<_>>()
}; };
@@ -262,7 +309,8 @@ impl WorkflowApi {
.into_iter() .into_iter()
.map(|story_id| { .map(|story_id| {
let results = workflow.results.get(&story_id).cloned().unwrap_or_default(); 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::<Vec<_>>() .collect::<Vec<_>>()
}; };
@@ -270,6 +318,91 @@ impl WorkflowApi {
Ok(Json(ReviewListResponse { stories })) Ok(Json(ReviewListResponse { stories }))
} }
/// Record coverage data for a story.
#[oai(path = "/workflow/coverage/record", method = "post")]
async fn record_coverage(
&self,
payload: Json<RecordCoveragePayload>,
) -> OpenApiResult<Json<bool>> {
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<CollectCoverageRequest>,
) -> OpenApiResult<Json<CoverageReportResponse>> {
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. /// Ensure a story can be accepted; returns an error when gates fail.
#[oai(path = "/workflow/acceptance/ensure", method = "post")] #[oai(path = "/workflow/acceptance/ensure", method = "post")]
async fn ensure_acceptance( async fn ensure_acceptance(

View File

@@ -49,6 +49,7 @@ pub struct StoryTestResults {
pub struct WorkflowState { pub struct WorkflowState {
pub stories: HashMap<String, StoryMetadata>, pub stories: HashMap<String, StoryMetadata>,
pub results: HashMap<String, StoryTestResults>, pub results: HashMap<String, StoryTestResults>,
pub coverage: HashMap<String, CoverageReport>,
} }
#[allow(dead_code)] #[allow(dead_code)]
@@ -105,6 +106,29 @@ impl WorkflowState {
Ok(()) Ok(())
} }
pub fn record_coverage(
&mut self,
story_id: String,
current_percent: f64,
threshold_percent: Option<f64>,
) {
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)] #[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<f64>,
}
/// Parse coverage percentage from a vitest coverage-summary.json string.
/// Expects JSON with `{"total": {"lines": {"pct": <number>}}}`.
pub fn parse_coverage_json(json_str: &str) -> Result<f64, String> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn warns_when_multiple_tests_fail() { fn warns_when_multiple_tests_fail() {
let results = StoryTestResults { let results = StoryTestResults {