use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use application::{ InstallPluginFromDirectory, JsonPluginManifestValidator, ListPluginRuntimeContributions, ListPlugins, UninstallPlugin, UninstallPluginInput, }; use infrastructure::{ ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore, TokioBroadcastEventBus, }; static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); fn temp_dir(label: &str) -> PathBuf { let n = TEMP_COUNTER.fetch_add(1, Ordering::SeqCst); let path = std::env::temp_dir().join(format!( "idea-plugin-functional-{label}-{}-{n}", std::process::id() )); let _ = fs::remove_dir_all(&path); fs::create_dir_all(&path).unwrap(); path } fn fixture_path(name: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests") .join("fixtures") .join("plugins") .join(name) } fn sdk_hello_plugin_path() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("../..") .join("sdk/IdeaSDK/examples/hello-plugin") } fn materialize_sdk_hello_plugin() -> PathBuf { let source = sdk_hello_plugin_path(); let root = temp_dir("hello-plugin-built"); fs::create_dir_all(root.join("dist")).unwrap(); fs::write( root.join("idea-plugin.json"), fs::read(source.join("idea-plugin.json")).unwrap(), ) .unwrap(); fs::write( root.join("dist/index.js"), "export function activate() { return 'hello-plugin'; }", ) .unwrap(); root } fn write_multifile_plugin(root: &Path) { fs::create_dir_all(root.join("dist/core")).unwrap(); fs::write( root.join("idea-plugin.json"), r#"{ "ideaPluginManifestVersion": 1, "id": "dev.acme.multifile", "displayName": "Multifile Plugin", "version": "0.1.0", "main": "dist/index.js", "trustLevel": "full", "capabilities": ["ui"], "contributes": {} }"#, ) .unwrap(); fs::write( root.join("dist/index.js"), "import { answer } from './constants.js'; export default answer;", ) .unwrap(); fs::write(root.join("dist/constants.js"), "export const answer = 42;").unwrap(); fs::write(root.join("dist/core/util.js"), "export const util = true;").unwrap(); } #[tokio::test] async fn installs_reference_fixture_and_loads_runtime_catalog() { let app_data = temp_dir("app-data"); let fixture = fixture_path("reference-minimal"); let packages = Arc::new(FsPluginPackageStore::new(&app_data)); let registry = Arc::new(FsPluginRegistryStore::new(&app_data)); let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0")); let events = Arc::new(TokioBroadcastEventBus::new()); let mcp = Arc::new(ExternalMcpPluginSupervisor::new()); let install = InstallPluginFromDirectory::new( packages.clone(), registry.clone(), validator.clone(), events, mcp.clone(), ); let result = install .execute(fixture.to_string_lossy().into_owned()) .await .unwrap(); assert_eq!(result.plugin.id, "dev.idea.fixtures.reference-minimal"); assert_eq!(result.plugin.display_name, "Reference Minimal Plugin"); assert_eq!(result.review.contribution_summary.top_level_menus, 1); assert_eq!(result.review.contribution_summary.menu_items, 1); assert!(result.restart_required); assert!(app_data .join("plugins/installed/dev.idea.fixtures.reference-minimal/idea-plugin.json") .is_file()); let admin = ListPlugins::new(packages.clone(), registry.clone(), validator.clone()) .execute() .await .unwrap(); assert_eq!(admin.len(), 1); assert_eq!(admin[0].id, "dev.idea.fixtures.reference-minimal"); assert_eq!( admin[0].lifecycle_state, domain::PluginLifecycleState::Enabled ); let catalog = ListPluginRuntimeContributions::new(packages, registry, validator) .execute() .await .unwrap(); assert_eq!(catalog.plugins.len(), 1); let plugin = &catalog.plugins[0]; assert_eq!(plugin.id, "dev.idea.fixtures.reference-minimal"); assert!(plugin .bundle_url .starts_with("idea-plugin://dev.idea.fixtures.reference-minimal/0.1.0/")); assert_eq!(plugin.contributes.menus.len(), 1); assert_eq!(plugin.contributes.menu_items.len(), 1); let _ = fs::remove_dir_all(app_data); } #[tokio::test] async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() { let app_data = temp_dir("hello-app-data"); let hello_plugin = materialize_sdk_hello_plugin(); let packages = Arc::new(FsPluginPackageStore::new(&app_data)); let registry = Arc::new(FsPluginRegistryStore::new(&app_data)); let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0")); let events = Arc::new(TokioBroadcastEventBus::new()); let mcp = Arc::new(ExternalMcpPluginSupervisor::new()); let install = InstallPluginFromDirectory::new( packages.clone(), registry.clone(), validator.clone(), events, mcp, ); let result = install .execute(hello_plugin.to_string_lossy().into_owned()) .await .unwrap(); assert_eq!(result.plugin.id, "com.example.hello-plugin"); assert_eq!(result.plugin.display_name, "Hello Plugin"); assert_eq!(result.review.contribution_summary.top_level_menus, 1); assert_eq!(result.review.contribution_summary.menu_items, 1); assert_eq!(result.review.contribution_summary.layouts, 1); assert!(app_data .join("plugins/installed/com.example.hello-plugin/dist/index.js") .is_file()); let catalog = ListPluginRuntimeContributions::new(packages, registry, validator) .execute() .await .unwrap(); assert_eq!(catalog.plugins.len(), 1); let plugin = &catalog.plugins[0]; assert_eq!(plugin.id, "com.example.hello-plugin"); assert!(plugin .bundle_url .starts_with("idea-plugin://com.example.hello-plugin/0.1.0/")); assert!(plugin.bundle_url.ends_with("/dist/index.js")); assert_eq!(plugin.contributes.menus[0].id, "hello-plugin.menu"); assert_eq!(plugin.contributes.menus[0].label.as_str(), "Hello Plugin"); assert_eq!( plugin.contributes.menu_items[0].command.as_str(), "hello-plugin" ); assert_eq!( plugin.contributes.layouts[0].layout_type.as_str(), "hello-plugin.hello-world" ); assert_eq!(plugin.contributes.layouts[0].label.as_str(), "hello-world"); let _ = fs::remove_dir_all(app_data); let _ = fs::remove_dir_all(hello_plugin); } #[tokio::test] async fn uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue() { let app_data = temp_dir("hello-reinstall-app-data"); let hello_plugin = materialize_sdk_hello_plugin(); let packages = Arc::new(FsPluginPackageStore::new(&app_data)); let registry = Arc::new(FsPluginRegistryStore::new(&app_data)); let storage = Arc::new(FsPluginStorageStore::new(&app_data)); let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0")); let events = Arc::new(TokioBroadcastEventBus::new()); let mcp = Arc::new(ExternalMcpPluginSupervisor::new()); let install = InstallPluginFromDirectory::new( packages.clone(), registry.clone(), validator.clone(), events.clone(), mcp.clone(), ); let uninstall = UninstallPlugin::new(packages.clone(), storage, registry.clone(), events, mcp); install .execute(hello_plugin.to_string_lossy().into_owned()) .await .unwrap(); let uninstall_result = uninstall .execute(UninstallPluginInput { plugin_id: "com.example.hello-plugin".to_owned(), }) .await .unwrap(); assert_eq!( uninstall_result.removal_outcome, domain::RemovalOutcome::Removed ); assert!(!app_data .join("plugins/installed/com.example.hello-plugin") .exists()); assert!( ListPlugins::new(packages.clone(), registry.clone(), validator.clone()) .execute() .await .unwrap() .is_empty() ); assert!(ListPluginRuntimeContributions::new( packages.clone(), registry.clone(), validator.clone() ) .execute() .await .unwrap() .plugins .is_empty()); let reinstall = install .execute(hello_plugin.to_string_lossy().into_owned()) .await .unwrap(); assert_eq!(reinstall.plugin.id, "com.example.hello-plugin"); assert_eq!( reinstall.plugin.lifecycle_state, domain::PluginLifecycleState::Enabled ); let catalog = ListPluginRuntimeContributions::new(packages, registry, validator) .execute() .await .unwrap(); assert_eq!(catalog.plugins.len(), 1); assert_eq!(catalog.plugins[0].id, "com.example.hello-plugin"); assert!(catalog.plugins[0].bundle_url.ends_with("/dist/index.js")); let _ = fs::remove_dir_all(app_data); let _ = fs::remove_dir_all(hello_plugin); } #[tokio::test] async fn uninstall_multifile_plugin_removes_package_registry_and_runtime_residue() { let app_data = temp_dir("multifile-uninstall-app-data"); let source = temp_dir("multifile-source"); write_multifile_plugin(&source); let packages = Arc::new(FsPluginPackageStore::new(&app_data)); let registry = Arc::new(FsPluginRegistryStore::new(&app_data)); let storage = Arc::new(FsPluginStorageStore::new(&app_data)); let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0")); let events = Arc::new(TokioBroadcastEventBus::new()); let mcp = Arc::new(ExternalMcpPluginSupervisor::new()); let install = InstallPluginFromDirectory::new( packages.clone(), registry.clone(), validator.clone(), events.clone(), mcp.clone(), ); let uninstall = UninstallPlugin::new(packages.clone(), storage, registry.clone(), events, mcp); install .execute(source.to_string_lossy().into_owned()) .await .unwrap(); assert!(app_data .join("plugins/installed/dev.acme.multifile/dist/constants.js") .is_file()); assert!(app_data .join("plugins/installed/dev.acme.multifile/dist/core/util.js") .is_file()); let plugin_data = app_data.join("plugins/data/dev.acme.multifile"); fs::create_dir_all(&plugin_data).unwrap(); fs::write(plugin_data.join("state.json"), r#"{"launches":1}"#).unwrap(); let uninstall_result = uninstall .execute(UninstallPluginInput { plugin_id: "dev.acme.multifile".to_owned(), }) .await .unwrap(); assert_eq!( uninstall_result.removal_outcome, domain::RemovalOutcome::Removed ); assert!(!app_data .join("plugins/installed/dev.acme.multifile") .exists()); assert!(!app_data.join("plugins/data/dev.acme.multifile").exists()); assert!( ListPlugins::new(packages.clone(), registry.clone(), validator.clone()) .execute() .await .unwrap() .is_empty() ); assert!( ListPluginRuntimeContributions::new(packages, registry, validator) .execute() .await .unwrap() .plugins .is_empty() ); let _ = fs::remove_dir_all(app_data); let _ = fs::remove_dir_all(source); } #[tokio::test] async fn installed_plugin_with_missing_main_is_isolated_from_runtime_catalog() { let app_data = temp_dir("missing-main-app-data"); let source = temp_dir("missing-main-source"); fs::write( source.join("idea-plugin.json"), r#"{ "ideaPluginManifestVersion": 1, "id": "dev.idea.fixtures.missing-main", "displayName": "Missing Main Plugin", "version": "0.1.0", "main": "dist/index.js", "trustLevel": "full", "capabilities": ["ui"], "contributes": {} }"#, ) .unwrap(); let packages = Arc::new(FsPluginPackageStore::new(&app_data)); let registry = Arc::new(FsPluginRegistryStore::new(&app_data)); let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0")); let events = Arc::new(TokioBroadcastEventBus::new()); let mcp = Arc::new(ExternalMcpPluginSupervisor::new()); let install = InstallPluginFromDirectory::new( packages.clone(), registry.clone(), validator.clone(), events, mcp, ); let result = install .execute(source.to_string_lossy().into_owned()) .await .unwrap(); assert_eq!(result.plugin.id, "dev.idea.fixtures.missing-main"); assert_eq!( result.plugin.lifecycle_state, domain::PluginLifecycleState::Invalid ); assert!(result .plugin .error .as_deref() .unwrap_or_default() .contains("not servable")); let catalog = ListPluginRuntimeContributions::new(packages, registry.clone(), validator) .execute() .await .unwrap(); assert!(catalog.plugins.is_empty()); let admin = ListPlugins::new( Arc::new(FsPluginPackageStore::new(&app_data)), registry, Arc::new(JsonPluginManifestValidator::new("0.3.0")), ) .execute() .await .unwrap(); assert_eq!( admin[0].lifecycle_state, domain::PluginLifecycleState::Invalid ); assert!(admin[0] .error .as_deref() .unwrap_or_default() .contains("not servable")); let _ = fs::remove_dir_all(source); let _ = fs::remove_dir_all(app_data); }