story-kit: merge 171_story_persist_test_results_to_story_files
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
use crate::agents::AgentStatus;
|
||||
use crate::http::context::AppContext;
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
use crate::io::story_metadata::{parse_front_matter, write_coverage_baseline};
|
||||
use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
@@ -400,22 +401,20 @@ pub fn list_bug_files(root: &Path) -> Result<Vec<(String, String)>, String> {
|
||||
Ok(bugs)
|
||||
}
|
||||
|
||||
/// Locate a work item file by searching work/2_current/ then work/1_upcoming/.
|
||||
/// Locate a work item file by searching all active pipeline stages.
|
||||
///
|
||||
/// Searches in priority order: 2_current, 1_upcoming, 3_qa, 4_merge, 5_done, 6_archived.
|
||||
fn find_story_file(project_root: &Path, story_id: &str) -> Result<PathBuf, String> {
|
||||
let filename = format!("{story_id}.md");
|
||||
let sk = project_root.join(".story_kit").join("work");
|
||||
// Check 2_current/ first
|
||||
let current_path = sk.join("2_current").join(&filename);
|
||||
if current_path.exists() {
|
||||
return Ok(current_path);
|
||||
}
|
||||
// Fall back to 1_upcoming/
|
||||
let upcoming_path = sk.join("1_upcoming").join(&filename);
|
||||
if upcoming_path.exists() {
|
||||
return Ok(upcoming_path);
|
||||
for stage in &["2_current", "1_upcoming", "3_qa", "4_merge", "5_done", "6_archived"] {
|
||||
let path = sk.join(stage).join(&filename);
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
Err(format!(
|
||||
"Story '{story_id}' not found in work/2_current/ or work/1_upcoming/."
|
||||
"Story '{story_id}' not found in any pipeline stage."
|
||||
))
|
||||
}
|
||||
|
||||
@@ -531,6 +530,172 @@ fn next_item_number(root: &std::path::Path) -> Result<u32, String> {
|
||||
Ok(max_num + 1)
|
||||
}
|
||||
|
||||
// ── Test result file persistence ──────────────────────────────────
|
||||
|
||||
const TEST_RESULTS_MARKER: &str = "<!-- story-kit-test-results:";
|
||||
|
||||
/// Write (or overwrite) the `## Test Results` section in a story file.
|
||||
///
|
||||
/// The section contains an HTML comment with JSON for machine parsing and a
|
||||
/// human-readable summary below it. If the section already exists it is
|
||||
/// replaced in-place. If the story file is not found, this is a no-op.
|
||||
pub fn write_test_results_to_story_file(
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
results: &StoryTestResults,
|
||||
) -> Result<(), String> {
|
||||
let path = find_story_file(project_root, story_id)?;
|
||||
let contents =
|
||||
fs::read_to_string(&path).map_err(|e| format!("Failed to read story file: {e}"))?;
|
||||
|
||||
let json = serde_json::to_string(results)
|
||||
.map_err(|e| format!("Failed to serialize test results: {e}"))?;
|
||||
|
||||
let section = build_test_results_section(&json, results);
|
||||
let new_contents = replace_or_append_section(&contents, "## Test Results", §ion);
|
||||
|
||||
fs::write(&path, &new_contents)
|
||||
.map_err(|e| format!("Failed to write story file: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read test results from the `## Test Results` section of a story file.
|
||||
///
|
||||
/// Returns `None` if the file is not found or contains no test results section.
|
||||
pub fn read_test_results_from_story_file(
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
) -> Option<StoryTestResults> {
|
||||
let path = find_story_file(project_root, story_id).ok()?;
|
||||
let contents = fs::read_to_string(&path).ok()?;
|
||||
parse_test_results_from_contents(&contents)
|
||||
}
|
||||
|
||||
/// Write coverage baseline to the front matter of a story file.
|
||||
///
|
||||
/// If the story file is not found, this is a no-op (returns Ok).
|
||||
pub fn write_coverage_baseline_to_story_file(
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
coverage_pct: f64,
|
||||
) -> Result<(), String> {
|
||||
let path = match find_story_file(project_root, story_id) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Ok(()), // No story file — skip silently
|
||||
};
|
||||
write_coverage_baseline(&path, coverage_pct)
|
||||
}
|
||||
|
||||
/// Build the `## Test Results` section text including JSON comment and human-readable summary.
|
||||
fn build_test_results_section(json: &str, results: &StoryTestResults) -> String {
|
||||
let mut s = String::from("## Test Results\n\n");
|
||||
s.push_str(&format!("{TEST_RESULTS_MARKER} {json} -->\n\n"));
|
||||
|
||||
// Unit tests
|
||||
let (unit_pass, unit_fail) = count_pass_fail(&results.unit);
|
||||
s.push_str(&format!(
|
||||
"### Unit Tests ({unit_pass} passed, {unit_fail} failed)\n\n"
|
||||
));
|
||||
if results.unit.is_empty() {
|
||||
s.push_str("*No unit tests recorded.*\n");
|
||||
} else {
|
||||
for t in &results.unit {
|
||||
s.push_str(&format_test_line(t));
|
||||
}
|
||||
}
|
||||
s.push('\n');
|
||||
|
||||
// Integration tests
|
||||
let (int_pass, int_fail) = count_pass_fail(&results.integration);
|
||||
s.push_str(&format!(
|
||||
"### Integration Tests ({int_pass} passed, {int_fail} failed)\n\n"
|
||||
));
|
||||
if results.integration.is_empty() {
|
||||
s.push_str("*No integration tests recorded.*\n");
|
||||
} else {
|
||||
for t in &results.integration {
|
||||
s.push_str(&format_test_line(t));
|
||||
}
|
||||
}
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
fn count_pass_fail(tests: &[TestCaseResult]) -> (usize, usize) {
|
||||
let pass = tests.iter().filter(|t| t.status == TestStatus::Pass).count();
|
||||
(pass, tests.len() - pass)
|
||||
}
|
||||
|
||||
fn format_test_line(t: &TestCaseResult) -> String {
|
||||
let icon = if t.status == TestStatus::Pass { "✅" } else { "❌" };
|
||||
match &t.details {
|
||||
Some(d) if !d.is_empty() => format!("- {icon} {} — {d}\n", t.name),
|
||||
_ => format!("- {icon} {}\n", t.name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the `## Test Results` section in `contents` with `new_section`,
|
||||
/// or append it if not present.
|
||||
fn replace_or_append_section(contents: &str, header: &str, new_section: &str) -> String {
|
||||
let lines: Vec<&str> = contents.lines().collect();
|
||||
let header_trimmed = header.trim();
|
||||
|
||||
// Find the start of the existing section
|
||||
let section_start = lines.iter().position(|l| l.trim() == header_trimmed);
|
||||
|
||||
if let Some(start) = section_start {
|
||||
// Find the next `##` heading after the section start (the end of this section)
|
||||
let section_end = lines[start + 1..]
|
||||
.iter()
|
||||
.position(|l| {
|
||||
let t = l.trim();
|
||||
t.starts_with("## ") && t != header_trimmed
|
||||
})
|
||||
.map(|i| start + 1 + i)
|
||||
.unwrap_or(lines.len());
|
||||
|
||||
let mut result = lines[..start].join("\n");
|
||||
if !result.is_empty() {
|
||||
result.push('\n');
|
||||
}
|
||||
result.push_str(new_section);
|
||||
if section_end < lines.len() {
|
||||
result.push('\n');
|
||||
result.push_str(&lines[section_end..].join("\n"));
|
||||
}
|
||||
if contents.ends_with('\n') {
|
||||
result.push('\n');
|
||||
}
|
||||
result
|
||||
} else {
|
||||
// Append at the end
|
||||
let mut result = contents.trim_end_matches('\n').to_string();
|
||||
result.push_str("\n\n");
|
||||
result.push_str(new_section);
|
||||
if !result.ends_with('\n') {
|
||||
result.push('\n');
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `StoryTestResults` from the JSON embedded in the `## Test Results` section.
|
||||
fn parse_test_results_from_contents(contents: &str) -> Option<StoryTestResults> {
|
||||
for line in contents.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(rest) = trimmed.strip_prefix(TEST_RESULTS_MARKER) {
|
||||
// rest looks like: ` {...} -->`
|
||||
if let Some(json_end) = rest.rfind("-->") {
|
||||
let json_str = rest[..json_end].trim();
|
||||
if let Ok(results) = serde_json::from_str::<StoryTestResults>(json_str) {
|
||||
return Some(results);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn validate_story_dirs(
|
||||
root: &std::path::Path,
|
||||
) -> Result<Vec<StoryValidationResult>, String> {
|
||||
@@ -1337,4 +1502,156 @@ mod tests {
|
||||
let spike_id = create_spike_file(tmp.path(), "My Spike", None).unwrap();
|
||||
assert!(spike_id.starts_with("6_spike_"), "expected spike number 6, got: {spike_id}");
|
||||
}
|
||||
|
||||
// ── Test result file persistence ──────────────────────────────
|
||||
|
||||
use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus};
|
||||
|
||||
fn make_results() -> StoryTestResults {
|
||||
StoryTestResults {
|
||||
unit: vec![
|
||||
TestCaseResult { name: "unit-pass".to_string(), status: TestStatus::Pass, details: None },
|
||||
TestCaseResult { name: "unit-fail".to_string(), status: TestStatus::Fail, details: Some("assertion failed".to_string()) },
|
||||
],
|
||||
integration: vec![
|
||||
TestCaseResult { name: "int-pass".to_string(), status: TestStatus::Pass, details: None },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_and_read_test_results_roundtrip() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".story_kit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(current.join("1_story_test.md"), "---\nname: Test\n---\n# Story\n").unwrap();
|
||||
|
||||
let results = make_results();
|
||||
write_test_results_to_story_file(tmp.path(), "1_story_test", &results).unwrap();
|
||||
|
||||
let read_back = read_test_results_from_story_file(tmp.path(), "1_story_test")
|
||||
.expect("should read back results");
|
||||
assert_eq!(read_back.unit.len(), 2);
|
||||
assert_eq!(read_back.integration.len(), 1);
|
||||
assert_eq!(read_back.unit[0].name, "unit-pass");
|
||||
assert_eq!(read_back.unit[1].status, TestStatus::Fail);
|
||||
assert_eq!(read_back.unit[1].details.as_deref(), Some("assertion failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_test_results_creates_readable_section() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".story_kit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
let story_path = current.join("2_story_check.md");
|
||||
fs::write(&story_path, "---\nname: Check\n---\n# Story\n\n## Acceptance Criteria\n\n- [ ] AC1\n").unwrap();
|
||||
|
||||
let results = make_results();
|
||||
write_test_results_to_story_file(tmp.path(), "2_story_check", &results).unwrap();
|
||||
|
||||
let contents = fs::read_to_string(&story_path).unwrap();
|
||||
assert!(contents.contains("## Test Results"));
|
||||
assert!(contents.contains("✅ unit-pass"));
|
||||
assert!(contents.contains("❌ unit-fail"));
|
||||
assert!(contents.contains("assertion failed"));
|
||||
assert!(contents.contains("story-kit-test-results:"));
|
||||
// Original content still present
|
||||
assert!(contents.contains("## Acceptance Criteria"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_test_results_overwrites_existing_section() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".story_kit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
let story_path = current.join("3_story_overwrite.md");
|
||||
fs::write(
|
||||
&story_path,
|
||||
"---\nname: Overwrite\n---\n# Story\n\n## Test Results\n\n<!-- story-kit-test-results: {} -->\n\n### Unit Tests (0 passed, 0 failed)\n\n*No unit tests recorded.*\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let results = make_results();
|
||||
write_test_results_to_story_file(tmp.path(), "3_story_overwrite", &results).unwrap();
|
||||
|
||||
let contents = fs::read_to_string(&story_path).unwrap();
|
||||
assert!(contents.contains("✅ unit-pass"));
|
||||
// Should have only one ## Test Results header
|
||||
let count = contents.matches("## Test Results").count();
|
||||
assert_eq!(count, 1, "should have exactly one ## Test Results section");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_test_results_returns_none_when_no_section() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".story_kit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(current.join("4_story_empty.md"), "---\nname: Empty\n---\n# Story\n").unwrap();
|
||||
|
||||
let result = read_test_results_from_story_file(tmp.path(), "4_story_empty");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_test_results_returns_none_for_unknown_story() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let result = read_test_results_from_story_file(tmp.path(), "99_story_unknown");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_test_results_finds_story_in_any_stage() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let qa_dir = tmp.path().join(".story_kit/work/3_qa");
|
||||
fs::create_dir_all(&qa_dir).unwrap();
|
||||
fs::write(qa_dir.join("5_story_qa.md"), "---\nname: QA Story\n---\n# Story\n").unwrap();
|
||||
|
||||
let results = StoryTestResults {
|
||||
unit: vec![TestCaseResult { name: "u1".to_string(), status: TestStatus::Pass, details: None }],
|
||||
integration: vec![],
|
||||
};
|
||||
write_test_results_to_story_file(tmp.path(), "5_story_qa", &results).unwrap();
|
||||
|
||||
let read_back = read_test_results_from_story_file(tmp.path(), "5_story_qa").unwrap();
|
||||
assert_eq!(read_back.unit.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_coverage_baseline_to_story_file_updates_front_matter() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".story_kit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(current.join("6_story_cov.md"), "---\nname: Cov Story\n---\n# Story\n").unwrap();
|
||||
|
||||
write_coverage_baseline_to_story_file(tmp.path(), "6_story_cov", 75.4).unwrap();
|
||||
|
||||
let contents = fs::read_to_string(current.join("6_story_cov.md")).unwrap();
|
||||
assert!(contents.contains("coverage_baseline: 75.4%"), "got: {contents}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_coverage_baseline_to_story_file_silent_on_missing_story() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Story doesn't exist — should succeed silently
|
||||
let result = write_coverage_baseline_to_story_file(tmp.path(), "99_story_missing", 50.0);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_or_append_section_appends_when_absent() {
|
||||
let contents = "---\nname: T\n---\n# Story\n";
|
||||
let new = replace_or_append_section(contents, "## Test Results", "## Test Results\n\nfoo\n");
|
||||
assert!(new.contains("## Test Results"));
|
||||
assert!(new.contains("foo"));
|
||||
assert!(new.contains("# Story"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_or_append_section_replaces_existing() {
|
||||
let contents = "# Story\n\n## Test Results\n\nold content\n\n## Other\n\nother content\n";
|
||||
let new = replace_or_append_section(contents, "## Test Results", "## Test Results\n\nnew content\n");
|
||||
assert!(new.contains("new content"));
|
||||
assert!(!new.contains("old content"));
|
||||
assert!(new.contains("## Other"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user