fix(tests): replace hardcoded /tmp paths with tempdir + add 300 unit tests (#659)

* test: add unit tests across 20 modules for coverage push

Add 300+ unit tests covering config, context, evaluation, extensions,
LLM, secrets, tools/builder, and tools/mcp modules. All tests are
pure unit tests (no mocks) exercising serde roundtrips, edge cases,
error paths, and business logic.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(tests): replace hardcoded /tmp paths with tempfile::tempdir

The e2e_metrics_test::test_metrics_collected_from_tool_trace test was
failing because setup_test_dir() created /tmp/ironclaw_metrics_test but
the fixture referenced /tmp/ironclaw_e2e_test/hello.txt (path mismatch).

Added LlmTrace::replace_paths() to substitute fixture paths at runtime,
then converted all 12 test files from hardcoded /tmp/ironclaw_* paths to
tempfile::tempdir(). Tests are now isolated, parallel-safe, and leave no
debris on disk.

Regression test: test_metrics_collected_from_tool_trace now passes
consistently regardless of prior /tmp state.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-03-07 08:24:24 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 8fbb782090
commit cf96a3253c
33 changed files with 5904 additions and 119 deletions
+27 -23
View File
@@ -9,7 +9,6 @@ mod support;
mod advanced {
use std::time::Duration;
use crate::support::cleanup::CleanupGuard;
use crate::support::test_rig::TestRigBuilder;
use crate::support::trace_llm::LlmTrace;
@@ -52,10 +51,12 @@ mod advanced {
#[tokio::test]
async fn user_steering() {
let _cleanup = CleanupGuard::new().file("/tmp/ironclaw_steer_test.txt");
let _ = std::fs::remove_file("/tmp/ironclaw_steer_test.txt");
let tmp = tempfile::tempdir().expect("create temp dir");
let test_file = tmp.path().join("ironclaw_steer_test.txt");
let mut trace = LlmTrace::from_file(format!("{FIXTURES}/steering.json")).unwrap();
trace.replace_paths("/tmp/ironclaw_steer_test.txt", test_file.to_str().unwrap());
let trace = LlmTrace::from_file(format!("{FIXTURES}/steering.json")).unwrap();
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.build()
@@ -67,8 +68,7 @@ mod advanced {
assert!(!all_responses[1].is_empty(), "Turn 2: no response");
// Extra: verify file on disk after steering.
let content = std::fs::read_to_string("/tmp/ironclaw_steer_test.txt")
.expect("steer test file should exist");
let content = std::fs::read_to_string(&test_file).expect("steer test file should exist");
assert_eq!(
content, "goodbye",
"File should contain 'goodbye' after steering"
@@ -91,10 +91,16 @@ mod advanced {
#[tokio::test]
async fn tool_error_recovery() {
let _cleanup = CleanupGuard::new().file("/tmp/ironclaw_recovery_test.txt");
let _ = std::fs::remove_file("/tmp/ironclaw_recovery_test.txt");
let tmp = tempfile::tempdir().expect("create temp dir");
let test_file = tmp.path().join("ironclaw_recovery_test.txt");
let mut trace =
LlmTrace::from_file(format!("{FIXTURES}/tool_error_recovery.json")).unwrap();
trace.replace_paths(
"/tmp/ironclaw_recovery_test.txt",
test_file.to_str().unwrap(),
);
let trace = LlmTrace::from_file(format!("{FIXTURES}/tool_error_recovery.json")).unwrap();
let rig = TestRigBuilder::new().with_trace(trace).build().await;
rig.send_message("Write 'recovered successfully' to a file for me.")
@@ -112,8 +118,7 @@ mod advanced {
);
// The second write should have succeeded on disk.
let content = std::fs::read_to_string("/tmp/ironclaw_recovery_test.txt")
.expect("recovery file should exist");
let content = std::fs::read_to_string(&test_file).expect("recovery file should exist");
assert_eq!(content, "recovered successfully");
// At least one write should have completed with success=true.
@@ -132,18 +137,18 @@ mod advanced {
#[tokio::test]
async fn long_tool_chain() {
let test_dir = "/tmp/ironclaw_chain_test";
let _cleanup = CleanupGuard::new().dir(test_dir);
let _ = std::fs::remove_dir_all(test_dir);
std::fs::create_dir_all(test_dir).unwrap();
let tmp = tempfile::tempdir().expect("create temp dir");
let test_dir = tmp.path().join("ironclaw_chain_test");
std::fs::create_dir_all(&test_dir).unwrap();
let mut trace = LlmTrace::from_file(format!("{FIXTURES}/long_tool_chain.json")).unwrap();
trace.replace_paths("/tmp/ironclaw_chain_test", test_dir.to_str().unwrap());
let trace = LlmTrace::from_file(format!("{FIXTURES}/long_tool_chain.json")).unwrap();
let rig = TestRigBuilder::new().with_trace(trace).build().await;
rig.send_message(
"Create a daily log at /tmp/ironclaw_chain_test/log.md, \
update it with afternoon activities, write an end-of-day summary, \
then read both files and give me a report.",
"Create a daily log, update it with afternoon activities, \
write an end-of-day summary, then read both files and give me a report.",
)
.await;
let responses = rig.wait_for_responses(1, TIMEOUT).await;
@@ -159,16 +164,15 @@ mod advanced {
);
// Verify files on disk.
let log =
std::fs::read_to_string(format!("{test_dir}/log.md")).expect("log.md should exist");
let log = std::fs::read_to_string(test_dir.join("log.md")).expect("log.md should exist");
assert!(
log.contains("Afternoon"),
"log.md missing Afternoon section"
);
assert!(log.contains("PR #42"), "log.md missing PR #42");
let summary = std::fs::read_to_string(format!("{test_dir}/summary.md"))
.expect("summary.md should exist");
let summary =
std::fs::read_to_string(test_dir.join("summary.md")).expect("summary.md should exist");
assert!(
summary.contains("accomplishments"),
"summary.md missing accomplishments"
+3 -11
View File
@@ -11,18 +11,10 @@ mod tests {
use std::time::Duration;
use crate::support::assertions::assert_all_tools_succeeded;
use crate::support::cleanup::CleanupGuard;
use crate::support::metrics::{RunResult, ScenarioResult, compare_runs};
use crate::support::test_rig::TestRigBuilder;
use crate::support::trace_llm::LlmTrace;
const TEST_DIR: &str = "/tmp/ironclaw_metrics_test";
fn setup_test_dir() {
let _ = std::fs::remove_dir_all(TEST_DIR);
std::fs::create_dir_all(TEST_DIR).expect("failed to create test directory");
}
/// Verify that metrics are collected from a simple text-only trace.
#[tokio::test]
async fn test_metrics_collected_from_text_trace() {
@@ -86,14 +78,14 @@ mod tests {
/// Verify that metrics capture tool calls from a file write/read flow.
#[tokio::test]
async fn test_metrics_collected_from_tool_trace() {
setup_test_dir();
let _cleanup = CleanupGuard::new().dir(TEST_DIR);
let tmp = tempfile::tempdir().expect("create temp dir");
let trace = LlmTrace::from_file(concat!(
let mut trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/file_write_read.json"
))
.expect("failed to load file_write_read.json");
trace.replace_paths("/tmp/ironclaw_e2e_test", tmp.path().to_str().unwrap());
let rig = TestRigBuilder::new().with_trace(trace).build().await;
+11 -9
View File
@@ -11,7 +11,6 @@ mod support;
mod spot_tests {
use std::time::Duration;
use crate::support::cleanup::CleanupGuard;
use crate::support::test_rig::TestRigBuilder;
use crate::support::trace_llm::LlmTrace;
@@ -98,10 +97,12 @@ mod spot_tests {
#[tokio::test]
async fn spot_chain_write_read() {
let _cleanup = CleanupGuard::new().file("/tmp/ironclaw_spot_test.txt");
let _ = std::fs::remove_file("/tmp/ironclaw_spot_test.txt");
let tmp = tempfile::tempdir().unwrap();
let test_file = tmp.path().join("ironclaw_spot_test.txt");
let mut trace = LlmTrace::from_file(format!("{FIXTURES}/chain_write_read.json")).unwrap();
trace.replace_paths("/tmp/ironclaw_spot_test.txt", test_file.to_str().unwrap());
let trace = LlmTrace::from_file(format!("{FIXTURES}/chain_write_read.json")).unwrap();
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.build()
@@ -117,8 +118,7 @@ mod spot_tests {
rig.verify_trace_expects(&trace, &responses);
// Extra: verify file on disk (can't express in expects).
let content =
std::fs::read_to_string("/tmp/ironclaw_spot_test.txt").expect("file should exist");
let content = std::fs::read_to_string(&test_file).expect("file should exist");
assert_eq!(content, "ironclaw spot check");
rig.shutdown();
@@ -166,10 +166,12 @@ mod spot_tests {
#[tokio::test]
async fn spot_memory_save_recall() {
let _cleanup = CleanupGuard::new().file("/tmp/bench-meeting.md");
let _ = std::fs::remove_file("/tmp/bench-meeting.md");
let tmp = tempfile::tempdir().unwrap();
let test_file = tmp.path().join("bench-meeting.md");
let mut trace = LlmTrace::from_file(format!("{FIXTURES}/memory_save_recall.json")).unwrap();
trace.replace_paths("/tmp/bench-meeting.md", test_file.to_str().unwrap());
let trace = LlmTrace::from_file(format!("{FIXTURES}/memory_save_recall.json")).unwrap();
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.build()
+19 -19
View File
@@ -10,19 +10,9 @@ mod support;
mod tests {
use std::time::Duration;
use crate::support::cleanup::CleanupGuard;
use crate::support::test_rig::TestRigBuilder;
use crate::support::trace_llm::LlmTrace;
const TEST_DIR_BASE: &str = "/tmp/ironclaw_coverage_test";
fn setup_test_dir(suffix: &str) -> String {
let dir = format!("{TEST_DIR_BASE}_{suffix}");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("failed to create test directory");
dir
}
// -----------------------------------------------------------------------
// json tool
// -----------------------------------------------------------------------
@@ -94,16 +84,21 @@ mod tests {
#[tokio::test]
async fn test_list_dir() {
let test_dir = setup_test_dir("list_dir");
let _cleanup = CleanupGuard::new().dir(&test_dir);
std::fs::write(format!("{test_dir}/file_a.txt"), "content a").unwrap();
std::fs::write(format!("{test_dir}/file_b.txt"), "content b").unwrap();
let tmp = tempfile::tempdir().expect("failed to create tempdir");
let test_dir = tmp.path().join("test_dir");
std::fs::create_dir_all(&test_dir).unwrap();
std::fs::write(test_dir.join("file_a.txt"), "content a").unwrap();
std::fs::write(test_dir.join("file_b.txt"), "content b").unwrap();
let trace = LlmTrace::from_file(concat!(
let mut trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/coverage/list_dir.json"
))
.expect("failed to load list_dir.json");
trace.replace_paths(
"/tmp/ironclaw_coverage_test_list_dir",
test_dir.to_str().unwrap(),
);
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
@@ -123,14 +118,19 @@ mod tests {
#[tokio::test]
async fn test_apply_patch_chain() {
let test_dir = setup_test_dir("apply_patch");
let _cleanup = CleanupGuard::new().dir(&test_dir);
let tmp = tempfile::tempdir().expect("failed to create tempdir");
let test_dir = tmp.path().join("test_dir");
std::fs::create_dir_all(&test_dir).unwrap();
let trace = LlmTrace::from_file(concat!(
let mut trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/coverage/apply_patch_chain.json"
))
.expect("failed to load apply_patch_chain.json");
trace.replace_paths(
"/tmp/ironclaw_coverage_test_apply_patch",
test_dir.to_str().unwrap(),
);
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
@@ -143,7 +143,7 @@ mod tests {
rig.verify_trace_expects(&trace, &responses);
// Extra: verify the patch was applied on disk.
let content = std::fs::read_to_string(format!("{test_dir}/patch_target.txt"))
let content = std::fs::read_to_string(test_dir.join("patch_target.txt"))
.expect("patch_target.txt should exist");
assert!(
content.contains("PATCHED"),
+5 -13
View File
@@ -8,29 +8,21 @@ mod support;
mod tests {
use std::time::Duration;
use crate::support::cleanup::CleanupGuard;
use crate::support::test_rig::TestRigBuilder;
use crate::support::trace_llm::LlmTrace;
const TEST_DIR: &str = "/tmp/ironclaw_e2e_test";
const TEST_FILE: &str = "/tmp/ironclaw_e2e_test/hello.txt";
const EXPECTED_CONTENT: &str = "Hello, E2E test!";
fn setup_test_dir() {
let _ = std::fs::remove_dir_all(TEST_DIR);
std::fs::create_dir_all(TEST_DIR).expect("failed to create test directory");
}
#[tokio::test]
async fn test_file_write_and_read_flow() {
setup_test_dir();
let _cleanup = CleanupGuard::new().dir(TEST_DIR);
let tmp = tempfile::tempdir().expect("create temp dir");
let fixture_path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/file_write_read.json"
);
let trace = LlmTrace::from_file(fixture_path).expect("failed to load trace fixture");
let mut trace = LlmTrace::from_file(fixture_path).expect("failed to load trace fixture");
trace.replace_paths("/tmp/ironclaw_e2e_test", tmp.path().to_str().unwrap());
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
@@ -44,8 +36,8 @@ mod tests {
rig.verify_trace_expects(&trace, &responses);
// Extra: verify file on disk (can't express in expects).
let file_content =
std::fs::read_to_string(TEST_FILE).expect("hello.txt should exist after write_file");
let file_content = std::fs::read_to_string(tmp.path().join("hello.txt"))
.expect("hello.txt should exist after write_file");
assert_eq!(file_content, EXPECTED_CONTENT);
rig.shutdown();
+6 -11
View File
@@ -91,22 +91,17 @@ mod tests {
#[tokio::test]
async fn tool_error_feedback() {
// Use a tempdir for the recovery file. The fixture's recovery path
// is updated to write here via the test_dir variable.
let tmp = tempfile::tempdir().expect("create temp dir");
let test_dir = tmp.path().to_str().expect("tempdir path");
// Patch the fixture's recovery path to use our tempdir.
let fixture_str = std::fs::read_to_string(concat!(
let mut trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/worker/tool_error_feedback.json"
))
.expect("read fixture");
let fixture_str = fixture_str.replace(
"/tmp/ironclaw_error_feedback_test/recovered.txt",
&format!("{test_dir}/recovered.txt"),
.expect("failed to load tool_error_feedback.json");
trace.replace_paths(
"/tmp/ironclaw_error_feedback_test",
tmp.path().to_str().unwrap(),
);
let trace: LlmTrace = serde_json::from_str(&fixture_str).expect("parse patched fixture");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
@@ -120,7 +115,7 @@ mod tests {
rig.verify_trace_expects(&trace, &responses);
// Verify the recovery file exists in the tempdir.
let content = std::fs::read_to_string(format!("{test_dir}/recovered.txt"))
let content = std::fs::read_to_string(tmp.path().join("recovered.txt"))
.expect("recovered.txt should exist");
assert!(
content.contains("recovered"),
+54
View File
@@ -236,6 +236,38 @@ impl LlmTrace {
Ok(trace)
}
/// Replace all occurrences of `old` with `new` in tool call arguments,
/// text content, and user input throughout the trace.
///
/// Used to substitute hardcoded fixture paths (e.g. `/tmp/ironclaw_test`)
/// with dynamic `tempfile::tempdir()` paths so tests don't collide.
pub fn replace_paths(&mut self, old: &str, new: &str) {
for turn in &mut self.turns {
if turn.user_input.contains(old) {
turn.user_input = turn.user_input.replace(old, new);
}
for step in &mut turn.steps {
match &mut step.response {
TraceResponse::ToolCalls { tool_calls, .. } => {
for tc in tool_calls {
replace_in_json_value(&mut tc.arguments, old, new);
}
}
TraceResponse::Text { content, .. } => {
if content.contains(old) {
*content = content.replace(old, new);
}
}
TraceResponse::UserInput { content } => {
if content.contains(old) {
*content = content.replace(old, new);
}
}
}
}
}
}
/// Return only the playable steps from the raw steps (text + tool_calls),
/// skipping `user_input` markers. Only meaningful for recorded traces that
/// were deserialized from a flat `steps` array.
@@ -248,6 +280,28 @@ impl LlmTrace {
}
}
/// Recursively replace `old` with `new` in all string values within a JSON tree.
fn replace_in_json_value(value: &mut serde_json::Value, old: &str, new: &str) {
match value {
serde_json::Value::String(s) => {
if s.contains(old) {
*s = s.replace(old, new);
}
}
serde_json::Value::Object(map) => {
for v in map.values_mut() {
replace_in_json_value(v, old, new);
}
}
serde_json::Value::Array(arr) => {
for v in arr {
replace_in_json_value(v, old, new);
}
}
_ => {}
}
}
// ---------------------------------------------------------------------------
// TraceLlm provider
// ---------------------------------------------------------------------------
+21 -17
View File
@@ -96,42 +96,46 @@ mod cleanup_tests {
#[test]
fn cleanup_guard_removes_file() {
let path = "/tmp/ironclaw_cleanup_guard_test.txt";
std::fs::write(path, "test").unwrap();
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("cleanup_guard_test.txt");
std::fs::write(&path, "test").unwrap();
let path_str = path.to_str().unwrap().to_string();
{
let _guard = CleanupGuard::new().file(path);
assert!(std::path::Path::new(path).exists());
let _guard = CleanupGuard::new().file(path_str);
assert!(path.exists());
}
assert!(!std::path::Path::new(path).exists());
assert!(!path.exists());
}
#[test]
fn cleanup_guard_removes_dir() {
let dir = "/tmp/ironclaw_cleanup_guard_test_dir";
std::fs::create_dir_all(dir).unwrap();
std::fs::write(format!("{dir}/file.txt"), "test").unwrap();
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("cleanup_guard_test_dir");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("file.txt"), "test").unwrap();
let dir_str = dir.to_str().unwrap().to_string();
{
let _guard = CleanupGuard::new().dir(dir);
assert!(std::path::Path::new(dir).exists());
let _guard = CleanupGuard::new().dir(dir_str);
assert!(dir.exists());
}
assert!(!std::path::Path::new(dir).exists());
assert!(!dir.exists());
}
#[test]
fn cleanup_guard_file_does_not_remove_dir() {
let dir = "/tmp/ironclaw_cleanup_guard_file_not_dir";
std::fs::create_dir_all(dir).unwrap();
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("cleanup_guard_file_not_dir");
std::fs::create_dir_all(&dir).unwrap();
let dir_str = dir.to_str().unwrap().to_string();
{
// Registering a directory path as .file() should not remove it
// (remove_file fails on directories).
let _guard = CleanupGuard::new().file(dir);
let _guard = CleanupGuard::new().file(dir_str);
}
assert!(
std::path::Path::new(dir).exists(),
dir.exists(),
"dir should still exist when registered as file"
);
// Clean up manually.
let _ = std::fs::remove_dir_all(dir);
}
}