diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 306c71960a..b2be23c45a 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -1114,297 +1114,5 @@ fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<(), String> { } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn plugin_mcp_file_supports_mcp_servers_object_format() { - let parsed = serde_json::from_str::( - r#"{ - "mcpServers": { - "sample": { - "command": "sample-mcp" - } - } -}"#, - ) - .expect("parse wrapped plugin mcp config") - .into_mcp_servers(); - - assert_eq!( - parsed, - HashMap::from([( - "sample".to_string(), - serde_json::json!({ - "command": "sample-mcp" - }), - )]) - ); - } - - #[test] - fn plugin_mcp_file_supports_mcp_servers_object_format_with_metadata() { - let parsed = serde_json::from_str::( - r#"{ - "$schema": "https://example.com/plugin-mcp.schema.json", - "mcpServers": { - "sample": { - "command": "sample-mcp" - } - } -}"#, - ) - .expect("parse plugin mcp config with metadata") - .into_mcp_servers(); - - assert_eq!( - parsed, - HashMap::from([( - "sample".to_string(), - serde_json::json!({ - "command": "sample-mcp" - }), - )]) - ); - } - - #[test] - fn plugin_mcp_file_supports_top_level_server_map_format() { - let parsed = serde_json::from_str::( - r#"{ - "linear": { - "type": "http", - "url": "https://mcp.linear.app/mcp" - } -}"#, - ) - .expect("parse flat plugin mcp config") - .into_mcp_servers(); - - assert_eq!( - parsed, - HashMap::from([( - "linear".to_string(), - serde_json::json!({ - "type": "http", - "url": "https://mcp.linear.app/mcp" - }), - )]) - ); - } - - #[test] - fn curated_plugin_cache_version_shortens_full_git_sha() { - assert_eq!( - curated_plugin_cache_version("0123456789abcdef0123456789abcdef01234567"), - "01234567" - ); - } - - #[test] - fn curated_plugin_cache_version_preserves_non_git_sha_versions() { - assert_eq!( - curated_plugin_cache_version("export-backup"), - "export-backup" - ); - assert_eq!(curated_plugin_cache_version("0123456"), "0123456"); - } - - #[test] - fn load_plugin_hooks_discovers_default_hooks_file() { - let tmp = tempfile::tempdir().expect("tempdir"); - let plugin_root = - AbsolutePathBuf::try_from(tmp.path().join("demo-plugin")).expect("plugin root"); - fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); - fs::create_dir_all(plugin_root.join("hooks")).expect("create hooks dir"); - fs::write( - plugin_root.join(".codex-plugin/plugin.json"), - r#"{ "name": "demo-plugin" }"#, - ) - .expect("write manifest"); - fs::write( - plugin_root.join("hooks/hooks.json"), - r#"{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [{ "type": "command", "command": "echo default" }] - } - ] - } -}"#, - ) - .expect("write hooks"); - - let manifest = load_plugin_manifest(plugin_root.as_path()).expect("manifest"); - let plugin_id = PluginId::parse("demo-plugin@test-marketplace").expect("plugin id"); - let sources = load_plugin_hooks(&plugin_root, &plugin_id, &manifest.paths); - - assert_eq!(sources.len(), 1); - assert_eq!( - sources[0].plugin_id, - PluginId::parse("demo-plugin@test-marketplace").expect("plugin id") - ); - assert_eq!(sources[0].source_relative_path, "hooks/hooks.json"); - assert_eq!(sources[0].hooks.handler_count(), 1); - } - - #[test] - fn load_plugin_hooks_manifest_paths_replace_default_hooks_file() { - let tmp = tempfile::tempdir().expect("tempdir"); - let plugin_root = - AbsolutePathBuf::try_from(tmp.path().join("demo-plugin")).expect("plugin root"); - fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); - fs::create_dir_all(plugin_root.join("hooks")).expect("create hooks dir"); - fs::write( - plugin_root.join(".codex-plugin/plugin.json"), - r#"{ - "name": "demo-plugin", - "hooks": ["./hooks/one.json", "./hooks/two.json"] -}"#, - ) - .expect("write manifest"); - fs::write( - plugin_root.join("hooks/hooks.json"), - r#"{ - "hooks": { - "PreToolUse": [ - { - "hooks": [{ "type": "command", "command": "echo ignored" }] - } - ] - } -}"#, - ) - .expect("write default hooks"); - fs::write( - plugin_root.join("hooks/one.json"), - r#"{ - "hooks": { - "PreToolUse": [ - { - "hooks": [{ "type": "command", "command": "echo one" }] - } - ] - } -}"#, - ) - .expect("write first hooks"); - fs::write( - plugin_root.join("hooks/two.json"), - r#"{ - "hooks": { - "PostToolUse": [ - { - "hooks": [{ "type": "command", "command": "echo two" }] - } - ] - } -}"#, - ) - .expect("write second hooks"); - - let manifest = load_plugin_manifest(plugin_root.as_path()).expect("manifest"); - let plugin_id = PluginId::parse("demo-plugin@test-marketplace").expect("plugin id"); - let sources = load_plugin_hooks(&plugin_root, &plugin_id, &manifest.paths); - - assert_eq!( - sources - .iter() - .map(|source| source.source_relative_path.as_str()) - .collect::>(), - vec!["hooks/one.json", "hooks/two.json"] - ); - assert_eq!( - sources - .iter() - .map(|source| source.hooks.handler_count()) - .collect::>(), - vec![1, 1] - ); - } - - #[test] - fn load_plugin_hooks_supports_inline_manifest_hooks() { - let tmp = tempfile::tempdir().expect("tempdir"); - let plugin_root = - AbsolutePathBuf::try_from(tmp.path().join("demo-plugin")).expect("plugin root"); - fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); - fs::write( - plugin_root.join(".codex-plugin/plugin.json"), - r#"{ - "name": "demo-plugin", - "hooks": { - "hooks": { - "SessionStart": [ - { - "matcher": "startup", - "hooks": [{ "type": "command", "command": "echo inline" }] - } - ] - } - } -}"#, - ) - .expect("write manifest"); - - let manifest = load_plugin_manifest(plugin_root.as_path()).expect("manifest"); - let plugin_id = PluginId::parse("demo-plugin@test-marketplace").expect("plugin id"); - let sources = load_plugin_hooks(&plugin_root, &plugin_id, &manifest.paths); - - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].source_relative_path, "plugin.json#hooks[0]"); - assert_eq!(sources[0].hooks.handler_count(), 1); - } - - #[test] - fn materialize_git_subdir_uses_sparse_checkout() { - let codex_home = tempfile::tempdir().expect("create codex home"); - let repo = tempfile::tempdir().expect("create git repo"); - let plugin_dir = repo.path().join("plugins/toolkit"); - fs::create_dir_all(&plugin_dir).expect("create plugin directory"); - fs::create_dir_all(repo.path().join("plugins/other")).expect("create other plugin"); - fs::write(plugin_dir.join("marker.txt"), "toolkit").expect("write plugin marker"); - fs::write(repo.path().join("plugins/other/marker.txt"), "other") - .expect("write other marker"); - fs::write(repo.path().join("root.txt"), "root").expect("write root marker"); - - run_git(&["init"], Some(repo.path())).expect("init git repo"); - run_git( - &["config", "user.email", "test@example.com"], - Some(repo.path()), - ) - .expect("configure git email"); - run_git(&["config", "user.name", "Test User"], Some(repo.path())) - .expect("configure git name"); - run_git(&["add", "."], Some(repo.path())).expect("stage git repo"); - run_git(&["commit", "-m", "init"], Some(repo.path())).expect("commit git repo"); - - let materialized = materialize_marketplace_plugin_source( - codex_home.path(), - &MarketplacePluginSource::Git { - url: repo.path().display().to_string(), - path: Some("plugins/toolkit".to_string()), - ref_name: None, - sha: None, - }, - ) - .expect("materialize git source"); - - assert_eq!( - plugin_dir.file_name(), - materialized.path.as_path().file_name() - ); - assert!(materialized.path.as_path().join("marker.txt").is_file()); - let checkout_root = materialized - .path - .as_path() - .parent() - .and_then(Path::parent) - .expect("materialized path should be nested under checkout root"); - assert!(!checkout_root.join("root.txt").exists()); - assert!(!checkout_root.join("plugins/other/marker.txt").exists()); - } -} +#[path = "loader_tests.rs"] +mod tests; diff --git a/codex-rs/core-plugins/src/loader_tests.rs b/codex-rs/core-plugins/src/loader_tests.rs new file mode 100644 index 0000000000..92ac416069 --- /dev/null +++ b/codex-rs/core-plugins/src/loader_tests.rs @@ -0,0 +1,347 @@ +use super::*; +use crate::manifest::load_plugin_manifest; +use codex_plugin::PluginId; +use pretty_assertions::assert_eq; + +#[test] +fn plugin_mcp_file_supports_mcp_servers_object_format() { + let parsed = serde_json::from_str::( + r#"{ + "mcpServers": { + "sample": { + "command": "sample-mcp" + } + } +}"#, + ) + .expect("parse wrapped plugin mcp config") + .into_mcp_servers(); + + assert_eq!( + parsed, + HashMap::from([( + "sample".to_string(), + serde_json::json!({ + "command": "sample-mcp" + }), + )]) + ); +} + +#[test] +fn plugin_mcp_file_supports_mcp_servers_object_format_with_metadata() { + let parsed = serde_json::from_str::( + r#"{ + "$schema": "https://example.com/plugin-mcp.schema.json", + "mcpServers": { + "sample": { + "command": "sample-mcp" + } + } +}"#, + ) + .expect("parse plugin mcp config with metadata") + .into_mcp_servers(); + + assert_eq!( + parsed, + HashMap::from([( + "sample".to_string(), + serde_json::json!({ + "command": "sample-mcp" + }), + )]) + ); +} + +#[test] +fn plugin_mcp_file_supports_top_level_server_map_format() { + let parsed = serde_json::from_str::( + r#"{ + "linear": { + "type": "http", + "url": "https://mcp.linear.app/mcp" + } +}"#, + ) + .expect("parse flat plugin mcp config") + .into_mcp_servers(); + + assert_eq!( + parsed, + HashMap::from([( + "linear".to_string(), + serde_json::json!({ + "type": "http", + "url": "https://mcp.linear.app/mcp" + }), + )]) + ); +} + +#[test] +fn curated_plugin_cache_version_shortens_full_git_sha() { + assert_eq!( + curated_plugin_cache_version("0123456789abcdef0123456789abcdef01234567"), + "01234567" + ); +} + +#[test] +fn curated_plugin_cache_version_preserves_non_git_sha_versions() { + assert_eq!( + curated_plugin_cache_version("export-backup"), + "export-backup" + ); + assert_eq!(curated_plugin_cache_version("0123456"), "0123456"); +} + +fn plugin_id() -> PluginId { + PluginId::parse("demo-plugin@test-marketplace").expect("plugin id") +} + +fn plugin_root() -> (tempfile::TempDir, AbsolutePathBuf) { + let tmp = tempfile::tempdir().expect("tempdir"); + let plugin_root = + AbsolutePathBuf::try_from(tmp.path().join("demo-plugin")).expect("plugin root"); + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); + fs::create_dir_all(plugin_root.join("hooks")).expect("create hooks dir"); + (tmp, plugin_root) +} + +fn write_manifest(plugin_root: &AbsolutePathBuf, manifest: &str) { + fs::write(plugin_root.join(".codex-plugin/plugin.json"), manifest).expect("write manifest"); +} + +fn write_hook_file(plugin_root: &AbsolutePathBuf, relative_path: &str, event: &str, command: &str) { + fs::write( + plugin_root.join(relative_path), + format!( + r#"{{ + "hooks": {{ + "{event}": [ + {{ + "hooks": [{{ "type": "command", "command": "{command}" }}] + }} + ] + }} +}}"# + ), + ) + .expect("write hooks"); +} + +fn load_sources(plugin_root: &AbsolutePathBuf) -> Vec { + let manifest = load_plugin_manifest(plugin_root.as_path()).expect("manifest"); + load_plugin_hooks(plugin_root, &plugin_id(), &manifest.paths) +} + +#[test] +fn load_plugin_hooks_discovers_default_hooks_file() { + let (_tmp, plugin_root) = plugin_root(); + write_manifest(&plugin_root, r#"{ "name": "demo-plugin" }"#); + fs::write( + plugin_root.join("hooks/hooks.json"), + r#"{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "echo default" }] + } + ] + } +}"#, + ) + .expect("write hooks"); + + let sources = load_sources(&plugin_root); + + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].plugin_id, plugin_id()); + assert_eq!(sources[0].source_relative_path, "hooks/hooks.json"); + assert_eq!(sources[0].hooks.handler_count(), 1); +} + +#[test] +fn load_plugin_hooks_supports_manifest_hook_path() { + let (_tmp, plugin_root) = plugin_root(); + write_manifest( + &plugin_root, + r#"{ + "name": "demo-plugin", + "hooks": "./hooks/one.json" +}"#, + ); + write_hook_file(&plugin_root, "hooks/one.json", "PreToolUse", "echo one"); + + let sources = load_sources(&plugin_root); + + assert_eq!( + sources + .iter() + .map(|source| source.source_relative_path.as_str()) + .collect::>(), + vec!["hooks/one.json"] + ); + assert_eq!(sources[0].hooks.handler_count(), 1); +} + +#[test] +fn load_plugin_hooks_manifest_paths_replace_default_hooks_file() { + let (_tmp, plugin_root) = plugin_root(); + write_manifest( + &plugin_root, + r#"{ + "name": "demo-plugin", + "hooks": ["./hooks/one.json", "./hooks/two.json"] +}"#, + ); + write_hook_file( + &plugin_root, + "hooks/hooks.json", + "PreToolUse", + "echo ignored", + ); + write_hook_file(&plugin_root, "hooks/one.json", "PreToolUse", "echo one"); + write_hook_file(&plugin_root, "hooks/two.json", "PostToolUse", "echo two"); + + let sources = load_sources(&plugin_root); + + assert_eq!( + sources + .iter() + .map(|source| source.source_relative_path.as_str()) + .collect::>(), + vec!["hooks/one.json", "hooks/two.json"] + ); + assert_eq!( + sources + .iter() + .map(|source| source.hooks.handler_count()) + .collect::>(), + vec![1, 1] + ); +} + +#[test] +fn load_plugin_hooks_supports_inline_manifest_hooks() { + let (_tmp, plugin_root) = plugin_root(); + write_manifest( + &plugin_root, + r#"{ + "name": "demo-plugin", + "hooks": { + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [{ "type": "command", "command": "echo inline" }] + } + ] + } + } +}"#, + ); + + let sources = load_sources(&plugin_root); + + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].source_relative_path, "plugin.json#hooks[0]"); + assert_eq!(sources[0].hooks.handler_count(), 1); +} + +#[test] +fn load_plugin_hooks_supports_inline_manifest_hook_list() { + let (_tmp, plugin_root) = plugin_root(); + write_manifest( + &plugin_root, + r#"{ + "name": "demo-plugin", + "hooks": [ + { + "hooks": { + "SessionStart": [ + { + "hooks": [{ "type": "command", "command": "echo inline one" }] + } + ] + } + }, + { + "hooks": { + "Stop": [ + { + "hooks": [{ "type": "command", "command": "echo inline two" }] + } + ] + } + } + ] +}"#, + ); + + let sources = load_sources(&plugin_root); + + assert_eq!( + sources + .iter() + .map(|source| source.source_relative_path.as_str()) + .collect::>(), + vec!["plugin.json#hooks[0]", "plugin.json#hooks[1]"] + ); + assert_eq!( + sources + .iter() + .map(|source| source.hooks.handler_count()) + .collect::>(), + vec![1, 1] + ); +} + +#[test] +fn materialize_git_subdir_uses_sparse_checkout() { + let codex_home = tempfile::tempdir().expect("create codex home"); + let repo = tempfile::tempdir().expect("create git repo"); + let plugin_dir = repo.path().join("plugins/toolkit"); + fs::create_dir_all(&plugin_dir).expect("create plugin directory"); + fs::create_dir_all(repo.path().join("plugins/other")).expect("create other plugin"); + fs::write(plugin_dir.join("marker.txt"), "toolkit").expect("write plugin marker"); + fs::write(repo.path().join("plugins/other/marker.txt"), "other").expect("write other marker"); + fs::write(repo.path().join("root.txt"), "root").expect("write root marker"); + + run_git(&["init"], Some(repo.path())).expect("init git repo"); + run_git( + &["config", "user.email", "test@example.com"], + Some(repo.path()), + ) + .expect("configure git email"); + run_git(&["config", "user.name", "Test User"], Some(repo.path())).expect("configure git name"); + run_git(&["add", "."], Some(repo.path())).expect("stage git repo"); + run_git(&["commit", "-m", "init"], Some(repo.path())).expect("commit git repo"); + + let materialized = materialize_marketplace_plugin_source( + codex_home.path(), + &MarketplacePluginSource::Git { + url: repo.path().display().to_string(), + path: Some("plugins/toolkit".to_string()), + ref_name: None, + sha: None, + }, + ) + .expect("materialize git source"); + + assert_eq!( + plugin_dir.file_name(), + materialized.path.as_path().file_name() + ); + assert!(materialized.path.as_path().join("marker.txt").is_file()); + let checkout_root = materialized + .path + .as_path() + .parent() + .and_then(Path::parent) + .expect("materialized path should be nested under checkout root"); + assert!(!checkout_root.join("root.txt").exists()); + assert!(!checkout_root.join("plugins/other/marker.txt").exists()); +}