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

View File

@@ -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<f64>,
}
#[derive(Object)]
struct AcceptanceResponse {
pub can_accept: bool,
@@ -47,6 +55,7 @@ struct AcceptanceResponse {
pub warning: Option<String>,
pub summary: TestRunSummaryResponse,
pub missing_categories: Vec<String>,
pub coverage_report: Option<CoverageReportResponse>,
}
#[derive(Object)]
@@ -57,6 +66,20 @@ struct ReviewStory {
pub warning: Option<String>,
pub summary: TestRunSummaryResponse,
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)]
@@ -96,8 +119,12 @@ fn load_current_story_metadata(ctx: &AppContext) -> Result<Vec<(String, StoryMet
Ok(stories)
}
fn to_review_story(story_id: &str, results: &StoryTestResults) -> 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<AcceptanceRequest>,
) -> OpenApiResult<Json<AcceptanceResponse>> {
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::<Vec<_>>()
};
@@ -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::<Vec<_>>()
};
@@ -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<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.
#[oai(path = "/workflow/acceptance/ensure", method = "post")]
async fn ensure_acceptance(