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:
@@ -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(
|
||||
|
||||
@@ -49,6 +49,7 @@ pub struct StoryTestResults {
|
||||
pub struct WorkflowState {
|
||||
pub stories: HashMap<String, StoryMetadata>,
|
||||
pub results: HashMap<String, StoryTestResults>,
|
||||
pub coverage: HashMap<String, CoverageReport>,
|
||||
}
|
||||
|
||||
#[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<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)]
|
||||
@@ -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)]
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user