mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Move config type tests into fabro-config
Tests for types defined in fabro-config (HookEvent, HookDefinition, HookConfig, McpServerConfig, McpTransport, etc.) now live alongside their definitions rather than in the downstream re-exporting crates. Tests for types that remain in fabro-hooks (HookContext, HookDecision, PromptHookResponse) stay in fabro-hooks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
98e8a9b7f6
commit
6ebbad800d
7 changed files with 623 additions and 621 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1395,6 +1395,7 @@ dependencies = [
|
|||
"dirs",
|
||||
"fabro-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"strsim",
|
||||
"tempfile",
|
||||
"toml",
|
||||
|
|
|
|||
|
|
@ -24,4 +24,6 @@ toml.workspace = true
|
|||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
tempfile = "3"
|
||||
toml.workspace = true
|
||||
|
|
|
|||
|
|
@ -228,3 +228,533 @@ impl HookConfig {
|
|||
Self { hooks }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hook_event_serde_round_trip() {
|
||||
let events = [
|
||||
HookEvent::RunStart,
|
||||
HookEvent::RunComplete,
|
||||
HookEvent::RunFailed,
|
||||
HookEvent::StageStart,
|
||||
HookEvent::StageComplete,
|
||||
HookEvent::StageFailed,
|
||||
HookEvent::StageRetrying,
|
||||
HookEvent::EdgeSelected,
|
||||
HookEvent::ParallelStart,
|
||||
HookEvent::ParallelComplete,
|
||||
HookEvent::SandboxReady,
|
||||
HookEvent::SandboxCleanup,
|
||||
HookEvent::CheckpointSaved,
|
||||
HookEvent::PreToolUse,
|
||||
HookEvent::PostToolUse,
|
||||
HookEvent::PostToolUseFailure,
|
||||
];
|
||||
for event in events {
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let back: HookEvent = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(event, back);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_event_serializes_as_snake_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&HookEvent::RunStart).unwrap(),
|
||||
"\"run_start\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&HookEvent::StageRetrying).unwrap(),
|
||||
"\"stage_retrying\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_event_display() {
|
||||
assert_eq!(HookEvent::RunStart.to_string(), "run_start");
|
||||
assert_eq!(HookEvent::CheckpointSaved.to_string(), "checkpoint_saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_event_blocking_defaults() {
|
||||
assert!(HookEvent::RunStart.is_blocking_by_default());
|
||||
assert!(HookEvent::StageStart.is_blocking_by_default());
|
||||
assert!(HookEvent::EdgeSelected.is_blocking_by_default());
|
||||
assert!(HookEvent::SandboxReady.is_blocking_by_default());
|
||||
assert!(!HookEvent::SandboxCleanup.is_blocking_by_default());
|
||||
assert!(!HookEvent::RunComplete.is_blocking_by_default());
|
||||
assert!(!HookEvent::StageFailed.is_blocking_by_default());
|
||||
assert!(!HookEvent::CheckpointSaved.is_blocking_by_default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_serde_round_trip() {
|
||||
let json = serde_json::to_string(&HookEvent::PreToolUse).unwrap();
|
||||
assert_eq!(json, "\"pre_tool_use\"");
|
||||
let back: HookEvent = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, HookEvent::PreToolUse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_is_blocking_by_default() {
|
||||
assert!(HookEvent::PreToolUse.is_blocking_by_default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_tool_use_is_not_blocking_by_default() {
|
||||
assert!(!HookEvent::PostToolUse.is_blocking_by_default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_tool_use_failure_is_not_blocking_by_default() {
|
||||
assert!(!HookEvent::PostToolUseFailure.is_blocking_by_default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_command_shorthand() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "stage_start"
|
||||
command = "./scripts/pre-check.sh"
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
assert_eq!(config.hooks.len(), 1);
|
||||
let hook = &config.hooks[0];
|
||||
assert_eq!(hook.event, HookEvent::StageStart);
|
||||
assert_eq!(hook.command.as_deref(), Some("./scripts/pre-check.sh"));
|
||||
let resolved = hook.resolved_hook_type().unwrap();
|
||||
assert!(
|
||||
matches!(&*resolved, HookType::Command { command } if command == "./scripts/pre-check.sh")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_explicit_command_type() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "run_start"
|
||||
type = "command"
|
||||
command = "echo hello"
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
assert_eq!(config.hooks.len(), 1);
|
||||
let hook = &config.hooks[0];
|
||||
assert_eq!(hook.event, HookEvent::RunStart);
|
||||
assert!(hook.resolved_hook_type().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_http_hook() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "run_complete"
|
||||
type = "http"
|
||||
url = "https://hooks.example.com/done"
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
let hook = &config.hooks[0];
|
||||
assert!(matches!(
|
||||
hook.resolved_hook_type().as_deref(),
|
||||
Some(HookType::Http { url, .. }) if url == "https://hooks.example.com/done"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_http_hook_with_allowed_env_vars() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "run_start"
|
||||
type = "http"
|
||||
url = "https://hooks.example.com/start"
|
||||
allowed_env_vars = ["API_KEY", "SECRET"]
|
||||
|
||||
[hooks.headers]
|
||||
Authorization = "Bearer $API_KEY"
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
let hook = &config.hooks[0];
|
||||
match &*hook.resolved_hook_type().unwrap() {
|
||||
HookType::Http {
|
||||
url,
|
||||
headers,
|
||||
allowed_env_vars,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(url, "https://hooks.example.com/start");
|
||||
assert_eq!(allowed_env_vars, &["API_KEY", "SECRET"]);
|
||||
assert_eq!(
|
||||
headers.as_ref().unwrap().get("Authorization").unwrap(),
|
||||
"Bearer $API_KEY"
|
||||
);
|
||||
}
|
||||
_ => panic!("expected Http hook type"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_http_hook_allowed_env_vars_defaults_empty() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "run_complete"
|
||||
type = "http"
|
||||
url = "https://hooks.example.com/done"
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
let hook = &config.hooks[0];
|
||||
match &*hook.resolved_hook_type().unwrap() {
|
||||
HookType::Http {
|
||||
allowed_env_vars, ..
|
||||
} => {
|
||||
assert!(allowed_env_vars.is_empty());
|
||||
}
|
||||
_ => panic!("expected Http hook type"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_full_hook_definition() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
name = "pre-check"
|
||||
event = "stage_start"
|
||||
command = "./check.sh"
|
||||
matcher = "agent_loop"
|
||||
blocking = true
|
||||
timeout_ms = 30000
|
||||
sandbox = false
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
let hook = &config.hooks[0];
|
||||
assert_eq!(hook.name.as_deref(), Some("pre-check"));
|
||||
assert_eq!(hook.event, HookEvent::StageStart);
|
||||
assert_eq!(hook.matcher.as_deref(), Some("agent_loop"));
|
||||
assert!(hook.is_blocking());
|
||||
assert_eq!(hook.timeout(), std::time::Duration::from_millis(30_000));
|
||||
assert!(!hook.runs_in_sandbox());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_defaults_to_event() {
|
||||
let blocking_def = HookDefinition {
|
||||
name: None,
|
||||
event: HookEvent::StageStart,
|
||||
command: Some("echo".into()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
};
|
||||
assert!(blocking_def.is_blocking());
|
||||
|
||||
let non_blocking_def = HookDefinition {
|
||||
event: HookEvent::StageComplete,
|
||||
..blocking_def.clone()
|
||||
};
|
||||
assert!(!non_blocking_def.is_blocking());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_override() {
|
||||
let def = HookDefinition {
|
||||
name: None,
|
||||
event: HookEvent::StageComplete,
|
||||
command: Some("echo".into()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: Some(true),
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
};
|
||||
assert!(def.is_blocking());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_defaults_to_60s() {
|
||||
let def = HookDefinition {
|
||||
name: None,
|
||||
event: HookEvent::RunStart,
|
||||
command: Some("echo".into()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
};
|
||||
assert_eq!(def.timeout(), std::time::Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_defaults_to_true() {
|
||||
let def = HookDefinition {
|
||||
name: None,
|
||||
event: HookEvent::RunStart,
|
||||
command: Some("echo".into()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
};
|
||||
assert!(def.runs_in_sandbox());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_name_uses_explicit() {
|
||||
let def = HookDefinition {
|
||||
name: Some("my-hook".into()),
|
||||
event: HookEvent::RunStart,
|
||||
command: Some("echo hi".into()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
};
|
||||
assert_eq!(def.effective_name(), "my-hook");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_name_generated_from_event_and_command() {
|
||||
let def = HookDefinition {
|
||||
name: None,
|
||||
event: HookEvent::RunStart,
|
||||
command: Some("echo hi".into()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
};
|
||||
assert_eq!(def.effective_name(), "run_start:echo hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_merge_concatenates() {
|
||||
let a = HookConfig {
|
||||
hooks: vec![HookDefinition {
|
||||
name: Some("hook-a".into()),
|
||||
event: HookEvent::RunStart,
|
||||
command: Some("echo a".into()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
}],
|
||||
};
|
||||
let b = HookConfig {
|
||||
hooks: vec![HookDefinition {
|
||||
name: Some("hook-b".into()),
|
||||
event: HookEvent::RunComplete,
|
||||
command: Some("echo b".into()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
}],
|
||||
};
|
||||
let merged = a.merge(b);
|
||||
assert_eq!(merged.hooks.len(), 2);
|
||||
assert_eq!(merged.hooks[0].name.as_deref(), Some("hook-a"));
|
||||
assert_eq!(merged.hooks[1].name.as_deref(), Some("hook-b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_merge_name_collision_later_wins() {
|
||||
let a = HookConfig {
|
||||
hooks: vec![HookDefinition {
|
||||
name: Some("shared".into()),
|
||||
event: HookEvent::RunStart,
|
||||
command: Some("echo a".into()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
}],
|
||||
};
|
||||
let b = HookConfig {
|
||||
hooks: vec![HookDefinition {
|
||||
name: Some("shared".into()),
|
||||
event: HookEvent::RunComplete,
|
||||
command: Some("echo b".into()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
}],
|
||||
};
|
||||
let merged = a.merge(b);
|
||||
assert_eq!(merged.hooks.len(), 1);
|
||||
assert_eq!(merged.hooks[0].event, HookEvent::RunComplete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_http_hook_tls_defaults_to_verify() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "run_complete"
|
||||
type = "http"
|
||||
url = "https://hooks.example.com/done"
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
let hook = &config.hooks[0];
|
||||
match &*hook.resolved_hook_type().unwrap() {
|
||||
HookType::Http { tls, .. } => assert_eq!(*tls, TlsMode::Verify),
|
||||
_ => panic!("expected Http hook type"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_http_hook_tls_no_verify() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "run_complete"
|
||||
type = "http"
|
||||
url = "https://hooks.example.com/done"
|
||||
tls = "no_verify"
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
let hook = &config.hooks[0];
|
||||
match &*hook.resolved_hook_type().unwrap() {
|
||||
HookType::Http { tls, .. } => assert_eq!(*tls, TlsMode::NoVerify),
|
||||
_ => panic!("expected Http hook type"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_http_hook_tls_off() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "run_complete"
|
||||
type = "http"
|
||||
url = "http://localhost:8080/done"
|
||||
tls = "off"
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
let hook = &config.hooks[0];
|
||||
match &*hook.resolved_hook_type().unwrap() {
|
||||
HookType::Http { tls, .. } => assert_eq!(*tls, TlsMode::Off),
|
||||
_ => panic!("expected Http hook type"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_prompt_hook() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "stage_start"
|
||||
type = "prompt"
|
||||
prompt = "Should this stage proceed?"
|
||||
model = "haiku"
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
let hook = &config.hooks[0];
|
||||
assert!(matches!(
|
||||
hook.resolved_hook_type().as_deref(),
|
||||
Some(HookType::Prompt { prompt, model })
|
||||
if prompt == "Should this stage proceed?" && *model == Some("haiku".into())
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_agent_hook() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "run_complete"
|
||||
type = "agent"
|
||||
prompt = "Verify tests pass."
|
||||
model = "sonnet"
|
||||
max_tool_rounds = 10
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
let hook = &config.hooks[0];
|
||||
assert!(matches!(
|
||||
hook.resolved_hook_type().as_deref(),
|
||||
Some(HookType::Agent { prompt, model, max_tool_rounds })
|
||||
if prompt == "Verify tests pass."
|
||||
&& *model == Some("sonnet".into())
|
||||
&& *max_tool_rounds == Some(10)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_hook_default_timeout_30s() {
|
||||
let def = HookDefinition {
|
||||
name: None,
|
||||
event: HookEvent::RunStart,
|
||||
command: None,
|
||||
hook_type: Some(HookType::Prompt {
|
||||
prompt: "check".into(),
|
||||
model: None,
|
||||
}),
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
};
|
||||
assert_eq!(def.timeout(), std::time::Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_hook_default_timeout_60s() {
|
||||
let def = HookDefinition {
|
||||
name: None,
|
||||
event: HookEvent::RunStart,
|
||||
command: None,
|
||||
hook_type: Some(HookType::Agent {
|
||||
prompt: "check".into(),
|
||||
model: None,
|
||||
max_tool_rounds: None,
|
||||
}),
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
};
|
||||
assert_eq!(def.timeout(), std::time::Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_name_generated_from_prompt_hook() {
|
||||
let def = HookDefinition {
|
||||
name: None,
|
||||
event: HookEvent::StageStart,
|
||||
command: None,
|
||||
hook_type: Some(HookType::Prompt {
|
||||
prompt: "Should this stage proceed?".into(),
|
||||
model: None,
|
||||
}),
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
};
|
||||
assert!(def.effective_name().starts_with("stage_start:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multiple_hooks() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "run_start"
|
||||
command = "echo start"
|
||||
|
||||
[[hooks]]
|
||||
event = "stage_complete"
|
||||
command = "echo done"
|
||||
matcher = "agent_loop"
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
assert_eq!(config.hooks.len(), 2);
|
||||
assert_eq!(config.hooks[0].event, HookEvent::RunStart);
|
||||
assert_eq!(config.hooks[1].event, HookEvent::StageComplete);
|
||||
assert_eq!(config.hooks[1].matcher.as_deref(), Some("agent_loop"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,3 +80,93 @@ impl McpServerEntry {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn stdio_config_construction() {
|
||||
let config = McpServerConfig {
|
||||
name: "test-server".into(),
|
||||
transport: McpTransport::Stdio {
|
||||
command: vec![
|
||||
"npx".into(),
|
||||
"-y".into(),
|
||||
"@modelcontextprotocol/server-filesystem".into(),
|
||||
],
|
||||
env: HashMap::new(),
|
||||
},
|
||||
startup_timeout_secs: 10,
|
||||
tool_timeout_secs: 60,
|
||||
};
|
||||
assert_eq!(config.name, "test-server");
|
||||
assert_eq!(config.startup_timeout(), Duration::from_secs(10));
|
||||
assert_eq!(config.tool_timeout(), Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_config_construction() {
|
||||
let config = McpServerConfig {
|
||||
name: "remote-server".into(),
|
||||
transport: McpTransport::Http {
|
||||
url: "https://example.com/mcp".into(),
|
||||
headers: HashMap::from([("Authorization".into(), "Bearer token".into())]),
|
||||
},
|
||||
startup_timeout_secs: 30,
|
||||
tool_timeout_secs: 60,
|
||||
};
|
||||
assert_eq!(config.name, "remote-server");
|
||||
assert_eq!(config.startup_timeout(), Duration::from_secs(30));
|
||||
assert_eq!(config.tool_timeout(), Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip_stdio() {
|
||||
let config = McpServerConfig {
|
||||
name: "fs".into(),
|
||||
transport: McpTransport::Stdio {
|
||||
command: vec!["node".into(), "server.js".into()],
|
||||
env: HashMap::from([("NODE_ENV".into(), "production".into())]),
|
||||
},
|
||||
startup_timeout_secs: 15,
|
||||
tool_timeout_secs: 90,
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let deserialized: McpServerConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.name, "fs");
|
||||
assert_eq!(deserialized.startup_timeout_secs, 15);
|
||||
assert_eq!(deserialized.tool_timeout_secs, 90);
|
||||
assert!(
|
||||
matches!(deserialized.transport, McpTransport::Stdio { command, .. } if command == vec!["node", "server.js"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip_http() {
|
||||
let config = McpServerConfig {
|
||||
name: "remote".into(),
|
||||
transport: McpTransport::Http {
|
||||
url: "https://mcp.example.com".into(),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
startup_timeout_secs: 10,
|
||||
tool_timeout_secs: 60,
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let deserialized: McpServerConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.name, "remote");
|
||||
assert!(
|
||||
matches!(deserialized.transport, McpTransport::Http { url, .. } if url == "https://mcp.example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_defaults_applied() {
|
||||
let json = r#"{"name":"minimal","transport":{"type":"stdio","command":["echo"]}}"#;
|
||||
let config: McpServerConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.startup_timeout_secs, 10);
|
||||
assert_eq!(config.tool_timeout_secs, 60);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,451 +1 @@
|
|||
pub use fabro_config::hook::{HookConfig, HookDefinition, HookEvent, HookType, TlsMode};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_command_shorthand() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "stage_start"
|
||||
command = "./scripts/pre-check.sh"
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
assert_eq!(config.hooks.len(), 1);
|
||||
let hook = &config.hooks[0];
|
||||
assert_eq!(hook.event, HookEvent::StageStart);
|
||||
assert_eq!(hook.command.as_deref(), Some("./scripts/pre-check.sh"));
|
||||
let resolved = hook.resolved_hook_type().unwrap();
|
||||
assert!(
|
||||
matches!(&*resolved, HookType::Command { command } if command == "./scripts/pre-check.sh")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_explicit_command_type() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "run_start"
|
||||
type = "command"
|
||||
command = "echo hello"
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
assert_eq!(config.hooks.len(), 1);
|
||||
let hook = &config.hooks[0];
|
||||
assert_eq!(hook.event, HookEvent::RunStart);
|
||||
assert!(hook.resolved_hook_type().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_http_hook() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "run_complete"
|
||||
type = "http"
|
||||
url = "https://hooks.example.com/done"
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
let hook = &config.hooks[0];
|
||||
assert!(matches!(
|
||||
hook.resolved_hook_type().as_deref(),
|
||||
Some(HookType::Http { url, .. }) if url == "https://hooks.example.com/done"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_http_hook_with_allowed_env_vars() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "run_start"
|
||||
type = "http"
|
||||
url = "https://hooks.example.com/start"
|
||||
allowed_env_vars = ["API_KEY", "SECRET"]
|
||||
|
||||
[hooks.headers]
|
||||
Authorization = "Bearer $API_KEY"
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
let hook = &config.hooks[0];
|
||||
match &*hook.resolved_hook_type().unwrap() {
|
||||
HookType::Http {
|
||||
url,
|
||||
headers,
|
||||
allowed_env_vars,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(url, "https://hooks.example.com/start");
|
||||
assert_eq!(allowed_env_vars, &["API_KEY", "SECRET"]);
|
||||
assert_eq!(
|
||||
headers.as_ref().unwrap().get("Authorization").unwrap(),
|
||||
"Bearer $API_KEY"
|
||||
);
|
||||
}
|
||||
_ => panic!("expected Http hook type"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_http_hook_allowed_env_vars_defaults_empty() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "run_complete"
|
||||
type = "http"
|
||||
url = "https://hooks.example.com/done"
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
let hook = &config.hooks[0];
|
||||
match &*hook.resolved_hook_type().unwrap() {
|
||||
HookType::Http {
|
||||
allowed_env_vars, ..
|
||||
} => {
|
||||
assert!(allowed_env_vars.is_empty());
|
||||
}
|
||||
_ => panic!("expected Http hook type"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_full_hook_definition() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
name = "pre-check"
|
||||
event = "stage_start"
|
||||
command = "./check.sh"
|
||||
matcher = "agent_loop"
|
||||
blocking = true
|
||||
timeout_ms = 30000
|
||||
sandbox = false
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
let hook = &config.hooks[0];
|
||||
assert_eq!(hook.name.as_deref(), Some("pre-check"));
|
||||
assert_eq!(hook.event, HookEvent::StageStart);
|
||||
assert_eq!(hook.matcher.as_deref(), Some("agent_loop"));
|
||||
assert!(hook.is_blocking());
|
||||
assert_eq!(hook.timeout(), std::time::Duration::from_millis(30_000));
|
||||
assert!(!hook.runs_in_sandbox());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_defaults_to_event() {
|
||||
let blocking_def = HookDefinition {
|
||||
name: None,
|
||||
event: HookEvent::StageStart,
|
||||
command: Some("echo".into()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
};
|
||||
assert!(blocking_def.is_blocking());
|
||||
|
||||
let non_blocking_def = HookDefinition {
|
||||
event: HookEvent::StageComplete,
|
||||
..blocking_def.clone()
|
||||
};
|
||||
assert!(!non_blocking_def.is_blocking());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_override() {
|
||||
let def = HookDefinition {
|
||||
name: None,
|
||||
event: HookEvent::StageComplete,
|
||||
command: Some("echo".into()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: Some(true),
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
};
|
||||
assert!(def.is_blocking());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_defaults_to_60s() {
|
||||
let def = HookDefinition {
|
||||
name: None,
|
||||
event: HookEvent::RunStart,
|
||||
command: Some("echo".into()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
};
|
||||
assert_eq!(def.timeout(), std::time::Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_defaults_to_true() {
|
||||
let def = HookDefinition {
|
||||
name: None,
|
||||
event: HookEvent::RunStart,
|
||||
command: Some("echo".into()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
};
|
||||
assert!(def.runs_in_sandbox());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_name_uses_explicit() {
|
||||
let def = HookDefinition {
|
||||
name: Some("my-hook".into()),
|
||||
event: HookEvent::RunStart,
|
||||
command: Some("echo hi".into()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
};
|
||||
assert_eq!(def.effective_name(), "my-hook");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_name_generated_from_event_and_command() {
|
||||
let def = HookDefinition {
|
||||
name: None,
|
||||
event: HookEvent::RunStart,
|
||||
command: Some("echo hi".into()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
};
|
||||
assert_eq!(def.effective_name(), "run_start:echo hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_merge_concatenates() {
|
||||
let a = HookConfig {
|
||||
hooks: vec![HookDefinition {
|
||||
name: Some("hook-a".into()),
|
||||
event: HookEvent::RunStart,
|
||||
command: Some("echo a".into()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
}],
|
||||
};
|
||||
let b = HookConfig {
|
||||
hooks: vec![HookDefinition {
|
||||
name: Some("hook-b".into()),
|
||||
event: HookEvent::RunComplete,
|
||||
command: Some("echo b".into()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
}],
|
||||
};
|
||||
let merged = a.merge(b);
|
||||
assert_eq!(merged.hooks.len(), 2);
|
||||
assert_eq!(merged.hooks[0].name.as_deref(), Some("hook-a"));
|
||||
assert_eq!(merged.hooks[1].name.as_deref(), Some("hook-b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_merge_name_collision_later_wins() {
|
||||
let a = HookConfig {
|
||||
hooks: vec![HookDefinition {
|
||||
name: Some("shared".into()),
|
||||
event: HookEvent::RunStart,
|
||||
command: Some("echo a".into()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
}],
|
||||
};
|
||||
let b = HookConfig {
|
||||
hooks: vec![HookDefinition {
|
||||
name: Some("shared".into()),
|
||||
event: HookEvent::RunComplete,
|
||||
command: Some("echo b".into()),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
}],
|
||||
};
|
||||
let merged = a.merge(b);
|
||||
assert_eq!(merged.hooks.len(), 1);
|
||||
assert_eq!(merged.hooks[0].event, HookEvent::RunComplete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_http_hook_tls_defaults_to_verify() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "run_complete"
|
||||
type = "http"
|
||||
url = "https://hooks.example.com/done"
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
let hook = &config.hooks[0];
|
||||
match &*hook.resolved_hook_type().unwrap() {
|
||||
HookType::Http { tls, .. } => assert_eq!(*tls, TlsMode::Verify),
|
||||
_ => panic!("expected Http hook type"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_http_hook_tls_no_verify() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "run_complete"
|
||||
type = "http"
|
||||
url = "https://hooks.example.com/done"
|
||||
tls = "no_verify"
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
let hook = &config.hooks[0];
|
||||
match &*hook.resolved_hook_type().unwrap() {
|
||||
HookType::Http { tls, .. } => assert_eq!(*tls, TlsMode::NoVerify),
|
||||
_ => panic!("expected Http hook type"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_http_hook_tls_off() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "run_complete"
|
||||
type = "http"
|
||||
url = "http://localhost:8080/done"
|
||||
tls = "off"
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
let hook = &config.hooks[0];
|
||||
match &*hook.resolved_hook_type().unwrap() {
|
||||
HookType::Http { tls, .. } => assert_eq!(*tls, TlsMode::Off),
|
||||
_ => panic!("expected Http hook type"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_prompt_hook() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "stage_start"
|
||||
type = "prompt"
|
||||
prompt = "Should this stage proceed?"
|
||||
model = "haiku"
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
let hook = &config.hooks[0];
|
||||
assert!(matches!(
|
||||
hook.resolved_hook_type().as_deref(),
|
||||
Some(HookType::Prompt { prompt, model })
|
||||
if prompt == "Should this stage proceed?" && *model == Some("haiku".into())
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_agent_hook() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "run_complete"
|
||||
type = "agent"
|
||||
prompt = "Verify tests pass."
|
||||
model = "sonnet"
|
||||
max_tool_rounds = 10
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
let hook = &config.hooks[0];
|
||||
assert!(matches!(
|
||||
hook.resolved_hook_type().as_deref(),
|
||||
Some(HookType::Agent { prompt, model, max_tool_rounds })
|
||||
if prompt == "Verify tests pass."
|
||||
&& *model == Some("sonnet".into())
|
||||
&& *max_tool_rounds == Some(10)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_hook_default_timeout_30s() {
|
||||
let def = HookDefinition {
|
||||
name: None,
|
||||
event: HookEvent::RunStart,
|
||||
command: None,
|
||||
hook_type: Some(HookType::Prompt {
|
||||
prompt: "check".into(),
|
||||
model: None,
|
||||
}),
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
};
|
||||
assert_eq!(def.timeout(), std::time::Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_hook_default_timeout_60s() {
|
||||
let def = HookDefinition {
|
||||
name: None,
|
||||
event: HookEvent::RunStart,
|
||||
command: None,
|
||||
hook_type: Some(HookType::Agent {
|
||||
prompt: "check".into(),
|
||||
model: None,
|
||||
max_tool_rounds: None,
|
||||
}),
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
};
|
||||
assert_eq!(def.timeout(), std::time::Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_name_generated_from_prompt_hook() {
|
||||
let def = HookDefinition {
|
||||
name: None,
|
||||
event: HookEvent::StageStart,
|
||||
command: None,
|
||||
hook_type: Some(HookType::Prompt {
|
||||
prompt: "Should this stage proceed?".into(),
|
||||
model: None,
|
||||
}),
|
||||
matcher: None,
|
||||
blocking: None,
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
};
|
||||
assert!(def.effective_name().starts_with("stage_start:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multiple_hooks() {
|
||||
let toml = r#"
|
||||
[[hooks]]
|
||||
event = "run_start"
|
||||
command = "echo start"
|
||||
|
||||
[[hooks]]
|
||||
event = "stage_complete"
|
||||
command = "echo done"
|
||||
matcher = "agent_loop"
|
||||
"#;
|
||||
let config: HookConfig = toml::from_str(toml).unwrap();
|
||||
assert_eq!(config.hooks.len(), 2);
|
||||
assert_eq!(config.hooks[0].event, HookEvent::RunStart);
|
||||
assert_eq!(config.hooks[1].event, HookEvent::StageComplete);
|
||||
assert_eq!(config.hooks[1].matcher.as_deref(), Some("agent_loop"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,63 +127,6 @@ pub struct HookResult {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hook_event_serde_round_trip() {
|
||||
let events = [
|
||||
HookEvent::RunStart,
|
||||
HookEvent::RunComplete,
|
||||
HookEvent::RunFailed,
|
||||
HookEvent::StageStart,
|
||||
HookEvent::StageComplete,
|
||||
HookEvent::StageFailed,
|
||||
HookEvent::StageRetrying,
|
||||
HookEvent::EdgeSelected,
|
||||
HookEvent::ParallelStart,
|
||||
HookEvent::ParallelComplete,
|
||||
HookEvent::SandboxReady,
|
||||
HookEvent::SandboxCleanup,
|
||||
HookEvent::CheckpointSaved,
|
||||
HookEvent::PreToolUse,
|
||||
HookEvent::PostToolUse,
|
||||
HookEvent::PostToolUseFailure,
|
||||
];
|
||||
for event in events {
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let back: HookEvent = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(event, back);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_event_serializes_as_snake_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&HookEvent::RunStart).unwrap(),
|
||||
"\"run_start\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&HookEvent::StageRetrying).unwrap(),
|
||||
"\"stage_retrying\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_event_display() {
|
||||
assert_eq!(HookEvent::RunStart.to_string(), "run_start");
|
||||
assert_eq!(HookEvent::CheckpointSaved.to_string(), "checkpoint_saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_event_blocking_defaults() {
|
||||
assert!(HookEvent::RunStart.is_blocking_by_default());
|
||||
assert!(HookEvent::StageStart.is_blocking_by_default());
|
||||
assert!(HookEvent::EdgeSelected.is_blocking_by_default());
|
||||
assert!(HookEvent::SandboxReady.is_blocking_by_default());
|
||||
assert!(!HookEvent::SandboxCleanup.is_blocking_by_default());
|
||||
assert!(!HookEvent::RunComplete.is_blocking_by_default());
|
||||
assert!(!HookEvent::StageFailed.is_blocking_by_default());
|
||||
assert!(!HookEvent::CheckpointSaved.is_blocking_by_default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_context_serde_round_trip() {
|
||||
let ctx = HookContext {
|
||||
|
|
@ -313,29 +256,6 @@ mod tests {
|
|||
assert_eq!(resp.reason.as_deref(), Some("not ready"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_serde_round_trip() {
|
||||
let json = serde_json::to_string(&HookEvent::PreToolUse).unwrap();
|
||||
assert_eq!(json, "\"pre_tool_use\"");
|
||||
let back: HookEvent = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, HookEvent::PreToolUse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_is_blocking_by_default() {
|
||||
assert!(HookEvent::PreToolUse.is_blocking_by_default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_tool_use_is_not_blocking_by_default() {
|
||||
assert!(!HookEvent::PostToolUse.is_blocking_by_default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_tool_use_failure_is_not_blocking_by_default() {
|
||||
assert!(!HookEvent::PostToolUseFailure.is_blocking_by_default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_context_with_tool_fields() {
|
||||
let mut ctx = HookContext::new(HookEvent::PreToolUse, "run-1".into(), "wf".into());
|
||||
|
|
|
|||
|
|
@ -2,94 +2,3 @@ pub use fabro_config::mcp::{
|
|||
default_startup_timeout_secs, default_tool_timeout_secs, McpServerConfig, McpServerEntry,
|
||||
McpTransport,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn stdio_config_construction() {
|
||||
let config = McpServerConfig {
|
||||
name: "test-server".into(),
|
||||
transport: McpTransport::Stdio {
|
||||
command: vec![
|
||||
"npx".into(),
|
||||
"-y".into(),
|
||||
"@modelcontextprotocol/server-filesystem".into(),
|
||||
],
|
||||
env: HashMap::new(),
|
||||
},
|
||||
startup_timeout_secs: 10,
|
||||
tool_timeout_secs: 60,
|
||||
};
|
||||
assert_eq!(config.name, "test-server");
|
||||
assert_eq!(config.startup_timeout(), Duration::from_secs(10));
|
||||
assert_eq!(config.tool_timeout(), Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_config_construction() {
|
||||
let config = McpServerConfig {
|
||||
name: "remote-server".into(),
|
||||
transport: McpTransport::Http {
|
||||
url: "https://example.com/mcp".into(),
|
||||
headers: HashMap::from([("Authorization".into(), "Bearer token".into())]),
|
||||
},
|
||||
startup_timeout_secs: 30,
|
||||
tool_timeout_secs: 60,
|
||||
};
|
||||
assert_eq!(config.name, "remote-server");
|
||||
assert_eq!(config.startup_timeout(), Duration::from_secs(30));
|
||||
assert_eq!(config.tool_timeout(), Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip_stdio() {
|
||||
let config = McpServerConfig {
|
||||
name: "fs".into(),
|
||||
transport: McpTransport::Stdio {
|
||||
command: vec!["node".into(), "server.js".into()],
|
||||
env: HashMap::from([("NODE_ENV".into(), "production".into())]),
|
||||
},
|
||||
startup_timeout_secs: 15,
|
||||
tool_timeout_secs: 90,
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let deserialized: McpServerConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.name, "fs");
|
||||
assert_eq!(deserialized.startup_timeout_secs, 15);
|
||||
assert_eq!(deserialized.tool_timeout_secs, 90);
|
||||
assert!(
|
||||
matches!(deserialized.transport, McpTransport::Stdio { command, .. } if command == vec!["node", "server.js"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip_http() {
|
||||
let config = McpServerConfig {
|
||||
name: "remote".into(),
|
||||
transport: McpTransport::Http {
|
||||
url: "https://mcp.example.com".into(),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
startup_timeout_secs: 10,
|
||||
tool_timeout_secs: 60,
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let deserialized: McpServerConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.name, "remote");
|
||||
assert!(
|
||||
matches!(deserialized.transport, McpTransport::Http { url, .. } if url == "https://mcp.example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_defaults_applied() {
|
||||
let json = r#"{"name":"minimal","transport":{"type":"stdio","command":["echo"]}}"#;
|
||||
let config: McpServerConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.startup_timeout_secs, 10);
|
||||
assert_eq!(config.tool_timeout_secs, 60);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue