//! Handler for the `test` bot command — run the project's test suite. //! //! Executes `script/test` from the project root (or an optional story worktree) //! and returns a formatted pass/fail summary with output (truncated for failures). //! //! Usage: //! `run_tests` — run from project root //! `run_tests ` — run from the worktree for story number N use super::CommandContext; use std::path::PathBuf; const TEST_SCRIPT: &str = "script/test"; /// Maximum number of output lines to include in the response. const MAX_OUTPUT_LINES: usize = 80; /// Resolve the working directory for the test run. /// /// If `args` is empty, returns the project root. If it is a story number, /// looks for a matching worktree under `.huskies/worktrees/`. Returns /// `Err(message)` when the number is given but no worktree is found. fn resolve_run_dir(ctx: &CommandContext) -> Result { let number = ctx.args.trim(); if number.is_empty() { return Ok(ctx.project_root.to_path_buf()); } // Validate: must be all digits. if !number.chars().all(|c| c.is_ascii_digit()) { return Err(format!( "**Test**\n\nInvalid argument `{number}`: expected a story number (digits only)." )); } let worktrees_dir = ctx.project_root.join(".huskies/worktrees"); let prefix = format!("{number}_"); match std::fs::read_dir(&worktrees_dir) { Ok(entries) => { for entry in entries.flatten() { let name = entry.file_name(); if name.to_string_lossy().starts_with(&prefix) && entry.path().is_dir() { return Ok(entry.path()); } } Err(format!( "**Test**\n\nNo worktree found for story `{number}`. \ Use `run_tests` (no arg) to run tests from the project root." )) } Err(e) => Err(format!( "**Test**\n\nCould not read worktrees directory: {e}" )), } } pub(super) fn handle_test(ctx: &CommandContext) -> Option { let run_dir = match resolve_run_dir(ctx) { Ok(d) => d, Err(msg) => return Some(msg), }; let script_path = run_dir.join(TEST_SCRIPT); if !script_path.exists() { return Some(format!( "**Test**\n\nTest script not found: `{TEST_SCRIPT}`\n\nEnsure `{TEST_SCRIPT}` exists in the project root." )); } let output = std::process::Command::new("bash") .arg(&script_path) .current_dir(&run_dir) .output(); match output { Err(e) => Some(format!("**Test**\n\nFailed to run test script: {e}")), Ok(out) => { let passed = out.status.success(); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let stderr = String::from_utf8_lossy(&out.stderr).to_string(); let combined = format!("{stdout}{stderr}"); let (tests_passed, tests_failed) = parse_test_counts(&combined); let truncated = truncate_output(&combined, MAX_OUTPUT_LINES); let status = if passed { "PASS" } else { "FAIL" }; let mut result = format!("**Test: {status}**\n\n"); if tests_passed > 0 || tests_failed > 0 { result.push_str(&format!("{tests_passed} passed, {tests_failed} failed\n\n")); } result.push_str(&format!("```\n{truncated}\n```")); Some(result) } } } /// Truncate output to at most `max_lines` tail lines. fn truncate_output(output: &str, max_lines: usize) -> String { let lines: Vec<&str> = output.lines().collect(); if lines.len() <= max_lines { return output.to_string(); } let omitted = lines.len() - max_lines; let tail = lines[lines.len() - max_lines..].join("\n"); format!("[... {omitted} lines omitted ...]\n{tail}") } /// Parse cumulative passed/failed counts from `cargo test` output lines. fn parse_test_counts(output: &str) -> (u64, u64) { let mut total_passed = 0u64; let mut total_failed = 0u64; for line in output.lines() { if line.contains("test result:") { if let Some(p) = extract_count(line, "passed") { total_passed += p; } if let Some(f) = extract_count(line, "failed") { total_failed += f; } } } (total_passed, total_failed) } fn extract_count(line: &str, label: &str) -> Option { let pos = line.find(label)?; let before = line[..pos].trim_end(); let num_str: String = before .chars() .rev() .take_while(|c| c.is_ascii_digit()) .collect(); if num_str.is_empty() { return None; } let num_str: String = num_str.chars().rev().collect(); num_str.parse().ok() } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::agents::AgentPool; use std::collections::HashSet; use std::sync::{Arc, Mutex}; fn make_ctx<'a>( agents: &'a Arc, ambient_rooms: &'a Arc>>, project_root: &'a std::path::Path, args: &'a str, ) -> super::super::CommandContext<'a> { super::super::CommandContext { bot_name: "Timmy", args, project_root, agents, ambient_rooms, room_id: "!test:example.com", } } fn test_agents() -> Arc { Arc::new(AgentPool::new_test(3000)) } fn test_ambient() -> Arc>> { Arc::new(Mutex::new(HashSet::new())) } fn write_script(dir: &std::path::Path, content: &str) { let script_dir = dir.join("script"); std::fs::create_dir_all(&script_dir).unwrap(); let path = script_dir.join("test"); std::fs::write(&path, content).unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); } } #[test] fn test_command_is_registered() { use super::super::commands; let found = commands().iter().any(|c| c.name == "run_tests"); assert!(found, "run_tests command must be in the registry"); } #[test] fn test_command_appears_in_help() { let result = super::super::tests::try_cmd_addressed( "Timmy", "@timmy:homeserver.local", "@timmy help", ); let output = result.unwrap(); assert!( output.contains("run_tests"), "help should list run_tests command: {output}" ); } #[test] fn test_command_missing_script_returns_error() { let dir = tempfile::tempdir().unwrap(); let agents = test_agents(); let ambient = test_ambient(); let ctx = make_ctx(&agents, &ambient, dir.path(), ""); let output = handle_test(&ctx).unwrap(); assert!( output.contains("not found") || output.contains("script"), "missing script should produce a clear error: {output}" ); } #[test] fn test_command_pass_when_script_exits_zero() { let dir = tempfile::tempdir().unwrap(); write_script( dir.path(), "#!/usr/bin/env bash\necho 'test result: ok. 4 passed; 0 failed'\nexit 0\n", ); let agents = test_agents(); let ambient = test_ambient(); let ctx = make_ctx(&agents, &ambient, dir.path(), ""); let output = handle_test(&ctx).unwrap(); assert!(output.contains("PASS"), "should show PASS: {output}"); assert!(output.contains('4'), "should show test count: {output}"); } #[test] fn test_command_fail_when_script_exits_nonzero() { let dir = tempfile::tempdir().unwrap(); write_script( dir.path(), "#!/usr/bin/env bash\necho 'test result: FAILED. 1 passed; 2 failed'\nexit 1\n", ); let agents = test_agents(); let ambient = test_ambient(); let ctx = make_ctx(&agents, &ambient, dir.path(), ""); let output = handle_test(&ctx).unwrap(); assert!(output.contains("FAIL"), "should show FAIL: {output}"); assert!(output.contains('2'), "should show failed count: {output}"); } #[test] fn test_command_works_via_dispatch() { let dir = tempfile::tempdir().unwrap(); write_script(dir.path(), "#!/usr/bin/env bash\necho 'ok'\nexit 0\n"); let agents = test_agents(); let ambient = test_ambient(); let room_id = "!test:example.com".to_string(); let dispatch = super::super::CommandDispatch { bot_name: "Timmy", bot_user_id: "@timmy:homeserver.local", project_root: dir.path(), agents: &agents, ambient_rooms: &ambient, room_id: &room_id, }; let result = super::super::try_handle_command(&dispatch, "@timmy run_tests"); assert!( result.is_some(), "run_tests command must respond via dispatch (not fall through to LLM)" ); } #[test] fn truncate_output_keeps_tail() { let lines: Vec = (1..=150).map(|i| format!("line {i}")).collect(); let text = lines.join("\n"); let result = truncate_output(&text, 80); assert!(result.contains("line 150"), "should keep last line"); assert!(result.contains("omitted"), "should note omitted lines"); } #[test] fn parse_test_counts_sums_multiple_results() { let output = "test result: ok. 5 passed; 0 failed;\ntest result: ok. 3 passed; 1 failed;"; let (p, f) = parse_test_counts(output); assert_eq!(p, 8); assert_eq!(f, 1); } // -- worktree tests ------------------------------------------------------- /// Create a fake worktree directory under `project_root/.huskies/worktrees/` /// named `{number}_fake_story` and optionally write `script/test` into it. fn create_worktree(project_root: &std::path::Path, number: u32, with_script: bool) { let wt = project_root .join(".huskies/worktrees") .join(format!("{number}_fake_story")); std::fs::create_dir_all(&wt).unwrap(); if with_script { write_script( &wt, "#!/usr/bin/env bash\necho 'test result: ok. 2 passed; 0 failed'\nexit 0\n", ); } } #[test] fn run_tests_with_no_args_uses_project_root() { let dir = tempfile::tempdir().unwrap(); write_script( dir.path(), "#!/usr/bin/env bash\necho 'test result: ok. 7 passed; 0 failed'\nexit 0\n", ); let agents = test_agents(); let ambient = test_ambient(); let ctx = make_ctx(&agents, &ambient, dir.path(), ""); let output = handle_test(&ctx).unwrap(); assert!( output.contains("PASS"), "no-arg should use project root: {output}" ); assert!( output.contains('7'), "should show count from project root script: {output}" ); } #[test] fn run_tests_with_story_number_uses_worktree() { let dir = tempfile::tempdir().unwrap(); create_worktree(dir.path(), 541, true); let agents = test_agents(); let ambient = test_ambient(); let ctx = make_ctx(&agents, &ambient, dir.path(), "541"); let output = handle_test(&ctx).unwrap(); assert!( output.contains("PASS"), "should run tests in worktree: {output}" ); assert!( output.contains('2'), "should show count from worktree script: {output}" ); } #[test] fn run_tests_with_unknown_story_number_returns_error() { let dir = tempfile::tempdir().unwrap(); // Create the worktrees dir but no matching entry std::fs::create_dir_all(dir.path().join(".huskies/worktrees")).unwrap(); let agents = test_agents(); let ambient = test_ambient(); let ctx = make_ctx(&agents, &ambient, dir.path(), "999"); let output = handle_test(&ctx).unwrap(); assert!( output.contains("No worktree found") || output.contains("999"), "unknown story number should return error: {output}" ); } #[test] fn run_tests_with_invalid_arg_returns_error() { let dir = tempfile::tempdir().unwrap(); let agents = test_agents(); let ambient = test_ambient(); let ctx = make_ctx(&agents, &ambient, dir.path(), "notanumber"); let output = handle_test(&ctx).unwrap(); assert!( output.contains("Invalid argument") || output.contains("notanumber"), "non-numeric arg should return error: {output}" ); } #[test] fn run_tests_with_story_number_via_dispatch() { let dir = tempfile::tempdir().unwrap(); create_worktree(dir.path(), 541, true); let agents = test_agents(); let ambient = test_ambient(); let room_id = "!test:example.com".to_string(); let dispatch = super::super::CommandDispatch { bot_name: "Timmy", bot_user_id: "@timmy:homeserver.local", project_root: dir.path(), agents: &agents, ambient_rooms: &ambient, room_id: &room_id, }; let result = super::super::try_handle_command(&dispatch, "@timmy run_tests 541"); assert!( result.is_some(), "run_tests with story number must respond via dispatch" ); let output = result.unwrap(); assert!( output.contains("PASS"), "should PASS for valid worktree: {output}" ); } }