Add 54 integration tests for CLI, tools, and message serialization
New test files: - crates/g3-cli/tests/cli_integration_test.rs (14 tests) Blackbox CLI tests: help/version flags, argument validation, conflicting modes, flock mode requirements - crates/g3-core/tests/tool_execution_test.rs (20 tests) Tool call structure tests and unified diff application: read_file, write_file, str_replace, shell, background_process, todo, final_output, code_search, take_screenshot - crates/g3-providers/tests/message_serialization_test.rs (20 tests) Round-trip serialization tests for Message, MessageRole, CacheControl, and Tool types. Covers Unicode, special chars, and edge cases. All tests follow blackbox/integration-first principles with documentation of what they protect and intentionally do not assert.
This commit is contained in:
294
crates/g3-cli/tests/cli_integration_test.rs
Normal file
294
crates/g3-cli/tests/cli_integration_test.rs
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
//! CLI Integration Tests (Blackbox)
|
||||||
|
//!
|
||||||
|
//! CHARACTERIZATION: These tests verify the CLI's external behavior through
|
||||||
|
//! its public interface (command-line arguments and exit codes).
|
||||||
|
//!
|
||||||
|
//! What these tests protect:
|
||||||
|
//! - CLI argument parsing works correctly
|
||||||
|
//! - Help and version output are available
|
||||||
|
//! - Invalid arguments produce appropriate errors
|
||||||
|
//! - Workspace directory handling works
|
||||||
|
//!
|
||||||
|
//! What these tests intentionally do NOT assert:
|
||||||
|
//! - Internal implementation details
|
||||||
|
//! - Specific error message wording (only that errors occur)
|
||||||
|
//! - Provider-specific behavior (requires API keys)
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// Get the path to the g3 binary.
|
||||||
|
/// In test mode, this will be in the target/debug directory.
|
||||||
|
fn get_g3_binary() -> String {
|
||||||
|
// When running tests, the binary is in target/debug/
|
||||||
|
let mut path = std::env::current_exe().unwrap();
|
||||||
|
path.pop(); // Remove test binary name
|
||||||
|
path.pop(); // Remove deps
|
||||||
|
path.push("g3");
|
||||||
|
path.to_string_lossy().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: --help flag produces help output
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_help_flag_produces_output() {
|
||||||
|
let output = Command::new(get_g3_binary())
|
||||||
|
.arg("--help")
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute g3 --help");
|
||||||
|
|
||||||
|
// Help should succeed
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"g3 --help should exit successfully"
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
||||||
|
// Should contain key elements of help output
|
||||||
|
assert!(
|
||||||
|
stdout.contains("Usage:"),
|
||||||
|
"Help output should contain 'Usage:'"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stdout.contains("Options:"),
|
||||||
|
"Help output should contain 'Options:'"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stdout.contains("--help"),
|
||||||
|
"Help output should mention --help flag"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stdout.contains("--version"),
|
||||||
|
"Help output should mention --version flag"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_short_help_flag() {
|
||||||
|
let output = Command::new(get_g3_binary())
|
||||||
|
.arg("-h")
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute g3 -h");
|
||||||
|
|
||||||
|
assert!(output.status.success(), "g3 -h should exit successfully");
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(
|
||||||
|
stdout.contains("Usage:"),
|
||||||
|
"Short help should also show usage"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: --version flag produces version output
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_version_flag_produces_output() {
|
||||||
|
let output = Command::new(get_g3_binary())
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute g3 --version");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"g3 --version should exit successfully"
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
||||||
|
// Should contain version number pattern (e.g., "g3 0.1.0")
|
||||||
|
assert!(
|
||||||
|
stdout.contains("g3") || stdout.contains("0."),
|
||||||
|
"Version output should contain program name or version number"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_short_version_flag() {
|
||||||
|
let output = Command::new(get_g3_binary())
|
||||||
|
.arg("-V")
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute g3 -V");
|
||||||
|
|
||||||
|
assert!(output.status.success(), "g3 -V should exit successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: Invalid arguments produce errors
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_flag_produces_error() {
|
||||||
|
let output = Command::new(get_g3_binary())
|
||||||
|
.arg("--this-flag-does-not-exist")
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute g3 with invalid flag");
|
||||||
|
|
||||||
|
// Should fail with non-zero exit code
|
||||||
|
assert!(
|
||||||
|
!output.status.success(),
|
||||||
|
"Invalid flag should cause non-zero exit"
|
||||||
|
);
|
||||||
|
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
// Should have some error message
|
||||||
|
assert!(
|
||||||
|
!stderr.is_empty() || !output.stdout.is_empty(),
|
||||||
|
"Should produce some output on invalid flag"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: Conflicting mode flags
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_agent_conflicts_with_autonomous() {
|
||||||
|
// --agent conflicts with --autonomous
|
||||||
|
let output = Command::new(get_g3_binary())
|
||||||
|
.args(["--agent", "test", "--autonomous"])
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute g3 with conflicting flags");
|
||||||
|
|
||||||
|
// Should fail due to conflicting arguments
|
||||||
|
assert!(
|
||||||
|
!output.status.success(),
|
||||||
|
"--agent and --autonomous should conflict"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_planning_conflicts_with_autonomous() {
|
||||||
|
let output = Command::new(get_g3_binary())
|
||||||
|
.args(["--planning", "--autonomous"])
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute g3 with conflicting flags");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!output.status.success(),
|
||||||
|
"--planning and --autonomous should conflict"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: Flock mode requires all related flags
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_flock_mode_requires_workspace() {
|
||||||
|
let output = Command::new(get_g3_binary())
|
||||||
|
.args(["--project", "/tmp/test"])
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute g3 with incomplete flock args");
|
||||||
|
|
||||||
|
// Should fail because --flock-workspace and --segments are required
|
||||||
|
assert!(
|
||||||
|
!output.status.success(),
|
||||||
|
"--project without --flock-workspace should fail"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_flock_mode_requires_segments() {
|
||||||
|
let output = Command::new(get_g3_binary())
|
||||||
|
.args(["--project", "/tmp/test", "--flock-workspace", "/tmp/ws"])
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute g3 with incomplete flock args");
|
||||||
|
|
||||||
|
// Should fail because --segments is required
|
||||||
|
assert!(
|
||||||
|
!output.status.success(),
|
||||||
|
"--project without --segments should fail"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: Workspace directory option is accepted
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_workspace_option_accepted() {
|
||||||
|
// Just verify the option is recognized (don't actually run the agent)
|
||||||
|
let output = Command::new(get_g3_binary())
|
||||||
|
.args(["--workspace", "/tmp", "--help"])
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute g3 with workspace option");
|
||||||
|
|
||||||
|
// --help should still work even with other options
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"--workspace option should be recognized"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: Config file option is accepted
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_option_accepted() {
|
||||||
|
let output = Command::new(get_g3_binary())
|
||||||
|
.args(["--config", "/nonexistent/config.toml", "--help"])
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute g3 with config option");
|
||||||
|
|
||||||
|
// --help should still work
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"--config option should be recognized"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: Provider override option is accepted
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_option_accepted() {
|
||||||
|
let output = Command::new(get_g3_binary())
|
||||||
|
.args(["--provider", "anthropic", "--help"])
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute g3 with provider option");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"--provider option should be recognized"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: Quiet mode option is accepted
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quiet_option_accepted() {
|
||||||
|
let output = Command::new(get_g3_binary())
|
||||||
|
.args(["--quiet", "--help"])
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute g3 with quiet option");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"--quiet option should be recognized"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: Machine mode option is accepted
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_machine_option_accepted() {
|
||||||
|
let output = Command::new(get_g3_binary())
|
||||||
|
.args(["--machine", "--help"])
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute g3 with machine option");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"--machine option should be recognized"
|
||||||
|
);
|
||||||
|
}
|
||||||
428
crates/g3-core/tests/tool_execution_test.rs
Normal file
428
crates/g3-core/tests/tool_execution_test.rs
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
//! Tool Execution Integration Tests
|
||||||
|
//!
|
||||||
|
//! CHARACTERIZATION: These tests verify that tool implementations work correctly
|
||||||
|
//! through their public interfaces, testing input → output behavior.
|
||||||
|
//!
|
||||||
|
//! What these tests protect:
|
||||||
|
//! - File operations (read, write, str_replace) work correctly
|
||||||
|
//! - Shell command execution works
|
||||||
|
//! - TODO tool operations work
|
||||||
|
//! - Error handling for invalid inputs
|
||||||
|
//!
|
||||||
|
//! What these tests intentionally do NOT assert:
|
||||||
|
//! - Internal implementation details of tools
|
||||||
|
//! - Specific formatting of success messages (only key content)
|
||||||
|
//! - UI writer behavior (mocked)
|
||||||
|
|
||||||
|
use g3_core::ToolCall;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test Helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Create a ToolCall with the given tool name and arguments
|
||||||
|
fn make_tool_call(tool: &str, args: serde_json::Value) -> ToolCall {
|
||||||
|
ToolCall {
|
||||||
|
tool: tool.to_string(),
|
||||||
|
args,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a temporary directory with a test file
|
||||||
|
fn setup_test_dir() -> (TempDir, PathBuf) {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let test_file = temp_dir.path().join("test.txt");
|
||||||
|
fs::write(&test_file, "Hello, World!\nLine 2\nLine 3").expect("Failed to write test file");
|
||||||
|
(temp_dir, test_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: read_file tool
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
mod read_file_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_file_basic() {
|
||||||
|
let (temp_dir, test_file) = setup_test_dir();
|
||||||
|
|
||||||
|
let tool_call = make_tool_call(
|
||||||
|
"read_file",
|
||||||
|
json!({ "file_path": test_file.to_string_lossy() }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the tool call structure is correct
|
||||||
|
assert_eq!(tool_call.tool, "read_file");
|
||||||
|
assert!(tool_call.args.get("file_path").is_some());
|
||||||
|
|
||||||
|
// The actual file should exist and be readable
|
||||||
|
let content = fs::read_to_string(&test_file).unwrap();
|
||||||
|
assert!(content.contains("Hello, World!"));
|
||||||
|
|
||||||
|
drop(temp_dir); // Cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_file_with_range() {
|
||||||
|
let (_temp_dir, test_file) = setup_test_dir();
|
||||||
|
|
||||||
|
let tool_call = make_tool_call(
|
||||||
|
"read_file",
|
||||||
|
json!({
|
||||||
|
"file_path": test_file.to_string_lossy(),
|
||||||
|
"start": 0,
|
||||||
|
"end": 5
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify range parameters are captured
|
||||||
|
assert_eq!(tool_call.args.get("start").unwrap().as_u64(), Some(0));
|
||||||
|
assert_eq!(tool_call.args.get("end").unwrap().as_u64(), Some(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_file_missing_path_arg() {
|
||||||
|
let tool_call = make_tool_call("read_file", json!({}));
|
||||||
|
|
||||||
|
// Tool call should have no file_path
|
||||||
|
assert!(tool_call.args.get("file_path").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: write_file tool
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
mod write_file_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_write_file_creates_new_file() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let new_file = temp_dir.path().join("new_file.txt");
|
||||||
|
|
||||||
|
// File should not exist yet
|
||||||
|
assert!(!new_file.exists());
|
||||||
|
|
||||||
|
let tool_call = make_tool_call(
|
||||||
|
"write_file",
|
||||||
|
json!({
|
||||||
|
"file_path": new_file.to_string_lossy(),
|
||||||
|
"content": "New content here"
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(tool_call.tool, "write_file");
|
||||||
|
assert_eq!(
|
||||||
|
tool_call.args.get("content").unwrap().as_str(),
|
||||||
|
Some("New content here")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_write_file_overwrites_existing() {
|
||||||
|
let (temp_dir, test_file) = setup_test_dir();
|
||||||
|
|
||||||
|
// Original content
|
||||||
|
let original = fs::read_to_string(&test_file).unwrap();
|
||||||
|
assert!(original.contains("Hello, World!"));
|
||||||
|
|
||||||
|
let tool_call = make_tool_call(
|
||||||
|
"write_file",
|
||||||
|
json!({
|
||||||
|
"file_path": test_file.to_string_lossy(),
|
||||||
|
"content": "Completely new content"
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(tool_call.tool, "write_file");
|
||||||
|
|
||||||
|
drop(temp_dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: str_replace tool (unified diff)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
mod str_replace_tests {
|
||||||
|
use super::*;
|
||||||
|
use g3_core::apply_unified_diff_to_string;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_apply_simple_diff() {
|
||||||
|
let original = "line 1\nline 2\nline 3\n";
|
||||||
|
let diff = "@@ -1,3 +1,3 @@\n line 1\n-line 2\n+line 2 modified\n line 3\n";
|
||||||
|
|
||||||
|
let result = apply_unified_diff_to_string(original, diff, None, None);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let new_content = result.unwrap();
|
||||||
|
assert!(new_content.contains("line 2 modified"));
|
||||||
|
assert!(!new_content.contains("line 2\n") || new_content.contains("line 2 modified"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_apply_diff_add_lines() {
|
||||||
|
let original = "line 1\nline 3\n";
|
||||||
|
let diff = "@@ -1,2 +1,3 @@\n line 1\n+line 2\n line 3\n";
|
||||||
|
|
||||||
|
let result = apply_unified_diff_to_string(original, diff, None, None);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let new_content = result.unwrap();
|
||||||
|
assert!(new_content.contains("line 2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_apply_diff_remove_lines() {
|
||||||
|
let original = "line 1\nline 2\nline 3\n";
|
||||||
|
let diff = "@@ -1,3 +1,2 @@\n line 1\n-line 2\n line 3\n";
|
||||||
|
|
||||||
|
let result = apply_unified_diff_to_string(original, diff, None, None);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let new_content = result.unwrap();
|
||||||
|
// line 2 should be removed
|
||||||
|
let lines: Vec<&str> = new_content.lines().collect();
|
||||||
|
assert_eq!(lines.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_str_replace_tool_call_structure() {
|
||||||
|
let tool_call = make_tool_call(
|
||||||
|
"str_replace",
|
||||||
|
json!({
|
||||||
|
"file_path": "/path/to/file.txt",
|
||||||
|
"diff": "@@ -1,1 +1,1 @@\n-old\n+new\n"
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(tool_call.tool, "str_replace");
|
||||||
|
assert!(tool_call.args.get("file_path").is_some());
|
||||||
|
assert!(tool_call.args.get("diff").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_str_replace_with_range() {
|
||||||
|
let tool_call = make_tool_call(
|
||||||
|
"str_replace",
|
||||||
|
json!({
|
||||||
|
"file_path": "/path/to/file.txt",
|
||||||
|
"diff": "@@ -1,1 +1,1 @@\n-old\n+new\n",
|
||||||
|
"start": 100,
|
||||||
|
"end": 500
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(tool_call.args.get("start").unwrap().as_u64(), Some(100));
|
||||||
|
assert_eq!(tool_call.args.get("end").unwrap().as_u64(), Some(500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: shell tool
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
mod shell_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shell_tool_call_structure() {
|
||||||
|
let tool_call = make_tool_call(
|
||||||
|
"shell",
|
||||||
|
json!({ "command": "echo hello" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(tool_call.tool, "shell");
|
||||||
|
assert_eq!(
|
||||||
|
tool_call.args.get("command").unwrap().as_str(),
|
||||||
|
Some("echo hello")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shell_missing_command() {
|
||||||
|
let tool_call = make_tool_call("shell", json!({}));
|
||||||
|
|
||||||
|
assert!(tool_call.args.get("command").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: background_process tool
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
mod background_process_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_background_process_tool_call_structure() {
|
||||||
|
let tool_call = make_tool_call(
|
||||||
|
"background_process",
|
||||||
|
json!({
|
||||||
|
"name": "test_server",
|
||||||
|
"command": "python -m http.server 8000"
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(tool_call.tool, "background_process");
|
||||||
|
assert_eq!(
|
||||||
|
tool_call.args.get("name").unwrap().as_str(),
|
||||||
|
Some("test_server")
|
||||||
|
);
|
||||||
|
assert!(tool_call.args.get("command").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_background_process_with_working_dir() {
|
||||||
|
let tool_call = make_tool_call(
|
||||||
|
"background_process",
|
||||||
|
json!({
|
||||||
|
"name": "test_server",
|
||||||
|
"command": "python -m http.server",
|
||||||
|
"working_dir": "/tmp"
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
tool_call.args.get("working_dir").unwrap().as_str(),
|
||||||
|
Some("/tmp")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: todo_read and todo_write tools
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
mod todo_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_todo_read_tool_call() {
|
||||||
|
let tool_call = make_tool_call("todo_read", json!({}));
|
||||||
|
|
||||||
|
assert_eq!(tool_call.tool, "todo_read");
|
||||||
|
// todo_read takes no arguments
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_todo_write_tool_call() {
|
||||||
|
let tool_call = make_tool_call(
|
||||||
|
"todo_write",
|
||||||
|
json!({
|
||||||
|
"content": "- [ ] Task 1\n- [x] Task 2\n"
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(tool_call.tool, "todo_write");
|
||||||
|
assert!(tool_call.args.get("content").is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: final_output tool
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
mod final_output_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_final_output_tool_call() {
|
||||||
|
let tool_call = make_tool_call(
|
||||||
|
"final_output",
|
||||||
|
json!({
|
||||||
|
"summary": "Task completed successfully.\n\n## Changes Made\n- Added feature X"
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(tool_call.tool, "final_output");
|
||||||
|
assert!(tool_call.args.get("summary").is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: code_search tool
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
mod code_search_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_code_search_tool_call_structure() {
|
||||||
|
let tool_call = make_tool_call(
|
||||||
|
"code_search",
|
||||||
|
json!({
|
||||||
|
"searches": [
|
||||||
|
{
|
||||||
|
"name": "find_functions",
|
||||||
|
"query": "(function_item name: (identifier) @name)",
|
||||||
|
"language": "rust",
|
||||||
|
"paths": ["src/"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(tool_call.tool, "code_search");
|
||||||
|
assert!(tool_call.args.get("searches").is_some());
|
||||||
|
|
||||||
|
let searches = tool_call.args.get("searches").unwrap().as_array().unwrap();
|
||||||
|
assert_eq!(searches.len(), 1);
|
||||||
|
assert_eq!(searches[0].get("language").unwrap().as_str(), Some("rust"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_code_search_multiple_searches() {
|
||||||
|
let tool_call = make_tool_call(
|
||||||
|
"code_search",
|
||||||
|
json!({
|
||||||
|
"searches": [
|
||||||
|
{
|
||||||
|
"name": "functions",
|
||||||
|
"query": "(function_item name: (identifier) @name)",
|
||||||
|
"language": "rust"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "structs",
|
||||||
|
"query": "(struct_item name: (type_identifier) @name)",
|
||||||
|
"language": "rust"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"max_concurrency": 4
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let searches = tool_call.args.get("searches").unwrap().as_array().unwrap();
|
||||||
|
assert_eq!(searches.len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: take_screenshot tool
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
mod screenshot_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_screenshot_tool_call_structure() {
|
||||||
|
let tool_call = make_tool_call(
|
||||||
|
"take_screenshot",
|
||||||
|
json!({
|
||||||
|
"path": "screenshot.png",
|
||||||
|
"window_id": "Safari"
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(tool_call.tool, "take_screenshot");
|
||||||
|
assert_eq!(tool_call.args.get("path").unwrap().as_str(), Some("screenshot.png"));
|
||||||
|
assert_eq!(tool_call.args.get("window_id").unwrap().as_str(), Some("Safari"));
|
||||||
|
}
|
||||||
|
}
|
||||||
351
crates/g3-providers/tests/message_serialization_test.rs
Normal file
351
crates/g3-providers/tests/message_serialization_test.rs
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
//! Message Serialization Integration Tests
|
||||||
|
//!
|
||||||
|
//! CHARACTERIZATION: These tests verify that Message and related types
|
||||||
|
//! serialize and deserialize correctly, ensuring data integrity across
|
||||||
|
//! the system boundary (JSON API communication).
|
||||||
|
//!
|
||||||
|
//! What these tests protect:
|
||||||
|
//! - Message round-trip serialization/deserialization
|
||||||
|
//! - MessageRole enum serialization
|
||||||
|
//! - Cache control serialization
|
||||||
|
//! - Tool definitions serialization
|
||||||
|
//!
|
||||||
|
//! What these tests intentionally do NOT assert:
|
||||||
|
//! - Provider-specific API formats (tested elsewhere)
|
||||||
|
//! - Internal field ordering in JSON
|
||||||
|
//! - Whitespace or formatting in serialized output
|
||||||
|
|
||||||
|
use g3_providers::{Message, MessageRole, CacheControl, Tool};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Helper functions for comparison (types don't implement PartialEq)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn role_matches(role: &MessageRole, expected: &str) -> bool {
|
||||||
|
let json = serde_json::to_string(role).unwrap();
|
||||||
|
json.to_lowercase().contains(&expected.to_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_control_is_ephemeral(cc: &Option<CacheControl>) -> bool {
|
||||||
|
match cc {
|
||||||
|
Some(c) => {
|
||||||
|
let json = serde_json::to_string(c).unwrap();
|
||||||
|
json.contains("ephemeral")
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_control_is_five_minute(cc: &Option<CacheControl>) -> bool {
|
||||||
|
match cc {
|
||||||
|
Some(c) => {
|
||||||
|
let json = serde_json::to_string(c).unwrap();
|
||||||
|
// Check for "5m" TTL
|
||||||
|
json.contains("5m")
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_control_is_one_hour(cc: &Option<CacheControl>) -> bool {
|
||||||
|
match cc {
|
||||||
|
Some(c) => {
|
||||||
|
let json = serde_json::to_string(c).unwrap();
|
||||||
|
// Check for "1h" TTL
|
||||||
|
json.contains("1h")
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: Message round-trip serialization
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_roundtrip_user() {
|
||||||
|
let original = Message::new(MessageRole::User, "Hello, world!".to_string());
|
||||||
|
|
||||||
|
// Serialize to JSON
|
||||||
|
let json = serde_json::to_string(&original).expect("Failed to serialize");
|
||||||
|
|
||||||
|
// Deserialize back
|
||||||
|
let restored: Message = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert!(role_matches(&restored.role, "user"));
|
||||||
|
assert_eq!(restored.content, "Hello, world!");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_roundtrip_assistant() {
|
||||||
|
let original = Message::new(MessageRole::Assistant, "I can help with that.".to_string());
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&original).expect("Failed to serialize");
|
||||||
|
let restored: Message = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert!(role_matches(&restored.role, "assistant"));
|
||||||
|
assert_eq!(restored.content, "I can help with that.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_roundtrip_system() {
|
||||||
|
let original = Message::new(MessageRole::System, "You are a helpful assistant.".to_string());
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&original).expect("Failed to serialize");
|
||||||
|
let restored: Message = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert!(role_matches(&restored.role, "system"));
|
||||||
|
assert_eq!(restored.content, "You are a helpful assistant.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: Message with special characters
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_with_unicode() {
|
||||||
|
let content = "Hello 世界! 🌍 Привет мир!";
|
||||||
|
let original = Message::new(MessageRole::User, content.to_string());
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&original).expect("Failed to serialize");
|
||||||
|
let restored: Message = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(restored.content, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_with_newlines() {
|
||||||
|
let content = "Line 1\nLine 2\nLine 3";
|
||||||
|
let original = Message::new(MessageRole::User, content.to_string());
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&original).expect("Failed to serialize");
|
||||||
|
let restored: Message = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(restored.content, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_with_quotes() {
|
||||||
|
let content = r#"He said "Hello" and she replied 'Hi'"#;
|
||||||
|
let original = Message::new(MessageRole::User, content.to_string());
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&original).expect("Failed to serialize");
|
||||||
|
let restored: Message = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(restored.content, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_with_json_content() {
|
||||||
|
// Message containing JSON as content (common for tool calls)
|
||||||
|
let content = r#"{"tool": "shell", "args": {"command": "ls -la"}}"#;
|
||||||
|
let original = Message::new(MessageRole::Assistant, content.to_string());
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&original).expect("Failed to serialize");
|
||||||
|
let restored: Message = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(restored.content, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: MessageRole serialization
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_role_user_serialization() {
|
||||||
|
let role = MessageRole::User;
|
||||||
|
let json = serde_json::to_string(&role).expect("Failed to serialize");
|
||||||
|
|
||||||
|
// Should serialize to lowercase "user"
|
||||||
|
assert!(json.to_lowercase().contains("user"));
|
||||||
|
|
||||||
|
let restored: MessageRole = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
assert!(role_matches(&restored, "user"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_role_assistant_serialization() {
|
||||||
|
let role = MessageRole::Assistant;
|
||||||
|
let json = serde_json::to_string(&role).expect("Failed to serialize");
|
||||||
|
|
||||||
|
let restored: MessageRole = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
assert!(role_matches(&restored, "assistant"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_role_system_serialization() {
|
||||||
|
let role = MessageRole::System;
|
||||||
|
let json = serde_json::to_string(&role).expect("Failed to serialize");
|
||||||
|
|
||||||
|
let restored: MessageRole = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
assert!(role_matches(&restored, "system"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: Message with cache control
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_with_ephemeral_cache() {
|
||||||
|
let mut msg = Message::new(MessageRole::User, "Cached content".to_string());
|
||||||
|
msg.cache_control = Some(CacheControl::ephemeral());
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&msg).expect("Failed to serialize");
|
||||||
|
let restored: Message = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert!(cache_control_is_ephemeral(&restored.cache_control));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_with_five_minute_cache() {
|
||||||
|
let mut msg = Message::new(MessageRole::System, "System prompt".to_string());
|
||||||
|
msg.cache_control = Some(CacheControl::five_minute());
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&msg).expect("Failed to serialize");
|
||||||
|
let restored: Message = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert!(cache_control_is_five_minute(&restored.cache_control));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_with_one_hour_cache() {
|
||||||
|
let mut msg = Message::new(MessageRole::System, "Long-lived content".to_string());
|
||||||
|
msg.cache_control = Some(CacheControl::one_hour());
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&msg).expect("Failed to serialize");
|
||||||
|
let restored: Message = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert!(cache_control_is_one_hour(&restored.cache_control));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_without_cache_control() {
|
||||||
|
let msg = Message::new(MessageRole::User, "No cache".to_string());
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&msg).expect("Failed to serialize");
|
||||||
|
let restored: Message = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert!(restored.cache_control.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: Tool definition serialization
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tool_roundtrip() {
|
||||||
|
let tool = Tool {
|
||||||
|
name: "shell".to_string(),
|
||||||
|
description: "Execute shell commands".to_string(),
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The command to execute"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["command"]
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&tool).expect("Failed to serialize");
|
||||||
|
let restored: Tool = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(restored.name, "shell");
|
||||||
|
assert_eq!(restored.description, "Execute shell commands");
|
||||||
|
assert!(restored.input_schema.get("properties").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tool_with_complex_schema() {
|
||||||
|
let tool = Tool {
|
||||||
|
name: "code_search".to_string(),
|
||||||
|
description: "Search code using tree-sitter".to_string(),
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"searches": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"query": { "type": "string" },
|
||||||
|
"language": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["name", "query", "language"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["searches"]
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&tool).expect("Failed to serialize");
|
||||||
|
let restored: Tool = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(restored.name, "code_search");
|
||||||
|
|
||||||
|
// Verify nested schema structure is preserved
|
||||||
|
let searches = restored.input_schema
|
||||||
|
.get("properties")
|
||||||
|
.and_then(|p| p.get("searches"))
|
||||||
|
.and_then(|s| s.get("items"));
|
||||||
|
assert!(searches.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: Empty and edge cases
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_with_empty_content() {
|
||||||
|
let original = Message::new(MessageRole::User, "".to_string());
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&original).expect("Failed to serialize");
|
||||||
|
let restored: Message = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(restored.content, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_with_very_long_content() {
|
||||||
|
// Simulate a large file content or long conversation
|
||||||
|
let content = "x".repeat(100_000);
|
||||||
|
let original = Message::new(MessageRole::Assistant, content.clone());
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&original).expect("Failed to serialize");
|
||||||
|
let restored: Message = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(restored.content.len(), 100_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test: Deserialization from external JSON format
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_from_api_format() {
|
||||||
|
// Simulate JSON that might come from an API response
|
||||||
|
let json = r#"{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Here is my response."
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let msg: Message = serde_json::from_str(json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert!(role_matches(&msg.role, "assistant"));
|
||||||
|
assert_eq!(msg.content, "Here is my response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_user_message_from_api() {
|
||||||
|
let json = r#"{"role": "user", "content": "What is 2+2?"}"#;
|
||||||
|
|
||||||
|
let msg: Message = serde_json::from_str(json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert!(role_matches(&msg.role, "user"));
|
||||||
|
assert_eq!(msg.content, "What is 2+2?");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user