feat: refactor sgclaw around zeroclaw compat runtime
This commit is contained in:
252
third_party/zeroclaw/tests/component/config_persistence.rs
vendored
Normal file
252
third_party/zeroclaw/tests/component/config_persistence.rs
vendored
Normal file
@@ -0,0 +1,252 @@
|
||||
//! TG2: Config Load/Save Round-Trip Tests
|
||||
//!
|
||||
//! Prevents: Pattern 2 — Config persistence & workspace discovery bugs (13% of user bugs).
|
||||
//! Issues: #547, #417, #621, #802
|
||||
//!
|
||||
//! Tests Config::load_or_init() with isolated temp directories, env var overrides,
|
||||
//! and config file round-trips to verify workspace discovery and persistence.
|
||||
|
||||
use std::fs;
|
||||
use zeroclaw::config::{AgentConfig, Config, MemoryConfig};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Config default construction
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn config_default_has_expected_provider() {
|
||||
let config = Config::default();
|
||||
assert!(
|
||||
config.default_provider.is_some(),
|
||||
"default config should have a default_provider"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_default_has_expected_model() {
|
||||
let config = Config::default();
|
||||
assert!(
|
||||
config.default_model.is_some(),
|
||||
"default config should have a default_model"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_default_temperature_positive() {
|
||||
let config = Config::default();
|
||||
assert!(
|
||||
config.default_temperature > 0.0,
|
||||
"default temperature should be positive"
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AgentConfig defaults
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn agent_config_default_max_tool_iterations() {
|
||||
let agent = AgentConfig::default();
|
||||
assert_eq!(
|
||||
agent.max_tool_iterations, 10,
|
||||
"default max_tool_iterations should be 10"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_config_default_max_history_messages() {
|
||||
let agent = AgentConfig::default();
|
||||
assert_eq!(
|
||||
agent.max_history_messages, 50,
|
||||
"default max_history_messages should be 50"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_config_default_tool_dispatcher() {
|
||||
let agent = AgentConfig::default();
|
||||
assert_eq!(
|
||||
agent.tool_dispatcher, "auto",
|
||||
"default tool_dispatcher should be 'auto'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_config_default_compact_context_on() {
|
||||
let agent = AgentConfig::default();
|
||||
assert!(
|
||||
agent.compact_context,
|
||||
"compact_context should default to true"
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MemoryConfig defaults
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn memory_config_default_backend() {
|
||||
let memory = MemoryConfig::default();
|
||||
assert!(
|
||||
!memory.backend.is_empty(),
|
||||
"memory backend should have a default value"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_config_default_embedding_provider() {
|
||||
let memory = MemoryConfig::default();
|
||||
// Default embedding_provider should be set (even if "none")
|
||||
assert!(
|
||||
!memory.embedding_provider.is_empty(),
|
||||
"embedding_provider should have a default value"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_config_default_vector_keyword_weights_sum_to_one() {
|
||||
let memory = MemoryConfig::default();
|
||||
let sum = memory.vector_weight + memory.keyword_weight;
|
||||
assert!(
|
||||
(sum - 1.0).abs() < 0.01,
|
||||
"vector_weight + keyword_weight should sum to ~1.0, got {sum}"
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Config TOML serialization round-trip
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn config_toml_roundtrip_preserves_provider() {
|
||||
let config = Config {
|
||||
default_provider: Some("deepseek".into()),
|
||||
default_model: Some("deepseek-chat".into()),
|
||||
default_temperature: 0.5,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let toml_str = toml::to_string(&config).expect("config should serialize to TOML");
|
||||
let parsed: Config = toml::from_str(&toml_str).expect("TOML should deserialize back");
|
||||
|
||||
assert_eq!(parsed.default_provider.as_deref(), Some("deepseek"));
|
||||
assert_eq!(parsed.default_model.as_deref(), Some("deepseek-chat"));
|
||||
assert!((parsed.default_temperature - 0.5).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_toml_roundtrip_preserves_agent_config() {
|
||||
let mut config = Config::default();
|
||||
config.agent.max_tool_iterations = 5;
|
||||
config.agent.max_history_messages = 25;
|
||||
config.agent.compact_context = true;
|
||||
|
||||
let toml_str = toml::to_string(&config).expect("config should serialize to TOML");
|
||||
let parsed: Config = toml::from_str(&toml_str).expect("TOML should deserialize back");
|
||||
|
||||
assert_eq!(parsed.agent.max_tool_iterations, 5);
|
||||
assert_eq!(parsed.agent.max_history_messages, 25);
|
||||
assert!(parsed.agent.compact_context);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_toml_roundtrip_preserves_memory_config() {
|
||||
let mut config = Config::default();
|
||||
config.memory.embedding_provider = "openai".into();
|
||||
config.memory.embedding_model = "text-embedding-3-small".into();
|
||||
config.memory.vector_weight = 0.8;
|
||||
config.memory.keyword_weight = 0.2;
|
||||
|
||||
let toml_str = toml::to_string(&config).expect("config should serialize to TOML");
|
||||
let parsed: Config = toml::from_str(&toml_str).expect("TOML should deserialize back");
|
||||
|
||||
assert_eq!(parsed.memory.embedding_provider, "openai");
|
||||
assert_eq!(parsed.memory.embedding_model, "text-embedding-3-small");
|
||||
assert!((parsed.memory.vector_weight - 0.8).abs() < f64::EPSILON);
|
||||
assert!((parsed.memory.keyword_weight - 0.2).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Config file write/read round-trip with tempdir
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn config_file_write_read_roundtrip() {
|
||||
let tmp = tempfile::TempDir::new().expect("tempdir creation should succeed");
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
|
||||
let mut config = Config {
|
||||
default_provider: Some("mistral".into()),
|
||||
default_model: Some("mistral-large".into()),
|
||||
..Default::default()
|
||||
};
|
||||
config.agent.max_tool_iterations = 15;
|
||||
|
||||
let toml_str = toml::to_string(&config).expect("config should serialize");
|
||||
fs::write(&config_path, &toml_str).expect("config file write should succeed");
|
||||
|
||||
let read_back = fs::read_to_string(&config_path).expect("config file read should succeed");
|
||||
let parsed: Config = toml::from_str(&read_back).expect("TOML should parse back");
|
||||
|
||||
assert_eq!(parsed.default_provider.as_deref(), Some("mistral"));
|
||||
assert_eq!(parsed.default_model.as_deref(), Some("mistral-large"));
|
||||
assert_eq!(parsed.agent.max_tool_iterations, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_file_with_missing_optional_fields_uses_defaults() {
|
||||
// Simulate a minimal config TOML that omits optional sections
|
||||
let minimal_toml = r#"
|
||||
default_temperature = 0.7
|
||||
"#;
|
||||
let parsed: Config = toml::from_str(minimal_toml).expect("minimal TOML should parse");
|
||||
|
||||
// Agent config should use defaults
|
||||
assert_eq!(parsed.agent.max_tool_iterations, 10);
|
||||
assert_eq!(parsed.agent.max_history_messages, 50);
|
||||
assert!(parsed.agent.compact_context);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_file_with_custom_agent_section() {
|
||||
let toml_with_agent = r#"
|
||||
default_temperature = 0.7
|
||||
|
||||
[agent]
|
||||
max_tool_iterations = 3
|
||||
compact_context = true
|
||||
"#;
|
||||
let parsed: Config =
|
||||
toml::from_str(toml_with_agent).expect("TOML with agent section should parse");
|
||||
|
||||
assert_eq!(parsed.agent.max_tool_iterations, 3);
|
||||
assert!(parsed.agent.compact_context);
|
||||
// max_history_messages should still use default
|
||||
assert_eq!(parsed.agent.max_history_messages, 50);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Workspace directory creation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn workspace_dir_creation_in_tempdir() {
|
||||
let tmp = tempfile::TempDir::new().expect("tempdir creation should succeed");
|
||||
let workspace_dir = tmp.path().join("workspace");
|
||||
|
||||
fs::create_dir_all(&workspace_dir).expect("workspace dir creation should succeed");
|
||||
assert!(workspace_dir.exists(), "workspace dir should exist");
|
||||
assert!(
|
||||
workspace_dir.is_dir(),
|
||||
"workspace path should be a directory"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_workspace_dir_creation() {
|
||||
let tmp = tempfile::TempDir::new().expect("tempdir creation should succeed");
|
||||
let nested_dir = tmp.path().join("deep").join("nested").join("workspace");
|
||||
|
||||
fs::create_dir_all(&nested_dir).expect("nested dir creation should succeed");
|
||||
assert!(nested_dir.exists(), "nested workspace dir should exist");
|
||||
}
|
||||
522
third_party/zeroclaw/tests/component/config_schema.rs
vendored
Normal file
522
third_party/zeroclaw/tests/component/config_schema.rs
vendored
Normal file
@@ -0,0 +1,522 @@
|
||||
//! Config Schema Boundary Tests
|
||||
//!
|
||||
//! Validates: config defaults, backward compatibility, invalid input rejection,
|
||||
//! and gateway/security/agent config boundary conditions.
|
||||
|
||||
use zeroclaw::config::{AutonomyConfig, ChannelsConfig, Config, GatewayConfig, SecurityConfig};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Invalid value fail-fast
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn config_unknown_keys_parse_without_error() {
|
||||
let toml_str = r#"
|
||||
default_temperature = 0.7
|
||||
totally_unknown_key = "should be ignored"
|
||||
another_fake = 42
|
||||
"#;
|
||||
let parsed: Config = toml::from_str(toml_str).expect("unknown keys should be ignored");
|
||||
assert!((parsed.default_temperature - 0.7).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_wrong_type_for_port_fails() {
|
||||
let toml_str = r#"
|
||||
[gateway]
|
||||
port = "not_a_number"
|
||||
"#;
|
||||
let result: Result<Config, _> = toml::from_str(toml_str);
|
||||
assert!(result.is_err(), "string for u16 port should fail to parse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_wrong_type_for_temperature_fails() {
|
||||
let toml_str = r#"
|
||||
default_temperature = "hot"
|
||||
"#;
|
||||
let result: Result<Config, _> = toml::from_str(toml_str);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"string for f64 temperature should fail to parse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_out_of_range_temperature_fails() {
|
||||
let toml_str = "default_temperature = 99.0\n";
|
||||
let result: Result<Config, _> = toml::from_str(toml_str);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"temperature 99.0 should be rejected at deserialization"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_negative_temperature_fails() {
|
||||
let toml_str = "default_temperature = -0.5\n";
|
||||
let result: Result<Config, _> = toml::from_str(toml_str);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"negative temperature should be rejected at deserialization"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_negative_port_fails() {
|
||||
let toml_str = r#"
|
||||
[gateway]
|
||||
port = -1
|
||||
"#;
|
||||
let result: Result<Config, _> = toml::from_str(toml_str);
|
||||
assert!(result.is_err(), "negative port should fail for u16");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_overflow_port_fails() {
|
||||
let toml_str = r#"
|
||||
[gateway]
|
||||
port = 99999
|
||||
"#;
|
||||
let result: Result<Config, _> = toml::from_str(toml_str);
|
||||
assert!(result.is_err(), "port > 65535 should fail for u16");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GatewayConfig boundary tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn gateway_config_defaults_are_secure() {
|
||||
let gw = GatewayConfig::default();
|
||||
assert_eq!(gw.port, 42617);
|
||||
assert_eq!(gw.host, "127.0.0.1");
|
||||
assert!(gw.require_pairing, "pairing should be required by default");
|
||||
assert!(
|
||||
!gw.allow_public_bind,
|
||||
"public bind should be denied by default"
|
||||
);
|
||||
assert!(
|
||||
!gw.trust_forwarded_headers,
|
||||
"forwarded headers should be untrusted by default"
|
||||
);
|
||||
assert!(
|
||||
gw.path_prefix.is_none(),
|
||||
"path_prefix should default to None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_config_rate_limit_defaults() {
|
||||
let gw = GatewayConfig::default();
|
||||
assert_eq!(gw.pair_rate_limit_per_minute, 10);
|
||||
assert_eq!(gw.webhook_rate_limit_per_minute, 60);
|
||||
assert_eq!(gw.rate_limit_max_keys, 10_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_config_idempotency_defaults() {
|
||||
let gw = GatewayConfig::default();
|
||||
assert_eq!(gw.idempotency_ttl_secs, 300);
|
||||
assert_eq!(gw.idempotency_max_keys, 10_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_config_toml_roundtrip() {
|
||||
let gw = GatewayConfig {
|
||||
port: 8080,
|
||||
host: "0.0.0.0".into(),
|
||||
require_pairing: false,
|
||||
pair_rate_limit_per_minute: 5,
|
||||
path_prefix: Some("/zeroclaw".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let toml_str = toml::to_string(&gw).expect("gateway config should serialize");
|
||||
let parsed: GatewayConfig = toml::from_str(&toml_str).expect("should deserialize back");
|
||||
|
||||
assert_eq!(parsed.port, 8080);
|
||||
assert_eq!(parsed.host, "0.0.0.0");
|
||||
assert!(!parsed.require_pairing);
|
||||
assert_eq!(parsed.pair_rate_limit_per_minute, 5);
|
||||
assert_eq!(parsed.path_prefix.as_deref(), Some("/zeroclaw"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_config_missing_section_uses_defaults() {
|
||||
let toml_str = r#"
|
||||
default_temperature = 0.5
|
||||
"#;
|
||||
let parsed: Config = toml::from_str(toml_str).expect("missing gateway section should parse");
|
||||
assert_eq!(parsed.gateway.port, 42617);
|
||||
assert_eq!(parsed.gateway.host, "127.0.0.1");
|
||||
assert!(parsed.gateway.require_pairing);
|
||||
assert!(!parsed.gateway.allow_public_bind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_config_partial_section_fills_defaults() {
|
||||
let toml_str = r#"
|
||||
default_temperature = 0.7
|
||||
|
||||
[gateway]
|
||||
port = 9090
|
||||
"#;
|
||||
let parsed: Config = toml::from_str(toml_str).expect("partial gateway should parse");
|
||||
assert_eq!(parsed.gateway.port, 9090);
|
||||
assert_eq!(parsed.gateway.host, "127.0.0.1");
|
||||
assert!(parsed.gateway.require_pairing);
|
||||
assert_eq!(parsed.gateway.pair_rate_limit_per_minute, 10);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GatewayConfig path_prefix validation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn gateway_path_prefix_rejects_missing_leading_slash() {
|
||||
let mut config = Config::default();
|
||||
config.gateway.path_prefix = Some("zeroclaw".into());
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("must start with '/'"),
|
||||
"expected leading-slash error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_path_prefix_rejects_trailing_slash() {
|
||||
let mut config = Config::default();
|
||||
config.gateway.path_prefix = Some("/zeroclaw/".into());
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("must not end with '/'"),
|
||||
"expected trailing-slash error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_path_prefix_rejects_bare_slash() {
|
||||
let mut config = Config::default();
|
||||
config.gateway.path_prefix = Some("/".into());
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("must not end with '/'"),
|
||||
"expected bare-slash error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_path_prefix_accepts_valid_prefixes() {
|
||||
for prefix in ["/zeroclaw", "/apps/zeroclaw", "/api/hassio_ingress/abc123"] {
|
||||
let mut config = Config::default();
|
||||
config.gateway.path_prefix = Some(prefix.into());
|
||||
config
|
||||
.validate()
|
||||
.unwrap_or_else(|e| panic!("prefix {prefix:?} should be valid, got: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_path_prefix_rejects_unsafe_characters() {
|
||||
for prefix in [
|
||||
"/zero claw",
|
||||
"/zero<claw",
|
||||
"/zero>claw",
|
||||
"/zero\"claw",
|
||||
"/zero?query",
|
||||
"/zero#frag",
|
||||
] {
|
||||
let mut config = Config::default();
|
||||
config.gateway.path_prefix = Some(prefix.into());
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("invalid character"),
|
||||
"prefix {prefix:?} should be rejected, got: {err}"
|
||||
);
|
||||
}
|
||||
// Leading/trailing whitespace is rejected by the starts_with('/') or
|
||||
// invalid-character check — either way it must not pass validation.
|
||||
for prefix in [" /zeroclaw ", " /zeroclaw"] {
|
||||
let mut config = Config::default();
|
||||
config.gateway.path_prefix = Some(prefix.into());
|
||||
assert!(
|
||||
config.validate().is_err(),
|
||||
"whitespace-padded prefix {prefix:?} should be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_path_prefix_accepts_none() {
|
||||
let config = Config::default();
|
||||
assert!(config.gateway.path_prefix.is_none());
|
||||
config
|
||||
.validate()
|
||||
.expect("absent path_prefix should be valid");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SecurityConfig boundary tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn security_config_defaults() {
|
||||
let sec = SecurityConfig::default();
|
||||
assert!(
|
||||
sec.sandbox.enabled.is_none(),
|
||||
"sandbox enabled should auto-detect (None) by default"
|
||||
);
|
||||
assert!(sec.audit.enabled, "audit should be enabled by default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn security_config_toml_roundtrip() {
|
||||
let mut sec = SecurityConfig::default();
|
||||
sec.sandbox.enabled = Some(true);
|
||||
sec.audit.max_size_mb = 200;
|
||||
|
||||
let toml_str = toml::to_string(&sec).expect("SecurityConfig should serialize");
|
||||
let parsed: SecurityConfig = toml::from_str(&toml_str).expect("should deserialize back");
|
||||
|
||||
assert_eq!(parsed.sandbox.enabled, Some(true));
|
||||
assert_eq!(parsed.audit.max_size_mb, 200);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AutonomyConfig boundary tests (security policy via Config.autonomy)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn autonomy_config_default_is_supervised() {
|
||||
let autonomy = AutonomyConfig::default();
|
||||
assert_eq!(
|
||||
format!("{:?}", autonomy.level),
|
||||
"Supervised",
|
||||
"default autonomy should be Supervised"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomy_config_default_max_actions_per_hour() {
|
||||
let autonomy = AutonomyConfig::default();
|
||||
assert!(
|
||||
autonomy.max_actions_per_hour > 0,
|
||||
"max_actions_per_hour should be positive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomy_config_default_workspace_only() {
|
||||
let autonomy = AutonomyConfig::default();
|
||||
assert!(
|
||||
autonomy.workspace_only,
|
||||
"workspace_only should default to true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomy_config_toml_roundtrip() {
|
||||
let mut config = Config::default();
|
||||
config.autonomy.max_actions_per_hour = 50;
|
||||
config.autonomy.workspace_only = false;
|
||||
|
||||
let toml_str = toml::to_string(&config).expect("config should serialize");
|
||||
let parsed: Config = toml::from_str(&toml_str).expect("should deserialize back");
|
||||
|
||||
assert_eq!(parsed.autonomy.max_actions_per_hour, 50);
|
||||
assert!(!parsed.autonomy.workspace_only);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Backward compatibility
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn config_empty_toml_uses_default_temperature() {
|
||||
let result: Result<Config, _> = toml::from_str("");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"empty TOML should succeed and use default temperature"
|
||||
);
|
||||
let config = result.unwrap();
|
||||
assert!((config.default_temperature - 0.7).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_minimal_toml_with_temperature_uses_defaults() {
|
||||
let toml_str = "default_temperature = 0.7\n";
|
||||
let parsed: Config = toml::from_str(toml_str).expect("minimal TOML should parse");
|
||||
assert_eq!(parsed.agent.max_tool_iterations, 10);
|
||||
assert_eq!(parsed.gateway.port, 42617);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_only_temperature_parses() {
|
||||
let toml_str = "default_temperature = 1.2\n";
|
||||
let parsed: Config = toml::from_str(toml_str).expect("temperature-only TOML should parse");
|
||||
assert!((parsed.default_temperature - 1.2).abs() < f64::EPSILON);
|
||||
assert_eq!(parsed.agent.max_tool_iterations, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_extra_unknown_keys_ignored() {
|
||||
let toml_str = r#"
|
||||
default_temperature = 0.5
|
||||
future_feature = true
|
||||
[some_future_section]
|
||||
value = 123
|
||||
"#;
|
||||
let parsed: Config =
|
||||
toml::from_str(toml_str).expect("unknown keys and sections should be ignored");
|
||||
assert!((parsed.default_temperature - 0.5).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Config merging edge cases
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn config_multiple_channels_coexist() {
|
||||
let toml_str = r#"
|
||||
default_temperature = 0.7
|
||||
|
||||
[channels_config.telegram]
|
||||
bot_token = "test_token"
|
||||
allowed_users = ["zeroclaw_user"]
|
||||
|
||||
[channels_config.discord]
|
||||
bot_token = "test_token"
|
||||
"#;
|
||||
let parsed: Config = toml::from_str(toml_str).expect("multi-channel config should parse");
|
||||
assert!(parsed.channels_config.telegram.is_some());
|
||||
assert!(parsed.channels_config.discord.is_some());
|
||||
assert!(parsed.channels_config.slack.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_nested_optional_sections_default_when_absent() {
|
||||
let toml_str = "default_temperature = 0.7\n";
|
||||
let parsed: Config = toml::from_str(toml_str).expect("minimal TOML should parse");
|
||||
assert!(parsed.channels_config.telegram.is_none());
|
||||
assert!(!parsed.composio.enabled);
|
||||
assert!(parsed.composio.api_key.is_none());
|
||||
assert!(parsed.browser.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_channels_default_cli_enabled() {
|
||||
let channels = ChannelsConfig::default();
|
||||
assert!(channels.cli, "CLI channel should be enabled by default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_channels_all_optional_channels_none_by_default() {
|
||||
let channels = ChannelsConfig::default();
|
||||
assert!(channels.telegram.is_none());
|
||||
assert!(channels.discord.is_none());
|
||||
assert!(channels.slack.is_none());
|
||||
assert!(channels.matrix.is_none());
|
||||
assert!(channels.lark.is_none());
|
||||
assert!(channels.feishu.is_none());
|
||||
assert!(channels.webhook.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_memory_defaults_when_section_absent() {
|
||||
let toml_str = "default_temperature = 0.7\n";
|
||||
let parsed: Config = toml::from_str(toml_str).expect("minimal TOML should parse");
|
||||
let mem = &parsed.memory;
|
||||
assert!(!mem.backend.is_empty());
|
||||
assert!(!mem.embedding_provider.is_empty());
|
||||
let weight_sum = mem.vector_weight + mem.keyword_weight;
|
||||
assert!(
|
||||
(weight_sum - 1.0).abs() < 0.01,
|
||||
"vector + keyword weights should sum to ~1.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_channels_without_cli_field() {
|
||||
let toml_str = r#"
|
||||
default_temperature = 0.7
|
||||
|
||||
[channels_config.matrix]
|
||||
homeserver = "https://matrix.example.com"
|
||||
access_token = "syt_test_token"
|
||||
room_id = "!abc123:example.com"
|
||||
allowed_users = ["@user:example.com"]
|
||||
"#;
|
||||
let parsed: Config = toml::from_str(toml_str)
|
||||
.expect("channels_config with only a Matrix section (no explicit cli field) should parse");
|
||||
assert!(
|
||||
parsed.channels_config.cli,
|
||||
"cli should default to true when omitted"
|
||||
);
|
||||
assert!(parsed.channels_config.matrix.is_some());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Issue #3456 – top-level [cli] section must not clash with channels_config.cli
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn config_toplevel_cli_section_with_whatsapp_parses() {
|
||||
// Exact config from issue #3456
|
||||
let toml_str = r#"
|
||||
[cli]
|
||||
|
||||
[channels_config.whatsapp]
|
||||
session_path = "~/.zeroclaw/state/whatsapp-web/session.db"
|
||||
allowed_numbers = ["*"]
|
||||
"#;
|
||||
let parsed: Config = toml::from_str(toml_str)
|
||||
.expect("top-level [cli] section with [channels_config.whatsapp] should parse");
|
||||
assert!(parsed.channels_config.whatsapp.is_some());
|
||||
let wa = parsed.channels_config.whatsapp.unwrap();
|
||||
assert_eq!(
|
||||
wa.session_path.as_deref(),
|
||||
Some("~/.zeroclaw/state/whatsapp-web/session.db")
|
||||
);
|
||||
assert_eq!(wa.allowed_numbers, vec!["*".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_only_whatsapp_channel_parses() {
|
||||
let toml_str = r#"
|
||||
[channels_config.whatsapp]
|
||||
session_path = "~/.zeroclaw/state/whatsapp-web/session.db"
|
||||
allowed_numbers = ["*"]
|
||||
"#;
|
||||
let parsed: Config =
|
||||
toml::from_str(toml_str).expect("config with only whatsapp channel should parse");
|
||||
assert!(parsed.channels_config.whatsapp.is_some());
|
||||
assert!(
|
||||
parsed.channels_config.cli,
|
||||
"cli should default to true when omitted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_channels_explicit_cli_true_with_whatsapp() {
|
||||
let toml_str = r#"
|
||||
[channels_config]
|
||||
cli = true
|
||||
|
||||
[channels_config.whatsapp]
|
||||
session_path = "~/.zeroclaw/state/whatsapp-web/session.db"
|
||||
allowed_numbers = ["*"]
|
||||
"#;
|
||||
let parsed: Config = toml::from_str(toml_str)
|
||||
.expect("explicit channels_config.cli=true with whatsapp should parse");
|
||||
assert!(parsed.channels_config.cli);
|
||||
assert!(parsed.channels_config.whatsapp.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_empty_parses_with_all_defaults() {
|
||||
let parsed: Config = toml::from_str("").expect("empty config should parse with all defaults");
|
||||
assert!(parsed.channels_config.cli);
|
||||
assert!(parsed.channels_config.whatsapp.is_none());
|
||||
assert!((parsed.default_temperature - 0.7).abs() < f64::EPSILON);
|
||||
}
|
||||
344
third_party/zeroclaw/tests/component/dockerignore_test.rs
vendored
Normal file
344
third_party/zeroclaw/tests/component/dockerignore_test.rs
vendored
Normal file
@@ -0,0 +1,344 @@
|
||||
//! Tests to verify .dockerignore excludes sensitive paths from Docker build context.
|
||||
//!
|
||||
//! These tests validate that:
|
||||
//! 1. The .dockerignore file exists
|
||||
//! 2. All security-critical paths are excluded
|
||||
//! 3. All build-essential paths are NOT excluded
|
||||
//! 4. Pattern syntax is valid
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Paths that MUST be excluded from Docker build context (security/performance)
|
||||
const MUST_EXCLUDE: &[&str] = &[
|
||||
".git",
|
||||
".githooks",
|
||||
"target",
|
||||
"docs",
|
||||
"examples",
|
||||
"tests",
|
||||
"*.md",
|
||||
"*.png",
|
||||
"*.db",
|
||||
"*.db-journal",
|
||||
".DS_Store",
|
||||
".github",
|
||||
"deny.toml",
|
||||
"LICENSE",
|
||||
".env",
|
||||
".tmp_*",
|
||||
];
|
||||
|
||||
/// Paths that MUST NOT be excluded (required for build)
|
||||
const MUST_INCLUDE: &[&str] = &["Cargo.toml", "Cargo.lock", "src/"];
|
||||
|
||||
/// Parse .dockerignore and return all non-comment, non-empty lines
|
||||
fn parse_dockerignore(content: &str) -> Vec<String> {
|
||||
content
|
||||
.lines()
|
||||
.map(|line| line.trim())
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.map(|line| line.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check if a pattern would match a given path
|
||||
fn pattern_matches(pattern: &str, path: &str) -> bool {
|
||||
// Handle negation patterns
|
||||
if pattern.starts_with('!') {
|
||||
return false; // Negation re-includes, so it doesn't "exclude"
|
||||
}
|
||||
|
||||
// Handle glob patterns
|
||||
if pattern.starts_with("*.") {
|
||||
let ext = &pattern[1..]; // e.g., ".md"
|
||||
return path.ends_with(ext);
|
||||
}
|
||||
|
||||
// Handle directory patterns (with or without trailing slash)
|
||||
let pattern_normalized = pattern.trim_end_matches('/');
|
||||
let path_normalized = path.trim_end_matches('/');
|
||||
|
||||
// Exact match
|
||||
if path_normalized == pattern_normalized {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Pattern is a prefix (directory match)
|
||||
if path_normalized.starts_with(&format!("{}/", pattern_normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wildcard prefix patterns like ".tmp_*"
|
||||
if pattern.contains('*') && !pattern.starts_with("*.") {
|
||||
let prefix = pattern.split('*').next().unwrap_or("");
|
||||
if !prefix.is_empty() && path.starts_with(prefix) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if any pattern in the list would exclude the given path
|
||||
fn is_excluded(patterns: &[String], path: &str) -> bool {
|
||||
let mut excluded = false;
|
||||
for pattern in patterns {
|
||||
if let Some(negated) = pattern.strip_prefix('!') {
|
||||
// Negation pattern - re-include
|
||||
if pattern_matches(negated, path) {
|
||||
excluded = false;
|
||||
}
|
||||
} else if pattern_matches(pattern, path) {
|
||||
excluded = true;
|
||||
}
|
||||
}
|
||||
excluded
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dockerignore_file_exists() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
|
||||
assert!(
|
||||
path.exists(),
|
||||
".dockerignore file must exist at project root"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dockerignore_excludes_security_critical_paths() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
|
||||
let content = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.expect("Failed to read .dockerignore");
|
||||
let patterns = parse_dockerignore(&content);
|
||||
|
||||
for must_exclude in MUST_EXCLUDE {
|
||||
// For glob patterns, test with a sample file
|
||||
let test_path = if must_exclude.starts_with("*.") {
|
||||
format!("sample{}", &must_exclude[1..])
|
||||
} else {
|
||||
must_exclude.to_string()
|
||||
};
|
||||
|
||||
assert!(
|
||||
is_excluded(&patterns, &test_path),
|
||||
"Path '{}' (tested as '{}') MUST be excluded by .dockerignore but is not. \
|
||||
This is a security/performance issue.",
|
||||
must_exclude,
|
||||
test_path
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dockerignore_does_not_exclude_build_essentials() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
|
||||
let content = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.expect("Failed to read .dockerignore");
|
||||
let patterns = parse_dockerignore(&content);
|
||||
|
||||
for must_include in MUST_INCLUDE {
|
||||
assert!(
|
||||
!is_excluded(&patterns, must_include),
|
||||
"Path '{}' MUST NOT be excluded by .dockerignore (required for build)",
|
||||
must_include
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dockerignore_excludes_git_directory() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
|
||||
let content = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.expect("Failed to read .dockerignore");
|
||||
let patterns = parse_dockerignore(&content);
|
||||
|
||||
// .git directory and its contents must be excluded
|
||||
assert!(is_excluded(&patterns, ".git"), ".git must be excluded");
|
||||
assert!(
|
||||
is_excluded(&patterns, ".git/config"),
|
||||
".git/config must be excluded"
|
||||
);
|
||||
assert!(
|
||||
is_excluded(&patterns, ".git/objects/pack/pack-abc123.pack"),
|
||||
".git subdirectories must be excluded"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dockerignore_excludes_target_directory() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
|
||||
let content = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.expect("Failed to read .dockerignore");
|
||||
let patterns = parse_dockerignore(&content);
|
||||
|
||||
assert!(is_excluded(&patterns, "target"), "target must be excluded");
|
||||
assert!(
|
||||
is_excluded(&patterns, "target/debug/zeroclaw"),
|
||||
"target/debug must be excluded"
|
||||
);
|
||||
assert!(
|
||||
is_excluded(&patterns, "target/release/zeroclaw"),
|
||||
"target/release must be excluded"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dockerignore_excludes_database_files() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
|
||||
let content = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.expect("Failed to read .dockerignore");
|
||||
let patterns = parse_dockerignore(&content);
|
||||
|
||||
assert!(
|
||||
is_excluded(&patterns, "brain.db"),
|
||||
"*.db files must be excluded"
|
||||
);
|
||||
assert!(
|
||||
is_excluded(&patterns, "memory.db"),
|
||||
"*.db files must be excluded"
|
||||
);
|
||||
assert!(
|
||||
is_excluded(&patterns, "brain.db-journal"),
|
||||
"*.db-journal files must be excluded"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dockerignore_excludes_markdown_files() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
|
||||
let content = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.expect("Failed to read .dockerignore");
|
||||
let patterns = parse_dockerignore(&content);
|
||||
|
||||
assert!(
|
||||
is_excluded(&patterns, "README.md"),
|
||||
"*.md files must be excluded"
|
||||
);
|
||||
assert!(
|
||||
is_excluded(&patterns, "CHANGELOG.md"),
|
||||
"*.md files must be excluded"
|
||||
);
|
||||
assert!(
|
||||
is_excluded(&patterns, "CONTRIBUTING.md"),
|
||||
"*.md files must be excluded"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dockerignore_excludes_image_files() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
|
||||
let content = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.expect("Failed to read .dockerignore");
|
||||
let patterns = parse_dockerignore(&content);
|
||||
|
||||
assert!(
|
||||
is_excluded(&patterns, "zeroclaw.png"),
|
||||
"*.png files must be excluded"
|
||||
);
|
||||
assert!(
|
||||
is_excluded(&patterns, "logo.png"),
|
||||
"*.png files must be excluded"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dockerignore_excludes_env_files() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
|
||||
let content = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.expect("Failed to read .dockerignore");
|
||||
let patterns = parse_dockerignore(&content);
|
||||
|
||||
assert!(
|
||||
is_excluded(&patterns, ".env"),
|
||||
".env must be excluded (contains secrets)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dockerignore_excludes_ci_configs() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
|
||||
let content = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.expect("Failed to read .dockerignore");
|
||||
let patterns = parse_dockerignore(&content);
|
||||
|
||||
assert!(
|
||||
is_excluded(&patterns, ".github"),
|
||||
".github must be excluded"
|
||||
);
|
||||
assert!(
|
||||
is_excluded(&patterns, ".github/workflows/ci.yml"),
|
||||
".github/workflows must be excluded"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dockerignore_has_valid_syntax() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
|
||||
let content = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.expect("Failed to read .dockerignore");
|
||||
|
||||
for (line_num, line) in content.lines().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for invalid patterns
|
||||
assert!(
|
||||
!trimmed.contains("**") || trimmed.matches("**").count() <= 2,
|
||||
"Line {}: Too many ** in pattern '{}'",
|
||||
line_num + 1,
|
||||
trimmed
|
||||
);
|
||||
|
||||
// Check for trailing spaces (can cause issues)
|
||||
assert!(
|
||||
line.trim_end() == line.trim_start().trim_end(),
|
||||
"Line {}: Pattern '{}' has leading whitespace which may cause issues",
|
||||
line_num + 1,
|
||||
line
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dockerignore_pattern_matching_edge_cases() {
|
||||
// Test the pattern matching logic itself
|
||||
let patterns = vec![
|
||||
".git".to_string(),
|
||||
".githooks".to_string(),
|
||||
"target".to_string(),
|
||||
"*.md".to_string(),
|
||||
"*.db".to_string(),
|
||||
".tmp_*".to_string(),
|
||||
".env".to_string(),
|
||||
];
|
||||
|
||||
// Should match
|
||||
assert!(is_excluded(&patterns, ".git"));
|
||||
assert!(is_excluded(&patterns, ".git/config"));
|
||||
assert!(is_excluded(&patterns, ".githooks"));
|
||||
assert!(is_excluded(&patterns, "target"));
|
||||
assert!(is_excluded(&patterns, "target/debug/build"));
|
||||
assert!(is_excluded(&patterns, "README.md"));
|
||||
assert!(is_excluded(&patterns, "brain.db"));
|
||||
assert!(is_excluded(&patterns, ".env"));
|
||||
|
||||
// Should NOT match
|
||||
assert!(!is_excluded(&patterns, "src"));
|
||||
assert!(!is_excluded(&patterns, "src/main.rs"));
|
||||
assert!(!is_excluded(&patterns, "Cargo.toml"));
|
||||
assert!(!is_excluded(&patterns, "Cargo.lock"));
|
||||
}
|
||||
158
third_party/zeroclaw/tests/component/gateway.rs
vendored
Normal file
158
third_party/zeroclaw/tests/component/gateway.rs
vendored
Normal file
@@ -0,0 +1,158 @@
|
||||
//! Gateway component tests.
|
||||
//!
|
||||
//! Tests public gateway infrastructure (rate limiter, idempotency, signature
|
||||
//! verification) in isolation. The gateway module (`zeroclaw::gateway`) exposes
|
||||
//! `verify_whatsapp_signature` and the server function `run_gateway`, but the
|
||||
//! internal rate limiter and idempotency store constructors are crate-private.
|
||||
//! Tests here verify behavior through the public API surface.
|
||||
|
||||
use zeroclaw::gateway::verify_whatsapp_signature;
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// WhatsApp webhook signature verification (public API)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Valid HMAC-SHA256 signature is accepted.
|
||||
#[test]
|
||||
fn gateway_whatsapp_valid_signature_accepted() {
|
||||
let secret = "test_app_secret";
|
||||
let body = b"test body content";
|
||||
|
||||
// Compute expected signature
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
|
||||
mac.update(body);
|
||||
let result = mac.finalize();
|
||||
let signature = hex::encode(result.into_bytes());
|
||||
let header = format!("sha256={signature}");
|
||||
|
||||
assert!(
|
||||
verify_whatsapp_signature(secret, body, &header),
|
||||
"Valid signature should be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
/// Wrong signature is rejected.
|
||||
#[test]
|
||||
fn gateway_whatsapp_wrong_signature_rejected() {
|
||||
let secret = "test_app_secret";
|
||||
let body = b"test body content";
|
||||
let header = "sha256=0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
assert!(
|
||||
!verify_whatsapp_signature(secret, body, header),
|
||||
"Wrong signature should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
/// Missing sha256= prefix is rejected.
|
||||
#[test]
|
||||
fn gateway_whatsapp_missing_prefix_rejected() {
|
||||
let secret = "test_app_secret";
|
||||
let body = b"test body content";
|
||||
let header = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
||||
|
||||
assert!(
|
||||
!verify_whatsapp_signature(secret, body, header),
|
||||
"Missing sha256= prefix should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
/// Empty signature is rejected.
|
||||
#[test]
|
||||
fn gateway_whatsapp_empty_signature_rejected() {
|
||||
let secret = "test_app_secret";
|
||||
let body = b"test body content";
|
||||
|
||||
assert!(
|
||||
!verify_whatsapp_signature(secret, body, ""),
|
||||
"Empty signature should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
/// Tampered body is rejected (signature computed for different body).
|
||||
#[test]
|
||||
fn gateway_whatsapp_tampered_body_rejected() {
|
||||
let secret = "test_app_secret";
|
||||
let original_body = b"original body";
|
||||
let tampered_body = b"tampered body";
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
|
||||
mac.update(original_body);
|
||||
let result = mac.finalize();
|
||||
let signature = hex::encode(result.into_bytes());
|
||||
let header = format!("sha256={signature}");
|
||||
|
||||
assert!(
|
||||
!verify_whatsapp_signature(secret, tampered_body, &header),
|
||||
"Tampered body should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
/// Different secrets produce different signatures.
|
||||
#[test]
|
||||
fn gateway_whatsapp_different_secrets_differ() {
|
||||
let body = b"same body";
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let mut mac1 = HmacSha256::new_from_slice(b"secret_one").unwrap();
|
||||
mac1.update(body);
|
||||
let sig1 = hex::encode(mac1.finalize().into_bytes());
|
||||
|
||||
let mut mac2 = HmacSha256::new_from_slice(b"secret_two").unwrap();
|
||||
mac2.update(body);
|
||||
let sig2 = hex::encode(mac2.finalize().into_bytes());
|
||||
|
||||
assert_ne!(
|
||||
sig1, sig2,
|
||||
"Different secrets should produce different signatures"
|
||||
);
|
||||
|
||||
let header1 = format!("sha256={sig1}");
|
||||
assert!(verify_whatsapp_signature("secret_one", body, &header1));
|
||||
assert!(!verify_whatsapp_signature("secret_two", body, &header1));
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Gateway constants and configuration validation
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Gateway body limit constant is reasonable.
|
||||
#[test]
|
||||
fn gateway_body_limit_is_reasonable() {
|
||||
assert_eq!(
|
||||
zeroclaw::gateway::MAX_BODY_SIZE,
|
||||
65_536,
|
||||
"Max body size should be 64KB"
|
||||
);
|
||||
}
|
||||
|
||||
/// Gateway timeout constant is reasonable.
|
||||
#[test]
|
||||
fn gateway_timeout_is_reasonable() {
|
||||
assert_eq!(
|
||||
zeroclaw::gateway::REQUEST_TIMEOUT_SECS,
|
||||
30,
|
||||
"Request timeout should be 30 seconds"
|
||||
);
|
||||
}
|
||||
|
||||
/// Gateway rate limit window is 60 seconds.
|
||||
#[test]
|
||||
fn gateway_rate_limit_window_is_60s() {
|
||||
assert_eq!(
|
||||
zeroclaw::gateway::RATE_LIMIT_WINDOW_SECS,
|
||||
60,
|
||||
"Rate limit window should be 60 seconds"
|
||||
);
|
||||
}
|
||||
79
third_party/zeroclaw/tests/component/gemini_capabilities.rs
vendored
Normal file
79
third_party/zeroclaw/tests/component/gemini_capabilities.rs
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
//! Gemini provider capabilities and contract tests.
|
||||
//!
|
||||
//! Validates that the Gemini provider correctly declares its capabilities
|
||||
//! through the public Provider trait, ensuring the agent loop selects the
|
||||
//! right tool-calling strategy (prompt-guided, not native).
|
||||
|
||||
use zeroclaw::providers::create_provider_with_url;
|
||||
use zeroclaw::providers::traits::Provider;
|
||||
|
||||
fn gemini_provider() -> Box<dyn Provider> {
|
||||
create_provider_with_url("gemini", Some("test-key"), None)
|
||||
.expect("Gemini provider should resolve with test key")
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Capabilities declaration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn gemini_reports_no_native_tool_calling() {
|
||||
let provider = gemini_provider();
|
||||
let caps = provider.capabilities();
|
||||
assert!(
|
||||
!caps.native_tool_calling,
|
||||
"Gemini should use prompt-guided tool calling, not native"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_reports_vision_support() {
|
||||
let provider = gemini_provider();
|
||||
let caps = provider.capabilities();
|
||||
assert!(caps.vision, "Gemini should report vision support");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_supports_native_tools_returns_false() {
|
||||
let provider = gemini_provider();
|
||||
assert!(
|
||||
!provider.supports_native_tools(),
|
||||
"supports_native_tools() must be false to trigger prompt-guided fallback in chat()"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_supports_vision_returns_true() {
|
||||
let provider = gemini_provider();
|
||||
assert!(provider.supports_vision());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tool conversion contract
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn gemini_convert_tools_returns_prompt_guided() {
|
||||
use zeroclaw::providers::traits::ToolsPayload;
|
||||
use zeroclaw::tools::traits::ToolSpec;
|
||||
|
||||
let provider = gemini_provider();
|
||||
let tools = vec![ToolSpec {
|
||||
name: "memory_store".to_string(),
|
||||
description: "Store a value in memory".to_string(),
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {"type": "string"},
|
||||
"value": {"type": "string"}
|
||||
},
|
||||
"required": ["key", "value"]
|
||||
}),
|
||||
}];
|
||||
|
||||
let payload = provider.convert_tools(&tools);
|
||||
assert!(
|
||||
matches!(payload, ToolsPayload::PromptGuided { .. }),
|
||||
"Gemini should return PromptGuided payload since native_tool_calling is false"
|
||||
);
|
||||
}
|
||||
11
third_party/zeroclaw/tests/component/mod.rs
vendored
Normal file
11
third_party/zeroclaw/tests/component/mod.rs
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
mod config_persistence;
|
||||
mod config_schema;
|
||||
mod dockerignore_test;
|
||||
mod gateway;
|
||||
mod gemini_capabilities;
|
||||
mod otel_dependency_feature_regression;
|
||||
mod provider_resolution;
|
||||
mod provider_schema;
|
||||
mod reply_target_field_regression;
|
||||
mod security;
|
||||
mod whatsapp_webhook_security;
|
||||
17
third_party/zeroclaw/tests/component/otel_dependency_feature_regression.rs
vendored
Normal file
17
third_party/zeroclaw/tests/component/otel_dependency_feature_regression.rs
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
#[test]
|
||||
fn opentelemetry_otlp_uses_blocking_reqwest_client() {
|
||||
let manifest = include_str!("../../Cargo.toml");
|
||||
let otlp_line = manifest
|
||||
.lines()
|
||||
.find(|line: &&str| line.trim_start().starts_with("opentelemetry-otlp ="))
|
||||
.expect("Cargo.toml must define opentelemetry-otlp dependency");
|
||||
|
||||
assert!(
|
||||
otlp_line.contains("\"reqwest-blocking-client\""),
|
||||
"opentelemetry-otlp must include reqwest-blocking-client to avoid Tokio reactor panics"
|
||||
);
|
||||
assert!(
|
||||
!otlp_line.contains("\"reqwest-client\""),
|
||||
"opentelemetry-otlp must not include async reqwest-client in this runtime mode"
|
||||
);
|
||||
}
|
||||
479
third_party/zeroclaw/tests/component/provider_resolution.rs
vendored
Normal file
479
third_party/zeroclaw/tests/component/provider_resolution.rs
vendored
Normal file
@@ -0,0 +1,479 @@
|
||||
//! TG1: Provider End-to-End Resolution Tests
|
||||
//!
|
||||
//! Prevents: Pattern 1 — Provider configuration & resolution bugs (27% of user bugs).
|
||||
//! Issues: #831, #834, #721, #580, #452, #451, #796, #843
|
||||
//!
|
||||
//! Tests the full pipeline from config values through `create_provider_with_url()`
|
||||
//! to provider construction, verifying factory resolution, URL construction,
|
||||
//! credential wiring, and auth header format.
|
||||
|
||||
use zeroclaw::providers::compatible::{AuthStyle, OpenAiCompatibleProvider};
|
||||
use zeroclaw::providers::{
|
||||
create_provider, create_provider_with_options, create_provider_with_url,
|
||||
};
|
||||
|
||||
/// Helper: assert provider creation succeeds
|
||||
fn assert_provider_ok(name: &str, key: Option<&str>, url: Option<&str>) {
|
||||
let result = create_provider_with_url(name, key, url);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"{name} provider should resolve: {}",
|
||||
result.err().map(|e| e.to_string()).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Factory resolution: each major provider name resolves without error
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_openai_provider() {
|
||||
assert_provider_ok("openai", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_anthropic_provider() {
|
||||
assert_provider_ok("anthropic", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_deepseek_provider() {
|
||||
assert_provider_ok("deepseek", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_mistral_provider() {
|
||||
assert_provider_ok("mistral", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_ollama_provider() {
|
||||
assert_provider_ok("ollama", None, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_groq_provider() {
|
||||
assert_provider_ok("groq", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_xai_provider() {
|
||||
assert_provider_ok("xai", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_together_provider() {
|
||||
assert_provider_ok("together", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_fireworks_provider() {
|
||||
assert_provider_ok("fireworks", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_perplexity_provider() {
|
||||
assert_provider_ok("perplexity", Some("test-key"), None);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Factory resolution: alias variants map to same provider
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn factory_grok_alias_resolves_to_xai() {
|
||||
assert_provider_ok("grok", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_kimi_alias_resolves_to_moonshot() {
|
||||
assert_provider_ok("kimi", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_zhipu_alias_resolves_to_glm() {
|
||||
assert_provider_ok("zhipu", Some("test-key"), None);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Custom URL provider creation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn factory_custom_http_url_resolves() {
|
||||
assert_provider_ok("custom:http://localhost:8080", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_custom_https_url_resolves() {
|
||||
assert_provider_ok("custom:https://api.example.com/v1", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_custom_ftp_url_rejected() {
|
||||
let result = create_provider_with_url("custom:ftp://example.com", None, None);
|
||||
assert!(result.is_err(), "ftp scheme should be rejected");
|
||||
let err_msg = result.err().unwrap().to_string();
|
||||
assert!(
|
||||
err_msg.contains("http://") || err_msg.contains("https://"),
|
||||
"error should mention valid schemes: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_custom_empty_url_rejected() {
|
||||
let result = create_provider_with_url("custom:", None, None);
|
||||
assert!(result.is_err(), "empty custom URL should be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_unknown_provider_rejected() {
|
||||
let result = create_provider_with_url("nonexistent_provider_xyz", None, None);
|
||||
assert!(result.is_err(), "unknown provider name should be rejected");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// OpenAiCompatibleProvider: credential and auth style wiring
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn compatible_provider_bearer_auth_style() {
|
||||
// Construction with Bearer auth should succeed
|
||||
let _provider = OpenAiCompatibleProvider::new(
|
||||
"TestProvider",
|
||||
"https://api.test.com",
|
||||
Some("sk-test-key-12345"),
|
||||
AuthStyle::Bearer,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_provider_xapikey_auth_style() {
|
||||
// Construction with XApiKey auth should succeed
|
||||
let _provider = OpenAiCompatibleProvider::new(
|
||||
"TestProvider",
|
||||
"https://api.test.com",
|
||||
Some("sk-test-key-12345"),
|
||||
AuthStyle::XApiKey,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_provider_custom_auth_header() {
|
||||
// Construction with Custom auth should succeed
|
||||
let _provider = OpenAiCompatibleProvider::new(
|
||||
"TestProvider",
|
||||
"https://api.test.com",
|
||||
Some("sk-test-key-12345"),
|
||||
AuthStyle::Custom("X-Custom-Auth".into()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_provider_no_credential() {
|
||||
// Construction without credential should succeed (for local providers)
|
||||
let _provider = OpenAiCompatibleProvider::new(
|
||||
"TestLocal",
|
||||
"http://localhost:11434",
|
||||
None,
|
||||
AuthStyle::Bearer,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_provider_base_url_trailing_slash_normalized() {
|
||||
// Construction with trailing slash URL should succeed
|
||||
let _provider = OpenAiCompatibleProvider::new(
|
||||
"TestProvider",
|
||||
"https://api.test.com/v1/",
|
||||
Some("key"),
|
||||
AuthStyle::Bearer,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Provider with api_url override (simulates #721 - Ollama api_url config)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn factory_ollama_with_custom_api_url() {
|
||||
assert_provider_ok("ollama", None, Some("http://192.168.1.100:11434"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_openai_with_custom_api_url() {
|
||||
assert_provider_ok(
|
||||
"openai",
|
||||
Some("test-key"),
|
||||
Some("https://custom-openai-proxy.example.com/v1"),
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Provider default convenience factory
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn convenience_factory_resolves_major_providers() {
|
||||
for provider_name in &[
|
||||
"openai",
|
||||
"anthropic",
|
||||
"deepseek",
|
||||
"mistral",
|
||||
"groq",
|
||||
"xai",
|
||||
"together",
|
||||
"fireworks",
|
||||
"perplexity",
|
||||
] {
|
||||
let result = create_provider(provider_name, Some("test-key"));
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"convenience factory should resolve {provider_name}: {}",
|
||||
result.err().map(|e| e.to_string()).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convenience_factory_ollama_no_key() {
|
||||
let result = create_provider("ollama", None);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"ollama should not require api key: {}",
|
||||
result.err().map(|e| e.to_string()).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Primary providers with custom implementations
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_openrouter_provider() {
|
||||
assert_provider_ok("openrouter", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_gemini_provider() {
|
||||
assert_provider_ok("gemini", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_bedrock_provider() {
|
||||
assert_provider_ok("bedrock", None, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_copilot_provider() {
|
||||
assert_provider_ok("copilot", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_synthetic_provider() {
|
||||
assert_provider_ok("synthetic", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_openai_codex_provider() {
|
||||
let options = zeroclaw::providers::ProviderRuntimeOptions::default();
|
||||
let result = create_provider_with_options("openai-codex", None, &options);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"openai-codex provider should resolve: {}",
|
||||
result.err().map(|e| e.to_string()).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// OpenAI-compatible ecosystem providers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_venice_provider() {
|
||||
assert_provider_ok("venice", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_cohere_provider() {
|
||||
assert_provider_ok("cohere", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_opencode_provider() {
|
||||
assert_provider_ok("opencode", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_opencode_go_provider() {
|
||||
assert_provider_ok("opencode-go", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_astrai_provider() {
|
||||
assert_provider_ok("astrai", Some("test-key"), None);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// China region providers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_moonshot_provider() {
|
||||
assert_provider_ok("moonshot", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_glm_provider() {
|
||||
assert_provider_ok("glm", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_qwen_provider() {
|
||||
assert_provider_ok("qwen", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_doubao_provider() {
|
||||
assert_provider_ok("doubao", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_qianfan_provider() {
|
||||
assert_provider_ok("qianfan", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_minimax_provider() {
|
||||
assert_provider_ok("minimax", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_kimi_code_provider() {
|
||||
assert_provider_ok("kimi-code", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_zai_provider() {
|
||||
assert_provider_ok("zai", Some("test-key"), None);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Local/self-hosted providers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_lmstudio_provider() {
|
||||
assert_provider_ok("lmstudio", None, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_llamacpp_provider() {
|
||||
assert_provider_ok("llamacpp", None, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_vllm_provider() {
|
||||
assert_provider_ok("vllm", None, None);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Cloud AI endpoints
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_vercel_provider() {
|
||||
assert_provider_ok("vercel", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_cloudflare_provider() {
|
||||
assert_provider_ok("cloudflare", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_nvidia_provider() {
|
||||
assert_provider_ok("nvidia", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_ovhcloud_provider() {
|
||||
assert_provider_ok("ovhcloud", Some("test-key"), None);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Alias resolution tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn factory_google_alias_resolves_to_gemini() {
|
||||
assert_provider_ok("google", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_google_gemini_alias_resolves_to_gemini() {
|
||||
assert_provider_ok("google-gemini", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_aws_bedrock_alias_resolves_to_bedrock() {
|
||||
assert_provider_ok("aws-bedrock", None, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_github_copilot_alias_resolves_to_copilot() {
|
||||
assert_provider_ok("github-copilot", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_vercel_ai_alias_resolves_to_vercel() {
|
||||
assert_provider_ok("vercel-ai", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_cloudflare_ai_alias_resolves_to_cloudflare() {
|
||||
assert_provider_ok("cloudflare-ai", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_opencode_zen_alias_resolves_to_opencode() {
|
||||
assert_provider_ok("opencode-zen", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_lm_studio_alias_resolves_to_lmstudio() {
|
||||
assert_provider_ok("lm-studio", None, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_llama_cpp_alias_resolves_to_llamacpp() {
|
||||
assert_provider_ok("llama.cpp", None, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_nvidia_nim_alias_resolves_to_nvidia() {
|
||||
assert_provider_ok("nvidia-nim", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_build_nvidia_com_alias_resolves_to_nvidia() {
|
||||
assert_provider_ok("build.nvidia.com", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_ovh_alias_resolves_to_ovhcloud() {
|
||||
assert_provider_ok("ovh", Some("test-key"), None);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Custom endpoint tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn factory_anthropic_custom_endpoint_resolves() {
|
||||
assert_provider_ok(
|
||||
"anthropic-custom:https://api.example.com",
|
||||
Some("test-key"),
|
||||
None,
|
||||
);
|
||||
}
|
||||
327
third_party/zeroclaw/tests/component/provider_schema.rs
vendored
Normal file
327
third_party/zeroclaw/tests/component/provider_schema.rs
vendored
Normal file
@@ -0,0 +1,327 @@
|
||||
//! TG7: Provider Schema Conformance Tests
|
||||
//!
|
||||
//! Prevents: Pattern 7 — External schema compatibility bugs (7% of user bugs).
|
||||
//! Issues: #769, #843
|
||||
//!
|
||||
//! Tests request/response serialization to verify required fields are present
|
||||
//! for each provider's API specification. Validates ChatMessage, ChatResponse,
|
||||
//! ToolCall, and AuthStyle serialization contracts.
|
||||
|
||||
use zeroclaw::providers::compatible::AuthStyle;
|
||||
use zeroclaw::providers::traits::{ChatMessage, ChatResponse, ToolCall};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ChatMessage serialization
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn chat_message_system_role_correct() {
|
||||
let msg = ChatMessage::system("You are a helpful assistant");
|
||||
assert_eq!(msg.role, "system");
|
||||
assert_eq!(msg.content, "You are a helpful assistant");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_message_user_role_correct() {
|
||||
let msg = ChatMessage::user("Hello");
|
||||
assert_eq!(msg.role, "user");
|
||||
assert_eq!(msg.content, "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_message_assistant_role_correct() {
|
||||
let msg = ChatMessage::assistant("Hi there!");
|
||||
assert_eq!(msg.role, "assistant");
|
||||
assert_eq!(msg.content, "Hi there!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_message_tool_role_correct() {
|
||||
let msg = ChatMessage::tool("tool result");
|
||||
assert_eq!(msg.role, "tool");
|
||||
assert_eq!(msg.content, "tool result");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_message_serializes_to_json_with_required_fields() {
|
||||
let msg = ChatMessage::user("test message");
|
||||
let json = serde_json::to_value(&msg).unwrap();
|
||||
|
||||
assert!(json.get("role").is_some(), "JSON must have 'role' field");
|
||||
assert!(
|
||||
json.get("content").is_some(),
|
||||
"JSON must have 'content' field"
|
||||
);
|
||||
assert_eq!(json["role"], "user");
|
||||
assert_eq!(json["content"], "test message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_message_json_roundtrip() {
|
||||
let original = ChatMessage::assistant("response text");
|
||||
let json_str = serde_json::to_string(&original).unwrap();
|
||||
let parsed: ChatMessage = serde_json::from_str(&json_str).unwrap();
|
||||
|
||||
assert_eq!(parsed.role, original.role);
|
||||
assert_eq!(parsed.content, original.content);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ToolCall serialization (#843 - tool_call_id field)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tool_call_has_required_fields() {
|
||||
let tc = ToolCall {
|
||||
id: "call_abc123".into(),
|
||||
name: "web_search".into(),
|
||||
arguments: r#"{"query": "rust programming"}"#.into(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&tc).unwrap();
|
||||
assert!(json.get("id").is_some(), "ToolCall must have 'id' field");
|
||||
assert!(
|
||||
json.get("name").is_some(),
|
||||
"ToolCall must have 'name' field"
|
||||
);
|
||||
assert!(
|
||||
json.get("arguments").is_some(),
|
||||
"ToolCall must have 'arguments' field"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_id_preserved_in_serialization() {
|
||||
let tc = ToolCall {
|
||||
id: "call_deepseek_42".into(),
|
||||
name: "shell".into(),
|
||||
arguments: r#"{"command": "ls"}"#.into(),
|
||||
};
|
||||
|
||||
let json_str = serde_json::to_string(&tc).unwrap();
|
||||
let parsed: ToolCall = serde_json::from_str(&json_str).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed.id, "call_deepseek_42",
|
||||
"tool_call_id must survive roundtrip"
|
||||
);
|
||||
assert_eq!(parsed.name, "shell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_arguments_contain_valid_json() {
|
||||
let tc = ToolCall {
|
||||
id: "call_1".into(),
|
||||
name: "file_write".into(),
|
||||
arguments: r#"{"path": "/tmp/test.txt", "content": "hello"}"#.into(),
|
||||
};
|
||||
|
||||
// Arguments should parse as valid JSON
|
||||
let args: serde_json::Value =
|
||||
serde_json::from_str(&tc.arguments).expect("tool call arguments should be valid JSON");
|
||||
assert!(args.get("path").is_some());
|
||||
assert!(args.get("content").is_some());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tool message with tool_call_id (DeepSeek requirement)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tool_response_message_can_embed_tool_call_id() {
|
||||
// DeepSeek requires tool_call_id in tool response messages.
|
||||
// The tool message content can embed the tool_call_id as JSON.
|
||||
let tool_response =
|
||||
ChatMessage::tool(r#"{"tool_call_id": "call_abc123", "content": "search results here"}"#);
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&tool_response.content)
|
||||
.expect("tool response content should be valid JSON");
|
||||
|
||||
assert!(
|
||||
parsed.get("tool_call_id").is_some(),
|
||||
"tool response should include tool_call_id for DeepSeek compatibility"
|
||||
);
|
||||
assert_eq!(parsed["tool_call_id"], "call_abc123");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ChatResponse structure
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn chat_response_text_only() {
|
||||
let resp = ChatResponse {
|
||||
text: Some("Hello world".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
|
||||
assert_eq!(resp.text_or_empty(), "Hello world");
|
||||
assert!(!resp.has_tool_calls());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_response_with_tool_calls() {
|
||||
let resp = ChatResponse {
|
||||
text: Some(String::new()),
|
||||
tool_calls: vec![ToolCall {
|
||||
id: "tc_1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: "{}".into(),
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
|
||||
assert!(resp.has_tool_calls());
|
||||
assert_eq!(resp.tool_calls.len(), 1);
|
||||
assert_eq!(resp.tool_calls[0].name, "echo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_response_text_or_empty_handles_none() {
|
||||
let resp = ChatResponse {
|
||||
text: None,
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
|
||||
assert_eq!(resp.text_or_empty(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_response_multiple_tool_calls() {
|
||||
let resp = ChatResponse {
|
||||
text: None,
|
||||
tool_calls: vec![
|
||||
ToolCall {
|
||||
id: "tc_1".into(),
|
||||
name: "shell".into(),
|
||||
arguments: r#"{"command": "ls"}"#.into(),
|
||||
},
|
||||
ToolCall {
|
||||
id: "tc_2".into(),
|
||||
name: "file_read".into(),
|
||||
arguments: r#"{"path": "test.txt"}"#.into(),
|
||||
},
|
||||
],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
|
||||
assert!(resp.has_tool_calls());
|
||||
assert_eq!(resp.tool_calls.len(), 2);
|
||||
// Each tool call should have a distinct id
|
||||
assert_ne!(resp.tool_calls[0].id, resp.tool_calls[1].id);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AuthStyle variants
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn auth_style_bearer_is_constructible() {
|
||||
let style = AuthStyle::Bearer;
|
||||
assert!(matches!(style, AuthStyle::Bearer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_style_xapikey_is_constructible() {
|
||||
let style = AuthStyle::XApiKey;
|
||||
assert!(matches!(style, AuthStyle::XApiKey));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_style_custom_header() {
|
||||
let style = AuthStyle::Custom("X-Custom-Auth".into());
|
||||
if let AuthStyle::Custom(header) = style {
|
||||
assert_eq!(header, "X-Custom-Auth");
|
||||
} else {
|
||||
panic!("expected AuthStyle::Custom");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Provider naming consistency
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn provider_construction_with_different_names() {
|
||||
use zeroclaw::providers::compatible::OpenAiCompatibleProvider;
|
||||
|
||||
// Construction with various names should succeed
|
||||
let _p1 = OpenAiCompatibleProvider::new(
|
||||
"DeepSeek",
|
||||
"https://api.deepseek.com",
|
||||
Some("test-key"),
|
||||
AuthStyle::Bearer,
|
||||
);
|
||||
let _p2 =
|
||||
OpenAiCompatibleProvider::new("deepseek", "https://api.test.com", None, AuthStyle::Bearer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_construction_with_different_auth_styles() {
|
||||
use zeroclaw::providers::compatible::OpenAiCompatibleProvider;
|
||||
|
||||
let _bearer = OpenAiCompatibleProvider::new(
|
||||
"Test",
|
||||
"https://api.test.com",
|
||||
Some("key"),
|
||||
AuthStyle::Bearer,
|
||||
);
|
||||
let _xapi = OpenAiCompatibleProvider::new(
|
||||
"Test",
|
||||
"https://api.test.com",
|
||||
Some("key"),
|
||||
AuthStyle::XApiKey,
|
||||
);
|
||||
let _custom = OpenAiCompatibleProvider::new(
|
||||
"Test",
|
||||
"https://api.test.com",
|
||||
Some("key"),
|
||||
AuthStyle::Custom("X-My-Auth".into()),
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Conversation history message ordering
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn chat_messages_maintain_role_sequence() {
|
||||
let history = [
|
||||
ChatMessage::system("You are helpful"),
|
||||
ChatMessage::user("What is Rust?"),
|
||||
ChatMessage::assistant("Rust is a systems programming language"),
|
||||
ChatMessage::user("Tell me more"),
|
||||
ChatMessage::assistant("It emphasizes safety and performance"),
|
||||
];
|
||||
|
||||
assert_eq!(history[0].role, "system");
|
||||
assert_eq!(history[1].role, "user");
|
||||
assert_eq!(history[2].role, "assistant");
|
||||
assert_eq!(history[3].role, "user");
|
||||
assert_eq!(history[4].role, "assistant");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_messages_with_tool_calls_maintain_sequence() {
|
||||
let history = [
|
||||
ChatMessage::system("You are helpful"),
|
||||
ChatMessage::user("Search for Rust"),
|
||||
ChatMessage::assistant("I'll search for that"),
|
||||
ChatMessage::tool(r#"{"tool_call_id": "tc_1", "content": "search results"}"#),
|
||||
ChatMessage::assistant("Based on the search results..."),
|
||||
];
|
||||
|
||||
assert_eq!(history.len(), 5);
|
||||
assert_eq!(history[3].role, "tool");
|
||||
assert_eq!(history[4].role, "assistant");
|
||||
|
||||
// Verify tool message content is valid JSON with tool_call_id
|
||||
let tool_content: serde_json::Value = serde_json::from_str(&history[3].content).unwrap();
|
||||
assert!(tool_content.get("tool_call_id").is_some());
|
||||
}
|
||||
70
third_party/zeroclaw/tests/component/reply_target_field_regression.rs
vendored
Normal file
70
third_party/zeroclaw/tests/component/reply_target_field_regression.rs
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
//! Regression guard for ChannelMessage field naming consistency.
|
||||
//!
|
||||
//! This test prevents accidental reintroduction of the removed `reply_to` field
|
||||
//! in Rust source code where `reply_target` must be used.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const SCAN_PATHS: &[&str] = &["src"];
|
||||
const FORBIDDEN_PATTERNS: &[&str] = &[".reply_to", "reply_to:"];
|
||||
|
||||
fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let entries = fs::read_dir(dir)
|
||||
.unwrap_or_else(|err| panic!("Failed to read directory {}: {err}", dir.display()));
|
||||
|
||||
for entry in entries {
|
||||
let entry =
|
||||
entry.unwrap_or_else(|err| panic!("Failed to read entry in {}: {err}", dir.display()));
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
collect_rs_files(&path, out);
|
||||
} else if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_does_not_use_legacy_reply_to_field() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let mut rust_files = Vec::new();
|
||||
|
||||
for relative in SCAN_PATHS {
|
||||
collect_rs_files(&root.join(relative), &mut rust_files);
|
||||
}
|
||||
|
||||
rust_files.sort();
|
||||
|
||||
let mut violations = Vec::new();
|
||||
|
||||
for file_path in rust_files {
|
||||
let content = fs::read_to_string(&file_path).unwrap_or_else(|err| {
|
||||
panic!("Failed to read source file {}: {err}", file_path.display())
|
||||
});
|
||||
|
||||
for (line_idx, line) in content.lines().enumerate() {
|
||||
for pattern in FORBIDDEN_PATTERNS {
|
||||
if line.contains(pattern) {
|
||||
let rel = file_path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(&file_path)
|
||||
.display()
|
||||
.to_string();
|
||||
violations.push(format!(
|
||||
"{rel}:{} contains forbidden pattern `{pattern}`: {}",
|
||||
line_idx + 1,
|
||||
line.trim()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"Found legacy `reply_to` field usage:\n{}",
|
||||
violations.join("\n")
|
||||
);
|
||||
}
|
||||
164
third_party/zeroclaw/tests/component/security.rs
vendored
Normal file
164
third_party/zeroclaw/tests/component/security.rs
vendored
Normal file
@@ -0,0 +1,164 @@
|
||||
//! Security component tests.
|
||||
//!
|
||||
//! The `security` module is `pub(crate)` so SecurityPolicy cannot be directly
|
||||
//! instantiated from integration tests. These tests validate security-related
|
||||
//! behavior through the public API surface: configuration defaults, autonomy
|
||||
//! config validation, and credential scrubbing patterns.
|
||||
|
||||
use zeroclaw::config::{AutonomyConfig, Config};
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Autonomy configuration defaults and validation
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Default autonomy level is "supervised".
|
||||
#[test]
|
||||
fn security_default_autonomy_is_supervised() {
|
||||
let config = AutonomyConfig::default();
|
||||
assert_eq!(
|
||||
format!("{:?}", config.level),
|
||||
"Supervised",
|
||||
"Default autonomy level should be Supervised"
|
||||
);
|
||||
}
|
||||
|
||||
/// Default workspace_only is true (restricts file access to workspace).
|
||||
#[test]
|
||||
fn security_default_workspace_only() {
|
||||
let config = AutonomyConfig::default();
|
||||
assert!(
|
||||
config.workspace_only,
|
||||
"Default workspace_only should be true for safety"
|
||||
);
|
||||
}
|
||||
|
||||
/// Max actions per hour has a reasonable default.
|
||||
#[test]
|
||||
fn security_default_max_actions_per_hour() {
|
||||
let config = AutonomyConfig::default();
|
||||
assert!(
|
||||
config.max_actions_per_hour > 0,
|
||||
"max_actions_per_hour should be positive"
|
||||
);
|
||||
assert!(
|
||||
config.max_actions_per_hour <= 1000,
|
||||
"max_actions_per_hour should have a reasonable upper bound"
|
||||
);
|
||||
}
|
||||
|
||||
/// Require approval for medium risk is enabled by default.
|
||||
#[test]
|
||||
fn security_default_require_approval_for_medium_risk() {
|
||||
let config = AutonomyConfig::default();
|
||||
assert!(
|
||||
config.require_approval_for_medium_risk,
|
||||
"Should require approval for medium-risk commands by default"
|
||||
);
|
||||
}
|
||||
|
||||
/// Block high risk commands is enabled by default.
|
||||
#[test]
|
||||
fn security_default_block_high_risk_commands() {
|
||||
let config = AutonomyConfig::default();
|
||||
assert!(
|
||||
config.block_high_risk_commands,
|
||||
"Should block high-risk commands by default"
|
||||
);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Security configuration
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Secret encryption is enabled by default.
|
||||
#[test]
|
||||
fn security_secrets_encryption_default() {
|
||||
let config = Config::default();
|
||||
assert!(
|
||||
config.secrets.encrypt,
|
||||
"Secret encryption should be enabled by default"
|
||||
);
|
||||
}
|
||||
|
||||
/// Full config has security sections populated with defaults.
|
||||
#[test]
|
||||
fn security_full_config_has_autonomy() {
|
||||
let config = Config::default();
|
||||
assert_eq!(
|
||||
format!("{:?}", config.autonomy.level),
|
||||
"Supervised",
|
||||
"Default config autonomy should be Supervised"
|
||||
);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Autonomy level serialization round-trip
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// AutonomyConfig serializes and deserializes correctly via TOML.
|
||||
#[test]
|
||||
fn security_autonomy_config_toml_roundtrip() {
|
||||
let original = AutonomyConfig::default();
|
||||
let toml_str = toml::to_string(&original).expect("Failed to serialize AutonomyConfig");
|
||||
let deserialized: AutonomyConfig =
|
||||
toml::from_str(&toml_str).expect("Failed to deserialize AutonomyConfig");
|
||||
assert_eq!(
|
||||
format!("{:?}", deserialized.level),
|
||||
format!("{:?}", original.level),
|
||||
"Autonomy level should survive TOML round-trip"
|
||||
);
|
||||
assert_eq!(
|
||||
deserialized.workspace_only, original.workspace_only,
|
||||
"workspace_only should survive TOML round-trip"
|
||||
);
|
||||
}
|
||||
|
||||
/// ReadOnly autonomy level parses from TOML string (with all required fields).
|
||||
#[test]
|
||||
fn security_readonly_autonomy_parses() {
|
||||
let original = AutonomyConfig::default();
|
||||
let mut toml_str = toml::to_string(&original).expect("Failed to serialize");
|
||||
// Override the level to readonly
|
||||
toml_str = toml_str.replace("level = \"supervised\"", "level = \"readonly\"");
|
||||
let config: AutonomyConfig = toml::from_str(&toml_str).expect("Failed to parse readonly");
|
||||
assert_eq!(format!("{:?}", config.level), "ReadOnly");
|
||||
}
|
||||
|
||||
/// Full autonomy level parses from TOML string (with all required fields).
|
||||
#[test]
|
||||
fn security_full_autonomy_parses() {
|
||||
let original = AutonomyConfig::default();
|
||||
let mut toml_str = toml::to_string(&original).expect("Failed to serialize");
|
||||
// Override the level to full and workspace_only to false
|
||||
toml_str = toml_str.replace("level = \"supervised\"", "level = \"full\"");
|
||||
toml_str = toml_str.replace("workspace_only = true", "workspace_only = false");
|
||||
let config: AutonomyConfig = toml::from_str(&toml_str).expect("Failed to parse full");
|
||||
assert_eq!(format!("{:?}", config.level), "Full");
|
||||
assert!(!config.workspace_only);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Credential pattern validation (via config/schema)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Config does not expose raw API keys in Debug output.
|
||||
#[test]
|
||||
fn security_config_debug_does_not_leak_api_key() {
|
||||
let config = Config {
|
||||
api_key: Some("sk-1234567890abcdef".to_string()),
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
// The Config struct should either not include api_key in Debug
|
||||
// or it should be masked. Check that raw key doesn't appear in debug output.
|
||||
let debug_output = format!("{:?}", config);
|
||||
|
||||
// If the full key appears in debug output, flag it.
|
||||
// Note: some configs may legitimately show partial keys — that's acceptable.
|
||||
// What matters is the full key isn't exposed in casual logging.
|
||||
if debug_output.contains("sk-1234567890abcdef") {
|
||||
// This is a known pattern — Config derives Debug which shows all fields.
|
||||
// Document it as an area for improvement but don't fail the test,
|
||||
// since the security boundary is at the scrub_credentials level in loop_.rs.
|
||||
}
|
||||
}
|
||||
133
third_party/zeroclaw/tests/component/whatsapp_webhook_security.rs
vendored
Normal file
133
third_party/zeroclaw/tests/component/whatsapp_webhook_security.rs
vendored
Normal file
@@ -0,0 +1,133 @@
|
||||
//! Integration tests for WhatsApp webhook signature verification.
|
||||
//!
|
||||
//! These tests validate that:
|
||||
//! 1. Webhooks with valid signatures are accepted
|
||||
//! 2. Webhooks with invalid signatures are rejected
|
||||
//! 3. Webhooks with missing signatures are rejected
|
||||
//! 4. Webhooks are rejected even if JSON is valid but signature is bad
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
/// Compute valid HMAC-SHA256 signature for a webhook payload
|
||||
fn compute_signature(app_secret: &str, body: &[u8]) -> String {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(app_secret.as_bytes()).unwrap();
|
||||
mac.update(body);
|
||||
let result = mac.finalize();
|
||||
format!("sha256={}", hex::encode(result.into_bytes()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsapp_signature_rejects_missing_sha256_prefix() {
|
||||
let secret = "test_app_secret";
|
||||
let body = b"test payload";
|
||||
let bad_sig = "abc123"; // Missing sha256= prefix
|
||||
|
||||
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
|
||||
secret, body, bad_sig
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsapp_signature_rejects_invalid_hex() {
|
||||
let secret = "test_app_secret";
|
||||
let body = b"test payload";
|
||||
let bad_sig = "sha256=not-valid-hex!!";
|
||||
|
||||
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
|
||||
secret, body, bad_sig
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsapp_signature_rejects_wrong_signature() {
|
||||
let secret = "test_app_secret";
|
||||
let body = b"test payload";
|
||||
let bad_sig = "sha256=00112233445566778899aabbccddeeff";
|
||||
|
||||
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
|
||||
secret, body, bad_sig
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsapp_signature_accepts_valid_signature() {
|
||||
let secret = "test_app_secret";
|
||||
let body = b"test payload";
|
||||
let valid_sig = compute_signature(secret, body);
|
||||
|
||||
assert!(zeroclaw::gateway::verify_whatsapp_signature(
|
||||
secret, body, &valid_sig
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsapp_signature_rejects_tampered_body() {
|
||||
let secret = "test_app_secret";
|
||||
let original_body = b"original message";
|
||||
let tampered_body = b"tampered message";
|
||||
|
||||
// Compute signature for original body
|
||||
let sig = compute_signature(secret, original_body);
|
||||
|
||||
// Tampered body should be rejected even with valid-looking signature
|
||||
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
|
||||
secret,
|
||||
tampered_body,
|
||||
&sig
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsapp_signature_rejects_wrong_secret() {
|
||||
let correct_secret = "correct_secret";
|
||||
let wrong_secret = "wrong_secret";
|
||||
let body = b"test payload";
|
||||
|
||||
// Compute signature with correct secret
|
||||
let sig = compute_signature(correct_secret, body);
|
||||
|
||||
// Wrong secret should reject the signature
|
||||
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
|
||||
wrong_secret,
|
||||
body,
|
||||
&sig
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsapp_signature_rejects_empty_signature() {
|
||||
let secret = "test_app_secret";
|
||||
let body = b"test payload";
|
||||
|
||||
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
|
||||
secret, body, ""
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsapp_signature_different_secrets_produce_different_sigs() {
|
||||
let secret1 = "secret_one";
|
||||
let secret2 = "secret_two";
|
||||
let body = b"same payload";
|
||||
|
||||
let sig1 = compute_signature(secret1, body);
|
||||
let sig2 = compute_signature(secret2, body);
|
||||
|
||||
// Different secrets should produce different signatures
|
||||
assert_ne!(sig1, sig2);
|
||||
|
||||
// Each signature should only verify with its own secret
|
||||
assert!(zeroclaw::gateway::verify_whatsapp_signature(
|
||||
secret1, body, &sig1
|
||||
));
|
||||
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
|
||||
secret2, body, &sig1
|
||||
));
|
||||
assert!(zeroclaw::gateway::verify_whatsapp_signature(
|
||||
secret2, body, &sig2
|
||||
));
|
||||
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
|
||||
secret1, body, &sig2
|
||||
));
|
||||
}
|
||||
BIN
third_party/zeroclaw/tests/fixtures/hello.mp3
vendored
Normal file
BIN
third_party/zeroclaw/tests/fixtures/hello.mp3
vendored
Normal file
Binary file not shown.
22
third_party/zeroclaw/tests/fixtures/test_document.pdf
vendored
Normal file
22
third_party/zeroclaw/tests/fixtures/test_document.pdf
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
%PDF-1.4
|
||||
%âãÏÓ
|
||||
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
|
||||
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
||||
3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R/Contents 5 0 R/Resources<</Font<</F1 4 0 R>>>>>>endobj
|
||||
4 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
|
||||
5 0 obj<</Length 41>>stream
|
||||
BT /F1 24 Tf 100 700 Td (Hello PDF) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
xref
|
||||
0 6
|
||||
0000000000 65535 f
|
||||
0000000015 00000 n
|
||||
0000000058 00000 n
|
||||
0000000107 00000 n
|
||||
0000000217 00000 n
|
||||
0000000278 00000 n
|
||||
trailer<</Size 6/Root 1 0 R>>
|
||||
startxref
|
||||
365
|
||||
%%EOF
|
||||
BIN
third_party/zeroclaw/tests/fixtures/test_photo.jpg
vendored
Normal file
BIN
third_party/zeroclaw/tests/fixtures/test_photo.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 B |
66
third_party/zeroclaw/tests/fixtures/traces/multi_tool_chain.json
vendored
Normal file
66
third_party/zeroclaw/tests/fixtures/traces/multi_tool_chain.json
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"model_name": "test-multi-tool-chain",
|
||||
"turns": [
|
||||
{
|
||||
"user_input": "Echo three messages in sequence",
|
||||
"steps": [
|
||||
{
|
||||
"response": {
|
||||
"type": "tool_calls",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"name": "echo",
|
||||
"arguments": {"message": "first"}
|
||||
}
|
||||
],
|
||||
"input_tokens": 30,
|
||||
"output_tokens": 15
|
||||
}
|
||||
},
|
||||
{
|
||||
"response": {
|
||||
"type": "tool_calls",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_2",
|
||||
"name": "echo",
|
||||
"arguments": {"message": "second"}
|
||||
}
|
||||
],
|
||||
"input_tokens": 60,
|
||||
"output_tokens": 15
|
||||
}
|
||||
},
|
||||
{
|
||||
"response": {
|
||||
"type": "tool_calls",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_3",
|
||||
"name": "echo",
|
||||
"arguments": {"message": "third"}
|
||||
}
|
||||
],
|
||||
"input_tokens": 90,
|
||||
"output_tokens": 15
|
||||
}
|
||||
},
|
||||
{
|
||||
"response": {
|
||||
"type": "text",
|
||||
"content": "I echoed three messages: first, second, and third.",
|
||||
"input_tokens": 120,
|
||||
"output_tokens": 20
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"expects": {
|
||||
"response_contains": ["first", "second", "third"],
|
||||
"tools_used": ["echo"],
|
||||
"max_tool_calls": 3,
|
||||
"all_tools_succeeded": true
|
||||
}
|
||||
}
|
||||
38
third_party/zeroclaw/tests/fixtures/traces/single_tool_echo.json
vendored
Normal file
38
third_party/zeroclaw/tests/fixtures/traces/single_tool_echo.json
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"model_name": "test-single-tool-echo",
|
||||
"turns": [
|
||||
{
|
||||
"user_input": "Echo hello for me",
|
||||
"steps": [
|
||||
{
|
||||
"response": {
|
||||
"type": "tool_calls",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"name": "echo",
|
||||
"arguments": {"message": "hello"}
|
||||
}
|
||||
],
|
||||
"input_tokens": 30,
|
||||
"output_tokens": 15
|
||||
}
|
||||
},
|
||||
{
|
||||
"response": {
|
||||
"type": "text",
|
||||
"content": "The echo tool said: hello",
|
||||
"input_tokens": 50,
|
||||
"output_tokens": 10
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"expects": {
|
||||
"response_contains": ["hello"],
|
||||
"tools_used": ["echo"],
|
||||
"max_tool_calls": 1,
|
||||
"all_tools_succeeded": true
|
||||
}
|
||||
}
|
||||
24
third_party/zeroclaw/tests/fixtures/traces/smoke_greeting.json
vendored
Normal file
24
third_party/zeroclaw/tests/fixtures/traces/smoke_greeting.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"model_name": "test-smoke-greeting",
|
||||
"turns": [
|
||||
{
|
||||
"user_input": "Hello, how are you?",
|
||||
"steps": [
|
||||
{
|
||||
"response": {
|
||||
"type": "text",
|
||||
"content": "Hello! I'm doing well, thank you for asking. How can I help you today?",
|
||||
"input_tokens": 20,
|
||||
"output_tokens": 15
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"expects": {
|
||||
"response_contains": ["Hello"],
|
||||
"response_not_contains": ["error", "ERROR"],
|
||||
"tools_used": [],
|
||||
"max_tool_calls": 0
|
||||
}
|
||||
}
|
||||
378
third_party/zeroclaw/tests/integration/agent.rs
vendored
Normal file
378
third_party/zeroclaw/tests/integration/agent.rs
vendored
Normal file
@@ -0,0 +1,378 @@
|
||||
//! End-to-end integration tests for agent orchestration.
|
||||
//!
|
||||
//! These tests exercise the full agent turn cycle through the public API,
|
||||
//! using mock providers and tools to validate orchestration behavior without
|
||||
//! external service dependencies. They complement the unit tests in
|
||||
//! `src/agent/tests.rs` by running at the integration test boundary.
|
||||
//!
|
||||
//! Ref: https://github.com/zeroclaw-labs/zeroclaw/issues/618 (item 6)
|
||||
|
||||
use crate::support::helpers::{
|
||||
build_agent, build_agent_xml, build_recording_agent, text_response, tool_response,
|
||||
StaticMemoryLoader,
|
||||
};
|
||||
use crate::support::{CountingTool, EchoTool, MockProvider, RecordingProvider};
|
||||
use zeroclaw::providers::traits::ChatMessage;
|
||||
use zeroclaw::providers::{ChatResponse, ConversationMessage, ToolCall};
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// E2E smoke tests — full agent turn cycle
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Validates the simplest happy path: user message → LLM text response.
|
||||
#[tokio::test]
|
||||
async fn e2e_simple_text_response() {
|
||||
let provider = Box::new(MockProvider::new(vec![text_response(
|
||||
"Hello from mock provider",
|
||||
)]));
|
||||
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
|
||||
|
||||
let response = agent.turn("hi").await.unwrap();
|
||||
assert!(!response.is_empty(), "Expected non-empty text response");
|
||||
}
|
||||
|
||||
/// Validates single tool call → tool execution → final LLM response.
|
||||
#[tokio::test]
|
||||
async fn e2e_single_tool_call_cycle() {
|
||||
let provider = Box::new(MockProvider::new(vec![
|
||||
tool_response(vec![ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: r#"{"message": "hello from tool"}"#.into(),
|
||||
}]),
|
||||
text_response("Tool executed successfully"),
|
||||
]));
|
||||
|
||||
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
|
||||
let response = agent.turn("run echo").await.unwrap();
|
||||
assert!(
|
||||
!response.is_empty(),
|
||||
"Expected non-empty response after tool execution"
|
||||
);
|
||||
}
|
||||
|
||||
/// Validates multi-step tool chain: tool A → tool B → tool C → final response.
|
||||
#[tokio::test]
|
||||
async fn e2e_multi_step_tool_chain() {
|
||||
let (counting_tool, count) = CountingTool::new();
|
||||
|
||||
let provider = Box::new(MockProvider::new(vec![
|
||||
tool_response(vec![ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "counter".into(),
|
||||
arguments: "{}".into(),
|
||||
}]),
|
||||
tool_response(vec![ToolCall {
|
||||
id: "tc2".into(),
|
||||
name: "counter".into(),
|
||||
arguments: "{}".into(),
|
||||
}]),
|
||||
text_response("Done after 2 tool calls"),
|
||||
]));
|
||||
|
||||
let mut agent = build_agent(provider, vec![Box::new(counting_tool)]);
|
||||
let response = agent.turn("count twice").await.unwrap();
|
||||
assert!(
|
||||
!response.is_empty(),
|
||||
"Expected non-empty response after tool chain"
|
||||
);
|
||||
assert_eq!(*count.lock().unwrap(), 2);
|
||||
}
|
||||
|
||||
/// Validates that the XML dispatcher path also works end-to-end.
|
||||
#[tokio::test]
|
||||
async fn e2e_xml_dispatcher_tool_call() {
|
||||
let provider = Box::new(MockProvider::new(vec![
|
||||
ChatResponse {
|
||||
text: Some(
|
||||
r#"<tool_call>
|
||||
{"name": "echo", "arguments": {"message": "xml dispatch"}}
|
||||
</tool_call>"#
|
||||
.into(),
|
||||
),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
},
|
||||
text_response("XML tool executed"),
|
||||
]));
|
||||
|
||||
let mut agent = build_agent_xml(provider, vec![Box::new(EchoTool)]);
|
||||
let response = agent.turn("test xml dispatch").await.unwrap();
|
||||
assert!(
|
||||
!response.is_empty(),
|
||||
"Expected non-empty response from XML dispatcher"
|
||||
);
|
||||
}
|
||||
|
||||
/// Validates that multiple sequential turns maintain conversation coherence.
|
||||
#[tokio::test]
|
||||
async fn e2e_multi_turn_conversation() {
|
||||
let provider = Box::new(MockProvider::new(vec![
|
||||
text_response("First response"),
|
||||
text_response("Second response"),
|
||||
text_response("Third response"),
|
||||
]));
|
||||
|
||||
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
|
||||
|
||||
let r1 = agent.turn("turn 1").await.unwrap();
|
||||
assert!(!r1.is_empty(), "Expected non-empty first response");
|
||||
|
||||
let r2 = agent.turn("turn 2").await.unwrap();
|
||||
assert!(!r2.is_empty(), "Expected non-empty second response");
|
||||
assert_ne!(r1, r2, "Sequential turn responses should be distinct");
|
||||
|
||||
let r3 = agent.turn("turn 3").await.unwrap();
|
||||
assert!(!r3.is_empty(), "Expected non-empty third response");
|
||||
assert_ne!(r2, r3, "Sequential turn responses should be distinct");
|
||||
}
|
||||
|
||||
/// Validates that the agent handles unknown tool names gracefully.
|
||||
#[tokio::test]
|
||||
async fn e2e_unknown_tool_recovery() {
|
||||
let provider = Box::new(MockProvider::new(vec![
|
||||
tool_response(vec![ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "nonexistent_tool".into(),
|
||||
arguments: "{}".into(),
|
||||
}]),
|
||||
text_response("Recovered from unknown tool"),
|
||||
]));
|
||||
|
||||
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
|
||||
let response = agent.turn("call missing tool").await.unwrap();
|
||||
assert!(
|
||||
!response.is_empty(),
|
||||
"Expected non-empty response after unknown tool recovery"
|
||||
);
|
||||
}
|
||||
|
||||
/// Validates parallel tool dispatch in a single response.
|
||||
#[tokio::test]
|
||||
async fn e2e_parallel_tool_dispatch() {
|
||||
let (counting_tool, count) = CountingTool::new();
|
||||
|
||||
let provider = Box::new(MockProvider::new(vec![
|
||||
tool_response(vec![
|
||||
ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "counter".into(),
|
||||
arguments: "{}".into(),
|
||||
},
|
||||
ToolCall {
|
||||
id: "tc2".into(),
|
||||
name: "counter".into(),
|
||||
arguments: "{}".into(),
|
||||
},
|
||||
]),
|
||||
text_response("Both tools ran"),
|
||||
]));
|
||||
|
||||
let mut agent = build_agent(provider, vec![Box::new(counting_tool)]);
|
||||
let response = agent.turn("run both").await.unwrap();
|
||||
assert!(
|
||||
!response.is_empty(),
|
||||
"Expected non-empty response after parallel dispatch"
|
||||
);
|
||||
assert_eq!(*count.lock().unwrap(), 2);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Multi-turn history fidelity & memory enrichment tests
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Validates that multi-turn conversation correctly accumulates history
|
||||
/// and passes growing message sequences to the provider on each turn.
|
||||
#[tokio::test]
|
||||
async fn e2e_multi_turn_history_fidelity() {
|
||||
let (provider, recorded) = RecordingProvider::new(vec![
|
||||
text_response("response 1"),
|
||||
text_response("response 2"),
|
||||
text_response("response 3"),
|
||||
]);
|
||||
|
||||
let mut agent = build_recording_agent(Box::new(provider), vec![], None);
|
||||
|
||||
let r1 = agent.turn("msg 1").await.unwrap();
|
||||
assert_eq!(r1, "response 1");
|
||||
|
||||
let r2 = agent.turn("msg 2").await.unwrap();
|
||||
assert_eq!(r2, "response 2");
|
||||
|
||||
let r3 = agent.turn("msg 3").await.unwrap();
|
||||
assert_eq!(r3, "response 3");
|
||||
|
||||
let requests = recorded.lock().unwrap();
|
||||
assert_eq!(requests.len(), 3, "Provider should receive 3 requests");
|
||||
|
||||
// Request 1: system + user("msg 1")
|
||||
let req1 = &requests[0];
|
||||
assert!(req1.len() >= 2);
|
||||
assert_eq!(req1[0].role, "system");
|
||||
assert_eq!(req1[1].role, "user");
|
||||
assert!(req1[1].content.contains("msg 1"));
|
||||
|
||||
// Request 2: system + user("msg 1") + assistant("response 1") + user("msg 2")
|
||||
let req2 = &requests[1];
|
||||
let req2_users: Vec<&ChatMessage> = req2.iter().filter(|m| m.role == "user").collect();
|
||||
let req2_assts: Vec<&ChatMessage> = req2.iter().filter(|m| m.role == "assistant").collect();
|
||||
assert_eq!(req2_users.len(), 2, "Request 2: expected 2 user messages");
|
||||
assert_eq!(
|
||||
req2_assts.len(),
|
||||
1,
|
||||
"Request 2: expected 1 assistant message"
|
||||
);
|
||||
assert!(req2_users[0].content.contains("msg 1"));
|
||||
assert!(req2_users[1].content.contains("msg 2"));
|
||||
assert_eq!(req2_assts[0].content, "response 1");
|
||||
|
||||
// Request 3: full history — 3 user + 2 assistant messages
|
||||
let req3 = &requests[2];
|
||||
let req3_users: Vec<&ChatMessage> = req3.iter().filter(|m| m.role == "user").collect();
|
||||
let req3_assts: Vec<&ChatMessage> = req3.iter().filter(|m| m.role == "assistant").collect();
|
||||
assert_eq!(req3_users.len(), 3, "Request 3: expected 3 user messages");
|
||||
assert_eq!(
|
||||
req3_assts.len(),
|
||||
2,
|
||||
"Request 3: expected 2 assistant messages"
|
||||
);
|
||||
assert!(req3_users[0].content.contains("msg 1"));
|
||||
assert!(req3_users[1].content.contains("msg 2"));
|
||||
assert!(req3_users[2].content.contains("msg 3"));
|
||||
assert_eq!(req3_assts[0].content, "response 1");
|
||||
assert_eq!(req3_assts[1].content, "response 2");
|
||||
|
||||
// Verify agent history: system + 3*(user + assistant) = 7
|
||||
let history = agent.history();
|
||||
assert_eq!(history.len(), 7);
|
||||
assert!(matches!(&history[0], ConversationMessage::Chat(c) if c.role == "system"));
|
||||
assert!(matches!(&history[1], ConversationMessage::Chat(c) if c.role == "user"));
|
||||
assert!(matches!(&history[2], ConversationMessage::Chat(c) if c.role == "assistant"));
|
||||
assert!(
|
||||
matches!(&history[6], ConversationMessage::Chat(c) if c.role == "assistant" && c.content == "response 3")
|
||||
);
|
||||
}
|
||||
|
||||
/// Validates that a custom MemoryLoader injects RAG context into user
|
||||
/// messages before they reach the provider.
|
||||
#[tokio::test]
|
||||
async fn e2e_memory_enrichment_injects_context() {
|
||||
let (provider, recorded) = RecordingProvider::new(vec![text_response("enriched response")]);
|
||||
|
||||
let memory_context = "[Memory context]\n- user_name: test_user\n[/Memory context]\n\n";
|
||||
let loader = StaticMemoryLoader::new(memory_context);
|
||||
|
||||
let mut agent = build_recording_agent(Box::new(provider), vec![], Some(Box::new(loader)));
|
||||
|
||||
let response = agent.turn("hello").await.unwrap();
|
||||
assert_eq!(response, "enriched response");
|
||||
|
||||
// Provider received enriched message
|
||||
let requests = recorded.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
let user_msg = requests[0].iter().find(|m| m.role == "user").unwrap();
|
||||
assert!(
|
||||
user_msg.content.contains("[Memory context]"),
|
||||
"User message should contain memory context, got: {}",
|
||||
user_msg.content,
|
||||
);
|
||||
assert!(
|
||||
user_msg.content.contains("user_name: test_user"),
|
||||
"User message should contain memory key-value pair",
|
||||
);
|
||||
assert!(
|
||||
user_msg.content.ends_with("hello"),
|
||||
"User message should end with original text, got: {}",
|
||||
user_msg.content,
|
||||
);
|
||||
|
||||
// Agent history also stores enriched message
|
||||
let history = agent.history();
|
||||
match &history[1] {
|
||||
ConversationMessage::Chat(c) => {
|
||||
assert_eq!(c.role, "user");
|
||||
assert!(c.content.contains("[Memory context]"));
|
||||
assert!(c.content.ends_with("hello"));
|
||||
}
|
||||
other => panic!("Expected Chat variant for user message, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates multi-turn conversation with memory enrichment: every user
|
||||
/// message is enriched, and the provider sees the full enriched history.
|
||||
#[tokio::test]
|
||||
async fn e2e_multi_turn_with_memory_enrichment() {
|
||||
let (provider, recorded) =
|
||||
RecordingProvider::new(vec![text_response("answer 1"), text_response("answer 2")]);
|
||||
|
||||
let memory_context = "[Memory context]\n- project: zeroclaw\n[/Memory context]\n\n";
|
||||
let loader = StaticMemoryLoader::new(memory_context);
|
||||
|
||||
let mut agent = build_recording_agent(Box::new(provider), vec![], Some(Box::new(loader)));
|
||||
|
||||
let r1 = agent.turn("first question").await.unwrap();
|
||||
assert_eq!(r1, "answer 1");
|
||||
|
||||
let r2 = agent.turn("second question").await.unwrap();
|
||||
assert_eq!(r2, "answer 2");
|
||||
|
||||
let requests = recorded.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
|
||||
// Turn 1: user message is enriched
|
||||
let req1_user = requests[0].iter().find(|m| m.role == "user").unwrap();
|
||||
assert!(req1_user.content.contains("[Memory context]"));
|
||||
assert!(req1_user.content.contains("project: zeroclaw"));
|
||||
assert!(req1_user.content.ends_with("first question"));
|
||||
|
||||
// Turn 2: both user messages enriched, assistant from turn 1 present
|
||||
let req2_users: Vec<&ChatMessage> = requests[1].iter().filter(|m| m.role == "user").collect();
|
||||
assert_eq!(req2_users.len(), 2, "Request 2 should have 2 user messages");
|
||||
|
||||
// Turn 1 user message still enriched in history
|
||||
assert!(req2_users[0].content.contains("[Memory context]"));
|
||||
assert!(req2_users[0].content.ends_with("first question"));
|
||||
|
||||
// Turn 2 user message also enriched
|
||||
assert!(req2_users[1].content.contains("[Memory context]"));
|
||||
assert!(req2_users[1].content.ends_with("second question"));
|
||||
|
||||
// Assistant response from turn 1 preserved
|
||||
let req2_assts: Vec<&ChatMessage> = requests[1]
|
||||
.iter()
|
||||
.filter(|m| m.role == "assistant")
|
||||
.collect();
|
||||
assert_eq!(req2_assts.len(), 1);
|
||||
assert_eq!(req2_assts[0].content, "answer 1");
|
||||
|
||||
// History: system + 2*(enriched_user + assistant) = 5
|
||||
assert_eq!(agent.history().len(), 5);
|
||||
}
|
||||
|
||||
/// Validates that empty memory context does not prepend memory text.
|
||||
/// A per-turn datetime prefix may still be present.
|
||||
#[tokio::test]
|
||||
async fn e2e_empty_memory_context_passthrough() {
|
||||
let (provider, recorded) = RecordingProvider::new(vec![text_response("plain response")]);
|
||||
|
||||
let loader = StaticMemoryLoader::new("");
|
||||
|
||||
let mut agent = build_recording_agent(Box::new(provider), vec![], Some(Box::new(loader)));
|
||||
|
||||
let response = agent.turn("hello").await.unwrap();
|
||||
assert_eq!(response, "plain response");
|
||||
|
||||
let requests = recorded.lock().unwrap();
|
||||
let user_msg = requests[0].iter().find(|m| m.role == "user").unwrap();
|
||||
assert!(
|
||||
user_msg.content.ends_with("hello"),
|
||||
"User payload should preserve original text suffix, got: {}",
|
||||
user_msg.content
|
||||
);
|
||||
assert!(
|
||||
!user_msg.content.contains("[Memory context]"),
|
||||
"Empty context should not prepend memory context text, got: {}",
|
||||
user_msg.content
|
||||
);
|
||||
}
|
||||
254
third_party/zeroclaw/tests/integration/agent_robustness.rs
vendored
Normal file
254
third_party/zeroclaw/tests/integration/agent_robustness.rs
vendored
Normal file
@@ -0,0 +1,254 @@
|
||||
//! TG4: Agent Loop Robustness Tests
|
||||
//!
|
||||
//! Prevents: Pattern 4 — Agent loop & tool call processing bugs (13% of user bugs).
|
||||
//! Issues: #746, #418, #777, #848
|
||||
//!
|
||||
//! Tests agent behavior with malformed tool calls, empty responses,
|
||||
//! max iteration limits, and cascading tool failures using mock providers.
|
||||
//! Complements inline parse_tool_calls tests in `src/agent/loop_.rs`.
|
||||
|
||||
use crate::support::helpers::{build_agent, text_response, tool_response};
|
||||
use crate::support::{CountingTool, EchoTool, FailingTool, MockProvider};
|
||||
use zeroclaw::providers::{ChatResponse, ToolCall};
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// TG4.1: Malformed tool call recovery
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Agent should recover when LLM returns text with residual XML tags (#746)
|
||||
#[tokio::test]
|
||||
async fn agent_recovers_from_text_with_xml_residue() {
|
||||
let provider = Box::new(MockProvider::new(vec![text_response(
|
||||
"Here is the result. Some leftover </tool_call> text after.",
|
||||
)]));
|
||||
|
||||
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
|
||||
let response = agent.turn("test").await.unwrap();
|
||||
assert!(
|
||||
!response.is_empty(),
|
||||
"agent should produce non-empty response despite XML residue"
|
||||
);
|
||||
}
|
||||
|
||||
/// Agent should handle tool call with empty arguments gracefully
|
||||
#[tokio::test]
|
||||
async fn agent_handles_tool_call_with_empty_arguments() {
|
||||
let provider = Box::new(MockProvider::new(vec![
|
||||
tool_response(vec![ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: "{}".into(),
|
||||
}]),
|
||||
text_response("Tool with empty args executed"),
|
||||
]));
|
||||
|
||||
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
|
||||
let response = agent.turn("call with empty args").await.unwrap();
|
||||
assert!(!response.is_empty());
|
||||
}
|
||||
|
||||
/// Agent should handle unknown tool name without crashing (#848 related)
|
||||
#[tokio::test]
|
||||
async fn agent_handles_nonexistent_tool_gracefully() {
|
||||
let provider = Box::new(MockProvider::new(vec![
|
||||
tool_response(vec![ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "absolutely_nonexistent_tool".into(),
|
||||
arguments: "{}".into(),
|
||||
}]),
|
||||
text_response("Recovered from unknown tool"),
|
||||
]));
|
||||
|
||||
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
|
||||
let response = agent.turn("call missing tool").await.unwrap();
|
||||
assert!(
|
||||
!response.is_empty(),
|
||||
"agent should recover from unknown tool"
|
||||
);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// TG4.2: Tool failure cascade handling (#848)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Agent should handle repeated tool failures without infinite loop
|
||||
#[tokio::test]
|
||||
async fn agent_handles_failing_tool() {
|
||||
let provider = Box::new(MockProvider::new(vec![
|
||||
tool_response(vec![ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "failing_tool".into(),
|
||||
arguments: "{}".into(),
|
||||
}]),
|
||||
text_response("Tool failed but I recovered"),
|
||||
]));
|
||||
|
||||
let mut agent = build_agent(provider, vec![Box::new(FailingTool)]);
|
||||
let response = agent.turn("use failing tool").await.unwrap();
|
||||
assert!(
|
||||
!response.is_empty(),
|
||||
"agent should produce response even after tool failure"
|
||||
);
|
||||
}
|
||||
|
||||
/// Agent should handle mixed tool calls (some succeed, some fail)
|
||||
#[tokio::test]
|
||||
async fn agent_handles_mixed_tool_success_and_failure() {
|
||||
let provider = Box::new(MockProvider::new(vec![
|
||||
tool_response(vec![
|
||||
ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: r#"{"message": "success"}"#.into(),
|
||||
},
|
||||
ToolCall {
|
||||
id: "tc2".into(),
|
||||
name: "failing_tool".into(),
|
||||
arguments: "{}".into(),
|
||||
},
|
||||
]),
|
||||
text_response("Mixed results processed"),
|
||||
]));
|
||||
|
||||
let mut agent = build_agent(provider, vec![Box::new(EchoTool), Box::new(FailingTool)]);
|
||||
let response = agent.turn("mixed tools").await.unwrap();
|
||||
assert!(!response.is_empty());
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// TG4.3: Iteration limit enforcement (#777)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Agent should not exceed max_tool_iterations (default=10) even with
|
||||
/// a provider that keeps returning tool calls
|
||||
#[tokio::test]
|
||||
async fn agent_respects_max_tool_iterations() {
|
||||
let (counting_tool, count) = CountingTool::new();
|
||||
|
||||
// Create 20 tool call responses - more than the default limit of 10
|
||||
let mut responses: Vec<ChatResponse> = (0..20)
|
||||
.map(|i| {
|
||||
tool_response(vec![ToolCall {
|
||||
id: format!("tc_{i}"),
|
||||
name: "counter".into(),
|
||||
arguments: "{}".into(),
|
||||
}])
|
||||
})
|
||||
.collect();
|
||||
// Add a final text response that would be used if limit is reached
|
||||
responses.push(text_response("Final response after iterations"));
|
||||
|
||||
let provider = Box::new(MockProvider::new(responses));
|
||||
let mut agent = build_agent(provider, vec![Box::new(counting_tool)]);
|
||||
|
||||
// Agent should complete (either by hitting iteration limit or running out of responses)
|
||||
let result = agent.turn("keep calling tools").await;
|
||||
// The agent should complete without hanging
|
||||
assert!(result.is_ok() || result.is_err());
|
||||
|
||||
let invocations = *count.lock().unwrap();
|
||||
assert!(
|
||||
invocations <= 10,
|
||||
"tool invocations ({invocations}) should not exceed default max_tool_iterations (10)"
|
||||
);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// TG4.4: Empty and whitespace responses
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Agent should handle empty text response from provider (#418 related)
|
||||
#[tokio::test]
|
||||
async fn agent_handles_empty_provider_response() {
|
||||
let provider = Box::new(MockProvider::new(vec![ChatResponse {
|
||||
text: Some(String::new()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
}]));
|
||||
|
||||
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
|
||||
// Should not panic
|
||||
let _result = agent.turn("test").await;
|
||||
}
|
||||
|
||||
/// Agent should handle None text response from provider
|
||||
#[tokio::test]
|
||||
async fn agent_handles_none_text_response() {
|
||||
let provider = Box::new(MockProvider::new(vec![ChatResponse {
|
||||
text: None,
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
}]));
|
||||
|
||||
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
|
||||
let _result = agent.turn("test").await;
|
||||
}
|
||||
|
||||
/// Agent should handle whitespace-only response
|
||||
#[tokio::test]
|
||||
async fn agent_handles_whitespace_only_response() {
|
||||
let provider = Box::new(MockProvider::new(vec![text_response(" \n\t ")]));
|
||||
|
||||
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
|
||||
let _result = agent.turn("test").await;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// TG4.5: Tool call with special content
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Agent should handle tool arguments with unicode content
|
||||
#[tokio::test]
|
||||
async fn agent_handles_unicode_tool_arguments() {
|
||||
let provider = Box::new(MockProvider::new(vec![
|
||||
tool_response(vec![ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: r#"{"message": "こんにちは世界 🌍"}"#.into(),
|
||||
}]),
|
||||
text_response("Unicode tool executed"),
|
||||
]));
|
||||
|
||||
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
|
||||
let response = agent.turn("unicode test").await.unwrap();
|
||||
assert!(!response.is_empty());
|
||||
}
|
||||
|
||||
/// Agent should handle tool arguments with nested JSON
|
||||
#[tokio::test]
|
||||
async fn agent_handles_nested_json_tool_arguments() {
|
||||
let provider = Box::new(MockProvider::new(vec![
|
||||
tool_response(vec![ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: r#"{"message": "{\"nested\": true, \"deep\": {\"level\": 3}}"}"#.into(),
|
||||
}]),
|
||||
text_response("Nested JSON tool executed"),
|
||||
]));
|
||||
|
||||
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
|
||||
let response = agent.turn("nested json test").await.unwrap();
|
||||
assert!(!response.is_empty());
|
||||
}
|
||||
|
||||
/// Agent should handle tool call followed by immediate text (no second LLM call)
|
||||
#[tokio::test]
|
||||
async fn agent_handles_sequential_tool_then_text() {
|
||||
let provider = Box::new(MockProvider::new(vec![
|
||||
tool_response(vec![ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: r#"{"message": "step 1"}"#.into(),
|
||||
}]),
|
||||
text_response("Final answer after tool"),
|
||||
]));
|
||||
|
||||
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
|
||||
let response = agent.turn("two step").await.unwrap();
|
||||
assert!(
|
||||
!response.is_empty(),
|
||||
"should produce final text after tool execution"
|
||||
);
|
||||
}
|
||||
310
third_party/zeroclaw/tests/integration/backup_cron_scheduling.rs
vendored
Normal file
310
third_party/zeroclaw/tests/integration/backup_cron_scheduling.rs
vendored
Normal file
@@ -0,0 +1,310 @@
|
||||
use tempfile::TempDir;
|
||||
use zeroclaw::config::schema::{CronJobDecl, CronScheduleDecl};
|
||||
use zeroclaw::config::Config;
|
||||
use zeroclaw::cron::{get_job, list_jobs, sync_declarative_jobs, JobType, Schedule};
|
||||
|
||||
fn test_config(tmp: &TempDir, schedule_cron: Option<String>) -> Config {
|
||||
let mut config = Config {
|
||||
workspace_dir: tmp.path().join("workspace"),
|
||||
config_path: tmp.path().join("config.toml"),
|
||||
..Config::default()
|
||||
};
|
||||
config.backup.schedule_cron = schedule_cron;
|
||||
std::fs::create_dir_all(&config.workspace_dir).unwrap();
|
||||
config
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_cron_job_synced_when_schedule_set() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp, Some("0 3 * * *".to_string()));
|
||||
|
||||
// Synthesize builtin backup job from config.backup.schedule_cron
|
||||
let mut jobs_with_builtin = config.cron.jobs.clone();
|
||||
if let Some(schedule_cron) = &config.backup.schedule_cron {
|
||||
let backup_job = CronJobDecl {
|
||||
id: "__builtin_backup".to_string(),
|
||||
name: Some("Scheduled backup".to_string()),
|
||||
job_type: "shell".to_string(),
|
||||
schedule: CronScheduleDecl::Cron {
|
||||
expr: schedule_cron.clone(),
|
||||
tz: None,
|
||||
},
|
||||
command: Some("backup create".to_string()),
|
||||
prompt: None,
|
||||
enabled: true,
|
||||
model: None,
|
||||
allowed_tools: None,
|
||||
session_target: None,
|
||||
delivery: None,
|
||||
};
|
||||
jobs_with_builtin.push(backup_job);
|
||||
}
|
||||
|
||||
sync_declarative_jobs(&config, &jobs_with_builtin).unwrap();
|
||||
|
||||
let job = get_job(&config, "__builtin_backup").unwrap();
|
||||
assert_eq!(job.id, "__builtin_backup");
|
||||
assert_eq!(job.command, "backup create");
|
||||
assert_eq!(job.source, "declarative");
|
||||
assert!(matches!(job.schedule, Schedule::Cron { ref expr, .. } if expr == "0 3 * * *"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_cron_job_not_synced_when_schedule_none() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp, None);
|
||||
|
||||
// No builtin backup job should be synthesized
|
||||
let jobs_with_builtin = config.cron.jobs.clone();
|
||||
sync_declarative_jobs(&config, &jobs_with_builtin).unwrap();
|
||||
|
||||
let result = get_job(&config, "__builtin_backup");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"builtin backup job should not exist when schedule_cron is None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_cron_job_removed_when_schedule_cleared() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_with_schedule = test_config(&tmp, Some("0 3 * * *".to_string()));
|
||||
|
||||
// First sync: create the builtin backup job
|
||||
let mut jobs_with_builtin = config_with_schedule.cron.jobs.clone();
|
||||
if let Some(schedule_cron) = &config_with_schedule.backup.schedule_cron {
|
||||
let backup_job = CronJobDecl {
|
||||
id: "__builtin_backup".to_string(),
|
||||
name: Some("Scheduled backup".to_string()),
|
||||
job_type: "shell".to_string(),
|
||||
schedule: CronScheduleDecl::Cron {
|
||||
expr: schedule_cron.clone(),
|
||||
tz: None,
|
||||
},
|
||||
command: Some("backup create".to_string()),
|
||||
prompt: None,
|
||||
enabled: true,
|
||||
model: None,
|
||||
allowed_tools: None,
|
||||
session_target: None,
|
||||
delivery: None,
|
||||
};
|
||||
jobs_with_builtin.push(backup_job);
|
||||
}
|
||||
sync_declarative_jobs(&config_with_schedule, &jobs_with_builtin).unwrap();
|
||||
assert!(get_job(&config_with_schedule, "__builtin_backup").is_ok());
|
||||
|
||||
// Second sync: remove schedule_cron from config
|
||||
let config_without_schedule = test_config(&tmp, None);
|
||||
let jobs_no_builtin = config_without_schedule.cron.jobs.clone();
|
||||
sync_declarative_jobs(&config_without_schedule, &jobs_no_builtin).unwrap();
|
||||
|
||||
let result = get_job(&config_without_schedule, "__builtin_backup");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"builtin backup job should be removed when schedule_cron is cleared"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_cron_job_schedule_updated() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_v1 = test_config(&tmp, Some("0 3 * * *".to_string()));
|
||||
|
||||
// First sync with schedule "0 3 * * *"
|
||||
let mut jobs_v1 = config_v1.cron.jobs.clone();
|
||||
if let Some(schedule_cron) = &config_v1.backup.schedule_cron {
|
||||
let backup_job = CronJobDecl {
|
||||
id: "__builtin_backup".to_string(),
|
||||
name: Some("Scheduled backup".to_string()),
|
||||
job_type: "shell".to_string(),
|
||||
schedule: CronScheduleDecl::Cron {
|
||||
expr: schedule_cron.clone(),
|
||||
tz: None,
|
||||
},
|
||||
command: Some("backup create".to_string()),
|
||||
prompt: None,
|
||||
enabled: true,
|
||||
model: None,
|
||||
allowed_tools: None,
|
||||
session_target: None,
|
||||
delivery: None,
|
||||
};
|
||||
jobs_v1.push(backup_job);
|
||||
}
|
||||
sync_declarative_jobs(&config_v1, &jobs_v1).unwrap();
|
||||
|
||||
let job_v1 = get_job(&config_v1, "__builtin_backup").unwrap();
|
||||
let next_run_v1 = job_v1.next_run;
|
||||
|
||||
// Second sync with schedule "0 2 * * *"
|
||||
let config_v2 = test_config(&tmp, Some("0 2 * * *".to_string()));
|
||||
let mut jobs_v2 = config_v2.cron.jobs.clone();
|
||||
if let Some(schedule_cron) = &config_v2.backup.schedule_cron {
|
||||
let backup_job = CronJobDecl {
|
||||
id: "__builtin_backup".to_string(),
|
||||
name: Some("Scheduled backup".to_string()),
|
||||
job_type: "shell".to_string(),
|
||||
schedule: CronScheduleDecl::Cron {
|
||||
expr: schedule_cron.clone(),
|
||||
tz: None,
|
||||
},
|
||||
command: Some("backup create".to_string()),
|
||||
prompt: None,
|
||||
enabled: true,
|
||||
model: None,
|
||||
allowed_tools: None,
|
||||
session_target: None,
|
||||
delivery: None,
|
||||
};
|
||||
jobs_v2.push(backup_job);
|
||||
}
|
||||
sync_declarative_jobs(&config_v2, &jobs_v2).unwrap();
|
||||
|
||||
let job_v2 = get_job(&config_v2, "__builtin_backup").unwrap();
|
||||
assert!(matches!(job_v2.schedule, Schedule::Cron { ref expr, .. } if expr == "0 2 * * *"));
|
||||
assert_ne!(
|
||||
job_v2.next_run, next_run_v1,
|
||||
"next_run should be recalculated when schedule changes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_cron_job_id_is_stable() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp, Some("0 3 * * *".to_string()));
|
||||
|
||||
// Sync twice with same config
|
||||
for _ in 0..2 {
|
||||
let mut jobs_with_builtin = config.cron.jobs.clone();
|
||||
if let Some(schedule_cron) = &config.backup.schedule_cron {
|
||||
let backup_job = CronJobDecl {
|
||||
id: "__builtin_backup".to_string(),
|
||||
name: Some("Scheduled backup".to_string()),
|
||||
job_type: "shell".to_string(),
|
||||
schedule: CronScheduleDecl::Cron {
|
||||
expr: schedule_cron.clone(),
|
||||
tz: None,
|
||||
},
|
||||
command: Some("backup create".to_string()),
|
||||
prompt: None,
|
||||
enabled: true,
|
||||
model: None,
|
||||
allowed_tools: None,
|
||||
session_target: None,
|
||||
delivery: None,
|
||||
};
|
||||
jobs_with_builtin.push(backup_job);
|
||||
}
|
||||
sync_declarative_jobs(&config, &jobs_with_builtin).unwrap();
|
||||
}
|
||||
|
||||
// Verify only one job exists with stable ID
|
||||
let job = get_job(&config, "__builtin_backup").unwrap();
|
||||
assert_eq!(job.id, "__builtin_backup");
|
||||
|
||||
let all_jobs = list_jobs(&config).unwrap();
|
||||
let backup_jobs: Vec<_> = all_jobs
|
||||
.iter()
|
||||
.filter(|j| j.id == "__builtin_backup")
|
||||
.collect();
|
||||
assert_eq!(
|
||||
backup_jobs.len(),
|
||||
1,
|
||||
"should have exactly one builtin backup job, not duplicates"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_cron_job_command_is_backup_create() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp, Some("0 3 * * *".to_string()));
|
||||
|
||||
let mut jobs_with_builtin = config.cron.jobs.clone();
|
||||
if let Some(schedule_cron) = &config.backup.schedule_cron {
|
||||
let backup_job = CronJobDecl {
|
||||
id: "__builtin_backup".to_string(),
|
||||
name: Some("Scheduled backup".to_string()),
|
||||
job_type: "shell".to_string(),
|
||||
schedule: CronScheduleDecl::Cron {
|
||||
expr: schedule_cron.clone(),
|
||||
tz: None,
|
||||
},
|
||||
command: Some("backup create".to_string()),
|
||||
prompt: None,
|
||||
enabled: true,
|
||||
model: None,
|
||||
allowed_tools: None,
|
||||
session_target: None,
|
||||
delivery: None,
|
||||
};
|
||||
jobs_with_builtin.push(backup_job);
|
||||
}
|
||||
sync_declarative_jobs(&config, &jobs_with_builtin).unwrap();
|
||||
|
||||
let job = get_job(&config, "__builtin_backup").unwrap();
|
||||
assert_eq!(job.command, "backup create");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_cron_job_type_is_shell() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp, Some("0 3 * * *".to_string()));
|
||||
|
||||
let mut jobs_with_builtin = config.cron.jobs.clone();
|
||||
if let Some(schedule_cron) = &config.backup.schedule_cron {
|
||||
let backup_job = CronJobDecl {
|
||||
id: "__builtin_backup".to_string(),
|
||||
name: Some("Scheduled backup".to_string()),
|
||||
job_type: "shell".to_string(),
|
||||
schedule: CronScheduleDecl::Cron {
|
||||
expr: schedule_cron.clone(),
|
||||
tz: None,
|
||||
},
|
||||
command: Some("backup create".to_string()),
|
||||
prompt: None,
|
||||
enabled: true,
|
||||
model: None,
|
||||
allowed_tools: None,
|
||||
session_target: None,
|
||||
delivery: None,
|
||||
};
|
||||
jobs_with_builtin.push(backup_job);
|
||||
}
|
||||
sync_declarative_jobs(&config, &jobs_with_builtin).unwrap();
|
||||
|
||||
let job = get_job(&config, "__builtin_backup").unwrap();
|
||||
assert_eq!(job.job_type, JobType::Shell);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_cron_job_source_is_declarative() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp, Some("0 3 * * *".to_string()));
|
||||
|
||||
let mut jobs_with_builtin = config.cron.jobs.clone();
|
||||
if let Some(schedule_cron) = &config.backup.schedule_cron {
|
||||
let backup_job = CronJobDecl {
|
||||
id: "__builtin_backup".to_string(),
|
||||
name: Some("Scheduled backup".to_string()),
|
||||
job_type: "shell".to_string(),
|
||||
schedule: CronScheduleDecl::Cron {
|
||||
expr: schedule_cron.clone(),
|
||||
tz: None,
|
||||
},
|
||||
command: Some("backup create".to_string()),
|
||||
prompt: None,
|
||||
enabled: true,
|
||||
model: None,
|
||||
allowed_tools: None,
|
||||
session_target: None,
|
||||
delivery: None,
|
||||
};
|
||||
jobs_with_builtin.push(backup_job);
|
||||
}
|
||||
sync_declarative_jobs(&config, &jobs_with_builtin).unwrap();
|
||||
|
||||
let job = get_job(&config, "__builtin_backup").unwrap();
|
||||
assert_eq!(job.source, "declarative");
|
||||
}
|
||||
1462
third_party/zeroclaw/tests/integration/channel_matrix.rs
vendored
Normal file
1462
third_party/zeroclaw/tests/integration/channel_matrix.rs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
328
third_party/zeroclaw/tests/integration/channel_routing.rs
vendored
Normal file
328
third_party/zeroclaw/tests/integration/channel_routing.rs
vendored
Normal file
@@ -0,0 +1,328 @@
|
||||
//! TG3: Channel Message Identity & Routing Tests
|
||||
//!
|
||||
//! Prevents: Pattern 3 — Channel message routing & identity bugs (17% of user bugs).
|
||||
//! Issues: #496, #483, #620, #415, #503
|
||||
//!
|
||||
//! Tests that ChannelMessage fields are used consistently and that the
|
||||
//! SendMessage → Channel trait contract preserves correct identity semantics.
|
||||
//! Verifies sender/reply_target field contracts to prevent field swaps.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use zeroclaw::channels::traits::{Channel, ChannelMessage, SendMessage};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ChannelMessage construction and field semantics
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn channel_message_sender_field_holds_platform_user_id() {
|
||||
// Simulates Telegram: sender should be numeric chat_id, not username
|
||||
let msg = ChannelMessage {
|
||||
id: "msg_1".into(),
|
||||
sender: "123456789".into(), // numeric chat_id
|
||||
reply_target: "msg_0".into(),
|
||||
content: "test message".into(),
|
||||
channel: "telegram".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
attachments: vec![],
|
||||
};
|
||||
|
||||
assert_eq!(msg.sender, "123456789");
|
||||
// Sender should be the platform-level user/chat identifier
|
||||
assert!(
|
||||
msg.sender.chars().all(|c| c.is_ascii_digit()),
|
||||
"Telegram sender should be numeric chat_id, got: {}",
|
||||
msg.sender
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_message_reply_target_distinct_from_sender() {
|
||||
// Simulates Discord: reply_target should be channel_id, not sender user_id
|
||||
let msg = ChannelMessage {
|
||||
id: "msg_1".into(),
|
||||
sender: "user_987654".into(), // Discord user ID
|
||||
reply_target: "channel_123".into(), // Discord channel ID for replies
|
||||
content: "test message".into(),
|
||||
channel: "discord".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
attachments: vec![],
|
||||
};
|
||||
|
||||
assert_ne!(
|
||||
msg.sender, msg.reply_target,
|
||||
"sender and reply_target should be distinct for Discord"
|
||||
);
|
||||
assert_eq!(msg.reply_target, "channel_123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_message_fields_not_swapped() {
|
||||
// Guards against #496 (Telegram) and #483 (Discord) field swap bugs
|
||||
let msg = ChannelMessage {
|
||||
id: "msg_42".into(),
|
||||
sender: "sender_value".into(),
|
||||
reply_target: "target_value".into(),
|
||||
content: "payload".into(),
|
||||
channel: "test".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
attachments: vec![],
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
msg.sender, "sender_value",
|
||||
"sender field should not be swapped"
|
||||
);
|
||||
assert_eq!(
|
||||
msg.reply_target, "target_value",
|
||||
"reply_target field should not be swapped"
|
||||
);
|
||||
assert_ne!(
|
||||
msg.sender, msg.reply_target,
|
||||
"sender and reply_target should remain distinct"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_message_preserves_all_fields_on_clone() {
|
||||
let original = ChannelMessage {
|
||||
id: "clone_test".into(),
|
||||
sender: "sender_123".into(),
|
||||
reply_target: "target_456".into(),
|
||||
content: "cloned content".into(),
|
||||
channel: "test_channel".into(),
|
||||
timestamp: 1700000001,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
attachments: vec![],
|
||||
};
|
||||
|
||||
let cloned = original.clone();
|
||||
|
||||
assert_eq!(cloned.id, original.id);
|
||||
assert_eq!(cloned.sender, original.sender);
|
||||
assert_eq!(cloned.reply_target, original.reply_target);
|
||||
assert_eq!(cloned.content, original.content);
|
||||
assert_eq!(cloned.channel, original.channel);
|
||||
assert_eq!(cloned.timestamp, original.timestamp);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SendMessage construction
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn send_message_new_sets_content_and_recipient() {
|
||||
let msg = SendMessage::new("Hello", "recipient_123");
|
||||
|
||||
assert_eq!(msg.content, "Hello");
|
||||
assert_eq!(msg.recipient, "recipient_123");
|
||||
assert!(msg.subject.is_none(), "subject should be None by default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_with_subject_sets_all_fields() {
|
||||
let msg = SendMessage::with_subject("Hello", "recipient_123", "Re: Test");
|
||||
|
||||
assert_eq!(msg.content, "Hello");
|
||||
assert_eq!(msg.recipient, "recipient_123");
|
||||
assert_eq!(msg.subject.as_deref(), Some("Re: Test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_recipient_carries_platform_target() {
|
||||
// Verifies that SendMessage::recipient is used as the platform delivery target
|
||||
// For Telegram: this should be the chat_id
|
||||
// For Discord: this should be the channel_id
|
||||
let telegram_msg = SendMessage::new("response", "123456789");
|
||||
assert_eq!(
|
||||
telegram_msg.recipient, "123456789",
|
||||
"Telegram SendMessage recipient should be chat_id"
|
||||
);
|
||||
|
||||
let discord_msg = SendMessage::new("response", "channel_987654");
|
||||
assert_eq!(
|
||||
discord_msg.recipient, "channel_987654",
|
||||
"Discord SendMessage recipient should be channel_id"
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Channel trait contract: send/listen roundtrip via DummyChannel
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Test channel that captures sent messages for assertion
|
||||
struct CapturingChannel {
|
||||
sent: std::sync::Mutex<Vec<SendMessage>>,
|
||||
}
|
||||
|
||||
impl CapturingChannel {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
sent: std::sync::Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn sent_messages(&self) -> Vec<SendMessage> {
|
||||
self.sent.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for CapturingChannel {
|
||||
fn name(&self) -> &str {
|
||||
"capturing"
|
||||
}
|
||||
|
||||
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||
self.sent.lock().unwrap().push(message.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
|
||||
tx.send(ChannelMessage {
|
||||
id: "listen_1".into(),
|
||||
sender: "test_sender".into(),
|
||||
reply_target: "test_target".into(),
|
||||
content: "incoming".into(),
|
||||
channel: "capturing".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
attachments: vec![],
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn channel_send_preserves_recipient() {
|
||||
let channel = CapturingChannel::new();
|
||||
let msg = SendMessage::new("Hello", "target_123");
|
||||
|
||||
channel.send(&msg).await.unwrap();
|
||||
|
||||
let sent = channel.sent_messages();
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert_eq!(sent[0].recipient, "target_123");
|
||||
assert_eq!(sent[0].content, "Hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn channel_listen_produces_correct_identity_fields() {
|
||||
let channel = CapturingChannel::new();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(1);
|
||||
|
||||
channel.listen(tx).await.unwrap();
|
||||
let received = rx.recv().await.expect("should receive message");
|
||||
|
||||
assert_eq!(received.sender, "test_sender");
|
||||
assert_eq!(received.reply_target, "test_target");
|
||||
assert_ne!(
|
||||
received.sender, received.reply_target,
|
||||
"listen() should populate sender and reply_target distinctly"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn channel_send_reply_uses_sender_from_listen() {
|
||||
let channel = CapturingChannel::new();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(1);
|
||||
|
||||
// Simulate: listen() → receive message → send reply using sender
|
||||
channel.listen(tx).await.unwrap();
|
||||
let incoming = rx.recv().await.expect("should receive message");
|
||||
|
||||
// Reply should go to the reply_target, not sender
|
||||
let reply = SendMessage::new("reply content", &incoming.reply_target);
|
||||
channel.send(&reply).await.unwrap();
|
||||
|
||||
let sent = channel.sent_messages();
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert_eq!(
|
||||
sent[0].recipient, "test_target",
|
||||
"reply should use reply_target as recipient"
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Channel trait default methods
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn channel_health_check_default_returns_true() {
|
||||
let channel = CapturingChannel::new();
|
||||
assert!(
|
||||
channel.health_check().await,
|
||||
"default health_check should return true"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn channel_typing_defaults_succeed() {
|
||||
let channel = CapturingChannel::new();
|
||||
assert!(channel.start_typing("target").await.is_ok());
|
||||
assert!(channel.stop_typing("target").await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn channel_draft_defaults() {
|
||||
let channel = CapturingChannel::new();
|
||||
assert!(!channel.supports_draft_updates());
|
||||
|
||||
let draft_result = channel
|
||||
.send_draft(&SendMessage::new("draft", "target"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
draft_result.is_none(),
|
||||
"default send_draft should return None"
|
||||
);
|
||||
|
||||
assert!(channel
|
||||
.update_draft("target", "msg_1", "updated")
|
||||
.await
|
||||
.is_ok());
|
||||
assert!(channel
|
||||
.finalize_draft("target", "msg_1", "final")
|
||||
.await
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Multiple messages: conversation context preservation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn channel_multiple_sends_preserve_order_and_recipients() {
|
||||
let channel = CapturingChannel::new();
|
||||
|
||||
channel
|
||||
.send(&SendMessage::new("msg 1", "target_a"))
|
||||
.await
|
||||
.unwrap();
|
||||
channel
|
||||
.send(&SendMessage::new("msg 2", "target_b"))
|
||||
.await
|
||||
.unwrap();
|
||||
channel
|
||||
.send(&SendMessage::new("msg 3", "target_a"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sent = channel.sent_messages();
|
||||
assert_eq!(sent.len(), 3);
|
||||
assert_eq!(sent[0].recipient, "target_a");
|
||||
assert_eq!(sent[1].recipient, "target_b");
|
||||
assert_eq!(sent[2].recipient, "target_a");
|
||||
assert_eq!(sent[0].content, "msg 1");
|
||||
assert_eq!(sent[1].content, "msg 2");
|
||||
assert_eq!(sent[2].content, "msg 3");
|
||||
}
|
||||
96
third_party/zeroclaw/tests/integration/hooks.rs
vendored
Normal file
96
third_party/zeroclaw/tests/integration/hooks.rs
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
use async_trait::async_trait;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use zeroclaw::hooks::{HookHandler, HookResult, HookRunner};
|
||||
use zeroclaw::tools::ToolResult;
|
||||
|
||||
struct CounterHook {
|
||||
gateway_starts: Arc<AtomicUsize>,
|
||||
tool_calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HookHandler for CounterHook {
|
||||
fn name(&self) -> &str {
|
||||
"counter"
|
||||
}
|
||||
|
||||
async fn on_gateway_start(&self, _host: &str, _port: u16) {
|
||||
self.gateway_starts.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
async fn on_after_tool_call(&self, _tool: &str, _result: &ToolResult, _duration: Duration) {
|
||||
self.tool_calls.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
struct ToolBlocker {
|
||||
blocked_tools: Vec<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HookHandler for ToolBlocker {
|
||||
fn name(&self) -> &str {
|
||||
"tool-blocker"
|
||||
}
|
||||
|
||||
fn priority(&self) -> i32 {
|
||||
100
|
||||
}
|
||||
|
||||
async fn before_tool_call(
|
||||
&self,
|
||||
name: String,
|
||||
args: serde_json::Value,
|
||||
) -> HookResult<(String, serde_json::Value)> {
|
||||
if self.blocked_tools.contains(&name) {
|
||||
HookResult::Cancel(format!("{name} is blocked"))
|
||||
} else {
|
||||
HookResult::Continue((name, args))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_runner_full_pipeline() {
|
||||
let gateway_starts = Arc::new(AtomicUsize::new(0));
|
||||
let tool_calls = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let mut runner = HookRunner::new();
|
||||
runner.register(Box::new(CounterHook {
|
||||
gateway_starts: gateway_starts.clone(),
|
||||
tool_calls: tool_calls.clone(),
|
||||
}));
|
||||
runner.register(Box::new(ToolBlocker {
|
||||
blocked_tools: vec!["dangerous".into()],
|
||||
}));
|
||||
|
||||
// Void hook: fire gateway start
|
||||
runner.fire_gateway_start("127.0.0.1", 8080).await;
|
||||
assert_eq!(gateway_starts.load(Ordering::SeqCst), 1);
|
||||
|
||||
// Modifying hook: safe tool passes through
|
||||
let result = runner
|
||||
.run_before_tool_call("safe_tool".into(), serde_json::json!({}))
|
||||
.await;
|
||||
assert!(!result.is_cancel());
|
||||
|
||||
// Modifying hook: dangerous tool is blocked
|
||||
let result = runner
|
||||
.run_before_tool_call("dangerous".into(), serde_json::json!({}))
|
||||
.await;
|
||||
assert!(result.is_cancel());
|
||||
|
||||
// Void hook: fire after tool call increments counter
|
||||
let tool_result = ToolResult {
|
||||
success: true,
|
||||
output: "ok".into(),
|
||||
error: None,
|
||||
};
|
||||
runner
|
||||
.fire_after_tool_call("safe_tool", &tool_result, Duration::from_millis(10))
|
||||
.await;
|
||||
assert_eq!(tool_calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
453
third_party/zeroclaw/tests/integration/memory_comparison.rs
vendored
Normal file
453
third_party/zeroclaw/tests/integration/memory_comparison.rs
vendored
Normal file
@@ -0,0 +1,453 @@
|
||||
//! Head-to-head comparison: SQLite vs Markdown memory backends
|
||||
//!
|
||||
//! Run with: cargo test --test memory_comparison -- --nocapture
|
||||
|
||||
use std::time::Instant;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// We test both backends through the public memory module
|
||||
use zeroclaw::memory::{markdown::MarkdownMemory, sqlite::SqliteMemory, Memory, MemoryCategory};
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
fn sqlite_backend(dir: &std::path::Path) -> SqliteMemory {
|
||||
SqliteMemory::new(dir).expect("SQLite init failed")
|
||||
}
|
||||
|
||||
fn markdown_backend(dir: &std::path::Path) -> MarkdownMemory {
|
||||
MarkdownMemory::new(dir)
|
||||
}
|
||||
|
||||
// ── Test 1: Store performance ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn compare_store_speed() {
|
||||
let tmp_sq = TempDir::new().unwrap();
|
||||
let tmp_md = TempDir::new().unwrap();
|
||||
let sq = sqlite_backend(tmp_sq.path());
|
||||
let md = markdown_backend(tmp_md.path());
|
||||
|
||||
let n = 100;
|
||||
|
||||
// SQLite: 100 stores
|
||||
let start = Instant::now();
|
||||
for i in 0..n {
|
||||
sq.store(
|
||||
&format!("key_{i}"),
|
||||
&format!("Memory entry number {i} about Rust programming"),
|
||||
MemoryCategory::Core,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let sq_dur = start.elapsed();
|
||||
|
||||
// Markdown: 100 stores
|
||||
let start = Instant::now();
|
||||
for i in 0..n {
|
||||
md.store(
|
||||
&format!("key_{i}"),
|
||||
&format!("Memory entry number {i} about Rust programming"),
|
||||
MemoryCategory::Core,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let md_dur = start.elapsed();
|
||||
|
||||
println!("\n============================================================");
|
||||
println!("STORE {n} entries:");
|
||||
println!(" SQLite: {:?}", sq_dur);
|
||||
println!(" Markdown: {:?}", md_dur);
|
||||
|
||||
// Both should succeed
|
||||
assert_eq!(sq.count().await.unwrap(), n);
|
||||
// Markdown count parses lines, may differ slightly from n
|
||||
let md_count = md.count().await.unwrap();
|
||||
assert!(md_count >= n, "Markdown stored {md_count}, expected >= {n}");
|
||||
}
|
||||
|
||||
// ── Test 2: Recall / search quality ────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn compare_recall_quality() {
|
||||
let tmp_sq = TempDir::new().unwrap();
|
||||
let tmp_md = TempDir::new().unwrap();
|
||||
let sq = sqlite_backend(tmp_sq.path());
|
||||
let md = markdown_backend(tmp_md.path());
|
||||
|
||||
// Seed both with identical data
|
||||
let entries = vec![
|
||||
(
|
||||
"lang_pref",
|
||||
"User prefers Rust over Python",
|
||||
MemoryCategory::Core,
|
||||
),
|
||||
(
|
||||
"editor",
|
||||
"Uses VS Code with rust-analyzer",
|
||||
MemoryCategory::Core,
|
||||
),
|
||||
("tz", "Timezone is EST, works 9-5", MemoryCategory::Core),
|
||||
(
|
||||
"proj1",
|
||||
"Working on ZeroClaw AI assistant",
|
||||
MemoryCategory::Daily,
|
||||
),
|
||||
(
|
||||
"proj2",
|
||||
"Previous project was a web scraper in Python",
|
||||
MemoryCategory::Daily,
|
||||
),
|
||||
(
|
||||
"deploy",
|
||||
"Deploys to Hetzner VPS via Docker",
|
||||
MemoryCategory::Core,
|
||||
),
|
||||
(
|
||||
"model",
|
||||
"Prefers Claude Sonnet for coding tasks",
|
||||
MemoryCategory::Core,
|
||||
),
|
||||
(
|
||||
"style",
|
||||
"Likes concise responses, no fluff",
|
||||
MemoryCategory::Core,
|
||||
),
|
||||
(
|
||||
"rust_note",
|
||||
"Rust's ownership model prevents memory bugs",
|
||||
MemoryCategory::Daily,
|
||||
),
|
||||
(
|
||||
"perf",
|
||||
"Cares about binary size and startup time",
|
||||
MemoryCategory::Core,
|
||||
),
|
||||
];
|
||||
|
||||
for (key, content, cat) in &entries {
|
||||
sq.store(key, content, cat.clone(), None).await.unwrap();
|
||||
md.store(key, content, cat.clone(), None).await.unwrap();
|
||||
}
|
||||
|
||||
// Test queries and compare results
|
||||
let queries = vec![
|
||||
("Rust", "Should find Rust-related entries"),
|
||||
("Python", "Should find Python references"),
|
||||
("deploy Docker", "Multi-keyword search"),
|
||||
("Claude", "Specific tool reference"),
|
||||
("javascript", "No matches expected"),
|
||||
("binary size startup", "Multi-keyword partial match"),
|
||||
];
|
||||
|
||||
println!("\n============================================================");
|
||||
println!("RECALL QUALITY (10 entries seeded):\n");
|
||||
|
||||
for (query, desc) in &queries {
|
||||
let sq_results = sq.recall(query, 10, None, None, None).await.unwrap();
|
||||
let md_results = md.recall(query, 10, None, None, None).await.unwrap();
|
||||
|
||||
println!(" Query: \"{query}\" — {desc}");
|
||||
println!(" SQLite: {} results", sq_results.len());
|
||||
for r in &sq_results {
|
||||
println!(
|
||||
" [{:.2}] {}: {}",
|
||||
r.score.unwrap_or(0.0),
|
||||
r.key,
|
||||
&r.content[..r.content.len().min(50)]
|
||||
);
|
||||
}
|
||||
println!(" Markdown: {} results", md_results.len());
|
||||
for r in &md_results {
|
||||
println!(
|
||||
" [{:.2}] {}: {}",
|
||||
r.score.unwrap_or(0.0),
|
||||
r.key,
|
||||
&r.content[..r.content.len().min(50)]
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 3: Recall speed at scale ──────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn compare_recall_speed() {
|
||||
let tmp_sq = TempDir::new().unwrap();
|
||||
let tmp_md = TempDir::new().unwrap();
|
||||
let sq = sqlite_backend(tmp_sq.path());
|
||||
let md = markdown_backend(tmp_md.path());
|
||||
|
||||
// Seed 200 entries
|
||||
let n = 200;
|
||||
for i in 0..n {
|
||||
let content = if i % 3 == 0 {
|
||||
format!("Rust is great for systems programming, entry {i}")
|
||||
} else if i % 3 == 1 {
|
||||
format!("Python is popular for data science, entry {i}")
|
||||
} else {
|
||||
format!("TypeScript powers modern web apps, entry {i}")
|
||||
};
|
||||
sq.store(&format!("e{i}"), &content, MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
md.store(&format!("e{i}"), &content, MemoryCategory::Daily, None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Benchmark recall
|
||||
let start = Instant::now();
|
||||
let sq_results = sq
|
||||
.recall("Rust systems", 10, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let sq_dur = start.elapsed();
|
||||
|
||||
let start = Instant::now();
|
||||
let md_results = md
|
||||
.recall("Rust systems", 10, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let md_dur = start.elapsed();
|
||||
|
||||
println!("\n============================================================");
|
||||
println!("RECALL from {n} entries (query: \"Rust systems\", limit 10):");
|
||||
println!(" SQLite: {:?} → {} results", sq_dur, sq_results.len());
|
||||
println!(" Markdown: {:?} → {} results", md_dur, md_results.len());
|
||||
|
||||
// Both should find results
|
||||
assert!(!sq_results.is_empty());
|
||||
assert!(!md_results.is_empty());
|
||||
}
|
||||
|
||||
// ── Test 4: Persistence (SQLite wins by design) ────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn compare_persistence() {
|
||||
let tmp_sq = TempDir::new().unwrap();
|
||||
let tmp_md = TempDir::new().unwrap();
|
||||
|
||||
// Store in both, then drop and re-open
|
||||
{
|
||||
let sq = sqlite_backend(tmp_sq.path());
|
||||
sq.store(
|
||||
"persist_test",
|
||||
"I should survive",
|
||||
MemoryCategory::Core,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
{
|
||||
let md = markdown_backend(tmp_md.path());
|
||||
md.store(
|
||||
"persist_test",
|
||||
"I should survive",
|
||||
MemoryCategory::Core,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Re-open
|
||||
let sq2 = sqlite_backend(tmp_sq.path());
|
||||
let md2 = markdown_backend(tmp_md.path());
|
||||
|
||||
let sq_entry = sq2.get("persist_test").await.unwrap();
|
||||
let md_entry = md2.get("persist_test").await.unwrap();
|
||||
|
||||
println!("\n============================================================");
|
||||
println!("PERSISTENCE (store → drop → re-open → get):");
|
||||
println!(
|
||||
" SQLite: {}",
|
||||
if sq_entry.is_some() {
|
||||
"✅ Survived"
|
||||
} else {
|
||||
"❌ Lost"
|
||||
}
|
||||
);
|
||||
println!(
|
||||
" Markdown: {}",
|
||||
if md_entry.is_some() {
|
||||
"✅ Survived"
|
||||
} else {
|
||||
"❌ Lost"
|
||||
}
|
||||
);
|
||||
|
||||
// SQLite should always persist by key
|
||||
assert!(sq_entry.is_some());
|
||||
assert_eq!(sq_entry.unwrap().content, "I should survive");
|
||||
|
||||
// Markdown persists content to files (get uses content search)
|
||||
assert!(md_entry.is_some());
|
||||
}
|
||||
|
||||
// ── Test 5: Upsert / update behavior ──────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn compare_upsert() {
|
||||
let tmp_sq = TempDir::new().unwrap();
|
||||
let tmp_md = TempDir::new().unwrap();
|
||||
let sq = sqlite_backend(tmp_sq.path());
|
||||
let md = markdown_backend(tmp_md.path());
|
||||
|
||||
// Store twice with same key, different content
|
||||
sq.store("pref", "likes Rust", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
sq.store("pref", "loves Rust", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
md.store("pref", "likes Rust", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
md.store("pref", "loves Rust", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sq_count = sq.count().await.unwrap();
|
||||
let md_count = md.count().await.unwrap();
|
||||
|
||||
let sq_entry = sq.get("pref").await.unwrap();
|
||||
let md_results = md.recall("loves Rust", 5, None, None, None).await.unwrap();
|
||||
|
||||
println!("\n============================================================");
|
||||
println!("UPSERT (store same key twice):");
|
||||
println!(
|
||||
" SQLite: count={sq_count}, latest=\"{}\"",
|
||||
sq_entry.as_ref().map_or("none", |e| &e.content)
|
||||
);
|
||||
println!(" Markdown: count={md_count} (append-only, both entries kept)");
|
||||
println!(" Can still find latest: {}", !md_results.is_empty());
|
||||
|
||||
// SQLite: upsert replaces, count stays at 1
|
||||
assert_eq!(sq_count, 1);
|
||||
assert_eq!(sq_entry.unwrap().content, "loves Rust");
|
||||
|
||||
// Markdown: append-only, count increases
|
||||
assert!(md_count >= 2, "Markdown should keep both entries");
|
||||
}
|
||||
|
||||
// ── Test 6: Forget / delete capability ─────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn compare_forget() {
|
||||
let tmp_sq = TempDir::new().unwrap();
|
||||
let tmp_md = TempDir::new().unwrap();
|
||||
let sq = sqlite_backend(tmp_sq.path());
|
||||
let md = markdown_backend(tmp_md.path());
|
||||
|
||||
sq.store("secret", "API key: sk-1234", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
md.store("secret", "API key: sk-1234", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sq_forgot = sq.forget("secret").await.unwrap();
|
||||
let md_forgot = md.forget("secret").await.unwrap();
|
||||
|
||||
println!("\n============================================================");
|
||||
println!("FORGET (delete sensitive data):");
|
||||
println!(
|
||||
" SQLite: {} (count={})",
|
||||
if sq_forgot { "✅ Deleted" } else { "❌ Kept" },
|
||||
sq.count().await.unwrap()
|
||||
);
|
||||
println!(
|
||||
" Markdown: {} (append-only by design)",
|
||||
if md_forgot {
|
||||
"✅ Deleted"
|
||||
} else {
|
||||
"⚠️ Cannot delete (audit trail)"
|
||||
},
|
||||
);
|
||||
|
||||
// SQLite can delete
|
||||
assert!(sq_forgot);
|
||||
assert_eq!(sq.count().await.unwrap(), 0);
|
||||
|
||||
// Markdown cannot delete (by design)
|
||||
assert!(!md_forgot);
|
||||
}
|
||||
|
||||
// ── Test 7: Category filtering ─────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn compare_category_filter() {
|
||||
let tmp_sq = TempDir::new().unwrap();
|
||||
let tmp_md = TempDir::new().unwrap();
|
||||
let sq = sqlite_backend(tmp_sq.path());
|
||||
let md = markdown_backend(tmp_md.path());
|
||||
|
||||
// Mix of categories
|
||||
sq.store("a", "core fact 1", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
sq.store("b", "core fact 2", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
sq.store("c", "daily note", MemoryCategory::Daily, None)
|
||||
.await
|
||||
.unwrap();
|
||||
sq.store("d", "convo msg", MemoryCategory::Conversation, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
md.store("a", "core fact 1", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
md.store("b", "core fact 2", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
md.store("c", "daily note", MemoryCategory::Daily, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sq_core = sq.list(Some(&MemoryCategory::Core), None).await.unwrap();
|
||||
let sq_daily = sq.list(Some(&MemoryCategory::Daily), None).await.unwrap();
|
||||
let sq_conv = sq
|
||||
.list(Some(&MemoryCategory::Conversation), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let sq_all = sq.list(None, None).await.unwrap();
|
||||
|
||||
let md_core = md.list(Some(&MemoryCategory::Core), None).await.unwrap();
|
||||
let md_daily = md.list(Some(&MemoryCategory::Daily), None).await.unwrap();
|
||||
let md_all = md.list(None, None).await.unwrap();
|
||||
|
||||
println!("\n============================================================");
|
||||
println!("CATEGORY FILTERING:");
|
||||
println!(
|
||||
" SQLite: core={}, daily={}, conv={}, all={}",
|
||||
sq_core.len(),
|
||||
sq_daily.len(),
|
||||
sq_conv.len(),
|
||||
sq_all.len()
|
||||
);
|
||||
println!(
|
||||
" Markdown: core={}, daily={}, all={}",
|
||||
md_core.len(),
|
||||
md_daily.len(),
|
||||
md_all.len()
|
||||
);
|
||||
|
||||
// SQLite: precise category filtering via SQL WHERE
|
||||
assert_eq!(sq_core.len(), 2);
|
||||
assert_eq!(sq_daily.len(), 1);
|
||||
assert_eq!(sq_conv.len(), 1);
|
||||
assert_eq!(sq_all.len(), 4);
|
||||
|
||||
// Markdown: categories determined by file location
|
||||
assert!(!md_core.is_empty());
|
||||
assert!(!md_all.is_empty());
|
||||
}
|
||||
375
third_party/zeroclaw/tests/integration/memory_restart.rs
vendored
Normal file
375
third_party/zeroclaw/tests/integration/memory_restart.rs
vendored
Normal file
@@ -0,0 +1,375 @@
|
||||
//! TG5: Memory Restart Resilience Tests
|
||||
//!
|
||||
//! Prevents: Pattern 5 — Memory & state persistence bugs (10% of user bugs).
|
||||
//! Issues: #430, #693, #802
|
||||
//!
|
||||
//! Tests SqliteMemory deduplication on restart, session scoping, concurrent
|
||||
//! message ordering, and recall behavior after re-initialization.
|
||||
|
||||
use std::sync::Arc;
|
||||
use zeroclaw::memory::sqlite::SqliteMemory;
|
||||
use zeroclaw::memory::traits::{Memory, MemoryCategory};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Deduplication: same key overwrites instead of duplicating (#430)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_store_same_key_deduplicates() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
// Store same key twice with different content
|
||||
mem.store("greeting", "hello world", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store("greeting", "hello updated", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should have exactly 1 entry, not 2
|
||||
let count = mem.count().await.unwrap();
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"storing same key twice should not create duplicates"
|
||||
);
|
||||
|
||||
// Content should be the latest version
|
||||
let entry = mem
|
||||
.get("greeting")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("entry should exist");
|
||||
assert_eq!(entry.content, "hello updated");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_store_different_keys_creates_separate_entries() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
mem.store("key_a", "content a", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store("key_b", "content b", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let count = mem.count().await.unwrap();
|
||||
assert_eq!(count, 2, "different keys should create separate entries");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Restart resilience: data persists across memory re-initialization
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_persists_across_reinitialization() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
// First "session": store data
|
||||
{
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
mem.store(
|
||||
"persistent_fact",
|
||||
"Rust is great",
|
||||
MemoryCategory::Core,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Second "session": re-create memory from same path
|
||||
{
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
let entry = mem
|
||||
.get("persistent_fact")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("entry should survive reinitialization");
|
||||
assert_eq!(entry.content, "Rust is great");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_restart_does_not_duplicate_on_rewrite() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
// First session: store entries
|
||||
{
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
mem.store("fact_1", "original content", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store("fact_2", "another fact", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Second session: re-store same keys (simulates channel re-reading history)
|
||||
{
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
mem.store("fact_1", "original content", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store("fact_2", "another fact", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let count = mem.count().await.unwrap();
|
||||
assert_eq!(
|
||||
count, 2,
|
||||
"re-storing same keys after restart should not create duplicates"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Session scoping: messages scoped to sessions don't leak
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_session_scoped_store_and_recall() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
// Store in different sessions
|
||||
mem.store(
|
||||
"session_a_fact",
|
||||
"fact from session A",
|
||||
MemoryCategory::Conversation,
|
||||
Some("session_a"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store(
|
||||
"session_b_fact",
|
||||
"fact from session B",
|
||||
MemoryCategory::Conversation,
|
||||
Some("session_b"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// List scoped to session_a
|
||||
let session_a_entries = mem
|
||||
.list(Some(&MemoryCategory::Conversation), Some("session_a"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
session_a_entries.len(),
|
||||
1,
|
||||
"session_a should have exactly 1 entry"
|
||||
);
|
||||
assert_eq!(session_a_entries[0].content, "fact from session A");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_global_recall_includes_all_sessions() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
mem.store(
|
||||
"global_a",
|
||||
"alpha content",
|
||||
MemoryCategory::Core,
|
||||
Some("s1"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store("global_b", "beta content", MemoryCategory::Core, Some("s2"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Global count should include all
|
||||
let count = mem.count().await.unwrap();
|
||||
assert_eq!(
|
||||
count, 2,
|
||||
"global count should include entries from all sessions"
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Recall and search behavior
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_recall_returns_relevant_results() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
mem.store(
|
||||
"lang_pref",
|
||||
"User prefers Rust programming",
|
||||
MemoryCategory::Core,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store(
|
||||
"food_pref",
|
||||
"User likes sushi for lunch",
|
||||
MemoryCategory::Core,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let results = mem
|
||||
.recall("Rust programming", 10, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!results.is_empty(), "recall should find matching entries");
|
||||
// The Rust-related entry should be in results
|
||||
assert!(
|
||||
results.iter().any(|e| e.content.contains("Rust")),
|
||||
"recall for 'Rust' should include the Rust-related entry"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_recall_respects_limit() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
for i in 0..10 {
|
||||
mem.store(
|
||||
&format!("entry_{i}"),
|
||||
&format!("test content number {i}"),
|
||||
MemoryCategory::Core,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let results = mem
|
||||
.recall("test content", 3, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
results.len() <= 3,
|
||||
"recall should respect limit of 3, got {}",
|
||||
results.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_recall_empty_query_returns_recent_entries() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
mem.store("fact", "some content", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Empty query uses time-only path: returns recent entries by updated_at
|
||||
let results = mem.recall("", 10, None, None, None).await.unwrap();
|
||||
assert_eq!(results.len(), 1, "empty query should return recent entries");
|
||||
assert_eq!(results[0].key, "fact");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Forget and health check
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_forget_removes_entry() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
mem.store("to_forget", "temporary info", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(mem.count().await.unwrap(), 1);
|
||||
|
||||
let removed = mem.forget("to_forget").await.unwrap();
|
||||
assert!(removed, "forget should return true for existing key");
|
||||
assert_eq!(mem.count().await.unwrap(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_forget_nonexistent_returns_false() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
let removed = mem.forget("nonexistent_key").await.unwrap();
|
||||
assert!(!removed, "forget should return false for nonexistent key");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_health_check_returns_true() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
assert!(mem.health_check().await, "health_check should return true");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Concurrent access
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_concurrent_stores_no_data_loss() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = Arc::new(SqliteMemory::new(tmp.path()).unwrap());
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for i in 0..5 {
|
||||
let mem_clone = mem.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
mem_clone
|
||||
.store(
|
||||
&format!("concurrent_{i}"),
|
||||
&format!("content from task {i}"),
|
||||
MemoryCategory::Core,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}));
|
||||
}
|
||||
|
||||
for handle in handles {
|
||||
handle.await.unwrap();
|
||||
}
|
||||
|
||||
let count = mem.count().await.unwrap();
|
||||
assert_eq!(
|
||||
count, 5,
|
||||
"all concurrent stores should succeed, got {count}"
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Memory categories
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_list_by_category() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
mem.store("core_fact", "core info", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store("daily_note", "daily note", MemoryCategory::Daily, None)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store(
|
||||
"conv_msg",
|
||||
"conversation msg",
|
||||
MemoryCategory::Conversation,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let core_entries = mem.list(Some(&MemoryCategory::Core), None).await.unwrap();
|
||||
assert_eq!(core_entries.len(), 1, "should have 1 Core entry");
|
||||
assert_eq!(core_entries[0].key, "core_fact");
|
||||
|
||||
let daily_entries = mem.list(Some(&MemoryCategory::Daily), None).await.unwrap();
|
||||
assert_eq!(daily_entries.len(), 1, "should have 1 Daily entry");
|
||||
}
|
||||
11
third_party/zeroclaw/tests/integration/mod.rs
vendored
Normal file
11
third_party/zeroclaw/tests/integration/mod.rs
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
mod agent;
|
||||
mod agent_robustness;
|
||||
mod backup_cron_scheduling;
|
||||
mod channel_matrix;
|
||||
mod channel_routing;
|
||||
mod hooks;
|
||||
mod memory_comparison;
|
||||
mod memory_restart;
|
||||
mod report_template_tool_test;
|
||||
mod telegram_attachment_fallback;
|
||||
mod telegram_finalize_draft;
|
||||
238
third_party/zeroclaw/tests/integration/report_template_tool_test.rs
vendored
Normal file
238
third_party/zeroclaw/tests/integration/report_template_tool_test.rs
vendored
Normal file
@@ -0,0 +1,238 @@
|
||||
//! Integration tests for ReportTemplateTool.
|
||||
|
||||
use serde_json::json;
|
||||
use zeroclaw::tools::{ReportTemplateTool, Tool};
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_weekly_status_en() {
|
||||
let tool = ReportTemplateTool::new();
|
||||
let params = json!({
|
||||
"template": "weekly_status",
|
||||
"language": "en",
|
||||
"variables": {
|
||||
"project_name": "Acme Platform",
|
||||
"period": "2026-W10",
|
||||
"completed": "- Task A\n- Task B",
|
||||
"in_progress": "- Task C",
|
||||
"blocked": "None",
|
||||
"next_steps": "- Task D"
|
||||
}
|
||||
});
|
||||
|
||||
let result = tool.execute(params).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("Project: Acme Platform"));
|
||||
assert!(result.output.contains("Period: 2026-W10"));
|
||||
assert!(result.output.contains("- Task A"));
|
||||
assert!(result.output.contains("## Completed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_sprint_review_de() {
|
||||
let tool = ReportTemplateTool::new();
|
||||
let params = json!({
|
||||
"template": "sprint_review",
|
||||
"language": "de",
|
||||
"variables": {
|
||||
"sprint_dates": "2026-03-01 bis 2026-03-14",
|
||||
"completed": "Feature X implementiert",
|
||||
"in_progress": "Feature Y",
|
||||
"blocked": "Keine",
|
||||
"velocity": "12 Story Points"
|
||||
}
|
||||
});
|
||||
|
||||
let result = tool.execute(params).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("## Sprint"));
|
||||
assert!(result.output.contains("## Erledigt"));
|
||||
assert!(result.output.contains("Feature X implementiert"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_risk_register_fr() {
|
||||
let tool = ReportTemplateTool::new();
|
||||
let params = json!({
|
||||
"template": "risk_register",
|
||||
"language": "fr",
|
||||
"variables": {
|
||||
"project_name": "Projet Alpha",
|
||||
"risks": "Risque de retard",
|
||||
"mitigations": "Augmenter les ressources"
|
||||
}
|
||||
});
|
||||
|
||||
let result = tool.execute(params).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("## Projet"));
|
||||
assert!(result.output.contains("## Risques"));
|
||||
assert!(result.output.contains("Risque de retard"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_milestone_report_it() {
|
||||
let tool = ReportTemplateTool::new();
|
||||
let params = json!({
|
||||
"template": "milestone_report",
|
||||
"language": "it",
|
||||
"variables": {
|
||||
"project_name": "Progetto Beta",
|
||||
"milestones": "M1: Completato\nM2: In corso",
|
||||
"status": "In linea con i tempi"
|
||||
}
|
||||
});
|
||||
|
||||
let result = tool.execute(params).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("## Progetto"));
|
||||
assert!(result.output.contains("## Milestone"));
|
||||
assert!(result.output.contains("M1: Completato"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_language_is_en() {
|
||||
let tool = ReportTemplateTool::new();
|
||||
let params = json!({
|
||||
"template": "weekly_status",
|
||||
"variables": {
|
||||
"project_name": "Test",
|
||||
"period": "W1",
|
||||
"completed": "Done",
|
||||
"in_progress": "WIP",
|
||||
"blocked": "None",
|
||||
"next_steps": "Next"
|
||||
}
|
||||
});
|
||||
|
||||
let result = tool.execute(params).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("## Summary"));
|
||||
assert!(result.output.contains("## Completed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_template_param_fails() {
|
||||
let tool = ReportTemplateTool::new();
|
||||
let params = json!({
|
||||
"variables": {
|
||||
"project_name": "Test"
|
||||
}
|
||||
});
|
||||
|
||||
let result = tool.execute(params).await;
|
||||
assert!(result.is_err());
|
||||
let error = result.unwrap_err().to_string();
|
||||
assert!(error.contains("missing template"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_variables_param_fails() {
|
||||
let tool = ReportTemplateTool::new();
|
||||
let params = json!({
|
||||
"template": "weekly_status"
|
||||
});
|
||||
|
||||
let result = tool.execute(params).await;
|
||||
assert!(result.is_err());
|
||||
let error = result.unwrap_err().to_string();
|
||||
assert!(error.contains("variables must be object"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_template_name_fails() {
|
||||
let tool = ReportTemplateTool::new();
|
||||
let params = json!({
|
||||
"template": "unknown_template",
|
||||
"variables": {
|
||||
"project_name": "Test"
|
||||
}
|
||||
});
|
||||
|
||||
let result = tool.execute(params).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_language_code_fails() {
|
||||
let tool = ReportTemplateTool::new();
|
||||
let params = json!({
|
||||
"template": "weekly_status",
|
||||
"language": "es",
|
||||
"variables": {
|
||||
"project_name": "Test"
|
||||
}
|
||||
});
|
||||
|
||||
let result = tool.execute(params).await;
|
||||
// Note: The current implementation doesn't fail on invalid language,
|
||||
// it falls back to English. We test this behavior.
|
||||
let result = result.unwrap();
|
||||
assert!(result.success);
|
||||
// Should render in English (default fallback)
|
||||
assert!(result.output.contains("## Summary"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_variables_map_renders() {
|
||||
let tool = ReportTemplateTool::new();
|
||||
let params = json!({
|
||||
"template": "weekly_status",
|
||||
"variables": {}
|
||||
});
|
||||
|
||||
let result = tool.execute(params).await.unwrap();
|
||||
assert!(result.success);
|
||||
// Placeholders should remain unchanged
|
||||
assert!(result.output.contains("{{project_name}}"));
|
||||
assert!(result.output.contains("{{period}}"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn injection_protection_enforced() {
|
||||
let tool = ReportTemplateTool::new();
|
||||
let params = json!({
|
||||
"template": "weekly_status",
|
||||
"variables": {
|
||||
"project_name": "Test {{injected}}",
|
||||
"period": "W1",
|
||||
"completed": "{{nested_var}}",
|
||||
"in_progress": "WIP",
|
||||
"blocked": "None",
|
||||
"next_steps": "Next",
|
||||
"injected": "SHOULD_NOT_APPEAR",
|
||||
"nested_var": "SHOULD_NOT_EXPAND"
|
||||
}
|
||||
});
|
||||
|
||||
let result = tool.execute(params).await.unwrap();
|
||||
assert!(result.success);
|
||||
// The value "Test {{injected}}" should be inserted literally
|
||||
assert!(result.output.contains("Test {{injected}}"));
|
||||
// The nested variable should not be expanded recursively
|
||||
assert!(result.output.contains("{{nested_var}}"));
|
||||
// The injected values should not appear
|
||||
assert!(!result.output.contains("SHOULD_NOT_APPEAR"));
|
||||
assert!(!result.output.contains("SHOULD_NOT_EXPAND"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_string_variable_values_coerced() {
|
||||
let tool = ReportTemplateTool::new();
|
||||
let params = json!({
|
||||
"template": "weekly_status",
|
||||
"variables": {
|
||||
"project_name": "Test",
|
||||
"period": 123,
|
||||
"completed": true,
|
||||
"in_progress": false,
|
||||
"blocked": null,
|
||||
"next_steps": ["array", "not", "supported"]
|
||||
}
|
||||
});
|
||||
|
||||
let result = tool.execute(params).await.unwrap();
|
||||
assert!(result.success);
|
||||
// Numbers and booleans should be coerced to strings
|
||||
// null and arrays should result in empty strings
|
||||
assert!(result.output.contains("Project: Test"));
|
||||
}
|
||||
298
third_party/zeroclaw/tests/integration/telegram_attachment_fallback.rs
vendored
Normal file
298
third_party/zeroclaw/tests/integration/telegram_attachment_fallback.rs
vendored
Normal file
@@ -0,0 +1,298 @@
|
||||
//! Regression tests for Telegram attachment fallback behavior.
|
||||
//!
|
||||
//! When sending media by URL fails (e.g. Telegram can't fetch the URL or the
|
||||
//! content type is wrong), the channel should fall back to sending the URL as
|
||||
//! a text link instead of losing the entire reply.
|
||||
//!
|
||||
//! Bug: Previously, `send_attachment()` would propagate the error from
|
||||
//! `send_document_by_url()` immediately via `?`, causing the entire reply
|
||||
//! (including already-sent text) to fail with no fallback.
|
||||
|
||||
use wiremock::matchers::{method, path_regex};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
use zeroclaw::channels::telegram::TelegramChannel;
|
||||
use zeroclaw::channels::traits::{Channel, SendMessage};
|
||||
|
||||
/// Helper: create a TelegramChannel pointing at a mock server.
|
||||
fn test_channel(mock_url: &str) -> TelegramChannel {
|
||||
TelegramChannel::new("TEST_TOKEN".into(), vec!["*".into()], false)
|
||||
.with_api_base(mock_url.to_string())
|
||||
}
|
||||
|
||||
/// Helper: mount a mock that accepts sendMessage requests (the fallback path).
|
||||
async fn mock_send_message_ok(server: &MockServer) {
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(r"/botTEST_TOKEN/sendMessage$"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"ok": true,
|
||||
"result": {
|
||||
"message_id": 1,
|
||||
"chat": {"id": 123},
|
||||
"text": "ok"
|
||||
}
|
||||
})))
|
||||
.expect(1..)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// When sendDocument by URL fails with "wrong type of the web page content",
|
||||
/// the channel should fall back to sending the URL as a text link.
|
||||
#[tokio::test]
|
||||
async fn document_url_failure_falls_back_to_text_link() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// sendDocument returns 400 (simulates Telegram rejecting the URL)
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(r"/botTEST_TOKEN/sendDocument$"))
|
||||
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error_code": 400,
|
||||
"description": "Bad Request: wrong type of the web page content"
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// sendMessage should succeed (this is the fallback)
|
||||
mock_send_message_ok(&server).await;
|
||||
|
||||
let channel = test_channel(&server.uri());
|
||||
let msg = SendMessage::new(
|
||||
"Here is the report [DOCUMENT:https://example.com/page.html]",
|
||||
"123",
|
||||
);
|
||||
|
||||
// This should NOT error — it should fall back to text
|
||||
let result = channel.send(&msg).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"send should succeed via text fallback, got: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// When sendPhoto by URL fails, the channel should fall back to text link.
|
||||
#[tokio::test]
|
||||
async fn photo_url_failure_falls_back_to_text_link() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(r"/botTEST_TOKEN/sendPhoto$"))
|
||||
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error_code": 400,
|
||||
"description": "Bad Request: failed to get HTTP URL content"
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
mock_send_message_ok(&server).await;
|
||||
|
||||
let channel = test_channel(&server.uri());
|
||||
let msg = SendMessage::new(
|
||||
"Check this [IMAGE:https://internal-server.local/screenshot.png]",
|
||||
"456",
|
||||
);
|
||||
|
||||
let result = channel.send(&msg).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"send should succeed via text fallback, got: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Text portion of a message with attachments is still delivered even when
|
||||
/// the attachment fails.
|
||||
#[tokio::test]
|
||||
async fn text_portion_delivered_before_attachment_failure() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// sendDocument fails
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(r"/botTEST_TOKEN/sendDocument$"))
|
||||
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error_code": 400,
|
||||
"description": "Bad Request: wrong type of the web page content"
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// sendMessage should be called at least twice:
|
||||
// 1. for the text portion ("Here is the file")
|
||||
// 2. for the fallback text link
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(r"/botTEST_TOKEN/sendMessage$"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"ok": true,
|
||||
"result": {
|
||||
"message_id": 1,
|
||||
"chat": {"id": 789},
|
||||
"text": "ok"
|
||||
}
|
||||
})))
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let channel = test_channel(&server.uri());
|
||||
let msg = SendMessage::new(
|
||||
"Here is the file [DOCUMENT:https://example.com/report.html]",
|
||||
"789",
|
||||
);
|
||||
|
||||
let result = channel.send(&msg).await;
|
||||
assert!(result.is_ok(), "send should succeed, got: {result:?}");
|
||||
}
|
||||
|
||||
/// When multiple attachments are present and one fails, the others should
|
||||
/// still be attempted (each gets its own fallback).
|
||||
#[tokio::test]
|
||||
async fn multiple_attachments_independent_fallback() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// sendDocument fails (for the .html attachment)
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(r"/botTEST_TOKEN/sendDocument$"))
|
||||
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error_code": 400,
|
||||
"description": "Bad Request: wrong type of the web page content"
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// sendPhoto also fails
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(r"/botTEST_TOKEN/sendPhoto$"))
|
||||
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error_code": 400,
|
||||
"description": "Bad Request: failed to get HTTP URL content"
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// sendMessage succeeds (text + 2 fallback links)
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(r"/botTEST_TOKEN/sendMessage$"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"ok": true,
|
||||
"result": {
|
||||
"message_id": 1,
|
||||
"chat": {"id": 100},
|
||||
"text": "ok"
|
||||
}
|
||||
})))
|
||||
.expect(3) // text + doc fallback + image fallback
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let channel = test_channel(&server.uri());
|
||||
let msg = SendMessage::new(
|
||||
"Files: [DOCUMENT:https://example.com/page.html] and [IMAGE:https://internal.local/pic.png]",
|
||||
"100",
|
||||
);
|
||||
|
||||
let result = channel.send(&msg).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"send should succeed with fallbacks for all attachments, got: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// When attachment succeeds, no fallback text is sent.
|
||||
#[tokio::test]
|
||||
async fn successful_attachment_no_fallback() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// sendDocument succeeds
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(r"/botTEST_TOKEN/sendDocument$"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"ok": true,
|
||||
"result": {
|
||||
"message_id": 2,
|
||||
"chat": {"id": 200},
|
||||
"document": {"file_id": "abc"}
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// sendMessage should only be called once (for the text portion),
|
||||
// NOT a second time for a fallback
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(r"/botTEST_TOKEN/sendMessage$"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"ok": true,
|
||||
"result": {
|
||||
"message_id": 1,
|
||||
"chat": {"id": 200},
|
||||
"text": "ok"
|
||||
}
|
||||
})))
|
||||
.expect(1) // only the text portion, no fallback
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let channel = test_channel(&server.uri());
|
||||
let msg = SendMessage::new(
|
||||
"Report attached [DOCUMENT:https://example.com/report.pdf]",
|
||||
"200",
|
||||
);
|
||||
|
||||
let result = channel.send(&msg).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"send should succeed normally, got: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Document-only message (no text) with URL failure should still send
|
||||
/// a fallback text link.
|
||||
#[tokio::test]
|
||||
async fn document_only_message_falls_back_to_text() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(r"/botTEST_TOKEN/sendDocument$"))
|
||||
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error_code": 400,
|
||||
"description": "Bad Request: failed to get HTTP URL content"
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// Fallback text link
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(r"/botTEST_TOKEN/sendMessage$"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"ok": true,
|
||||
"result": {
|
||||
"message_id": 1,
|
||||
"chat": {"id": 300},
|
||||
"text": "ok"
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let channel = test_channel(&server.uri());
|
||||
// Message is ONLY the attachment marker — no surrounding text
|
||||
let msg = SendMessage::new("[DOCUMENT:https://example.com/file.html]", "300");
|
||||
|
||||
let result = channel.send(&msg).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"document-only message should fall back to text, got: {result:?}"
|
||||
);
|
||||
}
|
||||
208
third_party/zeroclaw/tests/integration/telegram_finalize_draft.rs
vendored
Normal file
208
third_party/zeroclaw/tests/integration/telegram_finalize_draft.rs
vendored
Normal file
@@ -0,0 +1,208 @@
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{body_partial_json, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
use zeroclaw::channels::telegram::TelegramChannel;
|
||||
use zeroclaw::channels::traits::Channel;
|
||||
|
||||
fn test_channel(mock_url: &str) -> TelegramChannel {
|
||||
TelegramChannel::new("TEST_TOKEN".into(), vec!["*".into()], false)
|
||||
.with_api_base(mock_url.to_string())
|
||||
}
|
||||
|
||||
fn telegram_ok_response(message_id: i64) -> serde_json::Value {
|
||||
json!({
|
||||
"ok": true,
|
||||
"result": {
|
||||
"message_id": message_id,
|
||||
"chat": {"id": 123},
|
||||
"text": "ok"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn telegram_error_response(description: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"ok": false,
|
||||
"error_code": 400,
|
||||
"description": description,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finalize_draft_treats_not_modified_as_success() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/botTEST_TOKEN/editMessageText"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(400).set_body_json(telegram_error_response(
|
||||
"Bad Request: message is not modified",
|
||||
)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let channel = test_channel(&server.uri());
|
||||
let result = channel.finalize_draft("123", "42", "final text").await;
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"not modified should be treated as success, got: {result:?}"
|
||||
);
|
||||
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("requests should be captured");
|
||||
assert_eq!(requests.len(), 1, "should stop after first edit response");
|
||||
assert_eq!(requests[0].url.path(), "/botTEST_TOKEN/editMessageText");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finalize_draft_plain_retry_treats_not_modified_as_success() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/botTEST_TOKEN/editMessageText"))
|
||||
.and(body_partial_json(json!({
|
||||
"chat_id": "123",
|
||||
"message_id": 42,
|
||||
"parse_mode": "HTML",
|
||||
})))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(400)
|
||||
.set_body_json(telegram_error_response("Bad Request: can't parse entities")),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/botTEST_TOKEN/editMessageText"))
|
||||
.and(body_partial_json(json!({
|
||||
"chat_id": "123",
|
||||
"message_id": 42,
|
||||
"text": "Use **bold**",
|
||||
})))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(400).set_body_json(telegram_error_response(
|
||||
"Bad Request: message is not modified",
|
||||
)),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let channel = test_channel(&server.uri());
|
||||
let result = channel.finalize_draft("123", "42", "Use **bold**").await;
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"plain retry should accept not modified, got: {result:?}"
|
||||
);
|
||||
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("requests should be captured");
|
||||
assert_eq!(requests.len(), 2, "should only attempt the two edit calls");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finalize_draft_skips_send_message_when_delete_fails() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/botTEST_TOKEN/editMessageText"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(400).set_body_json(telegram_error_response(
|
||||
"Bad Request: message cannot be edited",
|
||||
)),
|
||||
)
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/botTEST_TOKEN/deleteMessage"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(400).set_body_json(telegram_error_response(
|
||||
"Bad Request: message to delete not found",
|
||||
)),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let channel = test_channel(&server.uri());
|
||||
let result = channel.finalize_draft("123", "42", "final text").await;
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"delete failure should skip sendMessage instead of erroring, got: {result:?}"
|
||||
);
|
||||
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("requests should be captured");
|
||||
assert_eq!(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|req| req.url.path() == "/botTEST_TOKEN/sendMessage")
|
||||
.count(),
|
||||
0,
|
||||
"sendMessage should be skipped when deleteMessage fails"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finalize_draft_sends_fresh_message_after_successful_delete() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/botTEST_TOKEN/editMessageText"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(400).set_body_json(telegram_error_response(
|
||||
"Bad Request: message cannot be edited",
|
||||
)),
|
||||
)
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/botTEST_TOKEN/deleteMessage"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(telegram_ok_response(42)))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/botTEST_TOKEN/sendMessage"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(telegram_ok_response(43)))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let channel = test_channel(&server.uri());
|
||||
let result = channel.finalize_draft("123", "42", "final text").await;
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"successful delete should allow safe sendMessage fallback, got: {result:?}"
|
||||
);
|
||||
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("requests should be captured");
|
||||
assert_eq!(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|req| req.url.path() == "/botTEST_TOKEN/sendMessage")
|
||||
.count(),
|
||||
1,
|
||||
"sendMessage should be attempted exactly once after delete succeeds"
|
||||
);
|
||||
}
|
||||
236
third_party/zeroclaw/tests/live/gemini_fallback_oauth_refresh.rs
vendored
Normal file
236
third_party/zeroclaw/tests/live/gemini_fallback_oauth_refresh.rs
vendored
Normal file
@@ -0,0 +1,236 @@
|
||||
//! E2E test for Gemini fallback with OAuth token refresh.
|
||||
//!
|
||||
//! This test validates that when:
|
||||
//! 1. Primary provider (OpenAI Codex) fails
|
||||
//! 2. Fallback to Gemini is triggered
|
||||
//! 3. Gemini OAuth tokens are expired (we manually expire them)
|
||||
//!
|
||||
//! Then:
|
||||
//! - Gemini provider's warmup() automatically refreshes the tokens
|
||||
//! - The fallback request succeeds
|
||||
//!
|
||||
//! Requires:
|
||||
//! - Live Gemini OAuth profile in `~/.zeroclaw/auth-profiles.json` with refresh_token
|
||||
//! - GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET env vars
|
||||
//!
|
||||
//! Run manually: `cargo test gemini_fallback_oauth_refresh -- --ignored --nocapture`
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{Duration, Utc};
|
||||
use serde_json::Value;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Tests that Gemini warmup() refreshes expired OAuth tokens.
|
||||
///
|
||||
/// This test:
|
||||
/// 1. Backs up real auth-profiles.json
|
||||
/// 2. Modifies it to set Gemini token as expired
|
||||
/// 3. Creates a Gemini provider and calls warmup()
|
||||
/// 4. Verifies token was refreshed
|
||||
/// 5. Restores original auth-profiles.json
|
||||
#[tokio::test]
|
||||
#[ignore = "requires live Gemini OAuth credentials with refresh_token"]
|
||||
async fn gemini_warmup_refreshes_expired_oauth_token() -> Result<()> {
|
||||
// Find ~/.zeroclaw/auth-profiles.json
|
||||
let home = env::var("HOME").expect("HOME env var not set");
|
||||
let zeroclaw_dir = PathBuf::from(home).join(".zeroclaw");
|
||||
let auth_profiles_path = zeroclaw_dir.join("auth-profiles.json");
|
||||
|
||||
if !auth_profiles_path.exists() {
|
||||
eprintln!(
|
||||
"⚠️ No auth-profiles.json found at {:?}",
|
||||
auth_profiles_path
|
||||
);
|
||||
eprintln!("Run: zeroclaw auth login --provider gemini");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Load current auth-profiles.json
|
||||
let original_content = fs::read_to_string(&auth_profiles_path)?;
|
||||
let mut data: Value = serde_json::from_str(&original_content)?;
|
||||
|
||||
println!("Loaded auth-profiles.json");
|
||||
|
||||
// Find Gemini profile
|
||||
let profiles = data
|
||||
.get_mut("profiles")
|
||||
.and_then(|p| p.as_object_mut())
|
||||
.ok_or_else(|| anyhow::anyhow!("No profiles object in auth-profiles.json"))?;
|
||||
|
||||
let gemini_profile_key = profiles
|
||||
.keys()
|
||||
.find(|k| k.starts_with("gemini:"))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No Gemini OAuth profile found. Run: zeroclaw auth login --provider gemini"
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let gemini_profile = profiles
|
||||
.get_mut(&gemini_profile_key)
|
||||
.ok_or_else(|| anyhow::anyhow!("Gemini profile not found"))?;
|
||||
|
||||
println!("Found Gemini profile: {}", gemini_profile_key);
|
||||
|
||||
// Check if profile has refresh_token
|
||||
if gemini_profile.get("refresh_token").is_none() {
|
||||
eprintln!("⚠️ Gemini profile has no refresh_token — cannot test refresh");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("✓ Gemini profile has refresh_token");
|
||||
|
||||
// Backup original expires_at
|
||||
let original_expires_at = gemini_profile.get("expires_at").cloned();
|
||||
println!("Original expires_at: {:?}", original_expires_at);
|
||||
|
||||
// Set expires_at to 1 hour ago (expired)
|
||||
let expired_time = Utc::now() - Duration::seconds(3600);
|
||||
let expired_str = expired_time.to_rfc3339();
|
||||
|
||||
gemini_profile
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("expires_at".to_string(), Value::String(expired_str.clone()));
|
||||
|
||||
println!("Set expires_at to: {} (expired)", expired_str);
|
||||
|
||||
// Ensure we restore original file even if test fails
|
||||
let restore_guard = scopeguard::guard(original_content.clone(), |backup| {
|
||||
if let Err(e) = fs::write(&auth_profiles_path, backup) {
|
||||
eprintln!("⚠️ Failed to restore auth-profiles.json: {}", e);
|
||||
} else {
|
||||
println!("✓ Restored original auth-profiles.json");
|
||||
}
|
||||
});
|
||||
|
||||
// Check required env vars
|
||||
if env::var("GEMINI_OAUTH_CLIENT_ID").is_err()
|
||||
|| env::var("GEMINI_OAUTH_CLIENT_SECRET").is_err()
|
||||
{
|
||||
eprintln!("⚠️ GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET required for refresh");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Write modified auth-profiles.json BEFORE creating provider
|
||||
fs::write(&auth_profiles_path, serde_json::to_string_pretty(&data)?)?;
|
||||
println!("✓ Wrote modified auth-profiles.json with expired token");
|
||||
|
||||
// Small delay to ensure file is flushed
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
// Create GeminiProvider using the default factory
|
||||
// This will load auth from ~/.zeroclaw/auth-profiles.json (with expired token)
|
||||
let provider = zeroclaw::providers::create_provider("gemini", None)?;
|
||||
|
||||
println!("Created Gemini provider with expired token");
|
||||
|
||||
// Call warmup() — should detect expired token and refresh it
|
||||
println!("Calling warmup() — should refresh expired token...");
|
||||
let warmup_result = provider.warmup().await;
|
||||
|
||||
if let Err(e) = warmup_result {
|
||||
eprintln!("❌ warmup() failed: {}", e);
|
||||
eprintln!("This might be expected if:");
|
||||
eprintln!(" - GEMINI_OAUTH_CLIENT_ID/SECRET are not set");
|
||||
eprintln!(" - Refresh token is invalid");
|
||||
eprintln!(" - Network is unavailable");
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
println!("✓ warmup() succeeded");
|
||||
|
||||
// Small delay to ensure file is written
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Re-load auth-profiles.json to check if token was refreshed
|
||||
let updated_content = fs::read_to_string(&auth_profiles_path)?;
|
||||
let updated_data: Value = serde_json::from_str(&updated_content)?;
|
||||
|
||||
let updated_profile = updated_data
|
||||
.get("profiles")
|
||||
.and_then(|p| p.as_object())
|
||||
.and_then(|p| p.get(&gemini_profile_key))
|
||||
.and_then(|p| p.as_object())
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to read updated profile"))?;
|
||||
|
||||
let new_expires_at = updated_profile.get("expires_at").and_then(|v| v.as_str());
|
||||
println!("New expires_at: {:?}", new_expires_at);
|
||||
|
||||
// Verify token was refreshed (expires_at should be in the future)
|
||||
if let Some(new_exp) = new_expires_at {
|
||||
let new_exp_dt = chrono::DateTime::parse_from_rfc3339(new_exp)?;
|
||||
let now = Utc::now();
|
||||
let seconds_from_now = new_exp_dt.signed_duration_since(now).num_seconds();
|
||||
|
||||
if seconds_from_now > 300 {
|
||||
println!(
|
||||
"✓ Token was refreshed! New expiry is {} seconds from now",
|
||||
seconds_from_now
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"⚠️ Token expiry is NOT in the future: {} seconds from now",
|
||||
seconds_from_now
|
||||
);
|
||||
eprintln!(" This might mean warmup() did not refresh the token.");
|
||||
eprintln!(" Original: {:?}", original_expires_at);
|
||||
eprintln!(" Set to (expired): {}", expired_str);
|
||||
eprintln!(" After warmup: {}", new_exp);
|
||||
}
|
||||
} else {
|
||||
eprintln!("⚠️ No expires_at found after warmup");
|
||||
}
|
||||
|
||||
// Try making a real request to verify token works
|
||||
println!("\nMaking real request to verify token works...");
|
||||
let response = provider
|
||||
.chat_with_system(
|
||||
Some("You are a concise assistant. Reply in one short sentence."),
|
||||
"Say 'OAuth refresh works'",
|
||||
"gemini-2.5-pro",
|
||||
0.7,
|
||||
)
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(text) => {
|
||||
println!("✓ Request succeeded! Response: {}", text);
|
||||
assert!(!text.is_empty(), "Response should not be empty");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("❌ Request failed: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup is handled by scopeguard
|
||||
drop(restore_guard);
|
||||
|
||||
println!("\n=== Test Passed ===");
|
||||
println!("Gemini warmup() correctly refreshed expired OAuth token!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Simpler test: just verify warmup() doesn't fail with valid credentials.
|
||||
/// This test doesn't modify auth-profiles.json.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires live Gemini OAuth credentials"]
|
||||
async fn gemini_warmup_with_valid_credentials() -> Result<()> {
|
||||
// Create provider from default config
|
||||
let provider = zeroclaw::providers::create_provider("gemini", None)?;
|
||||
|
||||
println!("Created Gemini provider");
|
||||
println!("Calling warmup()...");
|
||||
|
||||
// This should succeed if credentials are valid
|
||||
provider.warmup().await?;
|
||||
|
||||
println!("✓ warmup() succeeded with valid credentials");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
3
third_party/zeroclaw/tests/live/mod.rs
vendored
Normal file
3
third_party/zeroclaw/tests/live/mod.rs
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
mod gemini_fallback_oauth_refresh;
|
||||
mod openai_codex_vision_e2e;
|
||||
mod providers;
|
||||
268
third_party/zeroclaw/tests/live/openai_codex_vision_e2e.rs
vendored
Normal file
268
third_party/zeroclaw/tests/live/openai_codex_vision_e2e.rs
vendored
Normal file
@@ -0,0 +1,268 @@
|
||||
//! E2E test for vision support in providers.
|
||||
//!
|
||||
//! This test validates that:
|
||||
//! 1. Provider reports vision capability
|
||||
//! 2. Provider correctly processes messages with [IMAGE:...] markers
|
||||
//! 3. Request is sent to API with proper image_url format
|
||||
//!
|
||||
//! Requires:
|
||||
//! - Live provider OAuth credentials (OpenAI Codex or Gemini)
|
||||
//! - Test image at /tmp/test_vision.png
|
||||
//!
|
||||
//! Run manually: `cargo test provider_vision -- --ignored --nocapture`
|
||||
|
||||
use anyhow::Result;
|
||||
use zeroclaw::providers::{ChatMessage, ChatRequest, ProviderRuntimeOptions};
|
||||
|
||||
/// Tests that provider supports vision input.
|
||||
///
|
||||
/// This test:
|
||||
/// 1. Creates provider via factory (tries OpenAI Codex, falls back to Gemini)
|
||||
/// 2. Verifies vision capability is reported
|
||||
/// 3. Sends a message with [IMAGE:...] marker
|
||||
/// 4. Verifies request succeeds without capability error
|
||||
#[tokio::test]
|
||||
#[ignore = "requires live provider OAuth credentials"]
|
||||
async fn provider_vision_support() -> Result<()> {
|
||||
// Use Gemini provider (OpenAI Codex is rate-limited until 21 Feb)
|
||||
println!("Creating Gemini provider...");
|
||||
let provider = zeroclaw::providers::create_provider("gemini", None)?;
|
||||
let provider_name = "gemini";
|
||||
let model = "gemini-2.5-pro";
|
||||
|
||||
println!("✓ Created {} provider", provider_name);
|
||||
|
||||
// Warmup provider (for OAuth token refresh if needed)
|
||||
println!("Warming up provider...");
|
||||
provider.warmup().await?;
|
||||
println!("✓ Provider warmed up");
|
||||
|
||||
// Verify vision capability
|
||||
let capabilities = provider.capabilities();
|
||||
println!(
|
||||
"Provider {} capabilities: vision={}",
|
||||
provider_name, capabilities.vision
|
||||
);
|
||||
|
||||
if !capabilities.vision {
|
||||
anyhow::bail!(
|
||||
"❌ {} provider does not report vision capability! \
|
||||
Check that provider's capabilities() returns vision=true",
|
||||
provider_name
|
||||
);
|
||||
}
|
||||
|
||||
println!("✓ Provider {} reports vision=true", provider_name);
|
||||
|
||||
// Prepare test image path
|
||||
let test_image = "/tmp/test_vision.png";
|
||||
|
||||
if !std::path::Path::new(test_image).exists() {
|
||||
eprintln!("⚠️ Test image not found at {}", test_image);
|
||||
eprintln!("Creating minimal 1x1 PNG...");
|
||||
|
||||
// Create minimal PNG if missing
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
let png_data = general_purpose::STANDARD.decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
)?;
|
||||
std::fs::write(test_image, png_data)?;
|
||||
|
||||
println!("✓ Created test image at {}", test_image);
|
||||
}
|
||||
|
||||
// Prepare message with image marker
|
||||
let user_message = format!("What is in this image? [IMAGE:{}]", test_image);
|
||||
|
||||
println!("Sending message with image marker...");
|
||||
println!("Message: {}", user_message);
|
||||
|
||||
// Build chat request
|
||||
let messages = vec![
|
||||
ChatMessage::system("You are a helpful assistant that can analyze images."),
|
||||
ChatMessage::user(user_message.clone()),
|
||||
];
|
||||
|
||||
let request = ChatRequest {
|
||||
messages: &messages,
|
||||
tools: None,
|
||||
};
|
||||
|
||||
// Send request to provider
|
||||
println!("Using model: {}", model);
|
||||
let result = provider.chat(request, model, 0.7).await;
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
println!("✓ Request succeeded!");
|
||||
if let Some(text) = response.text {
|
||||
println!("Response text: {}", text);
|
||||
}
|
||||
println!("Tool calls: {}", response.tool_calls.len());
|
||||
|
||||
// Success: provider accepted vision input
|
||||
println!("\n✅ {} vision support is working!", provider_name);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("❌ Request failed: {}", e);
|
||||
|
||||
// Check if it's the capability error we're testing for
|
||||
let error_str = e.to_string();
|
||||
if error_str.contains("provider_capability_error")
|
||||
|| error_str.contains("does not support vision")
|
||||
{
|
||||
eprintln!("\n⚠️ CAPABILITY ERROR DETECTED!");
|
||||
eprintln!("This means the agent loop is still blocking vision input.");
|
||||
eprintln!("Possible causes:");
|
||||
eprintln!(" 1. Service binary not rebuilt (check timestamp)");
|
||||
eprintln!(" 2. Service not restarted with new binary");
|
||||
eprintln!(" 3. Provider factory returning wrong implementation");
|
||||
anyhow::bail!("Vision capability check failed in agent loop");
|
||||
}
|
||||
|
||||
// Other errors (API error, auth, etc) are also failures but different nature
|
||||
eprintln!("\n⚠️ Request failed with non-capability error");
|
||||
eprintln!("This might be:");
|
||||
eprintln!(" - API authentication issue");
|
||||
eprintln!(" - Network error");
|
||||
eprintln!(" - API format rejection");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests that OpenAI Codex second profile supports vision input.
|
||||
///
|
||||
/// This test:
|
||||
/// 1. Creates OpenAI Codex provider with "second" profile override
|
||||
/// 2. Verifies vision capability is reported
|
||||
/// 3. Sends a message with [IMAGE:...] marker
|
||||
/// 4. Verifies request succeeds without capability error
|
||||
#[tokio::test]
|
||||
#[ignore = "requires live OpenAI Codex OAuth credentials (second profile)"]
|
||||
async fn openai_codex_second_vision_support() -> Result<()> {
|
||||
println!("Creating OpenAI Codex provider with second profile...");
|
||||
|
||||
// Create provider with profile override
|
||||
let opts = ProviderRuntimeOptions {
|
||||
auth_profile_override: Some("second".to_string()),
|
||||
provider_api_url: None,
|
||||
zeroclaw_dir: None,
|
||||
secrets_encrypt: false,
|
||||
reasoning_enabled: None,
|
||||
reasoning_effort: None,
|
||||
provider_timeout_secs: None,
|
||||
provider_max_tokens: None,
|
||||
extra_headers: std::collections::HashMap::new(),
|
||||
api_path: None,
|
||||
};
|
||||
|
||||
let provider = zeroclaw::providers::create_provider_with_options("openai-codex", None, &opts)?;
|
||||
let provider_name = "openai-codex:second";
|
||||
let model = "gpt-5.3-codex";
|
||||
|
||||
println!("✓ Created {} provider", provider_name);
|
||||
|
||||
// Verify vision capability
|
||||
let capabilities = provider.capabilities();
|
||||
println!(
|
||||
"Provider {} capabilities: vision={}",
|
||||
provider_name, capabilities.vision
|
||||
);
|
||||
|
||||
if !capabilities.vision {
|
||||
anyhow::bail!(
|
||||
"❌ {} provider does not report vision capability! \
|
||||
Check that provider's capabilities() returns vision=true",
|
||||
provider_name
|
||||
);
|
||||
}
|
||||
|
||||
println!("✓ Provider {} reports vision=true", provider_name);
|
||||
|
||||
// Prepare test image path
|
||||
let test_image = "/tmp/test_vision.png";
|
||||
|
||||
if !std::path::Path::new(test_image).exists() {
|
||||
eprintln!("⚠️ Test image not found at {}", test_image);
|
||||
eprintln!("Creating minimal 1x1 PNG...");
|
||||
|
||||
// Create minimal PNG if missing
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
let png_data = general_purpose::STANDARD.decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
)?;
|
||||
std::fs::write(test_image, png_data)?;
|
||||
|
||||
println!("✓ Created test image at {}", test_image);
|
||||
}
|
||||
|
||||
// Prepare message with image marker
|
||||
let user_message = format!("What is in this image? [IMAGE:{}]", test_image);
|
||||
|
||||
println!("Sending message with image marker...");
|
||||
println!("Message: {}", user_message);
|
||||
|
||||
// Build chat request
|
||||
let messages = vec![
|
||||
ChatMessage::system("You are a helpful assistant that can analyze images."),
|
||||
ChatMessage::user(user_message.clone()),
|
||||
];
|
||||
|
||||
let request = ChatRequest {
|
||||
messages: &messages,
|
||||
tools: None,
|
||||
};
|
||||
|
||||
// Send request to provider
|
||||
println!("Using model: {}", model);
|
||||
let result = provider.chat(request, model, 0.7).await;
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
println!("✓ Request succeeded!");
|
||||
if let Some(text) = response.text {
|
||||
println!("Response text: {}", text);
|
||||
}
|
||||
println!("Tool calls: {}", response.tool_calls.len());
|
||||
|
||||
// Success: provider accepted vision input
|
||||
println!("\n✅ {} vision support is working!", provider_name);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("❌ Request failed: {}", e);
|
||||
|
||||
// Check if it's the capability error we're testing for
|
||||
let error_str = e.to_string();
|
||||
if error_str.contains("provider_capability_error")
|
||||
|| error_str.contains("does not support vision")
|
||||
{
|
||||
eprintln!("\n⚠️ CAPABILITY ERROR DETECTED!");
|
||||
eprintln!("This means the agent loop is still blocking vision input.");
|
||||
anyhow::bail!("Vision capability check failed in agent loop");
|
||||
}
|
||||
|
||||
// Check if it's rate limit
|
||||
if error_str.contains("429")
|
||||
|| error_str.contains("rate")
|
||||
|| error_str.contains("limit")
|
||||
{
|
||||
eprintln!("\n⚠️ RATE LIMITED!");
|
||||
eprintln!("Second OpenAI Codex profile is also rate-limited.");
|
||||
eprintln!("This is OK - it means both profiles share the same quota.");
|
||||
// Don't fail the test - rate limit is expected
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Other errors (API error, auth, etc) are also failures but different nature
|
||||
eprintln!("\n⚠️ Request failed with non-capability error");
|
||||
eprintln!("This might be:");
|
||||
eprintln!(" - API authentication issue");
|
||||
eprintln!(" - Network error");
|
||||
eprintln!(" - API format rejection");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
50
third_party/zeroclaw/tests/live/providers.rs
vendored
Normal file
50
third_party/zeroclaw/tests/live/providers.rs
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
//! Consolidated live provider tests.
|
||||
//!
|
||||
//! All tests in this module require real external API credentials and are
|
||||
//! marked with `#[ignore]`. Run with: `cargo test --test live -- --ignored`
|
||||
|
||||
use zeroclaw::providers::traits::{ChatMessage, Provider};
|
||||
use zeroclaw::providers::ProviderRuntimeOptions;
|
||||
|
||||
/// Sends a real multi-turn conversation to OpenAI Codex and verifies
|
||||
/// the model retains context from earlier messages.
|
||||
///
|
||||
/// Requires valid OAuth credentials in `~/.zeroclaw/`.
|
||||
/// Run manually: `cargo test e2e_live_openai_codex_multi_turn -- --ignored`
|
||||
#[tokio::test]
|
||||
#[ignore = "requires live OpenAI Codex OAuth credentials"]
|
||||
async fn e2e_live_openai_codex_multi_turn() {
|
||||
use zeroclaw::providers::openai_codex::OpenAiCodexProvider;
|
||||
|
||||
let provider = OpenAiCodexProvider::new(&ProviderRuntimeOptions::default(), None).unwrap();
|
||||
let model = "gpt-5.3-codex";
|
||||
|
||||
// Turn 1: establish a fact
|
||||
let messages_turn1 = vec![
|
||||
ChatMessage::system("You are a concise assistant. Reply in one short sentence."),
|
||||
ChatMessage::user("The secret word is \"zephyr\". Just confirm you noted it."),
|
||||
];
|
||||
let response1 = provider
|
||||
.chat_with_history(&messages_turn1, model, 0.0)
|
||||
.await;
|
||||
assert!(response1.is_ok(), "Turn 1 failed: {:?}", response1.err());
|
||||
let r1 = response1.unwrap();
|
||||
assert!(!r1.is_empty(), "Turn 1 returned empty response");
|
||||
|
||||
// Turn 2: ask the model to recall the fact
|
||||
let messages_turn2 = vec![
|
||||
ChatMessage::system("You are a concise assistant. Reply in one short sentence."),
|
||||
ChatMessage::user("The secret word is \"zephyr\". Just confirm you noted it."),
|
||||
ChatMessage::assistant(&r1),
|
||||
ChatMessage::user("What is the secret word?"),
|
||||
];
|
||||
let response2 = provider
|
||||
.chat_with_history(&messages_turn2, model, 0.0)
|
||||
.await;
|
||||
assert!(response2.is_ok(), "Turn 2 failed: {:?}", response2.err());
|
||||
let r2 = response2.unwrap().to_lowercase();
|
||||
assert!(
|
||||
r2.contains("zephyr"),
|
||||
"Model should recall 'zephyr' from history, got: {r2}",
|
||||
);
|
||||
}
|
||||
99
third_party/zeroclaw/tests/manual/telegram/generate_test_messages.py
vendored
Executable file
99
third_party/zeroclaw/tests/manual/telegram/generate_test_messages.py
vendored
Executable file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test message generator for Telegram integration testing.
|
||||
Generates messages of various lengths for testing message splitting.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
def generate_short_message():
|
||||
"""Generate a short message (< 100 chars)"""
|
||||
return "Hello! This is a short test message."
|
||||
|
||||
def generate_medium_message():
|
||||
"""Generate a medium message (~ 1000 chars)"""
|
||||
return "This is a medium-length test message. " * 25
|
||||
|
||||
def generate_long_message():
|
||||
"""Generate a long message (~ 5000 chars, > 4096 limit)"""
|
||||
return "This is a very long test message that will be split into multiple chunks. " * 70
|
||||
|
||||
def generate_exact_limit_message():
|
||||
"""Generate a message exactly at 4096 char limit"""
|
||||
base = "x" * 4096
|
||||
return base
|
||||
|
||||
def generate_over_limit_message():
|
||||
"""Generate a message just over the 4096 char limit"""
|
||||
return "x" * 4200
|
||||
|
||||
def generate_multi_chunk_message():
|
||||
"""Generate a message that requires 3+ chunks"""
|
||||
return "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " * 250
|
||||
|
||||
def generate_newline_message():
|
||||
"""Generate a message with many newlines (tests newline splitting)"""
|
||||
return "Line of text\n" * 400
|
||||
|
||||
def generate_word_boundary_message():
|
||||
"""Generate a message with clear word boundaries"""
|
||||
return "word " * 1000
|
||||
|
||||
def print_message_info(message, name):
|
||||
"""Print information about a message"""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"{name}")
|
||||
print(f"{'='*60}")
|
||||
print(f"Length: {len(message)} characters")
|
||||
print(f"Will split: {'Yes' if len(message) > 4096 else 'No'}")
|
||||
if len(message) > 4096:
|
||||
chunks = (len(message) + 4095) // 4096
|
||||
print(f"Estimated chunks: {chunks}")
|
||||
print(f"{'='*60}")
|
||||
print(message[:200] + "..." if len(message) > 200 else message)
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 1:
|
||||
test_type = sys.argv[1].lower()
|
||||
else:
|
||||
print("Usage: python3 generate_test_messages.py [type]")
|
||||
print("\nAvailable types:")
|
||||
print(" short - Short message (< 100 chars)")
|
||||
print(" medium - Medium message (~1000 chars)")
|
||||
print(" long - Long message (~5000 chars, requires splitting)")
|
||||
print(" exact - Exactly 4096 chars")
|
||||
print(" over - Just over 4096 chars")
|
||||
print(" multi - Very long (3+ chunks)")
|
||||
print(" newline - Many newlines (tests line splitting)")
|
||||
print(" word - Clear word boundaries")
|
||||
print(" all - Show info for all types")
|
||||
print("\nExample:")
|
||||
print(" python3 generate_test_messages.py long")
|
||||
sys.exit(1)
|
||||
|
||||
messages = {
|
||||
'short': ('Short Message', generate_short_message()),
|
||||
'medium': ('Medium Message', generate_medium_message()),
|
||||
'long': ('Long Message', generate_long_message()),
|
||||
'exact': ('Exact Limit (4096)', generate_exact_limit_message()),
|
||||
'over': ('Just Over Limit', generate_over_limit_message()),
|
||||
'multi': ('Multi-Chunk Message', generate_multi_chunk_message()),
|
||||
'newline': ('Newline Test', generate_newline_message()),
|
||||
'word': ('Word Boundary Test', generate_word_boundary_message()),
|
||||
}
|
||||
|
||||
if test_type == 'all':
|
||||
for name, msg in messages.values():
|
||||
print_message_info(msg, name)
|
||||
elif test_type in messages:
|
||||
name, msg = messages[test_type]
|
||||
# Just print the message for piping to Telegram
|
||||
print(msg)
|
||||
else:
|
||||
print(f"Error: Unknown type '{test_type}'")
|
||||
print("Run without arguments to see available types.")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
30
third_party/zeroclaw/tests/manual/telegram/quick_test.sh
vendored
Executable file
30
third_party/zeroclaw/tests/manual/telegram/quick_test.sh
vendored
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# Quick smoke test for Telegram integration
|
||||
# Run this before committing code changes
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔥 Quick Telegram Smoke Test"
|
||||
echo ""
|
||||
|
||||
# Test 1: Compile check
|
||||
echo -n "1. Compiling... "
|
||||
cargo build --release --quiet 2>&1 && echo "✓" || { echo "✗ FAILED"; exit 1; }
|
||||
|
||||
# Test 2: Unit tests
|
||||
echo -n "2. Running tests... "
|
||||
cargo test telegram_split --lib --quiet 2>&1 && echo "✓" || { echo "✗ FAILED"; exit 1; }
|
||||
|
||||
# Test 3: Health check
|
||||
echo -n "3. Health check... "
|
||||
timeout 7 target/release/zeroclaw channel doctor &>/dev/null && echo "✓" || echo "⚠ (configure bot first)"
|
||||
|
||||
# Test 4: File checks
|
||||
echo -n "4. Code structure... "
|
||||
grep -q "TELEGRAM_MAX_MESSAGE_LENGTH" src/channels/telegram.rs && \
|
||||
grep -q "split_message_for_telegram" src/channels/telegram.rs && \
|
||||
grep -q "tokio::time::timeout" src/channels/telegram.rs && \
|
||||
echo "✓" || { echo "✗ FAILED"; exit 1; }
|
||||
|
||||
echo ""
|
||||
echo "✅ Quick tests passed! Run ./tests/telegram/test_telegram_integration.sh for full suite."
|
||||
362
third_party/zeroclaw/tests/manual/telegram/test_telegram_integration.sh
vendored
Executable file
362
third_party/zeroclaw/tests/manual/telegram/test_telegram_integration.sh
vendored
Executable file
@@ -0,0 +1,362 @@
|
||||
#!/bin/bash
|
||||
# ZeroClaw Telegram Integration Test Suite
|
||||
# Automated testing script for Telegram channel functionality
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Test counters
|
||||
TOTAL_TESTS=0
|
||||
PASSED_TESTS=0
|
||||
FAILED_TESTS=0
|
||||
|
||||
# Helper functions
|
||||
print_header() {
|
||||
echo -e "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${BLUE}$1${NC}"
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
|
||||
}
|
||||
|
||||
print_test() {
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
echo -e "${YELLOW}Test $TOTAL_TESTS:${NC} $1"
|
||||
}
|
||||
|
||||
pass() {
|
||||
PASSED_TESTS=$((PASSED_TESTS + 1))
|
||||
echo -e "${GREEN}✓ PASS:${NC} $1\n"
|
||||
}
|
||||
|
||||
fail() {
|
||||
FAILED_TESTS=$((FAILED_TESTS + 1))
|
||||
echo -e "${RED}✗ FAIL:${NC} $1\n"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}⚠ WARNING:${NC} $1\n"
|
||||
}
|
||||
|
||||
# Banner
|
||||
clear
|
||||
cat << "EOF"
|
||||
⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡
|
||||
|
||||
███████╗███████╗██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗
|
||||
╚══███╔╝██╔════╝██╔══██╗██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║
|
||||
███╔╝ █████╗ ██████╔╝██║ ██║██║ ██║ ███████║██║ █╗ ██║
|
||||
███╔╝ ██╔══╝ ██╔══██╗██║ ██║██║ ██║ ██╔══██║██║███╗██║
|
||||
███████╗███████╗██║ ██║╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝
|
||||
╚══════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝
|
||||
|
||||
🧪 TELEGRAM INTEGRATION TEST SUITE 🧪
|
||||
|
||||
⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡
|
||||
EOF
|
||||
|
||||
echo -e "\n${BLUE}Started at:${NC} $(date)"
|
||||
echo -e "${BLUE}Working directory:${NC} $(pwd)\n"
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Phase 1: Code Quality Tests
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
print_header "Phase 1: Code Quality Tests"
|
||||
|
||||
# Test 1: Cargo test compilation
|
||||
print_test "Compiling test suite"
|
||||
if cargo test --lib --no-run &>/dev/null; then
|
||||
pass "Test suite compiles successfully"
|
||||
else
|
||||
fail "Test suite compilation failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test 2: Unit tests
|
||||
print_test "Running Telegram unit tests"
|
||||
TEST_OUTPUT=$(cargo test telegram --lib 2>&1)
|
||||
if echo "$TEST_OUTPUT" | grep -q "test result: ok"; then
|
||||
PASSED_COUNT=$(echo "$TEST_OUTPUT" | grep -oP '\d+(?= passed)' | head -1)
|
||||
pass "All Telegram unit tests passed ($PASSED_COUNT tests)"
|
||||
else
|
||||
fail "Some unit tests failed"
|
||||
echo "$TEST_OUTPUT" | grep "FAILED\|error"
|
||||
fi
|
||||
|
||||
# Test 3: Message splitting tests specifically
|
||||
print_test "Verifying message splitting tests"
|
||||
if cargo test telegram_split --lib --quiet 2>&1 | grep -q "8 passed"; then
|
||||
pass "All 8 message splitting tests passed"
|
||||
else
|
||||
fail "Message splitting tests incomplete"
|
||||
fi
|
||||
|
||||
# Test 4: Clippy linting
|
||||
print_test "Running Clippy lint checks"
|
||||
if cargo clippy --all-targets --quiet 2>&1 | grep -qv "error:"; then
|
||||
pass "No clippy errors found"
|
||||
else
|
||||
CLIPPY_ERRORS=$(cargo clippy --all-targets 2>&1 | grep "error:" | wc -l)
|
||||
fail "Clippy found $CLIPPY_ERRORS error(s)"
|
||||
fi
|
||||
|
||||
# Test 5: Code formatting
|
||||
print_test "Checking code formatting"
|
||||
if cargo fmt --check &>/dev/null; then
|
||||
pass "Code is properly formatted"
|
||||
else
|
||||
warn "Code formatting issues found (run 'cargo fmt' to fix)"
|
||||
fi
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Phase 2: Build Tests
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
print_header "Phase 2: Build Tests"
|
||||
|
||||
# Test 6: Debug build
|
||||
print_test "Debug build"
|
||||
if cargo build --quiet 2>&1; then
|
||||
pass "Debug build successful"
|
||||
else
|
||||
fail "Debug build failed"
|
||||
fi
|
||||
|
||||
# Test 7: Release build
|
||||
print_test "Release build with optimizations"
|
||||
START_TIME=$(date +%s)
|
||||
if cargo build --release --quiet 2>&1; then
|
||||
END_TIME=$(date +%s)
|
||||
BUILD_TIME=$((END_TIME - START_TIME))
|
||||
pass "Release build successful (${BUILD_TIME}s)"
|
||||
else
|
||||
fail "Release build failed"
|
||||
fi
|
||||
|
||||
# Test 8: Binary size check
|
||||
print_test "Binary size verification"
|
||||
if [ -f "target/release/zeroclaw" ]; then
|
||||
BINARY_SIZE=$(ls -lh target/release/zeroclaw | awk '{print $5}')
|
||||
SIZE_BYTES=$(stat -f%z target/release/zeroclaw 2>/dev/null || stat -c%s target/release/zeroclaw)
|
||||
SIZE_MB=$((SIZE_BYTES / 1024 / 1024))
|
||||
|
||||
if [ $SIZE_MB -le 10 ]; then
|
||||
pass "Binary size is optimal: $BINARY_SIZE (${SIZE_MB}MB)"
|
||||
else
|
||||
warn "Binary size is larger than expected: $BINARY_SIZE (${SIZE_MB}MB)"
|
||||
fi
|
||||
else
|
||||
fail "Release binary not found"
|
||||
fi
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Phase 3: Configuration Tests
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
print_header "Phase 3: Configuration Tests"
|
||||
|
||||
# Test 9: Config file existence
|
||||
print_test "Configuration file check"
|
||||
CONFIG_PATH="$HOME/.zeroclaw/config.toml"
|
||||
if [ -f "$CONFIG_PATH" ]; then
|
||||
pass "Config file exists at $CONFIG_PATH"
|
||||
|
||||
# Test 10: Telegram config
|
||||
print_test "Telegram configuration check"
|
||||
if grep -q "\[channels_config.telegram\]" "$CONFIG_PATH"; then
|
||||
pass "Telegram configuration found"
|
||||
|
||||
# Test 11: Bot token configured
|
||||
print_test "Bot token validation"
|
||||
if grep -q "bot_token = \"" "$CONFIG_PATH"; then
|
||||
pass "Bot token is configured"
|
||||
else
|
||||
warn "Bot token not set - integration tests will be skipped"
|
||||
fi
|
||||
|
||||
# Test 12: Allowlist configured
|
||||
print_test "User allowlist validation"
|
||||
if grep -q "allowed_users = \[" "$CONFIG_PATH"; then
|
||||
pass "User allowlist is configured"
|
||||
else
|
||||
warn "User allowlist not set"
|
||||
fi
|
||||
else
|
||||
warn "Telegram not configured - run 'zeroclaw onboard' first"
|
||||
fi
|
||||
else
|
||||
warn "No config file found - run 'zeroclaw onboard' first"
|
||||
fi
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Phase 4: Health Check Tests
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
print_header "Phase 4: Health Check Tests"
|
||||
|
||||
# Test 13: Health check timeout
|
||||
print_test "Health check timeout (should complete in <5s)"
|
||||
START_TIME=$(date +%s)
|
||||
HEALTH_OUTPUT=$(timeout 10 target/release/zeroclaw channel doctor 2>&1 || true)
|
||||
END_TIME=$(date +%s)
|
||||
HEALTH_TIME=$((END_TIME - START_TIME))
|
||||
|
||||
if [ $HEALTH_TIME -le 6 ]; then
|
||||
pass "Health check completed in ${HEALTH_TIME}s (timeout fix working)"
|
||||
else
|
||||
warn "Health check took ${HEALTH_TIME}s (expected <5s)"
|
||||
fi
|
||||
|
||||
# Test 14: Telegram connectivity
|
||||
print_test "Telegram API connectivity"
|
||||
if echo "$HEALTH_OUTPUT" | grep -q "Telegram.*healthy"; then
|
||||
pass "Telegram channel is healthy"
|
||||
elif echo "$HEALTH_OUTPUT" | grep -q "Telegram.*unhealthy"; then
|
||||
warn "Telegram channel is unhealthy - check bot token"
|
||||
elif echo "$HEALTH_OUTPUT" | grep -q "Telegram.*timed out"; then
|
||||
warn "Telegram health check timed out - network issue?"
|
||||
else
|
||||
warn "Could not determine Telegram health status"
|
||||
fi
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Phase 5: Feature Validation Tests
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
print_header "Phase 5: Feature Validation Tests"
|
||||
|
||||
# Test 15: Message splitting function exists
|
||||
print_test "Message splitting function implementation"
|
||||
if grep -q "fn split_message_for_telegram" src/channels/telegram.rs; then
|
||||
pass "Message splitting function implemented"
|
||||
else
|
||||
fail "Message splitting function not found"
|
||||
fi
|
||||
|
||||
# Test 16: Message length constant
|
||||
print_test "Telegram message length constant"
|
||||
if grep -q "const TELEGRAM_MAX_MESSAGE_LENGTH: usize = 4096" src/channels/telegram.rs; then
|
||||
pass "TELEGRAM_MAX_MESSAGE_LENGTH constant defined correctly"
|
||||
else
|
||||
fail "Message length constant missing or incorrect"
|
||||
fi
|
||||
|
||||
# Test 17: Timeout implementation
|
||||
print_test "Health check timeout implementation"
|
||||
if grep -q "tokio::time::timeout" src/channels/telegram.rs; then
|
||||
pass "Timeout mechanism implemented in health_check"
|
||||
else
|
||||
fail "Timeout not implemented in health_check"
|
||||
fi
|
||||
|
||||
# Test 18: chat_id validation
|
||||
print_test "chat_id validation implementation"
|
||||
if grep -q "let Some(chat_id) = chat_id else" src/channels/telegram.rs; then
|
||||
pass "chat_id validation implemented"
|
||||
else
|
||||
fail "chat_id validation missing"
|
||||
fi
|
||||
|
||||
# Test 19: Duration import
|
||||
print_test "std::time::Duration import"
|
||||
if grep -q "use std::time::Duration" src/channels/telegram.rs; then
|
||||
pass "Duration import added"
|
||||
else
|
||||
fail "Duration import missing"
|
||||
fi
|
||||
|
||||
# Test 20: Continuation markers
|
||||
print_test "Multi-part message markers"
|
||||
if grep -q "(continues...)" src/channels/telegram.rs && grep -q "(continued)" src/channels/telegram.rs; then
|
||||
pass "Continuation markers implemented for split messages"
|
||||
else
|
||||
fail "Continuation markers missing"
|
||||
fi
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Phase 6: Integration Test Preparation
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
print_header "Phase 6: Manual Integration Tests"
|
||||
|
||||
echo -e "${BLUE}The following tests require manual interaction:${NC}\n"
|
||||
|
||||
cat << 'EOF'
|
||||
📱 Manual Test Checklist:
|
||||
|
||||
1. [ ] Start the channel:
|
||||
zeroclaw channel start
|
||||
|
||||
2. [ ] Send a short message to your bot in Telegram:
|
||||
"Hello bot!"
|
||||
✓ Verify: Bot responds within 3 seconds
|
||||
|
||||
3. [ ] Send a long message (>4096 characters):
|
||||
python3 -c 'print("test " * 1000)'
|
||||
✓ Verify: Message is split into chunks
|
||||
✓ Verify: Chunks have (continues...) and (continued) markers
|
||||
✓ Verify: All chunks arrive in order
|
||||
|
||||
4. [ ] Test unauthorized access:
|
||||
- Edit config: allowed_users = ["999999999"]
|
||||
- Send a message
|
||||
✓ Verify: Warning log appears
|
||||
✓ Verify: Message is ignored
|
||||
- Restore correct user ID
|
||||
|
||||
5. [ ] Test rapid messages (10 messages in 5 seconds):
|
||||
✓ Verify: All messages are processed
|
||||
✓ Verify: No rate limit errors
|
||||
✓ Verify: Responses have delays
|
||||
|
||||
6. [ ] Check logs for errors:
|
||||
RUST_LOG=debug zeroclaw channel start
|
||||
✓ Verify: No unexpected errors
|
||||
✓ Verify: "missing chat_id" appears for malformed messages
|
||||
✓ Verify: Health check logs show "timed out" if needed
|
||||
|
||||
EOF
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Test Summary
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
print_header "Test Summary"
|
||||
|
||||
echo -e "${BLUE}Total Tests:${NC} $TOTAL_TESTS"
|
||||
echo -e "${GREEN}Passed:${NC} $PASSED_TESTS"
|
||||
echo -e "${RED}Failed:${NC} $FAILED_TESTS"
|
||||
echo -e "${YELLOW}Warnings:${NC} $((TOTAL_TESTS - PASSED_TESTS - FAILED_TESTS))"
|
||||
|
||||
PASS_RATE=$((PASSED_TESTS * 100 / TOTAL_TESTS))
|
||||
echo -e "\n${BLUE}Pass Rate:${NC} ${PASS_RATE}%"
|
||||
|
||||
if [ $FAILED_TESTS -eq 0 ]; then
|
||||
echo -e "\n${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${GREEN}✓ ALL AUTOMATED TESTS PASSED! 🎉${NC}"
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
|
||||
|
||||
echo -e "${BLUE}Next Steps:${NC}"
|
||||
echo -e "1. Run manual integration tests (see checklist above)"
|
||||
echo -e "2. Deploy to production when ready"
|
||||
echo -e "3. Monitor logs for issues\n"
|
||||
|
||||
exit 0
|
||||
else
|
||||
echo -e "\n${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${RED}✗ SOME TESTS FAILED${NC}"
|
||||
echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
|
||||
|
||||
echo -e "${BLUE}Troubleshooting:${NC}"
|
||||
echo -e "1. Review failed tests above"
|
||||
echo -e "2. Run: cargo test telegram --lib -- --nocapture"
|
||||
echo -e "3. Check: cargo clippy --all-targets"
|
||||
echo -e "4. Fix issues and re-run this script\n"
|
||||
|
||||
exit 1
|
||||
fi
|
||||
352
third_party/zeroclaw/tests/manual/telegram/testing-telegram.md
vendored
Normal file
352
third_party/zeroclaw/tests/manual/telegram/testing-telegram.md
vendored
Normal file
@@ -0,0 +1,352 @@
|
||||
# Telegram Integration Testing Guide
|
||||
|
||||
This guide covers testing the Telegram channel integration for ZeroClaw.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Automated Tests
|
||||
|
||||
```bash
|
||||
# Full test suite (20+ tests, ~2 minutes)
|
||||
./tests/telegram/test_telegram_integration.sh
|
||||
|
||||
# Quick smoke test (~10 seconds)
|
||||
./tests/telegram/quick_test.sh
|
||||
|
||||
# Just unit tests
|
||||
cargo test telegram --lib
|
||||
```
|
||||
|
||||
## 📋 Test Coverage
|
||||
|
||||
### Automated Tests (20 tests)
|
||||
|
||||
The `test_telegram_integration.sh` script runs:
|
||||
|
||||
**Phase 1: Code Quality (5 tests)**
|
||||
|
||||
- ✅ Test compilation
|
||||
- ✅ Unit tests (24 tests)
|
||||
- ✅ Message splitting tests (8 tests)
|
||||
- ✅ Clippy linting
|
||||
- ✅ Code formatting
|
||||
|
||||
**Phase 2: Build Tests (3 tests)**
|
||||
|
||||
- ✅ Debug build
|
||||
- ✅ Release build
|
||||
- ✅ Binary size verification (<10MB)
|
||||
|
||||
**Phase 3: Configuration Tests (4 tests)**
|
||||
|
||||
- ✅ Config file exists
|
||||
- ✅ Telegram section configured
|
||||
- ✅ Bot token set
|
||||
- ✅ User allowlist configured
|
||||
|
||||
**Phase 4: Health Check Tests (2 tests)**
|
||||
|
||||
- ✅ Health check timeout (<5s)
|
||||
- ✅ Telegram API connectivity
|
||||
|
||||
**Phase 5: Feature Validation (6 tests)**
|
||||
|
||||
- ✅ Message splitting function
|
||||
- ✅ Message length constant (4096)
|
||||
- ✅ Timeout implementation
|
||||
- ✅ chat_id validation
|
||||
- ✅ Duration import
|
||||
- ✅ Continuation markers
|
||||
|
||||
### Manual Tests (6 tests)
|
||||
|
||||
After running automated tests, perform these manual checks:
|
||||
|
||||
1. **Basic messaging**
|
||||
|
||||
```bash
|
||||
zeroclaw channel start
|
||||
```
|
||||
|
||||
- Send "Hello bot!" in Telegram
|
||||
- Verify response within 3 seconds
|
||||
|
||||
2. **Long message splitting**
|
||||
|
||||
```bash
|
||||
# Generate 5000+ char message
|
||||
python3 -c 'print("test " * 1000)'
|
||||
```
|
||||
|
||||
- Paste into Telegram
|
||||
- Verify: Message split into chunks
|
||||
- Verify: Markers show `(continues...)` and `(continued)`
|
||||
- Verify: All chunks arrive in order
|
||||
|
||||
3. **Unauthorized user blocking**
|
||||
|
||||
```toml
|
||||
# Edit ~/.zeroclaw/config.toml
|
||||
allowed_users = ["999999999"]
|
||||
```
|
||||
|
||||
- Send message to bot
|
||||
- Verify: Warning in logs
|
||||
- Verify: Message ignored
|
||||
- Restore correct user ID
|
||||
|
||||
4. **Rate limiting**
|
||||
- Send 10 messages rapidly
|
||||
- Verify: All processed
|
||||
- Verify: No "Too Many Requests" errors
|
||||
- Verify: Responses have delays
|
||||
|
||||
5. **Mention-only mode (group chats)**
|
||||
|
||||
```toml
|
||||
# Edit ~/.zeroclaw/config.toml
|
||||
[channels.telegram]
|
||||
mention_only = true
|
||||
```
|
||||
|
||||
- Add bot to a group chat
|
||||
- Send message without @botname mention
|
||||
- Verify: Bot does not respond
|
||||
- Send message with @botname mention
|
||||
- Verify: Bot responds and mention is stripped
|
||||
- DM/private chat should always work regardless of mention_only
|
||||
|
||||
6. **Error logging**
|
||||
|
||||
```bash
|
||||
RUST_LOG=debug zeroclaw channel start
|
||||
```
|
||||
|
||||
- Check for unexpected errors
|
||||
- Verify proper error handling
|
||||
|
||||
6. **Health check timeout**
|
||||
|
||||
```bash
|
||||
time zeroclaw channel doctor
|
||||
```
|
||||
|
||||
- Verify: Completes in <5 seconds
|
||||
|
||||
## 🔍 Test Results Interpretation
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- All 20 automated tests pass ✅
|
||||
- Health check completes in <5s ✅
|
||||
- Binary size <10MB ✅
|
||||
- No clippy warnings ✅
|
||||
- All manual tests pass ✅
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Issue: Health check times out**
|
||||
|
||||
```
|
||||
Solution: Check bot token is valid
|
||||
curl "https://api.telegram.org/bot<TOKEN>/getMe"
|
||||
```
|
||||
|
||||
**Issue: Bot doesn't respond**
|
||||
|
||||
```
|
||||
Solution: Check user allowlist
|
||||
1. Send message to bot
|
||||
2. Check logs for user_id
|
||||
3. Update config: allowed_users = ["YOUR_ID"]
|
||||
4. Run: zeroclaw onboard --channels-only
|
||||
```
|
||||
|
||||
**Issue: Message splitting not working**
|
||||
|
||||
```
|
||||
Solution: Verify code changes
|
||||
grep -n "split_message_for_telegram" src/channels/telegram.rs
|
||||
grep -n "TELEGRAM_MAX_MESSAGE_LENGTH" src/channels/telegram.rs
|
||||
```
|
||||
|
||||
## 🧪 Test Scenarios
|
||||
|
||||
### Scenario 1: First-Time Setup
|
||||
|
||||
```bash
|
||||
# 1. Run automated tests
|
||||
./tests/telegram/test_telegram_integration.sh
|
||||
|
||||
# 2. Configure Telegram
|
||||
zeroclaw onboard
|
||||
# Select Telegram channel
|
||||
# Enter bot token (from @BotFather)
|
||||
# Enter your user ID
|
||||
|
||||
# 3. Verify health
|
||||
zeroclaw channel doctor
|
||||
|
||||
# 4. Start channel
|
||||
zeroclaw channel start
|
||||
|
||||
# 5. Send test message in Telegram
|
||||
```
|
||||
|
||||
### Scenario 2: After Code Changes
|
||||
|
||||
```bash
|
||||
# 1. Quick validation
|
||||
./tests/telegram/quick_test.sh
|
||||
|
||||
# 2. Full test suite
|
||||
./tests/telegram/test_telegram_integration.sh
|
||||
|
||||
# 3. Manual smoke test
|
||||
zeroclaw channel start
|
||||
# Send message in Telegram
|
||||
```
|
||||
|
||||
### Scenario 3: Production Deployment
|
||||
|
||||
```bash
|
||||
# 1. Full test suite
|
||||
./tests/telegram/test_telegram_integration.sh
|
||||
|
||||
# 2. Load test (optional)
|
||||
# Send 100 messages rapidly
|
||||
for i in {1..100}; do
|
||||
echo "Test message $i" | \
|
||||
curl -X POST "https://api.telegram.org/bot<TOKEN>/sendMessage" \
|
||||
-d "chat_id=<CHAT_ID>" \
|
||||
-d "text=Message $i"
|
||||
done
|
||||
|
||||
# 3. Monitor logs
|
||||
RUST_LOG=info zeroclaw daemon
|
||||
|
||||
# 4. Check metrics
|
||||
zeroclaw status
|
||||
```
|
||||
|
||||
## 📊 Performance Benchmarks
|
||||
|
||||
Expected values after all fixes:
|
||||
|
||||
| Metric | Expected | How to Measure |
|
||||
| ---------------------- | ---------- | -------------------------------- |
|
||||
| Health check time | <5s | `time zeroclaw channel doctor` |
|
||||
| First response time | <3s | Time from sending to receiving |
|
||||
| Message split overhead | <50ms | Check logs for timing |
|
||||
| Memory usage | <10MB | `ps aux \| grep zeroclaw` |
|
||||
| Binary size | ~3-4MB | `ls -lh target/release/zeroclaw` |
|
||||
| Unit test coverage | 61/61 pass | `cargo test telegram --lib` |
|
||||
|
||||
## 🐛 Debugging Failed Tests
|
||||
|
||||
### Debug Unit Tests
|
||||
|
||||
```bash
|
||||
# Verbose output
|
||||
cargo test telegram --lib -- --nocapture
|
||||
|
||||
# Specific test
|
||||
cargo test telegram_split_over_limit -- --nocapture
|
||||
|
||||
# Show ignored tests
|
||||
cargo test telegram --lib -- --ignored
|
||||
```
|
||||
|
||||
### Debug Integration Issues
|
||||
|
||||
```bash
|
||||
# Maximum logging
|
||||
RUST_LOG=trace zeroclaw channel start
|
||||
|
||||
# Check Telegram API directly
|
||||
curl "https://api.telegram.org/bot<TOKEN>/getMe"
|
||||
curl "https://api.telegram.org/bot<TOKEN>/getUpdates"
|
||||
|
||||
# Validate config
|
||||
cat ~/.zeroclaw/config.toml | grep -A 3 "\[channels_config.telegram\]"
|
||||
```
|
||||
|
||||
### Debug Build Issues
|
||||
|
||||
```bash
|
||||
# Clean build
|
||||
cargo clean
|
||||
cargo build --release
|
||||
|
||||
# Check dependencies
|
||||
cargo tree | grep telegram
|
||||
|
||||
# Update dependencies
|
||||
cargo update
|
||||
```
|
||||
|
||||
## 🎯 CI/CD Integration
|
||||
|
||||
Add to your CI pipeline:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
name: Test Telegram Integration
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- name: Run tests
|
||||
run: |
|
||||
cargo test telegram --lib
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
- name: Check formatting
|
||||
run: cargo fmt --check
|
||||
```
|
||||
|
||||
## 📝 Test Checklist
|
||||
|
||||
Before merging code:
|
||||
|
||||
- [ ] `./tests/telegram/quick_test.sh` passes
|
||||
- [ ] `./tests/telegram/test_telegram_integration.sh` passes
|
||||
- [ ] Manual tests completed
|
||||
- [ ] No new clippy warnings
|
||||
- [ ] Code is formatted (`cargo fmt`)
|
||||
- [ ] Documentation updated
|
||||
- [ ] CHANGELOG.md updated
|
||||
|
||||
## 🚨 Emergency Rollback
|
||||
|
||||
If tests fail in production:
|
||||
|
||||
```bash
|
||||
# 1. Check git history
|
||||
git log --oneline src/channels/telegram.rs
|
||||
|
||||
# 2. Rollback to previous version
|
||||
git revert <commit-hash>
|
||||
|
||||
# 3. Rebuild
|
||||
cargo build --release
|
||||
|
||||
# 4. Restart service
|
||||
zeroclaw service restart
|
||||
|
||||
# 5. Verify
|
||||
zeroclaw channel doctor
|
||||
```
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- [Telegram Bot API Documentation](https://core.telegram.org/bots/api)
|
||||
- [ZeroClaw Main README](../../README.md)
|
||||
- [Contributing Guide](../../CONTRIBUTING.md)
|
||||
- [Issue Tracker](https://github.com/zeroclaw-labs/zeroclaw/issues)
|
||||
169
third_party/zeroclaw/tests/manual/test_dockerignore.sh
vendored
Executable file
169
third_party/zeroclaw/tests/manual/test_dockerignore.sh
vendored
Executable file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test script to verify .dockerignore excludes sensitive paths
|
||||
# Run: ./tests/manual/test_dockerignore.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
DOCKERIGNORE="$PROJECT_ROOT/.dockerignore"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
log_pass() {
|
||||
echo -e "${GREEN}✓${NC} $1"
|
||||
PASS=$((PASS + 1))
|
||||
}
|
||||
|
||||
log_fail() {
|
||||
echo -e "${RED}✗${NC} $1"
|
||||
FAIL=$((FAIL + 1))
|
||||
}
|
||||
|
||||
# Test 1: .dockerignore exists
|
||||
echo "=== Testing .dockerignore ==="
|
||||
if [[ -f "$DOCKERIGNORE" ]]; then
|
||||
log_pass ".dockerignore file exists"
|
||||
else
|
||||
log_fail ".dockerignore file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test 2: Required exclusions are present
|
||||
MUST_EXCLUDE=(
|
||||
".git"
|
||||
".githooks"
|
||||
"target"
|
||||
"docs"
|
||||
"examples"
|
||||
"tests"
|
||||
"*.md"
|
||||
"*.png"
|
||||
"*.db"
|
||||
"*.db-journal"
|
||||
".DS_Store"
|
||||
".github"
|
||||
"deny.toml"
|
||||
"LICENSE"
|
||||
".env"
|
||||
".tmp_*"
|
||||
)
|
||||
|
||||
for pattern in "${MUST_EXCLUDE[@]}"; do
|
||||
# Use fgrep for literal matching
|
||||
if grep -Fq "$pattern" "$DOCKERIGNORE" 2>/dev/null; then
|
||||
log_pass "Excludes: $pattern"
|
||||
else
|
||||
log_fail "Missing exclusion: $pattern"
|
||||
fi
|
||||
done
|
||||
|
||||
# Test 3: Build essentials are NOT excluded
|
||||
MUST_NOT_EXCLUDE=(
|
||||
"Cargo.toml"
|
||||
"Cargo.lock"
|
||||
"src"
|
||||
)
|
||||
|
||||
for path in "${MUST_NOT_EXCLUDE[@]}"; do
|
||||
if grep -qE "^${path}$" "$DOCKERIGNORE" 2>/dev/null; then
|
||||
log_fail "Build essential '$path' is incorrectly excluded"
|
||||
else
|
||||
log_pass "Build essential NOT excluded: $path"
|
||||
fi
|
||||
done
|
||||
|
||||
# Test 4: No syntax errors (basic validation)
|
||||
while IFS= read -r line; do
|
||||
# Skip empty lines and comments
|
||||
[[ -z "$line" || "$line" =~ ^# ]] && continue
|
||||
|
||||
# Check for common issues
|
||||
if [[ "$line" =~ [[:space:]]$ ]]; then
|
||||
log_fail "Trailing whitespace in pattern: '$line'"
|
||||
fi
|
||||
done < "$DOCKERIGNORE"
|
||||
log_pass "No trailing whitespace in patterns"
|
||||
|
||||
# Test 5: Verify Docker build context would be small
|
||||
echo ""
|
||||
echo "=== Simulating Docker build context ==="
|
||||
|
||||
# Create temp dir and simulate what would be sent
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
trap "rm -rf $TEMP_DIR" EXIT
|
||||
|
||||
# Use rsync with .dockerignore patterns to simulate Docker's behavior
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Count files that WOULD be sent (excluding .dockerignore patterns)
|
||||
TOTAL_FILES=$(find . -type f | wc -l | tr -d ' ')
|
||||
CONTEXT_FILES=$(find . -type f \
|
||||
! -path './.git/*' \
|
||||
! -path './target/*' \
|
||||
! -path './docs/*' \
|
||||
! -path './examples/*' \
|
||||
! -path './tests/*' \
|
||||
! -name '*.md' \
|
||||
! -name '*.png' \
|
||||
! -name '*.svg' \
|
||||
! -name '*.db' \
|
||||
! -name '*.db-journal' \
|
||||
! -name '.DS_Store' \
|
||||
! -path './.github/*' \
|
||||
! -name 'deny.toml' \
|
||||
! -name 'LICENSE' \
|
||||
! -name '.env' \
|
||||
! -name '.env.*' \
|
||||
2>/dev/null | wc -l | tr -d ' ')
|
||||
|
||||
echo "Total files in repo: $TOTAL_FILES"
|
||||
echo "Files in Docker context: $CONTEXT_FILES"
|
||||
|
||||
if [[ $CONTEXT_FILES -lt $TOTAL_FILES ]]; then
|
||||
log_pass "Docker context is smaller than full repo ($CONTEXT_FILES < $TOTAL_FILES files)"
|
||||
else
|
||||
log_fail "Docker context is not being reduced"
|
||||
fi
|
||||
|
||||
# Test 6: Verify critical security files would be excluded
|
||||
echo ""
|
||||
echo "=== Security checks ==="
|
||||
|
||||
# Check if .git would be excluded
|
||||
if [[ -d "$PROJECT_ROOT/.git" ]]; then
|
||||
if grep -q "^\.git$" "$DOCKERIGNORE"; then
|
||||
log_pass ".git directory will be excluded (security)"
|
||||
else
|
||||
log_fail ".git directory NOT excluded - SECURITY RISK"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if any .db files exist and would be excluded
|
||||
DB_FILES=$(find "$PROJECT_ROOT" -name "*.db" -type f 2>/dev/null | head -5)
|
||||
if [[ -n "$DB_FILES" ]]; then
|
||||
if grep -q "^\*\.db$" "$DOCKERIGNORE"; then
|
||||
log_pass "*.db files will be excluded (security)"
|
||||
else
|
||||
log_fail "*.db files NOT excluded - SECURITY RISK"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "=== Summary ==="
|
||||
echo -e "Passed: ${GREEN}$PASS${NC}"
|
||||
echo -e "Failed: ${RED}$FAIL${NC}"
|
||||
|
||||
if [[ $FAIL -gt 0 ]]; then
|
||||
echo -e "${RED}FAILED${NC}: $FAIL tests failed"
|
||||
exit 1
|
||||
else
|
||||
echo -e "${GREEN}PASSED${NC}: All tests passed"
|
||||
exit 0
|
||||
fi
|
||||
11
third_party/zeroclaw/tests/manual/tmux/onboard_wrapper.sh
vendored
Normal file
11
third_party/zeroclaw/tests/manual/tmux/onboard_wrapper.sh
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
config_dir="$1"
|
||||
bin_path="$2"
|
||||
|
||||
env ZEROCLAW_CONFIG_DIR="$config_dir" "$bin_path" onboard
|
||||
status=$?
|
||||
printf '\nEXIT_STATUS=%s\n' "$status"
|
||||
sleep 5
|
||||
200
third_party/zeroclaw/tests/manual/tmux/test_onboard_provider_input_paths.sh
vendored
Normal file
200
third_party/zeroclaw/tests/manual/tmux/test_onboard_provider_input_paths.sh
vendored
Normal file
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
BIN_PATH="${1:-$ROOT_DIR/target/debug/zeroclaw}"
|
||||
TMP_ROOT="/tmp/zeroclaw-tmux-onboard-$$"
|
||||
|
||||
cleanup() {
|
||||
tmux kill-session -t "zc_full_$$_custom" >/dev/null 2>&1 || true
|
||||
tmux kill-session -t "zc_update_$$_synthetic" >/dev/null 2>&1 || true
|
||||
rm -rf "$TMP_ROOT"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if ! command -v tmux >/dev/null 2>&1; then
|
||||
echo "tmux is required for this regression test" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -x "$BIN_PATH" ]]; then
|
||||
echo "Building zeroclaw..."
|
||||
cargo build --bin zeroclaw >/dev/null
|
||||
fi
|
||||
|
||||
mkdir -p "$TMP_ROOT"
|
||||
|
||||
start_onboard_session() {
|
||||
local session="$1"
|
||||
local config_dir="$2"
|
||||
tmux kill-session -t "$session" >/dev/null 2>&1 || true
|
||||
tmux new-session -d -x 240 -y 60 -s "$session" \
|
||||
"bash \"$ROOT_DIR/tests/manual/tmux/onboard_wrapper.sh\" \"$config_dir\" \"$BIN_PATH\""
|
||||
sleep 1
|
||||
}
|
||||
|
||||
paste_value() {
|
||||
local session="$1"
|
||||
local buffer_name="$2"
|
||||
local value="$3"
|
||||
tmux set-buffer -b "$buffer_name" "$value"
|
||||
tmux paste-buffer -t "$session":0.0 -b "$buffer_name" -p
|
||||
}
|
||||
|
||||
send_enter() {
|
||||
local session="$1"
|
||||
tmux send-keys -t "$session":0.0 Enter
|
||||
}
|
||||
|
||||
send_key() {
|
||||
local session="$1"
|
||||
local key="$2"
|
||||
tmux send-keys -t "$session":0.0 "$key"
|
||||
}
|
||||
|
||||
capture_recent() {
|
||||
local session="$1"
|
||||
tmux capture-pane -p -S -80 -t "$session":0.0
|
||||
}
|
||||
|
||||
assert_prompt_value_exact() {
|
||||
local session="$1"
|
||||
local prompt="$2"
|
||||
local value="$3"
|
||||
local label="$4"
|
||||
local line
|
||||
|
||||
line="$(
|
||||
capture_recent "$session" |
|
||||
awk -v prompt="$prompt" 'index($0, prompt) { line = $0 } END { if (line != "") print line; else exit 1 }'
|
||||
)"
|
||||
|
||||
local actual="${line#*"$prompt"}"
|
||||
if [[ "$actual" != "$value" ]]; then
|
||||
echo "Unexpected tmux paste rendering for $label" >&2
|
||||
echo "Prompt: $prompt" >&2
|
||||
echo "Expected: $value" >&2
|
||||
echo "Actual line: $line" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_full_custom_provider_flow() {
|
||||
local root="$TMP_ROOT/full"
|
||||
local config_dir="$root/config"
|
||||
local workspace_path="$root/ws"
|
||||
local session="zc_full_$$_custom"
|
||||
local base_url="https://e.invalid/v1"
|
||||
local api_key="sk-full-a1b2"
|
||||
local model="full-model-a1"
|
||||
|
||||
mkdir -p "$root"
|
||||
start_onboard_session "$session" "$config_dir"
|
||||
|
||||
send_key "$session" n
|
||||
sleep 1
|
||||
|
||||
paste_value "$session" zc_full_workspace "$workspace_path"
|
||||
sleep 1
|
||||
assert_prompt_value_exact "$session" " Enter workspace path: " "$workspace_path" "custom workspace path"
|
||||
send_enter "$session"
|
||||
sleep 1
|
||||
|
||||
for _ in 1 2 3 4 5; do
|
||||
send_key "$session" Down
|
||||
done
|
||||
send_enter "$session"
|
||||
sleep 1
|
||||
|
||||
paste_value "$session" zc_full_base_url "$base_url"
|
||||
sleep 1
|
||||
assert_prompt_value_exact \
|
||||
"$session" \
|
||||
" API base URL (e.g. http://localhost:1234 or https://my-api.com): " \
|
||||
"$base_url" \
|
||||
"custom provider base URL"
|
||||
send_enter "$session"
|
||||
sleep 1
|
||||
|
||||
paste_value "$session" zc_full_api_key "$api_key"
|
||||
sleep 1
|
||||
assert_prompt_value_exact \
|
||||
"$session" \
|
||||
" API key (or Enter to skip if not needed): " \
|
||||
"$api_key" \
|
||||
"custom provider API key"
|
||||
send_enter "$session"
|
||||
sleep 1
|
||||
|
||||
paste_value "$session" zc_full_model "$model"
|
||||
sleep 1
|
||||
assert_prompt_value_exact \
|
||||
"$session" \
|
||||
" Model name (e.g. llama3, gpt-4o, mistral) [default]: " \
|
||||
"$model" \
|
||||
"custom provider model"
|
||||
send_enter "$session"
|
||||
sleep 1
|
||||
}
|
||||
|
||||
run_update_custom_model_flow() {
|
||||
local root="$TMP_ROOT/update"
|
||||
local config_dir="$root/config"
|
||||
local session="zc_update_$$_synthetic"
|
||||
local api_key="sk-synth-a1b2"
|
||||
local model="synthetic-manual-a1"
|
||||
|
||||
mkdir -p "$root"
|
||||
|
||||
env ZEROCLAW_CONFIG_DIR="$config_dir" \
|
||||
"$BIN_PATH" onboard --provider openrouter --api-key seed-key --model openai/gpt-5-mini --force >/dev/null
|
||||
|
||||
start_onboard_session "$session" "$config_dir"
|
||||
|
||||
send_enter "$session"
|
||||
sleep 1
|
||||
send_enter "$session"
|
||||
sleep 1
|
||||
|
||||
for _ in 1 2 3; do
|
||||
send_key "$session" Down
|
||||
done
|
||||
send_enter "$session"
|
||||
sleep 1
|
||||
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14; do
|
||||
send_key "$session" Down
|
||||
done
|
||||
send_enter "$session"
|
||||
sleep 1
|
||||
|
||||
paste_value "$session" zc_update_api_key "$api_key"
|
||||
sleep 1
|
||||
assert_prompt_value_exact \
|
||||
"$session" \
|
||||
" Paste your API key (or press Enter to skip): " \
|
||||
"$api_key" \
|
||||
"provider-only API key"
|
||||
send_enter "$session"
|
||||
sleep 1
|
||||
|
||||
send_key "$session" Down
|
||||
send_enter "$session"
|
||||
sleep 1
|
||||
|
||||
paste_value "$session" zc_update_model "$model"
|
||||
sleep 1
|
||||
assert_prompt_value_exact \
|
||||
"$session" \
|
||||
" Enter custom model ID [anthropic/claude-sonnet-4.6]: " \
|
||||
"$model" \
|
||||
"custom model ID"
|
||||
send_enter "$session"
|
||||
sleep 1
|
||||
}
|
||||
|
||||
run_full_custom_provider_flow
|
||||
run_update_custom_model_flow
|
||||
|
||||
echo "tmux onboarding provider input paths passed"
|
||||
62
third_party/zeroclaw/tests/support/assertions.rs
vendored
Normal file
62
third_party/zeroclaw/tests/support/assertions.rs
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
//! Declarative expectation verification for trace fixtures.
|
||||
|
||||
use super::trace::TraceExpects;
|
||||
|
||||
/// Verify trace expectations against actual test results.
|
||||
///
|
||||
/// - `expects`: declarative expectations from the trace fixture
|
||||
/// - `final_response`: the final text response from the agent
|
||||
/// - `tools_called`: names of tools that were actually called
|
||||
/// - `label`: test label for error messages
|
||||
pub fn verify_expects(
|
||||
expects: &TraceExpects,
|
||||
final_response: &str,
|
||||
tools_called: &[String],
|
||||
label: &str,
|
||||
) {
|
||||
for needle in &expects.response_contains {
|
||||
assert!(
|
||||
final_response.contains(needle),
|
||||
"[{label}] Expected response to contain \"{needle}\", got: {final_response}"
|
||||
);
|
||||
}
|
||||
|
||||
for needle in &expects.response_not_contains {
|
||||
assert!(
|
||||
!final_response.contains(needle),
|
||||
"[{label}] Expected response NOT to contain \"{needle}\", got: {final_response}"
|
||||
);
|
||||
}
|
||||
|
||||
for tool in &expects.tools_used {
|
||||
assert!(
|
||||
tools_called.iter().any(|t| t == tool),
|
||||
"[{label}] Expected tool \"{tool}\" to be used, but tools called were: {tools_called:?}"
|
||||
);
|
||||
}
|
||||
|
||||
for tool in &expects.tools_not_used {
|
||||
assert!(
|
||||
!tools_called.iter().any(|t| t == tool),
|
||||
"[{label}] Expected tool \"{tool}\" NOT to be used, but it was called"
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(max) = expects.max_tool_calls {
|
||||
assert!(
|
||||
tools_called.len() <= max,
|
||||
"[{label}] Expected at most {max} tool calls, got {}",
|
||||
tools_called.len()
|
||||
);
|
||||
}
|
||||
|
||||
for pattern in &expects.response_matches {
|
||||
let re = regex::Regex::new(pattern).unwrap_or_else(|e| {
|
||||
panic!("[{label}] Invalid regex pattern \"{pattern}\": {e}");
|
||||
});
|
||||
assert!(
|
||||
re.is_match(final_response),
|
||||
"[{label}] Expected response to match regex \"{pattern}\", got: {final_response}"
|
||||
);
|
||||
}
|
||||
}
|
||||
142
third_party/zeroclaw/tests/support/helpers.rs
vendored
Normal file
142
third_party/zeroclaw/tests/support/helpers.rs
vendored
Normal file
@@ -0,0 +1,142 @@
|
||||
//! Shared builder helpers for constructing test agents.
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use zeroclaw::agent::agent::Agent;
|
||||
use zeroclaw::agent::dispatcher::{NativeToolDispatcher, XmlToolDispatcher};
|
||||
use zeroclaw::agent::memory_loader::MemoryLoader;
|
||||
use zeroclaw::config::MemoryConfig;
|
||||
use zeroclaw::memory;
|
||||
use zeroclaw::memory::Memory;
|
||||
use zeroclaw::observability::{NoopObserver, Observer};
|
||||
use zeroclaw::providers::{ChatResponse, Provider, ToolCall};
|
||||
use zeroclaw::tools::Tool;
|
||||
|
||||
/// Create an in-memory "none" backend for tests.
|
||||
pub fn make_memory() -> Arc<dyn Memory> {
|
||||
let cfg = MemoryConfig {
|
||||
backend: "none".into(),
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
Arc::from(memory::create_memory(&cfg, &std::env::temp_dir(), None).unwrap())
|
||||
}
|
||||
|
||||
/// Create a `NoopObserver` for tests.
|
||||
pub fn make_observer() -> Arc<dyn Observer> {
|
||||
Arc::from(NoopObserver {})
|
||||
}
|
||||
|
||||
/// Create a text-only `ChatResponse`.
|
||||
pub fn text_response(text: &str) -> ChatResponse {
|
||||
ChatResponse {
|
||||
text: Some(text.into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a `ChatResponse` with tool calls.
|
||||
pub fn tool_response(calls: Vec<ToolCall>) -> ChatResponse {
|
||||
ChatResponse {
|
||||
text: Some(String::new()),
|
||||
tool_calls: calls,
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an agent with `NativeToolDispatcher`.
|
||||
pub fn build_agent(provider: Box<dyn Provider>, tools: Vec<Box<dyn Tool>>) -> Agent {
|
||||
Agent::builder()
|
||||
.provider(provider)
|
||||
.tools(tools)
|
||||
.memory(make_memory())
|
||||
.observer(make_observer())
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
.workspace_dir(std::env::temp_dir())
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Build an agent with `XmlToolDispatcher`.
|
||||
pub fn build_agent_xml(provider: Box<dyn Provider>, tools: Vec<Box<dyn Tool>>) -> Agent {
|
||||
Agent::builder()
|
||||
.provider(provider)
|
||||
.tools(tools)
|
||||
.memory(make_memory())
|
||||
.observer(make_observer())
|
||||
.tool_dispatcher(Box::new(XmlToolDispatcher))
|
||||
.workspace_dir(std::env::temp_dir())
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Build an agent with optional custom `MemoryLoader`.
|
||||
pub fn build_recording_agent(
|
||||
provider: Box<dyn Provider>,
|
||||
tools: Vec<Box<dyn Tool>>,
|
||||
memory_loader: Option<Box<dyn MemoryLoader>>,
|
||||
) -> Agent {
|
||||
let mut builder = Agent::builder()
|
||||
.provider(provider)
|
||||
.tools(tools)
|
||||
.memory(make_memory())
|
||||
.observer(make_observer())
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
.workspace_dir(std::env::temp_dir());
|
||||
|
||||
if let Some(loader) = memory_loader {
|
||||
builder = builder.memory_loader(loader);
|
||||
}
|
||||
|
||||
builder.build().unwrap()
|
||||
}
|
||||
|
||||
/// Build an agent with real `SqliteMemory` in a temporary directory.
|
||||
pub fn build_agent_with_sqlite_memory(
|
||||
provider: Box<dyn Provider>,
|
||||
tools: Vec<Box<dyn Tool>>,
|
||||
temp_dir: &std::path::Path,
|
||||
) -> Agent {
|
||||
let cfg = MemoryConfig {
|
||||
backend: "sqlite".into(),
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
let mem = Arc::from(memory::create_memory(&cfg, temp_dir, None).unwrap());
|
||||
Agent::builder()
|
||||
.provider(provider)
|
||||
.tools(tools)
|
||||
.memory(mem)
|
||||
.observer(make_observer())
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
.workspace_dir(std::env::temp_dir())
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Mock memory loader that returns a static context string.
|
||||
pub struct StaticMemoryLoader {
|
||||
context: String,
|
||||
}
|
||||
|
||||
impl StaticMemoryLoader {
|
||||
pub fn new(context: &str) -> Self {
|
||||
Self {
|
||||
context: context.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MemoryLoader for StaticMemoryLoader {
|
||||
async fn load_context(
|
||||
&self,
|
||||
_memory: &dyn Memory,
|
||||
_user_message: &str,
|
||||
_session_id: Option<&str>,
|
||||
) -> Result<String> {
|
||||
Ok(self.context.clone())
|
||||
}
|
||||
}
|
||||
86
third_party/zeroclaw/tests/support/mock_channel.rs
vendored
Normal file
86
third_party/zeroclaw/tests/support/mock_channel.rs
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
//! Mock channel for system-level tests.
|
||||
//!
|
||||
//! `TestChannel` implements the `Channel` trait with MPSC-based message
|
||||
//! injection and response capture for race-free testing.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use zeroclaw::channels::traits::{Channel, ChannelMessage, SendMessage};
|
||||
|
||||
/// A test channel that captures sent messages and supports message injection.
|
||||
pub struct TestChannel {
|
||||
name: String,
|
||||
sent_messages: Arc<Mutex<Vec<SendMessage>>>,
|
||||
typing_events: Arc<Mutex<Vec<TypingEvent>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TypingEvent {
|
||||
Start(String),
|
||||
Stop(String),
|
||||
}
|
||||
|
||||
impl TestChannel {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
sent_messages: Arc::new(Mutex::new(Vec::new())),
|
||||
typing_events: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all messages sent through this channel.
|
||||
pub fn sent_messages(&self) -> Vec<SendMessage> {
|
||||
self.sent_messages.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Get all typing events recorded by this channel.
|
||||
pub fn typing_events(&self) -> Vec<TypingEvent> {
|
||||
self.typing_events.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Clear captured messages and events.
|
||||
pub fn clear(&self) {
|
||||
self.sent_messages.lock().unwrap().clear();
|
||||
self.typing_events.lock().unwrap().clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for TestChannel {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||
self.sent_messages.lock().unwrap().push(message.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn listen(&self, _tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
|
||||
// System tests drive the agent via turn() rather than channel listen,
|
||||
// so this is a no-op. For channel-driven tests, messages are injected
|
||||
// via the MPSC sender directly.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
|
||||
self.typing_events
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(TypingEvent::Start(recipient.to_string()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop_typing(&self, recipient: &str) -> anyhow::Result<()> {
|
||||
self.typing_events
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(TypingEvent::Stop(recipient.to_string()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
199
third_party/zeroclaw/tests/support/mock_provider.rs
vendored
Normal file
199
third_party/zeroclaw/tests/support/mock_provider.rs
vendored
Normal file
@@ -0,0 +1,199 @@
|
||||
//! Shared mock provider implementations for integration tests.
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use zeroclaw::providers::traits::{ChatMessage, TokenUsage};
|
||||
use zeroclaw::providers::{ChatRequest, ChatResponse, Provider, ToolCall};
|
||||
|
||||
use super::trace::{LlmTrace, TraceResponse};
|
||||
|
||||
/// Mock provider that returns scripted responses in FIFO order.
|
||||
pub struct MockProvider {
|
||||
responses: Mutex<Vec<ChatResponse>>,
|
||||
}
|
||||
|
||||
impl MockProvider {
|
||||
pub fn new(responses: Vec<ChatResponse>) -> Self {
|
||||
Self {
|
||||
responses: Mutex::new(responses),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for MockProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
Ok("fallback".into())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
_request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<ChatResponse> {
|
||||
let mut guard = self.responses.lock().unwrap();
|
||||
if guard.is_empty() {
|
||||
return Ok(ChatResponse {
|
||||
text: Some("done".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
});
|
||||
}
|
||||
Ok(guard.remove(0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock provider that returns scripted responses AND records every request.
|
||||
pub struct RecordingProvider {
|
||||
responses: Mutex<Vec<ChatResponse>>,
|
||||
recorded_requests: Arc<Mutex<Vec<Vec<ChatMessage>>>>,
|
||||
}
|
||||
|
||||
impl RecordingProvider {
|
||||
pub fn new(responses: Vec<ChatResponse>) -> (Self, Arc<Mutex<Vec<Vec<ChatMessage>>>>) {
|
||||
let recorded = Arc::new(Mutex::new(Vec::new()));
|
||||
let provider = Self {
|
||||
responses: Mutex::new(responses),
|
||||
recorded_requests: recorded.clone(),
|
||||
};
|
||||
(provider, recorded)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for RecordingProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
Ok("fallback".into())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<ChatResponse> {
|
||||
self.recorded_requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(request.messages.to_vec());
|
||||
|
||||
let mut guard = self.responses.lock().unwrap();
|
||||
if guard.is_empty() {
|
||||
return Ok(ChatResponse {
|
||||
text: Some("done".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
});
|
||||
}
|
||||
Ok(guard.remove(0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider that replays responses from an `LlmTrace` fixture.
|
||||
///
|
||||
/// Each call to `chat()` returns the next step from the trace in FIFO order.
|
||||
/// If the agent calls the provider more times than there are steps, an error is returned.
|
||||
pub struct TraceLlmProvider {
|
||||
steps: Mutex<Vec<TraceResponse>>,
|
||||
trace_name: String,
|
||||
}
|
||||
|
||||
impl TraceLlmProvider {
|
||||
pub fn from_trace(trace: &LlmTrace) -> Self {
|
||||
let mut steps = Vec::new();
|
||||
for turn in &trace.turns {
|
||||
for step in &turn.steps {
|
||||
steps.push(step.response.clone());
|
||||
}
|
||||
}
|
||||
Self {
|
||||
steps: Mutex::new(steps),
|
||||
trace_name: trace.model_name.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for TraceLlmProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
Ok("fallback".into())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
_request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<ChatResponse> {
|
||||
let mut guard = self.steps.lock().unwrap();
|
||||
if guard.is_empty() {
|
||||
anyhow::bail!(
|
||||
"TraceLlmProvider({}) exhausted: no more steps in trace",
|
||||
self.trace_name
|
||||
);
|
||||
}
|
||||
let step = guard.remove(0);
|
||||
match step {
|
||||
TraceResponse::Text {
|
||||
content,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
} => Ok(ChatResponse {
|
||||
text: Some(content),
|
||||
tool_calls: vec![],
|
||||
usage: Some(TokenUsage {
|
||||
input_tokens: Some(input_tokens),
|
||||
output_tokens: Some(output_tokens),
|
||||
cached_input_tokens: None,
|
||||
}),
|
||||
reasoning_content: None,
|
||||
}),
|
||||
TraceResponse::ToolCalls {
|
||||
tool_calls,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
} => {
|
||||
let calls = tool_calls
|
||||
.into_iter()
|
||||
.map(|tc| ToolCall {
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
arguments: serde_json::to_string(&tc.arguments).unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
Ok(ChatResponse {
|
||||
text: Some(String::new()),
|
||||
tool_calls: calls,
|
||||
usage: Some(TokenUsage {
|
||||
input_tokens: Some(input_tokens),
|
||||
output_tokens: Some(output_tokens),
|
||||
cached_input_tokens: None,
|
||||
}),
|
||||
reasoning_content: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
152
third_party/zeroclaw/tests/support/mock_tools.rs
vendored
Normal file
152
third_party/zeroclaw/tests/support/mock_tools.rs
vendored
Normal file
@@ -0,0 +1,152 @@
|
||||
//! Shared mock tool implementations for integration tests.
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use zeroclaw::tools::{Tool, ToolResult};
|
||||
|
||||
/// Simple tool that echoes its input argument.
|
||||
pub struct EchoTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for EchoTool {
|
||||
fn name(&self) -> &str {
|
||||
"echo"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"Echoes the input message"
|
||||
}
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {"type": "string"}
|
||||
}
|
||||
})
|
||||
}
|
||||
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
|
||||
let msg = args
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("(empty)")
|
||||
.to_string();
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: msg,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool that tracks invocation count for verifying dispatch.
|
||||
pub struct CountingTool {
|
||||
count: Arc<Mutex<usize>>,
|
||||
}
|
||||
|
||||
impl CountingTool {
|
||||
pub fn new() -> (Self, Arc<Mutex<usize>>) {
|
||||
let count = Arc::new(Mutex::new(0));
|
||||
(
|
||||
Self {
|
||||
count: count.clone(),
|
||||
},
|
||||
count,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for CountingTool {
|
||||
fn name(&self) -> &str {
|
||||
"counter"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"Counts invocations"
|
||||
}
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({"type": "object"})
|
||||
}
|
||||
async fn execute(&self, _args: serde_json::Value) -> Result<ToolResult> {
|
||||
let mut c = self.count.lock().unwrap();
|
||||
*c += 1;
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("call #{}", *c),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool that always fails, simulating a broken external service.
|
||||
pub struct FailingTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FailingTool {
|
||||
fn name(&self) -> &str {
|
||||
"failing_tool"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"Always fails"
|
||||
}
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({"type": "object"})
|
||||
}
|
||||
async fn execute(&self, _args: serde_json::Value) -> Result<ToolResult> {
|
||||
Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some("Service unavailable: connection timeout".into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool that captures all arguments for assertion.
|
||||
pub struct RecordingTool {
|
||||
name: String,
|
||||
calls: Arc<Mutex<Vec<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl RecordingTool {
|
||||
pub fn new(name: &str) -> (Self, Arc<Mutex<Vec<serde_json::Value>>>) {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
(
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
calls: calls.clone(),
|
||||
},
|
||||
calls,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for RecordingTool {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"Records all arguments for assertion"
|
||||
}
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {"type": "string"}
|
||||
}
|
||||
})
|
||||
}
|
||||
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
|
||||
self.calls.lock().unwrap().push(args.clone());
|
||||
let output = args
|
||||
.get("input")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("recorded")
|
||||
.to_string();
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
11
third_party/zeroclaw/tests/support/mod.rs
vendored
Normal file
11
third_party/zeroclaw/tests/support/mod.rs
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
pub mod assertions;
|
||||
pub mod helpers;
|
||||
pub mod mock_channel;
|
||||
pub mod mock_provider;
|
||||
pub mod mock_tools;
|
||||
pub mod trace;
|
||||
|
||||
pub use mock_provider::{MockProvider, RecordingProvider};
|
||||
pub use mock_tools::{CountingTool, EchoTool, FailingTool, RecordingTool};
|
||||
84
third_party/zeroclaw/tests/support/trace.rs
vendored
Normal file
84
third_party/zeroclaw/tests/support/trace.rs
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
//! JSON trace fixture types for deterministic LLM response replay.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
|
||||
/// A complete LLM conversation trace loaded from a JSON fixture.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LlmTrace {
|
||||
pub model_name: String,
|
||||
pub turns: Vec<TraceTurn>,
|
||||
#[serde(default)]
|
||||
pub expects: TraceExpects,
|
||||
}
|
||||
|
||||
/// A single conversation turn (user input + LLM response steps).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TraceTurn {
|
||||
pub user_input: String,
|
||||
pub steps: Vec<TraceStep>,
|
||||
}
|
||||
|
||||
/// A single LLM response step within a turn.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TraceStep {
|
||||
pub response: TraceResponse,
|
||||
}
|
||||
|
||||
/// The response content — either plain text or tool calls.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum TraceResponse {
|
||||
#[serde(rename = "text")]
|
||||
Text {
|
||||
content: String,
|
||||
#[serde(default)]
|
||||
input_tokens: u64,
|
||||
#[serde(default)]
|
||||
output_tokens: u64,
|
||||
},
|
||||
#[serde(rename = "tool_calls")]
|
||||
ToolCalls {
|
||||
tool_calls: Vec<TraceToolCall>,
|
||||
#[serde(default)]
|
||||
input_tokens: u64,
|
||||
#[serde(default)]
|
||||
output_tokens: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// A tool call within a trace response.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TraceToolCall {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub arguments: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Declarative expectations for trace verification.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct TraceExpects {
|
||||
#[serde(default)]
|
||||
pub response_contains: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub response_not_contains: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub tools_used: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub tools_not_used: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub max_tool_calls: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub all_tools_succeeded: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub response_matches: Vec<String>,
|
||||
}
|
||||
|
||||
impl LlmTrace {
|
||||
/// Load a trace from a JSON file.
|
||||
pub fn from_file(path: &Path) -> anyhow::Result<Self> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let trace: LlmTrace = serde_json::from_str(&content)?;
|
||||
Ok(trace)
|
||||
}
|
||||
}
|
||||
149
third_party/zeroclaw/tests/system/full_stack.rs
vendored
Normal file
149
third_party/zeroclaw/tests/system/full_stack.rs
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
//! System-level tests — full agent orchestration with real components.
|
||||
//!
|
||||
//! These tests wire ALL internal components together:
|
||||
//! MockProvider → Agent → Tools → Memory → Agent response
|
||||
//!
|
||||
//! Unlike integration tests, system tests use real memory backends (SQLite)
|
||||
//! and verify end-to-end data flow across component boundaries.
|
||||
|
||||
use crate::support::helpers::{build_agent_with_sqlite_memory, text_response, tool_response};
|
||||
use crate::support::{CountingTool, EchoTool, MockProvider, RecordingTool};
|
||||
use zeroclaw::providers::ToolCall;
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Full-stack system tests
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Simplest system test: inject message → MockProvider returns text → verify response.
|
||||
#[tokio::test]
|
||||
async fn system_simple_text_response() {
|
||||
let provider = Box::new(MockProvider::new(vec![text_response(
|
||||
"System test response",
|
||||
)]));
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let mut agent =
|
||||
build_agent_with_sqlite_memory(provider, vec![Box::new(EchoTool)], temp_dir.path());
|
||||
|
||||
let response = agent.turn("hello system").await.unwrap();
|
||||
assert_eq!(response, "System test response");
|
||||
}
|
||||
|
||||
/// Full tool execution flow: message → provider requests tool → tool executes →
|
||||
/// result fed back to provider → final response.
|
||||
#[tokio::test]
|
||||
async fn system_tool_execution_flow() {
|
||||
let provider = Box::new(MockProvider::new(vec![
|
||||
tool_response(vec![ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: r#"{"message": "system echo test"}"#.into(),
|
||||
}]),
|
||||
text_response("Echo returned: system echo test"),
|
||||
]));
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let mut agent =
|
||||
build_agent_with_sqlite_memory(provider, vec![Box::new(EchoTool)], temp_dir.path());
|
||||
|
||||
let response = agent.turn("run echo").await.unwrap();
|
||||
assert!(
|
||||
!response.is_empty(),
|
||||
"Expected response after tool execution flow"
|
||||
);
|
||||
}
|
||||
|
||||
/// Multi-turn conversation with real SQLite memory — verify history accumulation.
|
||||
#[tokio::test]
|
||||
async fn system_multi_turn_conversation() {
|
||||
let provider = Box::new(MockProvider::new(vec![
|
||||
text_response("First system response"),
|
||||
text_response("Second system response"),
|
||||
text_response("Third system response"),
|
||||
]));
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let mut agent =
|
||||
build_agent_with_sqlite_memory(provider, vec![Box::new(EchoTool)], temp_dir.path());
|
||||
|
||||
let r1 = agent.turn("turn 1").await.unwrap();
|
||||
assert_eq!(r1, "First system response");
|
||||
|
||||
let r2 = agent.turn("turn 2").await.unwrap();
|
||||
assert_eq!(r2, "Second system response");
|
||||
|
||||
let r3 = agent.turn("turn 3").await.unwrap();
|
||||
assert_eq!(r3, "Third system response");
|
||||
|
||||
// Verify history accumulated across turns
|
||||
let history = agent.history();
|
||||
// system + 3*(user + assistant) = 7
|
||||
assert_eq!(history.len(), 7, "History should contain 7 messages");
|
||||
}
|
||||
|
||||
/// Tool execution is recorded and arguments are passed correctly.
|
||||
#[tokio::test]
|
||||
async fn system_tool_arguments_passed_correctly() {
|
||||
let (recording_tool, calls) = RecordingTool::new("recorder");
|
||||
|
||||
let provider = Box::new(MockProvider::new(vec![
|
||||
tool_response(vec![ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "recorder".into(),
|
||||
arguments: r#"{"input": "test_value_42"}"#.into(),
|
||||
}]),
|
||||
text_response("Tool recorded the input"),
|
||||
]));
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let mut agent =
|
||||
build_agent_with_sqlite_memory(provider, vec![Box::new(recording_tool)], temp_dir.path());
|
||||
|
||||
let response = agent.turn("record something").await.unwrap();
|
||||
assert!(!response.is_empty());
|
||||
|
||||
let recorded_calls = calls.lock().unwrap();
|
||||
assert_eq!(
|
||||
recorded_calls.len(),
|
||||
1,
|
||||
"Tool should be called exactly once"
|
||||
);
|
||||
assert_eq!(
|
||||
recorded_calls[0]["input"].as_str().unwrap(),
|
||||
"test_value_42",
|
||||
"Tool should receive correct arguments"
|
||||
);
|
||||
}
|
||||
|
||||
/// Multiple tools in a single response — both execute and results feed back.
|
||||
#[tokio::test]
|
||||
async fn system_parallel_tool_execution() {
|
||||
let (counting_tool, count) = CountingTool::new();
|
||||
|
||||
let provider = Box::new(MockProvider::new(vec![
|
||||
tool_response(vec![
|
||||
ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: r#"{"message": "first"}"#.into(),
|
||||
},
|
||||
ToolCall {
|
||||
id: "tc2".into(),
|
||||
name: "counter".into(),
|
||||
arguments: "{}".into(),
|
||||
},
|
||||
]),
|
||||
text_response("Both tools completed"),
|
||||
]));
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let mut agent = build_agent_with_sqlite_memory(
|
||||
provider,
|
||||
vec![Box::new(EchoTool), Box::new(counting_tool)],
|
||||
temp_dir.path(),
|
||||
);
|
||||
|
||||
let response = agent.turn("run both tools").await.unwrap();
|
||||
assert_eq!(response, "Both tools completed");
|
||||
assert_eq!(*count.lock().unwrap(), 1, "Counter should be invoked once");
|
||||
}
|
||||
1
third_party/zeroclaw/tests/system/mod.rs
vendored
Normal file
1
third_party/zeroclaw/tests/system/mod.rs
vendored
Normal file
@@ -0,0 +1 @@
|
||||
mod full_stack;
|
||||
2
third_party/zeroclaw/tests/test_component.rs
vendored
Normal file
2
third_party/zeroclaw/tests/test_component.rs
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
mod component;
|
||||
mod support;
|
||||
2
third_party/zeroclaw/tests/test_integration.rs
vendored
Normal file
2
third_party/zeroclaw/tests/test_integration.rs
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
mod integration;
|
||||
mod support;
|
||||
2
third_party/zeroclaw/tests/test_live.rs
vendored
Normal file
2
third_party/zeroclaw/tests/test_live.rs
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
mod live;
|
||||
mod support;
|
||||
2
third_party/zeroclaw/tests/test_system.rs
vendored
Normal file
2
third_party/zeroclaw/tests/test_system.rs
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
mod support;
|
||||
mod system;
|
||||
Reference in New Issue
Block a user