From beeb529d8ffae802ff7f1429e98fc24f8ceabb11 Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 19 Apr 2026 11:24:57 +0800 Subject: [PATCH] test(protocols,skills): add 90 tests for MCP types + skill loader/runner zclaw-protocols: +43 tests covering mcp_types serde, ContentBlock variants, transport config builders, and domain type roundtrips. zclaw-skills: +47 tests covering SKILL.md/TOML parsing, auto-classify, PromptOnlySkill execution, and SkillManifest/SkillResult roundtrips. Batch 8 of audit plan (plans/stateless-petting-rossum.md). --- .../tests/mcp_transport_tests.rs | 55 ++++ .../tests/mcp_types_domain_tests.rs | 186 ++++++++++++ .../zclaw-protocols/tests/mcp_types_tests.rs | 267 ++++++++++++++++++ crates/zclaw-skills/tests/loader_tests.rs | 247 ++++++++++++++++ crates/zclaw-skills/tests/runner_tests.rs | 78 +++++ .../zclaw-skills/tests/skill_types_tests.rs | 148 ++++++++++ 6 files changed, 981 insertions(+) create mode 100644 crates/zclaw-protocols/tests/mcp_transport_tests.rs create mode 100644 crates/zclaw-protocols/tests/mcp_types_domain_tests.rs create mode 100644 crates/zclaw-protocols/tests/mcp_types_tests.rs create mode 100644 crates/zclaw-skills/tests/loader_tests.rs create mode 100644 crates/zclaw-skills/tests/runner_tests.rs create mode 100644 crates/zclaw-skills/tests/skill_types_tests.rs diff --git a/crates/zclaw-protocols/tests/mcp_transport_tests.rs b/crates/zclaw-protocols/tests/mcp_transport_tests.rs new file mode 100644 index 0000000..5208ad7 --- /dev/null +++ b/crates/zclaw-protocols/tests/mcp_transport_tests.rs @@ -0,0 +1,55 @@ +//! Tests for MCP Transport configuration (McpServerConfig) +//! +//! These tests cover McpServerConfig builder methods without spawning processes. + +use std::collections::HashMap; +use zclaw_protocols::McpServerConfig; + +#[test] +fn npx_config_creates_correct_command() { + let config = McpServerConfig::npx("@modelcontextprotocol/server-memory"); + assert_eq!(config.command, "npx"); + assert_eq!(config.args, vec!["-y", "@modelcontextprotocol/server-memory"]); + assert!(config.env.is_empty()); + assert!(config.cwd.is_none()); +} + +#[test] +fn node_config_creates_correct_command() { + let config = McpServerConfig::node("/path/to/server.js"); + assert_eq!(config.command, "node"); + assert_eq!(config.args, vec!["/path/to/server.js"]); +} + +#[test] +fn python_config_creates_correct_command() { + let config = McpServerConfig::python("mcp_server.py"); + assert_eq!(config.command, "python"); + assert_eq!(config.args, vec!["mcp_server.py"]); +} + +#[test] +fn env_adds_variables() { + let config = McpServerConfig::node("server.js") + .env("API_KEY", "secret123") + .env("DEBUG", "true"); + assert_eq!(config.env.get("API_KEY").unwrap(), "secret123"); + assert_eq!(config.env.get("DEBUG").unwrap(), "true"); +} + +#[test] +fn cwd_sets_working_directory() { + let config = McpServerConfig::node("server.js").cwd("/tmp/work"); + assert_eq!(config.cwd.unwrap(), "/tmp/work"); +} + +#[test] +fn combined_builder_pattern() { + let config = McpServerConfig::npx("@scope/server") + .env("PORT", "3000") + .cwd("/app"); + assert_eq!(config.command, "npx"); + assert_eq!(config.args.len(), 2); + assert_eq!(config.env.len(), 1); + assert_eq!(config.cwd.unwrap(), "/app"); +} diff --git a/crates/zclaw-protocols/tests/mcp_types_domain_tests.rs b/crates/zclaw-protocols/tests/mcp_types_domain_tests.rs new file mode 100644 index 0000000..28b86b1 --- /dev/null +++ b/crates/zclaw-protocols/tests/mcp_types_domain_tests.rs @@ -0,0 +1,186 @@ +//! Tests for MCP domain types (mcp.rs) — McpTool, McpContent, McpResource, etc. + +use std::collections::HashMap; +use zclaw_protocols::*; + +// === McpTool === + +#[test] +fn mcp_tool_roundtrip() { + let tool = McpTool { + name: "search".to_string(), + description: "Search documents".to_string(), + input_schema: serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}), + }; + let json = serde_json::to_string(&tool).unwrap(); + let parsed: McpTool = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.name, "search"); + assert_eq!(parsed.description, "Search documents"); +} + +#[test] +fn mcp_tool_empty_description() { + let tool = McpTool { + name: "ping".to_string(), + description: String::new(), + input_schema: serde_json::json!({}), + }; + let parsed: McpTool = serde_json::from_str(&serde_json::to_string(&tool).unwrap()).unwrap(); + assert!(parsed.description.is_empty()); +} + +// === McpContent === + +#[test] +fn mcp_content_text_roundtrip() { + let content = McpContent::Text { text: "hello".to_string() }; + let json = serde_json::to_string(&content).unwrap(); + let parsed: McpContent = serde_json::from_str(&json).unwrap(); + match parsed { + McpContent::Text { text } => assert_eq!(text, "hello"), + _ => panic!("Expected Text"), + } +} + +#[test] +fn mcp_content_image_roundtrip() { + let content = McpContent::Image { + data: "base64==".to_string(), + mime_type: "image/png".to_string(), + }; + let json = serde_json::to_string(&content).unwrap(); + let parsed: McpContent = serde_json::from_str(&json).unwrap(); + match parsed { + McpContent::Image { data, mime_type } => { + assert_eq!(data, "base64=="); + assert_eq!(mime_type, "image/png"); + } + _ => panic!("Expected Image"), + } +} + +#[test] +fn mcp_content_resource_roundtrip() { + let content = McpContent::Resource { + resource: McpResourceContent { + uri: "file:///test.txt".to_string(), + mime_type: Some("text/plain".to_string()), + text: Some("content".to_string()), + blob: None, + }, + }; + let json = serde_json::to_string(&content).unwrap(); + let parsed: McpContent = serde_json::from_str(&json).unwrap(); + match parsed { + McpContent::Resource { resource } => { + assert_eq!(resource.uri, "file:///test.txt"); + assert_eq!(resource.text.unwrap(), "content"); + } + _ => panic!("Expected Resource"), + } +} + +// === McpToolCallRequest === + +#[test] +fn mcp_tool_call_request_serialization() { + let mut args = HashMap::new(); + args.insert("query".to_string(), serde_json::json!("test")); + let req = McpToolCallRequest { + name: "search".to_string(), + arguments: args, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"name\":\"search\"")); + assert!(json.contains("\"query\":\"test\"")); +} + +// === McpToolCallResponse === + +#[test] +fn mcp_tool_call_response_parse_success() { + let json = r#"{"content":[{"type":"text","text":"found 3 results"}],"is_error":false}"#; + let resp: McpToolCallResponse = serde_json::from_str(json).unwrap(); + assert!(!resp.is_error); + assert_eq!(resp.content.len(), 1); +} + +#[test] +fn mcp_tool_call_response_parse_error() { + let json = r#"{"content":[{"type":"text","text":"tool not found"}],"is_error":true}"#; + let resp: McpToolCallResponse = serde_json::from_str(json).unwrap(); + assert!(resp.is_error); +} + +// === McpResource === + +#[test] +fn mcp_resource_roundtrip() { + let res = McpResource { + uri: "file:///doc.md".to_string(), + name: "Documentation".to_string(), + description: Some("Project docs".to_string()), + mime_type: Some("text/markdown".to_string()), + }; + let json = serde_json::to_string(&res).unwrap(); + let parsed: McpResource = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.uri, "file:///doc.md"); + assert_eq!(parsed.description.unwrap(), "Project docs"); +} + +// === McpPrompt === + +#[test] +fn mcp_prompt_roundtrip() { + let prompt = McpPrompt { + name: "summarize".to_string(), + description: "Summarize text".to_string(), + arguments: vec![ + McpPromptArgument { + name: "length".to_string(), + description: "Target length".to_string(), + required: false, + }, + ], + }; + let json = serde_json::to_string(&prompt).unwrap(); + let parsed: McpPrompt = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.arguments.len(), 1); + assert!(!parsed.arguments[0].required); +} + +// === McpServerInfo === + +#[test] +fn mcp_server_info_roundtrip() { + let info = McpServerInfo { + name: "test-mcp".to_string(), + version: "2.0.0".to_string(), + protocol_version: "2024-11-05".to_string(), + }; + let json = serde_json::to_string(&info).unwrap(); + let parsed: McpServerInfo = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.name, "test-mcp"); + assert_eq!(parsed.protocol_version, "2024-11-05"); +} + +// === McpCapabilities === + +#[test] +fn mcp_capabilities_default_empty() { + let caps = McpCapabilities::default(); + assert!(caps.tools.is_none()); + assert!(caps.resources.is_none()); + assert!(caps.prompts.is_none()); +} + +#[test] +fn mcp_capabilities_with_tools() { + let caps = McpCapabilities { + tools: Some(McpToolCapabilities { list_changed: true }), + resources: None, + prompts: None, + }; + let json = serde_json::to_string(&caps).unwrap(); + assert!(json.contains("\"list_changed\":true")); +} diff --git a/crates/zclaw-protocols/tests/mcp_types_tests.rs b/crates/zclaw-protocols/tests/mcp_types_tests.rs new file mode 100644 index 0000000..526a735 --- /dev/null +++ b/crates/zclaw-protocols/tests/mcp_types_tests.rs @@ -0,0 +1,267 @@ +//! Tests for MCP JSON-RPC types (mcp_types.rs) +//! +//! Covers: serialization, deserialization, builder patterns, edge cases. + +use serde_json; +use zclaw_protocols::*; + +// === JsonRpcRequest === + +#[test] +fn jsonrpc_request_new_has_correct_defaults() { + let req = JsonRpcRequest::new(42, "tools/list"); + assert_eq!(req.jsonrpc, "2.0"); + assert_eq!(req.id, 42); + assert_eq!(req.method, "tools/list"); + assert!(req.params.is_none()); +} + +#[test] +fn jsonrpc_request_with_params() { + let req = JsonRpcRequest::new(1, "tools/call") + .with_params(serde_json::json!({"name": "search"})); + let serialized = serde_json::to_string(&req).unwrap(); + assert!(serialized.contains("\"params\"")); + assert!(serialized.contains("\"name\":\"search\"")); +} + +#[test] +fn jsonrpc_request_skip_null_params() { + let req = JsonRpcRequest::new(1, "ping"); + let serialized = serde_json::to_string(&req).unwrap(); + // params is None, should be skipped + assert!(!serialized.contains("\"params\"")); +} + +// === JsonRpcResponse === + +#[test] +fn jsonrpc_response_parse_success() { + let json = r#"{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}"#; + let resp: JsonRpcResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.id, 1); + assert!(resp.result.is_some()); + assert!(resp.error.is_none()); +} + +#[test] +fn jsonrpc_response_parse_error() { + let json = r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32600,"message":"Invalid Request"}}"#; + let resp: JsonRpcResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.id, 2); + assert!(resp.result.is_none()); + let err = resp.error.unwrap(); + assert_eq!(err.code, -32600); + assert_eq!(err.message, "Invalid Request"); +} + +#[test] +fn jsonrpc_response_parse_error_with_data() { + let json = r#"{"jsonrpc":"2.0","id":3,"error":{"code":-32602,"message":"Bad params","data":{"field":"uri"}}}"#; + let resp: JsonRpcResponse = serde_json::from_str(json).unwrap(); + let err = resp.error.unwrap(); + assert!(err.data.is_some()); + assert_eq!(err.data.unwrap()["field"], "uri"); +} + +// === InitializeRequest === + +#[test] +fn initialize_request_default() { + let req = InitializeRequest::default(); + assert_eq!(req.protocol_version, "2024-11-05"); + assert_eq!(req.client_info.name, "zclaw"); + assert!(!req.client_info.version.is_empty()); +} + +#[test] +fn initialize_request_serializes() { + let req = InitializeRequest::default(); + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"protocol_version\":\"2024-11-05\"")); + assert!(json.contains("\"client_info\"")); +} + +// === ServerCapabilities === + +#[test] +fn server_capabilities_empty() { + let json = r#"{"protocol_version":"2024-11-05","capabilities":{},"server_info":{"name":"test","version":"1.0"}}"#; + let result: InitializeResult = serde_json::from_str(json).unwrap(); + assert!(result.capabilities.tools.is_none()); + assert!(result.capabilities.resources.is_none()); +} + +#[test] +fn server_capabilities_with_tools() { + let json = r#"{"protocol_version":"2024-11-05","capabilities":{"tools":{"list_changed":true}},"server_info":{"name":"test","version":"1.0"}}"#; + let result: InitializeResult = serde_json::from_str(json).unwrap(); + let tools = result.capabilities.tools.unwrap(); + assert!(tools.list_changed); +} + +// === ContentBlock === + +#[test] +fn content_block_text() { + let json = r#"{"type":"text","text":"hello world"}"#; + let block: ContentBlock = serde_json::from_str(json).unwrap(); + match block { + ContentBlock::Text { text } => assert_eq!(text, "hello world"), + _ => panic!("Expected Text variant"), + } +} + +#[test] +fn content_block_image() { + let json = r#"{"type":"image","data":"base64data","mime_type":"image/png"}"#; + let block: ContentBlock = serde_json::from_str(json).unwrap(); + match block { + ContentBlock::Image { data, mime_type } => { + assert_eq!(data, "base64data"); + assert_eq!(mime_type, "image/png"); + } + _ => panic!("Expected Image variant"), + } +} + +#[test] +fn content_block_resource() { + let json = r#"{"type":"resource","resource":{"uri":"file:///test.txt","text":"content"}}"#; + let block: ContentBlock = serde_json::from_str(json).unwrap(); + match block { + ContentBlock::Resource { resource } => { + assert_eq!(resource.uri, "file:///test.txt"); + assert_eq!(resource.text.unwrap(), "content"); + } + _ => panic!("Expected Resource variant"), + } +} + +// === CallToolResult === + +#[test] +fn call_tool_result_parse() { + let json = r#"{"content":[{"type":"text","text":"result"}],"is_error":false}"#; + let result: CallToolResult = serde_json::from_str(json).unwrap(); + assert!(!result.is_error); + assert_eq!(result.content.len(), 1); +} + +#[test] +fn call_tool_result_error() { + let json = r#"{"content":[{"type":"text","text":"something went wrong"}],"is_error":true}"#; + let result: CallToolResult = serde_json::from_str(json).unwrap(); + assert!(result.is_error); +} + +// === ListToolsResult === + +#[test] +fn list_tools_result_with_cursor() { + let json = r#"{"tools":[{"name":"search","input_schema":{"type":"object"}}],"next_cursor":"abc123"}"#; + let result: ListToolsResult = serde_json::from_str(json).unwrap(); + assert_eq!(result.tools.len(), 1); + assert_eq!(result.tools[0].name, "search"); + assert_eq!(result.next_cursor.unwrap(), "abc123"); +} + +#[test] +fn list_tools_result_without_cursor() { + let json = r#"{"tools":[]}"#; + let result: ListToolsResult = serde_json::from_str(json).unwrap(); + assert!(result.tools.is_empty()); + assert!(result.next_cursor.is_none()); +} + +// === Resource types === + +#[test] +fn resource_parse_with_optional_fields() { + let json = r#"{"uri":"file:///doc.txt","name":"doc","description":"A doc","mime_type":"text/plain"}"#; + let res: Resource = serde_json::from_str(json).unwrap(); + assert_eq!(res.uri, "file:///doc.txt"); + assert_eq!(res.name, "doc"); + assert_eq!(res.description.unwrap(), "A doc"); + assert_eq!(res.mime_type.unwrap(), "text/plain"); +} + +#[test] +fn resource_parse_minimal() { + let json = r#"{"uri":"file:///x","name":"x"}"#; + let res: Resource = serde_json::from_str(json).unwrap(); + assert!(res.description.is_none()); + assert!(res.mime_type.is_none()); +} + +// === LoggingLevel === + +#[test] +fn logging_level_serialize_roundtrip() { + let levels = vec![ + LoggingLevel::Debug, + LoggingLevel::Info, + LoggingLevel::Warning, + LoggingLevel::Error, + LoggingLevel::Critical, + LoggingLevel::Emergency, + ]; + for level in levels { + let json = serde_json::to_string(&level).unwrap(); + let parsed: LoggingLevel = serde_json::from_str(&json).unwrap(); + assert_eq!(std::mem::discriminant(&level), std::mem::discriminant(&parsed)); + } +} + +// === InitializedNotification === + +#[test] +fn initialized_notification_fields() { + let n = InitializedNotification::new(); + assert_eq!(n.jsonrpc, "2.0"); + assert_eq!(n.method, "notifications/initialized"); +} + +#[test] +fn initialized_notification_serializes() { + let n = InitializedNotification::default(); + let json = serde_json::to_string(&n).unwrap(); + assert!(json.contains("\"notifications/initialized\"")); +} + +// === Prompt types === + +#[test] +fn prompt_parse_with_arguments() { + let json = r#"{"name":"greet","description":"Greeting","arguments":[{"name":"lang","description":"Language","required":true}]}"#; + let prompt: Prompt = serde_json::from_str(json).unwrap(); + assert_eq!(prompt.name, "greet"); + assert_eq!(prompt.arguments.len(), 1); + assert!(prompt.arguments[0].required); +} + +#[test] +fn prompt_message_parse() { + let json = r#"{"role":"user","content":{"type":"text","text":"hello"}}"#; + let msg: PromptMessage = serde_json::from_str(json).unwrap(); + assert_eq!(msg.role, "user"); +} + +// === McpClientConfig === + +#[test] +fn mcp_client_config_roundtrip() { + let config = McpClientConfig { + server_url: "http://localhost:3000".to_string(), + server_info: McpServerInfo { + name: "test-server".to_string(), + version: "1.0.0".to_string(), + protocol_version: "2024-11-05".to_string(), + }, + capabilities: McpCapabilities::default(), + }; + let json = serde_json::to_string(&config).unwrap(); + let parsed: McpClientConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.server_url, config.server_url); + assert_eq!(parsed.server_info.name, "test-server"); +} diff --git a/crates/zclaw-skills/tests/loader_tests.rs b/crates/zclaw-skills/tests/loader_tests.rs new file mode 100644 index 0000000..47f542f --- /dev/null +++ b/crates/zclaw-skills/tests/loader_tests.rs @@ -0,0 +1,247 @@ +//! Tests for skill loader — SKILL.md and TOML parsing + +use zclaw_skills::*; + +// === parse_skill_md === + +#[test] +fn parse_skill_md_basic_frontmatter() { + let content = r#"--- +name: "Code Reviewer" +description: "Reviews code" +version: "1.0.0" +mode: prompt-only +tags: coding, review +--- +# Code Reviewer +Reviews code for quality. +"#; + let manifest = parse_skill_md(content).unwrap(); + assert_eq!(manifest.name, "Code Reviewer"); + assert_eq!(manifest.description, "Reviews code"); + assert_eq!(manifest.version, "1.0.0"); + assert_eq!(manifest.mode, zclaw_skills::SkillMode::PromptOnly); + assert_eq!(manifest.tags, vec!["coding", "review"]); +} + +#[test] +fn parse_skill_md_with_triggers_list() { + let content = r#"--- +name: "Translator" +description: "Translates text" +version: "1.0.0" +mode: prompt-only +triggers: + - "翻译" + - "translate" + - "中译英" +--- +# Translator +"#; + let manifest = parse_skill_md(content).unwrap(); + assert_eq!(manifest.triggers, vec!["翻译", "translate", "中译英"]); +} + +#[test] +fn parse_skill_md_with_tools_list() { + let content = r#"--- +name: "Builder" +description: "Builds projects" +version: "1.0.0" +mode: shell +tools: + - "bash" + - "cargo" +--- +# Builder +"#; + let manifest = parse_skill_md(content).unwrap(); + assert_eq!(manifest.tools, vec!["bash", "cargo"]); + assert_eq!(manifest.mode, zclaw_skills::SkillMode::Shell); +} + +#[test] +fn parse_skill_md_with_category() { + let content = r#"--- +name: "Math Solver" +description: "Solves math problems" +version: "1.0.0" +mode: prompt-only +category: "math" +--- +# Math Solver +"#; + let manifest = parse_skill_md(content).unwrap(); + assert_eq!(manifest.category.unwrap(), "math"); +} + +#[test] +fn parse_skill_md_auto_classify_coding() { + let content = r#"--- +name: "Code Helper" +description: "Helps with programming and debugging" +version: "1.0.0" +mode: prompt-only +--- +# Code Helper +"#; + let manifest = parse_skill_md(content).unwrap(); + // Should auto-classify as "coding" based on description + assert_eq!(manifest.category.unwrap(), "coding"); +} + +#[test] +fn parse_skill_md_auto_classify_translation() { + let content = r#"--- +name: "Translator" +description: "Helps with translation between languages" +version: "1.0.0" +mode: prompt-only +--- +# Translator +"#; + let manifest = parse_skill_md(content).unwrap(); + // Should auto-classify based on "translat" keyword + assert!(manifest.category.is_some(), "Should auto-classify translation skill"); +} + +#[test] +fn parse_skill_md_no_frontmatter_extracts_name() { + let content = "# My Skill\n\nThis is a cool skill."; + let manifest = parse_skill_md(content).unwrap(); + assert_eq!(manifest.name, "My Skill"); +} + +#[test] +fn parse_skill_md_fallback_name() { + let content = "Just some text without structure."; + let manifest = parse_skill_md(content).unwrap(); + assert_eq!(manifest.name, "unnamed-skill"); +} + +#[test] +fn parse_skill_md_id_generation() { + let content = "---\nname: \"Hello World\"\n---\n"; + let manifest = parse_skill_md(content).unwrap(); + assert_eq!(manifest.id.as_str(), "hello-world"); +} + +#[test] +fn parse_skill_md_all_modes() { + for (mode_str, expected) in &[ + ("prompt-only", zclaw_skills::SkillMode::PromptOnly), + ("python", zclaw_skills::SkillMode::Python), + ("shell", zclaw_skills::SkillMode::Shell), + ("wasm", zclaw_skills::SkillMode::Wasm), + ("native", zclaw_skills::SkillMode::Native), + ] { + let content = format!("---\nname: \"Test\"\nmode: {}\n---\n", mode_str); + let manifest = parse_skill_md(&content).unwrap(); + assert_eq!(&manifest.mode, expected, "Failed for mode: {}", mode_str); + } +} + +#[test] +fn parse_skill_md_capabilities_csv() { + let content = "---\nname: \"Multi\"\ncapabilities: llm, web, file\n---\n"; + let manifest = parse_skill_md(content).unwrap(); + assert_eq!(manifest.capabilities, vec!["llm", "web", "file"]); +} + +// === parse_skill_toml === + +#[test] +fn parse_skill_toml_basic() { + let content = r#" +name = "Calculator" +description = "Performs calculations" +version = "2.0.0" +mode = "prompt_only" +"#; + let manifest = parse_skill_toml(content).unwrap(); + assert_eq!(manifest.name, "Calculator"); + assert_eq!(manifest.description, "Performs calculations"); + assert_eq!(manifest.version, "2.0.0"); +} + +#[test] +fn parse_skill_toml_with_id() { + let content = r#" +id = "my-calc" +name = "Calculator" +description = "Calc" +"#; + let manifest = parse_skill_toml(content).unwrap(); + assert_eq!(manifest.id.as_str(), "my-calc"); +} + +#[test] +fn parse_skill_toml_generates_id_from_name() { + let content = "name = \"Hello World\"\ndescription = \"x\""; + let manifest = parse_skill_toml(content).unwrap(); + assert_eq!(manifest.id.as_str(), "hello-world"); +} + +#[test] +fn parse_skill_toml_requires_name() { + let content = r#"description = "no name""#; + let result = parse_skill_toml(content); + assert!(result.is_err()); +} + +#[test] +fn parse_skill_toml_arrays() { + let content = r#" +name = "X" +description = "x" +tags = ["a", "b", "c"] +capabilities = ["llm"] +triggers = ["go", "run"] +"#; + let manifest = parse_skill_toml(content).unwrap(); + assert_eq!(manifest.tags, vec!["a", "b", "c"]); + assert_eq!(manifest.capabilities, vec!["llm"]); + assert_eq!(manifest.triggers, vec!["go", "run"]); +} + +#[test] +fn parse_skill_toml_category() { + let content = r#" +name = "X" +description = "x" +category = "data" +"#; + let manifest = parse_skill_toml(content).unwrap(); + assert_eq!(manifest.category.unwrap(), "data"); +} + +#[test] +fn parse_skill_toml_tools() { + let content = r#" +name = "X" +description = "x" +tools = ["bash", "cargo"] +"#; + let manifest = parse_skill_toml(content).unwrap(); + assert_eq!(manifest.tools, vec!["bash", "cargo"]); +} + +#[test] +fn parse_skill_toml_ignores_comments_and_sections() { + let content = r#" +# This is a comment +[section] +name = "X" +description = "x" +"#; + let manifest = parse_skill_toml(content).unwrap(); + assert_eq!(manifest.name, "X"); +} + +// === discover_skills === + +#[test] +fn discover_skills_nonexistent_dir() { + let result = discover_skills(std::path::Path::new("/nonexistent/path")).unwrap(); + assert!(result.is_empty()); +} diff --git a/crates/zclaw-skills/tests/runner_tests.rs b/crates/zclaw-skills/tests/runner_tests.rs new file mode 100644 index 0000000..d93b509 --- /dev/null +++ b/crates/zclaw-skills/tests/runner_tests.rs @@ -0,0 +1,78 @@ +//! Tests for PromptOnlySkill runner + +use zclaw_skills::*; +use zclaw_types::SkillId; + +/// Helper to create a minimal manifest +fn test_manifest(mode: SkillMode) -> SkillManifest { + SkillManifest { + id: SkillId::new("test-prompt-skill"), + name: "Test Prompt Skill".to_string(), + description: "A test prompt skill".to_string(), + version: "1.0.0".to_string(), + author: None, + mode, + capabilities: vec![], + input_schema: None, + output_schema: None, + tags: vec![], + category: None, + triggers: vec![], + tools: vec![], + enabled: true, + } +} + +#[tokio::test] +async fn prompt_only_skill_returns_formatted_prompt() { + let manifest = test_manifest(SkillMode::PromptOnly); + let template = "Hello {{input}}, welcome!".to_string(); + let skill = PromptOnlySkill::new(manifest, template); + + let ctx = SkillContext::default(); + let skill_ref: &dyn Skill = &skill; + let result = skill_ref.execute(&ctx, serde_json::json!("World")).await.unwrap(); + + assert!(result.success); + let output = result.output.as_str().unwrap(); + assert_eq!(output, "Hello World, welcome!"); +} + +#[tokio::test] +async fn prompt_only_skill_json_input() { + let manifest = test_manifest(SkillMode::PromptOnly); + let template = "Input: {{input}}".to_string(); + let skill = PromptOnlySkill::new(manifest, template); + + let ctx = SkillContext::default(); + let input = serde_json::json!({"key": "value"}); + let skill_ref: &dyn Skill = &skill; + let result = skill_ref.execute(&ctx, input).await.unwrap(); + + assert!(result.success); + let output = result.output.as_str().unwrap(); + assert!(output.contains("key")); + assert!(output.contains("value")); +} + +#[tokio::test] +async fn prompt_only_skill_no_placeholder() { + let manifest = test_manifest(SkillMode::PromptOnly); + let template = "Static prompt content".to_string(); + let skill = PromptOnlySkill::new(manifest, template); + + let ctx = SkillContext::default(); + let skill_ref: &dyn Skill = &skill; + let result = skill_ref.execute(&ctx, serde_json::json!("ignored")).await.unwrap(); + + assert!(result.success); + assert_eq!(result.output.as_str().unwrap(), "Static prompt content"); +} + +#[tokio::test] +async fn prompt_only_skill_manifest() { + let manifest = test_manifest(SkillMode::PromptOnly); + let skill = PromptOnlySkill::new(manifest.clone(), "prompt".to_string()); + assert_eq!(skill.manifest().id.as_str(), "test-prompt-skill"); + assert_eq!(skill.manifest().name, "Test Prompt Skill"); +} diff --git a/crates/zclaw-skills/tests/skill_types_tests.rs b/crates/zclaw-skills/tests/skill_types_tests.rs new file mode 100644 index 0000000..c03b7bb --- /dev/null +++ b/crates/zclaw-skills/tests/skill_types_tests.rs @@ -0,0 +1,148 @@ +//! Tests for zclaw-skills types: SkillManifest, SkillMode, SkillResult, SkillContext + +use serde_json; +use zclaw_skills::*; +use zclaw_types::SkillId; + +// === SkillMode === + +#[test] +fn skill_mode_serialization_roundtrip() { + let modes = vec![ + SkillMode::PromptOnly, + SkillMode::Python, + SkillMode::Shell, + SkillMode::Wasm, + SkillMode::Native, + ]; + for mode in modes { + let json = serde_json::to_string(&mode).unwrap(); + let parsed: SkillMode = serde_json::from_str(&json).unwrap(); + assert_eq!(mode, parsed); + } +} + +#[test] +fn skill_mode_snake_case_serialization() { + let json = serde_json::to_string(&SkillMode::PromptOnly).unwrap(); + assert!(json.contains("prompt_only")); +} + +// === SkillResult === + +#[test] +fn skill_result_success() { + let result = SkillResult::success(serde_json::json!({"answer": 42})); + assert!(result.success); + assert!(result.error.is_none()); + assert_eq!(result.output["answer"], 42); +} + +#[test] +fn skill_result_error() { + let result = SkillResult::error("execution failed"); + assert!(!result.success); + assert_eq!(result.error.unwrap(), "execution failed"); + assert!(result.output.is_null()); +} + +#[test] +fn skill_result_roundtrip() { + let result = SkillResult { + success: true, + output: serde_json::json!("hello"), + error: None, + duration_ms: Some(150), + tokens_used: Some(42), + }; + let json = serde_json::to_string(&result).unwrap(); + let parsed: SkillResult = serde_json::from_str(&json).unwrap(); + assert!(parsed.success); + assert_eq!(parsed.duration_ms.unwrap(), 150); + assert_eq!(parsed.tokens_used.unwrap(), 42); +} + +// === SkillManifest === + +#[test] +fn skill_manifest_full_roundtrip() { + let manifest = SkillManifest { + id: SkillId::new("test-skill"), + name: "Test Skill".to_string(), + description: "A test skill".to_string(), + version: "2.0.0".to_string(), + author: Some("tester".to_string()), + mode: SkillMode::PromptOnly, + capabilities: vec!["llm".to_string()], + input_schema: Some(serde_json::json!({"type": "object"})), + output_schema: None, + tags: vec!["test".to_string()], + category: Some("coding".to_string()), + triggers: vec!["test trigger".to_string()], + tools: vec!["bash".to_string()], + enabled: true, + }; + let json = serde_json::to_string(&manifest).unwrap(); + let parsed: SkillManifest = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.id.as_str(), "test-skill"); + assert_eq!(parsed.name, "Test Skill"); + assert_eq!(parsed.mode, SkillMode::PromptOnly); + assert_eq!(parsed.capabilities.len(), 1); + assert_eq!(parsed.triggers.len(), 1); + assert_eq!(parsed.tools.len(), 1); + assert_eq!(parsed.category.unwrap(), "coding"); + assert!(parsed.enabled); +} + +#[test] +fn skill_manifest_default_enabled() { + let json = r#"{"id":"x","name":"X","description":"x","version":"1.0","mode":"prompt_only"}"#; + let manifest: SkillManifest = serde_json::from_str(json).unwrap(); + assert!(manifest.enabled, "enabled should default to true"); +} + +#[test] +fn skill_manifest_disabled() { + let json = r#"{"id":"x","name":"X","description":"x","version":"1.0","mode":"prompt_only","enabled":false}"#; + let manifest: SkillManifest = serde_json::from_str(json).unwrap(); + assert!(!manifest.enabled); +} + +#[test] +fn skill_manifest_all_modes_roundtrip() { + for mode in &[SkillMode::PromptOnly, SkillMode::Python, SkillMode::Shell, SkillMode::Wasm] { + let manifest = SkillManifest { + id: SkillId::new("m"), + name: "M".into(), + description: "d".into(), + version: "1.0".into(), + author: None, + mode: mode.clone(), + capabilities: vec![], + input_schema: None, + output_schema: None, + tags: vec![], + category: None, + triggers: vec![], + tools: vec![], + enabled: true, + }; + let json = serde_json::to_string(&manifest).unwrap(); + let parsed: SkillManifest = serde_json::from_str(&json).unwrap(); + assert_eq!(*mode, parsed.mode); + } +} + +// === SkillContext === + +#[test] +fn skill_context_default() { + let ctx = SkillContext::default(); + assert!(ctx.agent_id.is_empty()); + assert!(ctx.session_id.is_empty()); + assert!(ctx.working_dir.is_none()); + assert_eq!(ctx.timeout_secs, 60); + assert!(!ctx.network_allowed); + assert!(!ctx.file_access_allowed); + assert!(ctx.llm.is_none()); +}