mod common;
use common::cli::{BrWorkspace, extract_json_payload, run_br};
use serde_json::Value;
fn parse_created_id(stdout: &str) -> String {
let line = stdout.lines().next().unwrap_or("");
let normalized = line.strip_prefix("✓ ").unwrap_or(line);
let id_part = normalized
.strip_prefix("Created ")
.and_then(|rest| rest.split(':').next())
.unwrap_or("");
id_part.trim().to_string()
}
#[test]
fn e2e_epic_status_shows_progress() {
let _log = common::test_log("e2e_epic_status_shows_progress");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create_epic = run_br(
&workspace,
["create", "Test Epic", "--type", "epic"],
"create_epic",
);
assert!(
create_epic.status.success(),
"create epic failed: {}",
create_epic.stderr
);
let epic_id = parse_created_id(&create_epic.stdout);
let create_task1 = run_br(
&workspace,
["create", "Task 1", "--type", "task"],
"create_task1",
);
assert!(
create_task1.status.success(),
"create task1 failed: {}",
create_task1.stderr
);
let task1_id = parse_created_id(&create_task1.stdout);
let create_task2 = run_br(
&workspace,
["create", "Task 2", "--type", "task"],
"create_task2",
);
assert!(
create_task2.status.success(),
"create task2 failed: {}",
create_task2.stderr
);
let task2_id = parse_created_id(&create_task2.stdout);
let dep1 = run_br(
&workspace,
["dep", "add", &task1_id, &epic_id, "--type", "parent-child"],
"dep_add_1",
);
assert!(dep1.status.success(), "dep add 1 failed: {}", dep1.stderr);
let dep2 = run_br(
&workspace,
["dep", "add", &task2_id, &epic_id, "--type", "parent-child"],
"dep_add_2",
);
assert!(dep2.status.success(), "dep add 2 failed: {}", dep2.stderr);
let status = run_br(
&workspace,
["epic", "status", "--json"],
"epic_status_initial",
);
assert!(
status.status.success(),
"epic status failed: {}",
status.stderr
);
let payload = extract_json_payload(&status.stdout);
let status_json: Vec<Value> = serde_json::from_str(&payload).expect("parse epic status json");
assert!(!status_json.is_empty(), "expected at least one epic");
let epic_entry = status_json
.iter()
.find(|e| e["epic"]["id"] == epic_id)
.expect("epic not found in status");
assert_eq!(epic_entry["total_children"], 2);
assert_eq!(epic_entry["closed_children"], 0);
assert_eq!(epic_entry["eligible_for_close"], false);
let status_text = run_br(&workspace, ["epic", "status"], "epic_status_text");
assert!(
status_text.status.success(),
"epic status text failed: {}",
status_text.stderr
);
assert!(
status_text.stdout.contains("0/2 children closed"),
"expected 0/2 progress in output: {}",
status_text.stdout
);
}
#[test]
fn e2e_epic_status_eligible_when_all_children_closed() {
let _log = common::test_log("e2e_epic_status_eligible_when_all_children_closed");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create_epic = run_br(
&workspace,
["create", "Closeable Epic", "--type", "epic"],
"create_epic",
);
assert!(create_epic.status.success());
let epic_id = parse_created_id(&create_epic.stdout);
let create_task = run_br(
&workspace,
["create", "Single Task", "--type", "task"],
"create_task",
);
assert!(create_task.status.success());
let task_id = parse_created_id(&create_task.stdout);
let dep = run_br(
&workspace,
["dep", "add", &task_id, &epic_id, "--type", "parent-child"],
"dep_add",
);
assert!(dep.status.success());
let status1 = run_br(
&workspace,
["epic", "status", "--json"],
"epic_status_before",
);
assert!(status1.status.success());
let payload1 = extract_json_payload(&status1.stdout);
let json1: Vec<Value> = serde_json::from_str(&payload1).unwrap();
let epic1 = json1.iter().find(|e| e["epic"]["id"] == epic_id).unwrap();
assert_eq!(epic1["eligible_for_close"], false);
let close_task = run_br(&workspace, ["close", &task_id, "--force"], "close_task");
assert!(
close_task.status.success(),
"close task failed: {}",
close_task.stderr
);
let status2 = run_br(
&workspace,
["epic", "status", "--json"],
"epic_status_after",
);
assert!(status2.status.success());
let payload2 = extract_json_payload(&status2.stdout);
let json2: Vec<Value> = serde_json::from_str(&payload2).unwrap();
let epic2 = json2.iter().find(|e| e["epic"]["id"] == epic_id).unwrap();
assert_eq!(epic2["total_children"], 1);
assert_eq!(epic2["closed_children"], 1);
assert_eq!(epic2["eligible_for_close"], true);
}
#[test]
fn e2e_epic_close_eligible_closes_epics() {
let _log = common::test_log("e2e_epic_close_eligible_closes_epics");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success());
let create_epic = run_br(
&workspace,
["create", "Auto-closeable Epic", "--type", "epic"],
"create_epic",
);
assert!(create_epic.status.success());
let epic_id = parse_created_id(&create_epic.stdout);
let create_task = run_br(
&workspace,
["create", "Task for epic", "--type", "task"],
"create_task",
);
assert!(create_task.status.success());
let task_id = parse_created_id(&create_task.stdout);
let dep = run_br(
&workspace,
["dep", "add", &task_id, &epic_id, "--type", "parent-child"],
"dep_add",
);
assert!(dep.status.success());
let close_task = run_br(&workspace, ["close", &task_id, "--force"], "close_task");
assert!(close_task.status.success());
let show_before = run_br(&workspace, ["show", &epic_id, "--json"], "show_before");
assert!(show_before.status.success());
let payload_before = extract_json_payload(&show_before.stdout);
let show_json_before: Vec<Value> = serde_json::from_str(&payload_before).unwrap();
assert_eq!(show_json_before[0]["status"], "open");
let close_eligible = run_br(
&workspace,
["epic", "close-eligible", "--json"],
"close_eligible",
);
assert!(
close_eligible.status.success(),
"close-eligible failed: {}",
close_eligible.stderr
);
let payload = extract_json_payload(&close_eligible.stdout);
let result: Value = serde_json::from_str(&payload).unwrap();
assert_eq!(result["count"], 1);
assert!(
result["closed"]
.as_array()
.unwrap()
.iter()
.any(|id| id == &epic_id)
);
let show_after = run_br(&workspace, ["show", &epic_id, "--json"], "show_after");
assert!(show_after.status.success());
let payload_after = extract_json_payload(&show_after.stdout);
let show_json_after: Vec<Value> = serde_json::from_str(&payload_after).unwrap();
assert_eq!(show_json_after[0]["status"], "closed");
assert!(
show_json_after[0]["close_reason"]
.as_str()
.unwrap_or("")
.contains("children completed"),
"close reason should mention children completed"
);
}
#[test]
fn e2e_epic_close_eligible_dry_run() {
let _log = common::test_log("e2e_epic_close_eligible_dry_run");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success());
let create_epic = run_br(
&workspace,
["create", "Dry Run Epic", "--type", "epic"],
"create_epic",
);
assert!(create_epic.status.success());
let epic_id = parse_created_id(&create_epic.stdout);
let create_task = run_br(
&workspace,
["create", "Dry run task", "--type", "task"],
"create_task",
);
assert!(create_task.status.success());
let task_id = parse_created_id(&create_task.stdout);
let dep = run_br(
&workspace,
["dep", "add", &task_id, &epic_id, "--type", "parent-child"],
"dep_add",
);
assert!(dep.status.success());
let close_task = run_br(&workspace, ["close", &task_id, "--force"], "close_task");
assert!(close_task.status.success());
let dry_run = run_br(
&workspace,
["epic", "close-eligible", "--dry-run"],
"dry_run",
);
assert!(
dry_run.status.success(),
"dry-run failed: {}",
dry_run.stderr
);
assert!(
dry_run.stdout.contains("Would close"),
"dry-run output should mention 'Would close': {}",
dry_run.stdout
);
let show = run_br(
&workspace,
["show", &epic_id, "--json"],
"show_after_dry_run",
);
assert!(show.status.success());
let payload = extract_json_payload(&show.stdout);
let show_json: Vec<Value> = serde_json::from_str(&payload).unwrap();
assert_eq!(
show_json[0]["status"], "open",
"epic should remain open after dry-run"
);
}
#[test]
fn e2e_epic_status_eligible_only_filter() {
let _log = common::test_log("e2e_epic_status_eligible_only_filter");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success());
let create_epic1 = run_br(
&workspace,
["create", "Eligible Epic", "--type", "epic"],
"create_epic1",
);
assert!(create_epic1.status.success());
let epic1_id = parse_created_id(&create_epic1.stdout);
let create_epic2 = run_br(
&workspace,
["create", "Not Eligible Epic", "--type", "epic"],
"create_epic2",
);
assert!(create_epic2.status.success());
let epic2_id = parse_created_id(&create_epic2.stdout);
let create_task1 = run_br(
&workspace,
["create", "Task 1", "--type", "task"],
"create_task1",
);
assert!(create_task1.status.success());
let task1_id = parse_created_id(&create_task1.stdout);
let dep1 = run_br(
&workspace,
["dep", "add", &task1_id, &epic1_id, "--type", "parent-child"],
"dep_add_1",
);
assert!(dep1.status.success());
let close_task1 = run_br(&workspace, ["close", &task1_id, "--force"], "close_task1");
assert!(close_task1.status.success());
let create_task2 = run_br(
&workspace,
["create", "Task 2", "--type", "task"],
"create_task2",
);
assert!(create_task2.status.success());
let task2_id = parse_created_id(&create_task2.stdout);
let dep2 = run_br(
&workspace,
["dep", "add", &task2_id, &epic2_id, "--type", "parent-child"],
"dep_add_2",
);
assert!(dep2.status.success());
let status_all = run_br(&workspace, ["epic", "status", "--json"], "status_all");
assert!(status_all.status.success());
let payload_all = extract_json_payload(&status_all.stdout);
let json_all: Vec<Value> = serde_json::from_str(&payload_all).unwrap();
assert_eq!(json_all.len(), 2, "should have 2 epics");
let status_eligible = run_br(
&workspace,
["epic", "status", "--eligible-only", "--json"],
"status_eligible",
);
assert!(status_eligible.status.success());
let payload_eligible = extract_json_payload(&status_eligible.stdout);
let json_eligible: Vec<Value> = serde_json::from_str(&payload_eligible).unwrap();
assert_eq!(json_eligible.len(), 1, "should have only 1 eligible epic");
assert_eq!(json_eligible[0]["epic"]["id"], epic1_id);
}
#[test]
fn e2e_epic_childless_epic_not_eligible() {
let _log = common::test_log("e2e_epic_childless_epic_not_eligible");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success());
let create_epic = run_br(
&workspace,
["create", "Childless Epic", "--type", "epic"],
"create_epic",
);
assert!(create_epic.status.success());
let epic_id = parse_created_id(&create_epic.stdout);
let status = run_br(&workspace, ["epic", "status", "--json"], "epic_status");
assert!(status.status.success());
let payload = extract_json_payload(&status.stdout);
let json: Vec<Value> = serde_json::from_str(&payload).unwrap();
let epic = json.iter().find(|e| e["epic"]["id"] == epic_id).unwrap();
assert_eq!(epic["total_children"], 0);
assert_eq!(epic["closed_children"], 0);
assert_eq!(
epic["eligible_for_close"], false,
"childless epic should not be eligible for close"
);
let close_eligible = run_br(
&workspace,
["epic", "close-eligible", "--json"],
"close_eligible",
);
assert!(close_eligible.status.success());
let payload_close = extract_json_payload(&close_eligible.stdout);
let result: Value = serde_json::from_str(&payload_close).expect("close-eligible json");
assert_eq!(result["count"], 0);
assert_eq!(
result["closed"]
.as_array()
.expect("closed array for close-eligible")
.len(),
0
);
}
#[test]
fn e2e_epic_close_eligible_dry_run_json_empty_array() {
let _log = common::test_log("e2e_epic_close_eligible_dry_run_json_empty_array");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create_epic = run_br(
&workspace,
["create", "Lonely epic", "--type", "epic"],
"create_epic",
);
assert!(
create_epic.status.success(),
"create epic failed: {}",
create_epic.stderr
);
let dry_run = run_br(
&workspace,
["epic", "close-eligible", "--dry-run", "--json"],
"close_eligible_dry_run_json_empty",
);
assert!(
dry_run.status.success(),
"dry-run failed: {}",
dry_run.stderr
);
let payload = extract_json_payload(&dry_run.stdout);
let result: Value = serde_json::from_str(&payload).expect("close-eligible dry-run json");
let eligible = result
.as_array()
.expect("dry-run close-eligible should return an array");
assert!(eligible.is_empty(), "expected no eligible epics");
}
#[test]
fn e2e_epic_nested_epics() {
let _log = common::test_log("e2e_epic_nested_epics");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success());
let create_parent = run_br(
&workspace,
["create", "Parent Epic", "--type", "epic"],
"create_parent",
);
assert!(create_parent.status.success());
let parent_id = parse_created_id(&create_parent.stdout);
let create_child = run_br(
&workspace,
["create", "Child Epic", "--type", "epic"],
"create_child",
);
assert!(create_child.status.success());
let child_id = parse_created_id(&create_child.stdout);
let create_task = run_br(
&workspace,
["create", "Nested Task", "--type", "task"],
"create_task",
);
assert!(create_task.status.success());
let task_id = parse_created_id(&create_task.stdout);
let dep1 = run_br(
&workspace,
["dep", "add", &task_id, &child_id, "--type", "parent-child"],
"dep_task_to_child",
);
assert!(dep1.status.success());
let dep2 = run_br(
&workspace,
[
"dep",
"add",
&child_id,
&parent_id,
"--type",
"parent-child",
],
"dep_child_to_parent",
);
assert!(dep2.status.success());
let status = run_br(&workspace, ["epic", "status", "--json"], "epic_status");
assert!(status.status.success());
let payload = extract_json_payload(&status.stdout);
let json: Vec<Value> = serde_json::from_str(&payload).unwrap();
let parent_epic = json.iter().find(|e| e["epic"]["id"] == parent_id).unwrap();
assert_eq!(
parent_epic["total_children"], 1,
"parent should have 1 child (the child epic)"
);
let child_epic = json.iter().find(|e| e["epic"]["id"] == child_id).unwrap();
assert_eq!(
child_epic["total_children"], 1,
"child epic should have 1 child (the task)"
);
let close_task = run_br(&workspace, ["close", &task_id, "--force"], "close_task");
assert!(close_task.status.success());
let status2 = run_br(
&workspace,
["epic", "status", "--json"],
"epic_status_after_task_close",
);
assert!(status2.status.success());
let payload2 = extract_json_payload(&status2.stdout);
let json2: Vec<Value> = serde_json::from_str(&payload2).unwrap();
let child_epic2 = json2.iter().find(|e| e["epic"]["id"] == child_id).unwrap();
assert_eq!(
child_epic2["eligible_for_close"], true,
"child epic should be eligible"
);
let parent_epic2 = json2.iter().find(|e| e["epic"]["id"] == parent_id).unwrap();
assert_eq!(
parent_epic2["eligible_for_close"], false,
"parent epic not eligible until child epic closed"
);
}
#[test]
fn e2e_epic_no_epics_message() {
let _log = common::test_log("e2e_epic_no_epics_message");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success());
let create_task = run_br(
&workspace,
["create", "Just a task", "--type", "task"],
"create_task",
);
assert!(create_task.status.success());
let status = run_br(&workspace, ["epic", "status"], "epic_status_none");
assert!(status.status.success());
assert!(
status.stdout.contains("No open epics found"),
"should show no epics message: {}",
status.stdout
);
let status_json = run_br(
&workspace,
["epic", "status", "--json"],
"epic_status_json_none",
);
assert!(status_json.status.success());
let payload = extract_json_payload(&status_json.stdout);
let json: Vec<Value> = serde_json::from_str(&payload).unwrap();
assert!(json.is_empty(), "JSON should be empty array when no epics");
}
#[test]
fn e2e_epic_close_eligible_no_eligible_message() {
let _log = common::test_log("e2e_epic_close_eligible_no_eligible_message");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success());
let create_epic = run_br(
&workspace,
["create", "Open Children Epic", "--type", "epic"],
"create_epic",
);
assert!(create_epic.status.success());
let epic_id = parse_created_id(&create_epic.stdout);
let create_task = run_br(
&workspace,
["create", "Open Task", "--type", "task"],
"create_task",
);
assert!(create_task.status.success());
let task_id = parse_created_id(&create_task.stdout);
let dep = run_br(
&workspace,
["dep", "add", &task_id, &epic_id, "--type", "parent-child"],
"dep_add",
);
assert!(dep.status.success());
let close_eligible = run_br(
&workspace,
["epic", "close-eligible"],
"close_eligible_none",
);
assert!(close_eligible.status.success());
assert!(
close_eligible.stdout.contains("No epics eligible"),
"should say no epics eligible: {}",
close_eligible.stdout
);
}
#[test]
fn e2e_epic_multiple_children_partial_progress() {
let _log = common::test_log("e2e_epic_multiple_children_partial_progress");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success());
let create_epic = run_br(
&workspace,
["create", "Large Epic", "--type", "epic"],
"create_epic",
);
assert!(create_epic.status.success());
let epic_id = parse_created_id(&create_epic.stdout);
let mut task_ids = Vec::new();
for i in 1..=5 {
let create_task = run_br(
&workspace,
["create", &format!("Task {i}"), "--type", "task"],
&format!("create_task_{i}"),
);
assert!(create_task.status.success());
let task_id = parse_created_id(&create_task.stdout);
let dep = run_br(
&workspace,
["dep", "add", &task_id, &epic_id, "--type", "parent-child"],
&format!("dep_add_{i}"),
);
assert!(dep.status.success());
task_ids.push(task_id);
}
let status1 = run_br(&workspace, ["epic", "status", "--json"], "status_0_of_5");
assert!(status1.status.success());
let payload1 = extract_json_payload(&status1.stdout);
let json1: Vec<Value> = serde_json::from_str(&payload1).unwrap();
let epic1 = json1.iter().find(|e| e["epic"]["id"] == epic_id).unwrap();
assert_eq!(epic1["total_children"], 5);
assert_eq!(epic1["closed_children"], 0);
for task_id in task_ids.iter().take(3) {
let close = run_br(&workspace, ["close", task_id, "--force"], "close_task");
assert!(close.status.success());
}
let status2 = run_br(&workspace, ["epic", "status", "--json"], "status_3_of_5");
assert!(status2.status.success());
let payload2 = extract_json_payload(&status2.stdout);
let json2: Vec<Value> = serde_json::from_str(&payload2).unwrap();
let epic2 = json2.iter().find(|e| e["epic"]["id"] == epic_id).unwrap();
assert_eq!(epic2["total_children"], 5);
assert_eq!(epic2["closed_children"], 3);
assert_eq!(epic2["eligible_for_close"], false);
let status_text = run_br(&workspace, ["epic", "status"], "status_text_partial");
assert!(status_text.status.success());
assert!(
status_text.stdout.contains("3/5 children closed"),
"should show 3/5 progress: {}",
status_text.stdout
);
assert!(
status_text.stdout.contains("60%"),
"should show 60% progress: {}",
status_text.stdout
);
for task_id in task_ids.iter().skip(3) {
let close = run_br(&workspace, ["close", task_id, "--force"], "close_remaining");
assert!(close.status.success());
}
let status3 = run_br(&workspace, ["epic", "status", "--json"], "status_5_of_5");
assert!(status3.status.success());
let payload3 = extract_json_payload(&status3.stdout);
let json3: Vec<Value> = serde_json::from_str(&payload3).unwrap();
let epic3 = json3.iter().find(|e| e["epic"]["id"] == epic_id).unwrap();
assert_eq!(epic3["closed_children"], 5);
assert_eq!(epic3["eligible_for_close"], true);
}
#[test]
fn e2e_epic_closed_epic_not_shown() {
let _log = common::test_log("e2e_epic_closed_epic_not_shown");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success());
let create_epic = run_br(
&workspace,
["create", "Already Closed Epic", "--type", "epic"],
"create_epic",
);
assert!(create_epic.status.success());
let epic_id = parse_created_id(&create_epic.stdout);
let close_epic = run_br(&workspace, ["close", &epic_id], "close_epic");
assert!(close_epic.status.success());
let status = run_br(&workspace, ["epic", "status", "--json"], "epic_status");
assert!(status.status.success());
let payload = extract_json_payload(&status.stdout);
let json: Vec<Value> = serde_json::from_str(&payload).unwrap();
assert!(
!json.iter().any(|e| e["epic"]["id"] == epic_id),
"closed epic should not appear in status"
);
}
#[test]
fn e2e_epic_deleted_child_removes_dependency() {
let _log = common::test_log("e2e_epic_deleted_child_removes_dependency");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success());
let create_epic = run_br(
&workspace,
["create", "Tombstone Test Epic", "--type", "epic"],
"create_epic",
);
assert!(create_epic.status.success());
let epic_id = parse_created_id(&create_epic.stdout);
let create_task = run_br(
&workspace,
["create", "Task to delete", "--type", "task"],
"create_task",
);
assert!(create_task.status.success());
let task_id = parse_created_id(&create_task.stdout);
let dep = run_br(
&workspace,
["dep", "add", &task_id, &epic_id, "--type", "parent-child"],
"dep_add",
);
assert!(dep.status.success());
let status_before = run_br(
&workspace,
["epic", "status", "--json"],
"epic_status_before",
);
assert!(status_before.status.success());
let payload_before = extract_json_payload(&status_before.stdout);
let json_before: Vec<Value> = serde_json::from_str(&payload_before).unwrap();
let epic_before = json_before
.iter()
.find(|e| e["epic"]["id"] == epic_id)
.unwrap();
assert_eq!(epic_before["total_children"], 1);
let delete_task = run_br(
&workspace,
[
"delete",
&task_id,
"--force",
"--reason",
"Testing tombstone",
],
"delete_task",
);
assert!(delete_task.status.success());
let status = run_br(
&workspace,
["epic", "status", "--json"],
"epic_status_after_delete",
);
assert!(status.status.success());
let payload = extract_json_payload(&status.stdout);
let json: Vec<Value> = serde_json::from_str(&payload).unwrap();
let epic = json.iter().find(|e| e["epic"]["id"] == epic_id).unwrap();
assert_eq!(
epic["total_children"], 0,
"deleted child's dependency should be removed"
);
assert_eq!(epic["closed_children"], 0);
assert_eq!(
epic["eligible_for_close"], false,
"childless epic should not be eligible"
);
}