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
+368 -8
View File
@@ -1026,24 +1026,384 @@ impl Tool for BuildSoftwareTool {
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::builder::core::*;
#[test]
fn test_language_extensions() {
fn test_language_extension_all_variants() {
assert_eq!(Language::Rust.extension(), "rs");
assert_eq!(Language::Python.extension(), "py");
assert_eq!(Language::TypeScript.extension(), "ts");
assert_eq!(Language::JavaScript.extension(), "js");
assert_eq!(Language::Go.extension(), "go");
assert_eq!(Language::Bash.extension(), "sh");
}
#[test]
fn test_build_commands() {
assert!(Language::Rust.build_command("/tmp/project").is_some());
assert!(Language::Python.build_command("/tmp/project").is_none());
fn test_language_build_command_compiled_returns_some() {
let dir = "/tmp/project";
let rust_cmd = Language::Rust.build_command(dir);
assert!(rust_cmd.is_some());
assert!(rust_cmd.unwrap().contains("cargo build"));
let ts_cmd = Language::TypeScript.build_command(dir);
assert!(ts_cmd.is_some());
assert!(ts_cmd.unwrap().contains("npm run build"));
let go_cmd = Language::Go.build_command(dir);
assert!(go_cmd.is_some());
assert!(go_cmd.unwrap().contains("go build"));
}
#[test]
fn test_software_type_serialization() {
let json = serde_json::to_string(&SoftwareType::WasmTool).unwrap();
assert_eq!(json, "\"wasm_tool\"");
fn test_language_build_command_interpreted_returns_none() {
let dir = "/tmp/project";
assert!(Language::Python.build_command(dir).is_none());
assert!(Language::JavaScript.build_command(dir).is_none());
assert!(Language::Bash.build_command(dir).is_none());
}
#[test]
fn test_language_build_command_includes_project_dir() {
let dir = "/home/user/my_project";
for lang in [Language::Rust, Language::TypeScript, Language::Go] {
let cmd = lang.build_command(dir);
assert!(
cmd.as_ref().unwrap().contains(dir),
"{:?} build command should contain project dir",
lang
);
}
}
#[test]
fn test_language_test_command_all_variants_non_empty() {
let dir = "/tmp/project";
let all_languages = [
Language::Rust,
Language::Python,
Language::TypeScript,
Language::JavaScript,
Language::Go,
Language::Bash,
];
for lang in all_languages {
let cmd = lang.test_command(dir);
assert!(
!cmd.is_empty(),
"{:?} test command should not be empty",
lang
);
assert!(
cmd.contains(dir),
"{:?} test command should contain project dir",
lang
);
}
}
#[test]
fn test_language_test_command_specific_tools() {
let dir = "/tmp/p";
assert!(Language::Rust.test_command(dir).contains("cargo test"));
assert!(Language::Python.test_command(dir).contains("pytest"));
assert!(Language::TypeScript.test_command(dir).contains("npm test"));
assert!(Language::JavaScript.test_command(dir).contains("npm test"));
assert!(Language::Go.test_command(dir).contains("go test"));
assert!(Language::Bash.test_command(dir).contains("shellcheck"));
}
#[test]
fn test_software_type_serde_roundtrip() {
let variants = [
SoftwareType::WasmTool,
SoftwareType::CliBinary,
SoftwareType::Library,
SoftwareType::Script,
SoftwareType::WebService,
];
let expected_strings = [
"\"wasm_tool\"",
"\"cli_binary\"",
"\"library\"",
"\"script\"",
"\"web_service\"",
];
for (variant, expected) in variants.iter().zip(expected_strings.iter()) {
let json = serde_json::to_string(variant).unwrap();
assert_eq!(&json, expected, "serialization mismatch for {:?}", variant);
let deserialized: SoftwareType = serde_json::from_str(&json).unwrap();
assert_eq!(
&deserialized, variant,
"roundtrip mismatch for {:?}",
variant
);
}
}
#[test]
fn test_language_serde_roundtrip() {
let variants = [
Language::Rust,
Language::Python,
Language::TypeScript,
Language::JavaScript,
Language::Go,
Language::Bash,
];
let expected_strings = [
"\"rust\"",
"\"python\"",
"\"type_script\"",
"\"java_script\"",
"\"go\"",
"\"bash\"",
];
for (variant, expected) in variants.iter().zip(expected_strings.iter()) {
let json = serde_json::to_string(variant).unwrap();
assert_eq!(&json, expected, "serialization mismatch for {:?}", variant);
let deserialized: Language = serde_json::from_str(&json).unwrap();
assert_eq!(
&deserialized, variant,
"roundtrip mismatch for {:?}",
variant
);
}
}
#[test]
fn test_build_requirement_serde_roundtrip() {
let req = BuildRequirement {
name: "my_tool".into(),
description: "A tool that does stuff".into(),
software_type: SoftwareType::WasmTool,
language: Language::Rust,
input_spec: Some("JSON object with 'query' field".into()),
output_spec: Some("JSON object with 'result' field".into()),
dependencies: vec!["serde".into(), "reqwest".into()],
capabilities: vec!["http".into(), "workspace".into()],
};
let json = serde_json::to_string(&req).unwrap();
let deserialized: BuildRequirement = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, req.name);
assert_eq!(deserialized.description, req.description);
assert_eq!(deserialized.software_type, req.software_type);
assert_eq!(deserialized.language, req.language);
assert_eq!(deserialized.input_spec, req.input_spec);
assert_eq!(deserialized.output_spec, req.output_spec);
assert_eq!(deserialized.dependencies, req.dependencies);
assert_eq!(deserialized.capabilities, req.capabilities);
}
#[test]
fn test_build_requirement_serde_optional_fields_none() {
let req = BuildRequirement {
name: "minimal".into(),
description: "Bare minimum".into(),
software_type: SoftwareType::Script,
language: Language::Bash,
input_spec: None,
output_spec: None,
dependencies: vec![],
capabilities: vec![],
};
let json = serde_json::to_string(&req).unwrap();
let deserialized: BuildRequirement = serde_json::from_str(&json).unwrap();
assert!(deserialized.input_spec.is_none());
assert!(deserialized.output_spec.is_none());
assert!(deserialized.dependencies.is_empty());
assert!(deserialized.capabilities.is_empty());
}
#[test]
fn test_builder_config_default_sensible_values() {
let config = BuilderConfig::default();
assert!(config.max_iterations > 0, "max_iterations must be positive");
assert!(!config.timeout.is_zero(), "timeout must be non-zero");
assert!(
config.timeout.as_secs() >= 60,
"timeout should be at least 60 seconds"
);
assert!(config.validate_wasm, "validate_wasm should default to true");
assert!(config.run_tests, "run_tests should default to true");
assert!(config.auto_register, "auto_register should default to true");
assert!(
!config.cleanup_on_failure,
"cleanup_on_failure should default to false for debugging"
);
assert!(
config.wasm_output_dir.is_none(),
"wasm_output_dir should default to None"
);
assert!(
config
.build_dir
.to_string_lossy()
.contains("ironclaw-builds"),
"build_dir should contain 'ironclaw-builds'"
);
}
#[test]
fn test_build_phase_serde_roundtrip() {
let variants = [
BuildPhase::Analyzing,
BuildPhase::Scaffolding,
BuildPhase::Implementing,
BuildPhase::Building,
BuildPhase::Testing,
BuildPhase::Fixing,
BuildPhase::Validating,
BuildPhase::Registering,
BuildPhase::Packaging,
BuildPhase::Complete,
BuildPhase::Failed,
];
for variant in &variants {
let json = serde_json::to_string(variant).unwrap();
let deserialized: BuildPhase = serde_json::from_str(&json).unwrap();
assert_eq!(
&deserialized, variant,
"roundtrip mismatch for {:?}",
variant
);
}
}
#[test]
fn test_build_result_serde_success() {
let result = BuildResult {
build_id: Uuid::nil(),
requirement: BuildRequirement {
name: "test_tool".into(),
description: "test".into(),
software_type: SoftwareType::WasmTool,
language: Language::Rust,
input_spec: None,
output_spec: None,
dependencies: vec![],
capabilities: vec![],
},
artifact_path: PathBuf::from("/tmp/test.wasm"),
logs: vec![],
success: true,
error: None,
started_at: Utc::now(),
completed_at: Utc::now(),
iterations: 3,
validation_warnings: vec![],
tests_passed: 5,
tests_failed: 0,
registered: true,
};
let json = serde_json::to_string(&result).unwrap();
let deserialized: BuildResult = serde_json::from_str(&json).unwrap();
assert!(deserialized.success);
assert!(deserialized.error.is_none());
assert_eq!(deserialized.iterations, 3);
assert_eq!(deserialized.tests_passed, 5);
assert_eq!(deserialized.tests_failed, 0);
assert!(deserialized.registered);
}
#[test]
fn test_build_result_serde_failure() {
let result = BuildResult {
build_id: Uuid::nil(),
requirement: BuildRequirement {
name: "broken".into(),
description: "fails".into(),
software_type: SoftwareType::CliBinary,
language: Language::Go,
input_spec: None,
output_spec: None,
dependencies: vec![],
capabilities: vec![],
},
artifact_path: PathBuf::from("/tmp/broken"),
logs: vec![],
success: false,
error: Some("compilation error: undefined reference".into()),
started_at: Utc::now(),
completed_at: Utc::now(),
iterations: 10,
validation_warnings: vec!["missing export".into()],
tests_passed: 2,
tests_failed: 3,
registered: false,
};
let json = serde_json::to_string(&result).unwrap();
let deserialized: BuildResult = serde_json::from_str(&json).unwrap();
assert!(!deserialized.success);
assert_eq!(
deserialized.error.as_deref(),
Some("compilation error: undefined reference")
);
assert_eq!(deserialized.iterations, 10);
assert_eq!(deserialized.validation_warnings.len(), 1);
assert_eq!(deserialized.tests_passed, 2);
assert_eq!(deserialized.tests_failed, 3);
assert!(!deserialized.registered);
}
#[test]
fn test_build_result_default_fields_from_json() {
// Verify #[serde(default)] fields can be omitted in JSON
let json = serde_json::json!({
"build_id": "00000000-0000-0000-0000-000000000000",
"requirement": {
"name": "x",
"description": "y",
"software_type": "script",
"language": "bash",
"input_spec": null,
"output_spec": null,
"dependencies": [],
"capabilities": []
},
"artifact_path": "/tmp/x.sh",
"logs": [],
"success": true,
"error": null,
"started_at": "2025-01-01T00:00:00Z",
"completed_at": "2025-01-01T00:01:00Z",
"iterations": 1
});
let result: BuildResult = serde_json::from_value(json).unwrap();
assert_eq!(result.validation_warnings, Vec::<String>::new());
assert_eq!(result.tests_passed, 0);
assert_eq!(result.tests_failed, 0);
assert!(!result.registered);
}
#[test]
fn test_build_log_serde_roundtrip() {
let log = BuildLog {
timestamp: Utc::now(),
phase: BuildPhase::Building,
message: "Running cargo build".into(),
details: Some("cargo build --release 2>&1".into()),
};
let json = serde_json::to_string(&log).unwrap();
let deserialized: BuildLog = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.phase, BuildPhase::Building);
assert_eq!(deserialized.message, "Running cargo build");
assert_eq!(
deserialized.details.as_deref(),
Some("cargo build --release 2>&1")
);
}
#[test]
fn test_build_log_serde_details_none() {
let log = BuildLog {
timestamp: Utc::now(),
phase: BuildPhase::Complete,
message: "Done".into(),
details: None,
};
let json = serde_json::to_string(&log).unwrap();
let deserialized: BuildLog = serde_json::from_str(&json).unwrap();
assert!(deserialized.details.is_none());
assert_eq!(deserialized.phase, BuildPhase::Complete);
}
}
+159
View File
@@ -498,4 +498,163 @@ mod tests {
assert_eq!(template.name, "WASM HTTP Tool");
assert!(!template.files.is_empty());
}
#[test]
fn test_render_no_variables() {
let engine = TemplateEngine::new();
let input = "Hello, world! No placeholders here.";
assert_eq!(engine.render(input), input);
}
#[test]
fn test_render_variable_not_found() {
let mut engine = TemplateEngine::new();
engine.set("name", "ironclaw");
let input = "Name: {{name}}, Missing: {{missing}}";
assert_eq!(engine.render(input), "Name: ironclaw, Missing: {{missing}}");
}
#[test]
fn test_render_multiple_replacements_of_same_variable() {
let mut engine = TemplateEngine::new();
engine.set("x", "42");
assert_eq!(engine.render("{{x}} + {{x}} = 2*{{x}}"), "42 + 42 = 2*42");
}
#[test]
fn test_set_overwrites_existing_variable() {
let mut engine = TemplateEngine::new();
engine.set("color", "red");
assert_eq!(engine.render("{{color}}"), "red");
engine.set("color", "blue");
assert_eq!(engine.render("{{color}}"), "blue");
}
#[test]
fn test_render_template_all_files() {
let mut engine = TemplateEngine::new();
engine.set("name", "my_tool");
engine.set("description", "does stuff");
let template = Template::get(TemplateType::CliBinary);
let rendered = engine.render_template(&template);
assert_eq!(rendered.len(), template.files.len());
// Paths should have variables substituted
for (path, _content) in &rendered {
assert!(!path.contains("{{name}}"));
}
// Content should have variables substituted
for (_path, content) in &rendered {
assert!(!content.contains("{{name}}"));
assert!(!content.contains("{{description}}"));
}
}
#[test]
fn test_all_template_types_return_non_empty() {
let all_types = [
TemplateType::WasmHttpTool,
TemplateType::WasmTransformTool,
TemplateType::WasmComputeTool,
TemplateType::CliBinary,
TemplateType::PythonScript,
TemplateType::BashScript,
];
for tt in all_types {
let t = Template::get(tt);
assert!(!t.name.is_empty(), "{:?} has empty name", tt);
assert!(!t.description.is_empty(), "{:?} has empty description", tt);
assert!(!t.files.is_empty(), "{:?} has no files", tt);
for f in &t.files {
assert!(
!f.content.is_empty(),
"{:?} file {:?} has empty content",
tt,
f.path
);
}
}
}
#[test]
fn test_template_type_serde_roundtrip() {
let all_types = [
TemplateType::WasmHttpTool,
TemplateType::WasmTransformTool,
TemplateType::WasmComputeTool,
TemplateType::CliBinary,
TemplateType::PythonScript,
TemplateType::BashScript,
];
for tt in all_types {
let json = serde_json::to_string(&tt).unwrap();
let back: TemplateType = serde_json::from_str(&json).unwrap();
assert_eq!(back, tt, "roundtrip failed for {:?} (json: {})", tt, json);
}
}
#[test]
fn test_each_template_has_at_least_one_required_file() {
let all_types = [
TemplateType::WasmHttpTool,
TemplateType::WasmTransformTool,
TemplateType::WasmComputeTool,
TemplateType::CliBinary,
TemplateType::PythonScript,
TemplateType::BashScript,
];
for tt in all_types {
let t = Template::get(tt);
let required_count = t.files.iter().filter(|f| f.is_required).count();
assert!(required_count >= 1, "{:?} has no required files", tt);
}
}
#[test]
fn test_template_file_extensions() {
// WASM and CLI templates should have Cargo.toml and .rs files
for tt in [
TemplateType::WasmHttpTool,
TemplateType::WasmTransformTool,
TemplateType::WasmComputeTool,
TemplateType::CliBinary,
] {
let t = Template::get(tt);
let paths: Vec<&str> = t.files.iter().map(|f| f.path).collect();
assert!(
paths.iter().any(|p| p.ends_with("Cargo.toml")),
"{:?} missing Cargo.toml",
tt
);
assert!(
paths.iter().any(|p| p.ends_with(".rs")),
"{:?} missing .rs file",
tt
);
}
// Python template should have a .py file
let py = Template::get(TemplateType::PythonScript);
assert!(py.files.iter().any(|f| f.path.ends_with(".py")));
// Bash template should have a .sh file
let bash = Template::get(TemplateType::BashScript);
assert!(bash.files.iter().any(|f| f.path.ends_with(".sh")));
}
#[test]
fn test_python_and_bash_templates_have_name_in_path() {
let py = Template::get(TemplateType::PythonScript);
assert!(
py.files.iter().any(|f| f.path.contains("{{name}}")),
"PythonScript template should have {{{{name}}}} in a file path"
);
let bash = Template::get(TemplateType::BashScript);
assert!(
bash.files.iter().any(|f| f.path.contains("{{name}}")),
"BashScript template should have {{{{name}}}} in a file path"
);
}
}
+159 -1
View File
@@ -315,5 +315,163 @@ mod tests {
);
}
// Note: Full WASM parsing tests would require actual WASM binaries
#[test]
fn test_validate_bytes_invalid_bytes() {
let validator = WasmValidator::new();
let garbage = b"this is not a wasm module at all";
let result = validator.validate_bytes(garbage).unwrap();
assert!(!result.is_valid);
assert!(
result
.errors
.iter()
.any(|e| matches!(e, ValidationError::InvalidModule(_)))
);
}
#[test]
fn test_validate_bytes_empty() {
let validator = WasmValidator::new();
let result = validator.validate_bytes(b"").unwrap();
assert!(!result.is_valid);
assert!(
result
.errors
.iter()
.any(|e| matches!(e, ValidationError::InvalidModule(_)))
);
}
#[test]
fn test_validate_bytes_minimal_wasm_missing_run_export() {
let validator = WasmValidator::new();
// Minimal valid WASM: magic number + version
let minimal_wasm = b"\x00asm\x01\x00\x00\x00";
let result = validator.validate_bytes(minimal_wasm).unwrap();
assert!(!result.is_valid);
assert!(
result
.errors
.iter()
.any(|e| matches!(e, ValidationError::MissingExport(name) if name == "run"))
);
assert_eq!(result.size_bytes, 8);
}
#[test]
fn test_validation_result_is_valid_when_no_errors() {
let result = ValidationResult {
is_valid: true,
errors: vec![],
warnings: vec!["some warning".to_string()],
exports: vec![],
imports: vec![],
size_bytes: 0,
};
assert!(result.is_valid);
assert!(result.errors.is_empty());
}
#[test]
fn test_validation_result_is_invalid_when_errors_present() {
let result = ValidationResult {
is_valid: false,
errors: vec![ValidationError::MissingExport("run".to_string())],
warnings: vec![],
exports: vec![],
imports: vec![],
size_bytes: 0,
};
assert!(!result.is_valid);
assert_eq!(result.errors.len(), 1);
}
#[test]
fn test_validation_error_display() {
let io_err =
ValidationError::IoError(std::io::Error::new(std::io::ErrorKind::NotFound, "gone"));
assert!(io_err.to_string().contains("Failed to read WASM file"));
let invalid = ValidationError::InvalidModule("bad magic".to_string());
assert!(invalid.to_string().contains("Invalid WASM module"));
assert!(invalid.to_string().contains("bad magic"));
let missing = ValidationError::MissingExport("run".to_string());
assert!(missing.to_string().contains("Missing required export"));
assert!(missing.to_string().contains("run"));
let sig = ValidationError::InvalidSignature {
name: "run".to_string(),
expected: "() -> i32".to_string(),
actual: "() -> ()".to_string(),
};
assert!(sig.to_string().contains("Invalid export signature"));
assert!(sig.to_string().contains("run"));
let disallowed = ValidationError::DisallowedImport {
module: "evil".to_string(),
name: "hack".to_string(),
};
assert!(disallowed.to_string().contains("disallowed import"));
assert!(disallowed.to_string().contains("evil::hack"));
let too_large = ValidationError::TooLarge {
size: 200,
max: 100,
};
assert!(too_large.to_string().contains("200"));
assert!(too_large.to_string().contains("100"));
let other = ValidationError::Other("something broke".to_string());
assert!(other.to_string().contains("something broke"));
}
#[test]
fn test_export_kind_equality() {
assert_eq!(ExportKind::Function, ExportKind::Function);
assert_eq!(ExportKind::Memory, ExportKind::Memory);
assert_eq!(ExportKind::Table, ExportKind::Table);
assert_eq!(ExportKind::Global, ExportKind::Global);
assert_ne!(ExportKind::Function, ExportKind::Memory);
assert_ne!(ExportKind::Table, ExportKind::Global);
}
#[test]
fn test_import_kind_equality() {
assert_eq!(ImportKind::Function, ImportKind::Function);
assert_eq!(ImportKind::Memory, ImportKind::Memory);
assert_eq!(ImportKind::Table, ImportKind::Table);
assert_eq!(ImportKind::Global, ImportKind::Global);
assert_ne!(ImportKind::Function, ImportKind::Global);
assert_ne!(ImportKind::Memory, ImportKind::Table);
}
#[test]
fn test_validate_bytes_exceeds_max_size() {
let validator = WasmValidator::new().with_max_size(4);
// 8 bytes, over the 4-byte limit
let minimal_wasm = b"\x00asm\x01\x00\x00\x00";
let result = validator.validate_bytes(minimal_wasm).unwrap();
assert!(!result.is_valid);
assert!(
result
.errors
.iter()
.any(|e| matches!(e, ValidationError::TooLarge { size: 8, max: 4 }))
);
}
#[test]
fn test_with_max_size_then_validate_over_limit() {
let validator = WasmValidator::new().with_max_size(16);
let oversized = vec![0u8; 32];
let result = validator.validate_bytes(&oversized).unwrap();
assert!(!result.is_valid);
assert!(
result
.errors
.iter()
.any(|e| matches!(e, ValidationError::TooLarge { size: 32, max: 16 }))
);
}
}