From 808d917994d3c58847e890ec8edf11691ee8c99a Mon Sep 17 00:00:00 2001 From: sandikodev Date: Thu, 9 Apr 2026 17:19:18 +0700 Subject: [PATCH 1/7] test(todo): add unit tests for has_duplicates, generate_new_todo_id, and deserialization todo.rs had zero tests. Add coverage for: - has_duplicates: empty, unique, and duplicate cases - generate_new_todo_id: uniqueness (with sleep) and numeric format - TodoList deserialization: Create, Complete, Add, Remove variants --- crates/chat-cli/src/cli/chat/tools/todo.rs | 88 ++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/crates/chat-cli/src/cli/chat/tools/todo.rs b/crates/chat-cli/src/cli/chat/tools/todo.rs index 204657f270..b812adea93 100644 --- a/crates/chat-cli/src/cli/chat/tools/todo.rs +++ b/crates/chat-cli/src/cli/chat/tools/todo.rs @@ -481,3 +481,91 @@ where let mut seen = HashSet::with_capacity(vec.len()); vec.iter().any(|item| !seen.insert(item)) } + +#[cfg(test)] +mod tests { + use super::*; + + // ── has_duplicates ─────────────────────────────────────────────────────── + + #[test] + fn has_duplicates_empty() { + assert!(!has_duplicates::(&[])); + } + + #[test] + fn has_duplicates_unique() { + assert!(!has_duplicates(&[1, 2, 3])); + } + + #[test] + fn has_duplicates_with_duplicate() { + assert!(has_duplicates(&[1, 2, 1])); + } + + // ── generate_new_todo_id ───────────────────────────────────────────────── + + #[test] + fn generate_new_todo_id_is_unique() { + // IDs are millisecond timestamps — sleep to guarantee different values + let a = generate_new_todo_id(); + std::thread::sleep(std::time::Duration::from_millis(2)); + let b = generate_new_todo_id(); + assert_ne!(a, b, "IDs generated at different times must be unique"); + } + + #[test] + fn generate_new_todo_id_format() { + let id = generate_new_todo_id(); + assert!(!id.is_empty()); + assert!(id.chars().all(|c| c.is_ascii_digit()), "ID must be numeric: {id}"); + } + + // ── TodoList deserialization ───────────────────────────────────────────── + + #[test] + fn deserialize_create() { + let v = serde_json::json!({ + "command": "create", + "todo_list_description": "test todo", + "tasks": ["task 1", "task 2"] + }); + let tl = serde_json::from_value::(v).unwrap(); + assert!(matches!(tl, TodoList::Create { .. })); + } + + #[test] + fn deserialize_complete() { + let v = serde_json::json!({ + "command": "complete", + "current_id": "abc123", + "completed_indices": [0], + "context_update": "done" + }); + let tl = serde_json::from_value::(v).unwrap(); + assert!(matches!(tl, TodoList::Complete { .. })); + } + + #[test] + fn deserialize_add() { + let v = serde_json::json!({ + "command": "add", + "current_id": "abc123", + "new_tasks": ["new task"], + "insert_indices": [0] + }); + let tl = serde_json::from_value::(v).unwrap(); + assert!(matches!(tl, TodoList::Add { .. })); + } + + #[test] + fn deserialize_remove() { + let v = serde_json::json!({ + "command": "remove", + "current_id": "abc123", + "remove_indices": [1] + }); + let tl = serde_json::from_value::(v).unwrap(); + assert!(matches!(tl, TodoList::Remove { .. })); + } +} From 2cb3af02b7de9502d733221fd0300f0001a46260 Mon Sep 17 00:00:00 2001 From: sandikodev Date: Thu, 9 Apr 2026 17:30:38 +0700 Subject: [PATCH 2/7] test(tools): add edge case tests for sanitize_path_tool_arg Add three additional tests covering: - absolute path passes through unchanged - empty string does not panic - tilde only expands at the start of a path, not in the middle --- crates/chat-cli/src/cli/chat/tools/mod.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/chat-cli/src/cli/chat/tools/mod.rs b/crates/chat-cli/src/cli/chat/tools/mod.rs index 4859fbf210..20ce121d14 100644 --- a/crates/chat-cli/src/cli/chat/tools/mod.rs +++ b/crates/chat-cli/src/cli/chat/tools/mod.rs @@ -615,4 +615,26 @@ mod tests { ) .await; } + + #[tokio::test] + async fn test_sanitize_path_absolute_unchanged() { + let os = Os::new().await.unwrap(); + let actual = sanitize_path_tool_arg(&os, "/absolute/path"); + assert_eq!(actual, os.fs.chroot_path("/absolute/path")); + } + + #[tokio::test] + async fn test_sanitize_path_empty_string() { + let os = Os::new().await.unwrap(); + // Empty string should not panic — result is implementation-defined but stable + let _ = sanitize_path_tool_arg(&os, ""); + } + + #[tokio::test] + async fn test_sanitize_path_tilde_only_expands_at_start() { + let os = Os::new().await.unwrap(); + // Tilde in the middle should not expand + let actual = sanitize_path_tool_arg(&os, "/foo/~/bar"); + assert_eq!(actual, os.fs.chroot_path("/foo/~/bar")); + } } From fc862468ab076a45c7cbc1d5553ed1d541991a63 Mon Sep 17 00:00:00 2001 From: sandikodev Date: Thu, 9 Apr 2026 20:09:02 +0700 Subject: [PATCH 3/7] test(tools): add unit tests for custom_tool and knowledge deserialization custom_tool.rs: - default_timeout is 120_000ms (2 minutes) - get_default_scopes returns non-empty strings - TransportType::default() is Stdio knowledge.rs: - TodoList deserialization for Add, Search, Remove, Clear variants --- .../src/cli/chat/tools/custom_tool.rs | 28 ++++++++++++ .../chat-cli/src/cli/chat/tools/knowledge.rs | 43 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/crates/chat-cli/src/cli/chat/tools/custom_tool.rs b/crates/chat-cli/src/cli/chat/tools/custom_tool.rs index 90b344812a..13134843f8 100644 --- a/crates/chat-cli/src/cli/chat/tools/custom_tool.rs +++ b/crates/chat-cli/src/cli/chat/tools/custom_tool.rs @@ -196,3 +196,31 @@ impl CustomTool { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_timeout_is_two_minutes_in_ms() { + assert_eq!(default_timeout(), 120 * 1000); + } + + #[test] + fn get_default_scopes_returns_non_empty() { + let scopes = get_default_scopes(); + assert!(!scopes.is_empty(), "default OAuth scopes must not be empty"); + } + + #[test] + fn get_default_scopes_are_strings() { + for scope in get_default_scopes() { + assert!(!scope.is_empty(), "each scope must be a non-empty string"); + } + } + + #[test] + fn transport_type_default_is_stdio() { + assert!(matches!(TransportType::default(), TransportType::Stdio)); + } +} diff --git a/crates/chat-cli/src/cli/chat/tools/knowledge.rs b/crates/chat-cli/src/cli/chat/tools/knowledge.rs index 7055bf3c5e..3ba232c880 100644 --- a/crates/chat-cli/src/cli/chat/tools/knowledge.rs +++ b/crates/chat-cli/src/cli/chat/tools/knowledge.rs @@ -579,3 +579,46 @@ impl Knowledge { output.trim_end().to_string() // Remove trailing newline } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize_add() { + let v = serde_json::json!({ + "command": "add", + "name": "my-kb", + "value": "/some/path" + }); + let k = serde_json::from_value::(v).unwrap(); + assert!(matches!(k, Knowledge::Add(_))); + } + + #[test] + fn deserialize_search() { + let v = serde_json::json!({ + "command": "search", + "query": "find something" + }); + let k = serde_json::from_value::(v).unwrap(); + assert!(matches!(k, Knowledge::Search(_))); + } + + #[test] + fn deserialize_remove() { + let v = serde_json::json!({ + "command": "remove", + "identifier": "my-kb" + }); + let k = serde_json::from_value::(v).unwrap(); + assert!(matches!(k, Knowledge::Remove(_))); + } + + #[test] + fn deserialize_clear() { + let v = serde_json::json!({ "command": "clear", "confirm": true }); + let k = serde_json::from_value::(v).unwrap(); + assert!(matches!(k, Knowledge::Clear(_))); + } +} From db837e002eec293f3a70efe30affefa9ea3c8154 Mon Sep 17 00:00:00 2001 From: sandikodev Date: Thu, 9 Apr 2026 22:25:21 +0700 Subject: [PATCH 4/7] test(tools): add unit tests for thinking and introspect thinking.rs (was 0 tests): - deserialization - invoke returns empty output - validate accepts both empty and non-empty thoughts introspect.rs (was 0 tests): - deserialization with and without query - validate always succeeds --- .../chat-cli/src/cli/chat/tools/introspect.rs | 26 +++++++++++++++ .../chat-cli/src/cli/chat/tools/thinking.rs | 33 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/crates/chat-cli/src/cli/chat/tools/introspect.rs b/crates/chat-cli/src/cli/chat/tools/introspect.rs index e27d6a5350..96757bccba 100644 --- a/crates/chat-cli/src/cli/chat/tools/introspect.rs +++ b/crates/chat-cli/src/cli/chat/tools/introspect.rs @@ -186,3 +186,29 @@ impl Introspect { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize_with_query() { + let v = serde_json::json!({ "query": "how do I use /compact?" }); + let i = serde_json::from_value::(v).unwrap(); + assert_eq!(i.query, Some("how do I use /compact?".to_string())); + } + + #[test] + fn deserialize_without_query() { + let v = serde_json::json!({}); + let i = serde_json::from_value::(v).unwrap(); + assert_eq!(i.query, None); + } + + #[tokio::test] + async fn validate_always_succeeds() { + let i = Introspect { query: None }; + let os = crate::os::Os::new().await.unwrap(); + assert!(i.validate(&os).await.is_ok()); + } +} diff --git a/crates/chat-cli/src/cli/chat/tools/thinking.rs b/crates/chat-cli/src/cli/chat/tools/thinking.rs index b35388f440..fac418157d 100644 --- a/crates/chat-cli/src/cli/chat/tools/thinking.rs +++ b/crates/chat-cli/src/cli/chat/tools/thinking.rs @@ -71,3 +71,36 @@ impl Thinking { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize_thinking() { + let v = serde_json::json!({ "thought": "let me reason through this" }); + let t = serde_json::from_value::(v).unwrap(); + assert_eq!(t.thought, "let me reason through this"); + } + + #[tokio::test] + async fn invoke_returns_empty_output() { + let t = Thinking { thought: "some thought".to_string() }; + let result = t.invoke(std::io::sink()).await.unwrap(); + assert!(matches!(result.output, OutputKind::Text(ref s) if s.is_empty())); + } + + #[tokio::test] + async fn validate_accepts_empty_thought() { + let mut t = Thinking { thought: String::new() }; + let os = crate::os::Os::new().await.unwrap(); + assert!(t.validate(&os).await.is_ok()); + } + + #[tokio::test] + async fn validate_accepts_non_empty_thought() { + let mut t = Thinking { thought: "complex reasoning".to_string() }; + let os = crate::os::Os::new().await.unwrap(); + assert!(t.validate(&os).await.is_ok()); + } +} From e603ff8e415a0191f4b089e55d5df309949865a9 Mon Sep 17 00:00:00 2001 From: sandikodev Date: Fri, 10 Apr 2026 03:06:25 +0700 Subject: [PATCH 5/7] test(delegate): expand test coverage for pure functions and deserialization delegate.rs had only 1 test (get_schema). Add coverage for: - truncate_description: period truncation, long string, short string - format_launch_success: contains agent name and task - AgentStatus::default is Running - Delegate deserialization: Launch with task, Status without agent - resolve_agent_name fallback chain (explicit > configured > default) --- .../chat-cli/src/cli/chat/tools/delegate.rs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/crates/chat-cli/src/cli/chat/tools/delegate.rs b/crates/chat-cli/src/cli/chat/tools/delegate.rs index fd7a1e31ce..6b1f58e0a2 100644 --- a/crates/chat-cli/src/cli/chat/tools/delegate.rs +++ b/crates/chat-cli/src/cli/chat/tools/delegate.rs @@ -599,4 +599,71 @@ mod tests { let schema = schemars::schema_for!(Delegate); println!("{}", serde_json::to_string_pretty(&schema).unwrap()); } + + // ── truncate_description ───────────────────────────────────────────────── + + #[test] + fn truncate_at_first_period() { + assert_eq!(truncate_description("Short desc. More text."), "Short desc."); + } + + #[test] + fn truncate_long_string_without_period() { + let long = "a".repeat(80); + let result = truncate_description(&long); + assert_eq!(result.len(), 57); + } + + #[test] + fn truncate_short_string_without_period_unchanged() { + assert_eq!(truncate_description("short"), "short"); + } + + // ── format_launch_success ──────────────────────────────────────────────── + + #[test] + fn format_launch_success_contains_agent_and_task() { + let msg = format_launch_success("my-agent", "do the thing"); + assert!(msg.contains("my-agent")); + assert!(msg.contains("do the thing")); + } + + // ── AgentStatus ────────────────────────────────────────────────────────── + + #[test] + fn agent_status_default_is_running() { + assert!(matches!(AgentStatus::default(), AgentStatus::Running)); + } + + // ── Delegate deserialization ───────────────────────────────────────────── + + #[test] + fn deserialize_launch() { + let v = serde_json::json!({ + "operation": "launch", + "task": "write a hello world program" + }); + let d = serde_json::from_value::(v).unwrap(); + assert!(matches!(d.operation, Operation::Launch)); + assert_eq!(d.task.as_deref(), Some("write a hello world program")); + } + + #[test] + fn deserialize_status_all() { + let v = serde_json::json!({ "operation": "status" }); + let d = serde_json::from_value::(v).unwrap(); + assert!(matches!(d.operation, Operation::Status)); + assert!(d.agent.is_none()); + } + + #[test] + fn resolve_agent_name_fallback_chain() { + // explicit > configured_default > DEFAULT_AGENT_NAME + fn resolve<'a>(explicit: Option<&'a str>, configured: Option<&'a str>) -> &'a str { + explicit.or(configured).unwrap_or(DEFAULT_AGENT_NAME) + } + assert_eq!(resolve(Some("explicit"), Some("configured")), "explicit"); + assert_eq!(resolve(None, Some("configured")), "configured"); + assert_eq!(resolve(None, None), DEFAULT_AGENT_NAME); + } } From 5900fd9e4d19b16b45a07e57f9339da00076086c Mon Sep 17 00:00:00 2001 From: sandikodev Date: Sun, 12 Apr 2026 10:46:11 +0700 Subject: [PATCH 6/7] test(paths): add unit tests for add_gitignore_globs and canonicalizes_path paths.rs had zero tests. Add coverage for: - add_gitignore_globs: matches file and children, trailing slash, invalid pattern error - canonicalizes_path: absolute path, dotdot resolution --- crates/chat-cli/src/util/paths.rs | 45 +++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/chat-cli/src/util/paths.rs b/crates/chat-cli/src/util/paths.rs index d75eae3f32..f881ca418c 100644 --- a/crates/chat-cli/src/util/paths.rs +++ b/crates/chat-cli/src/util/paths.rs @@ -331,3 +331,48 @@ impl<'a> GlobalPaths<'a> { .join("data.sqlite3")) } } + +#[cfg(test)] +mod tests { + use globset::GlobSetBuilder; + + use super::*; + + #[test] + fn add_gitignore_globs_adds_file_and_dir_patterns() { + let mut builder = GlobSetBuilder::new(); + add_gitignore_globs(&mut builder, "target").unwrap(); + let set = builder.build().unwrap(); + assert!(set.is_match("target"), "should match the path itself"); + assert!(set.is_match("target/debug/foo"), "should match children"); + } + + #[test] + fn add_gitignore_globs_with_trailing_slash() { + let mut builder = GlobSetBuilder::new(); + add_gitignore_globs(&mut builder, "node_modules/").unwrap(); + let set = builder.build().unwrap(); + assert!(set.is_match("node_modules/package/index.js")); + } + + #[test] + fn add_gitignore_globs_invalid_pattern_returns_error() { + let mut builder = GlobSetBuilder::new(); + // '[' without closing ']' is an invalid glob + assert!(add_gitignore_globs(&mut builder, "[invalid").is_err()); + } + + #[tokio::test] + async fn canonicalizes_absolute_path() { + let os = Os::new().await.unwrap(); + let result = canonicalizes_path(&os, "/tmp").unwrap(); + assert!(result.starts_with('/'), "result must be absolute: {result}"); + } + + #[tokio::test] + async fn canonicalizes_path_with_dotdot() { + let os = Os::new().await.unwrap(); + let result = canonicalizes_path(&os, "/tmp/../tmp").unwrap(); + assert!(!result.contains(".."), ".. should be resolved: {result}"); + } +} From 6d5e3139609d549190b0eae8fd99d0c94efdcbab Mon Sep 17 00:00:00 2001 From: sandikodev Date: Sun, 12 Apr 2026 15:32:14 +0700 Subject: [PATCH 7/7] test(env_var): add unit tests using Env::from_slice for injectable env env_var.rs had zero tests. Add coverage for: - get_mock_chat_response: present and absent - is_sigv4_enabled: with value, empty value, absent - get_editor: returns non-empty string Uses Env::from_slice (the correct test pattern) instead of std::env::set_var since Env::new() in test mode uses a Fake backend that ignores real env vars. --- crates/chat-cli/src/util/env_var.rs | 43 +++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/chat-cli/src/util/env_var.rs b/crates/chat-cli/src/util/env_var.rs index 8f855833d1..abff266442 100644 --- a/crates/chat-cli/src/util/env_var.rs +++ b/crates/chat-cli/src/util/env_var.rs @@ -94,3 +94,46 @@ pub fn get_all_env_vars() -> std::env::Vars { pub fn get_telemetry_client_id(env: &Env) -> Result { env.get(Q_TELEMETRY_CLIENT_ID) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::os::Env; + + #[test] + fn get_mock_chat_response_present() { + let env = Env::from_slice(&[("Q_MOCK_CHAT_RESPONSE", "hello")]); + assert_eq!(get_mock_chat_response(&env), Some("hello".to_string())); + } + + #[test] + fn get_mock_chat_response_absent() { + let env = Env::from_slice(&[]); + assert_eq!(get_mock_chat_response(&env), None); + } + + #[test] + fn is_sigv4_enabled_with_value() { + let env = Env::from_slice(&[("AMAZON_Q_SIGV4", "true")]); + assert!(is_sigv4_enabled(&env)); + } + + #[test] + fn is_sigv4_enabled_empty_value() { + let env = Env::from_slice(&[("AMAZON_Q_SIGV4", "")]); + assert!(!is_sigv4_enabled(&env)); + } + + #[test] + fn is_sigv4_enabled_absent() { + let env = Env::from_slice(&[]); + assert!(!is_sigv4_enabled(&env)); + } + + #[test] + fn get_editor_fallback() { + // get_editor() reads from real env — just verify it returns a non-empty string + let editor = get_editor(); + assert!(!editor.is_empty()); + } +}