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 async_trait::async_trait; use domain::{ PluginId, PluginManifestValidator, PluginPackageStore, PluginRegistryStore, Project, ProjectId, ProjectPath, ProjectPluginActivation, ProjectPluginSettings, ProjectPluginSettingsStore, ProjectStore, RemoteRef, StoreError, }; use infrastructure::{ ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore, TokioBroadcastEventBus, }; static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); struct RuntimeProjectStore { project: Project, } #[async_trait] impl ProjectStore for RuntimeProjectStore { async fn list_projects(&self) -> Result, StoreError> { Ok(vec![self.project.clone()]) } async fn load_project(&self, id: ProjectId) -> Result { (id == self.project.id) .then(|| self.project.clone()) .ok_or(StoreError::NotFound) } async fn save_project(&self, _project: &Project) -> Result<(), StoreError> { Ok(()) } async fn save_workspace(&self, _workspace: &domain::Workspace) -> Result<(), StoreError> { Ok(()) } async fn load_workspace(&self) -> Result { Ok(domain::Workspace::default()) } } struct RuntimeProjectSettings { settings: ProjectPluginSettings, } #[async_trait] impl ProjectPluginSettingsStore for RuntimeProjectSettings { async fn load(&self, _project_root: &ProjectPath) -> Result { Ok(self.settings.clone()) } async fn save( &self, _project_root: &ProjectPath, _settings: &ProjectPluginSettings, ) -> Result<(), StoreError> { Ok(()) } } fn runtime_catalog_usecase( packages: Arc, registry: Arc, validator: Arc, plugin_id: &str, ) -> (ListPluginRuntimeContributions, ProjectId) { let project_id = ProjectId::new_random(); let project = Project::new( project_id, "Runtime project", ProjectPath::new("/runtime-project").unwrap(), RemoteRef::Local, 0, ) .unwrap(); let settings = ProjectPluginSettings { plugins: vec![ProjectPluginActivation { plugin_id: PluginId::new(plugin_id).unwrap(), enabled: true, }], ..ProjectPluginSettings::default() }; ( ListPluginRuntimeContributions::new( packages, registry, validator, Arc::new(RuntimeProjectStore { project }), Arc::new(RuntimeProjectSettings { settings }), ), project_id, ) } 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!(!result.plugin.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 (runtime, project_id) = runtime_catalog_usecase( packages, registry, validator, "dev.idea.fixtures.reference-minimal", ); let catalog = runtime.execute(Some(project_id)).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!( PathBuf::from(&plugin.plugin_root), app_data .join("plugins/installed/dev.idea.fixtures.reference-minimal") .canonicalize() .unwrap() ); 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.slash_commands, 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 (runtime, project_id) = runtime_catalog_usecase(packages, registry, validator, "com.example.hello-plugin"); let catalog = runtime.execute(Some(project_id)).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!( PathBuf::from(&plugin.plugin_root), app_data .join("plugins/installed/com.example.hello-plugin") .canonicalize() .unwrap() ); 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.slash_commands[0].name, "/hello"); assert_eq!( plugin.contributes.slash_commands[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() ); let (runtime, project_id) = runtime_catalog_usecase( packages.clone(), registry.clone(), validator.clone(), "com.example.hello-plugin", ); assert!(runtime .execute(Some(project_id)) .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 (runtime, project_id) = runtime_catalog_usecase(packages, registry, validator, "com.example.hello-plugin"); let catalog = runtime.execute(Some(project_id)).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() ); let (runtime, project_id) = runtime_catalog_usecase(packages, registry, validator, "dev.acme.multifile"); assert!(runtime .execute(Some(project_id)) .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 (runtime, project_id) = runtime_catalog_usecase( packages, registry.clone(), validator, "dev.idea.fixtures.missing-main", ); let catalog = runtime.execute(Some(project_id)).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); }