Les contributions runtime des plugins (menus, slash commands, layouts, surfaces) ne sont exposées que dans les projets liés au plugin. - Définition du lien: ProjectPluginSettingsStore par racine projet (domaine) — une contribution runtime-active n'entre dans le catalogue que si elle est explicitement activée pour le projet courant; hors projet focalisé, catalogue vide - Application: ListPluginRuntimeContributions.execute prend un project_id optionnel, charge projet + settings et filtre le registre par activation projet; tests fake store/settings (catalogue vide sans projet, activation, layout, slash commands) - Backend/app-tauri: plugin_list_runtime_contributions et slash commands list/execute acceptent projectId optionnel (DTO camelCase, défaut serde), BackendCore recâblé avec store_port et project_plugin_settings_store; test DTO de scope optionnel - Frontend: PluginGateway.listRuntimeContributions(projectId) (ports, tauri, http/unsupported, mock alignés); PluginRuntimeProvider re-scope le catalogue au changement de focus projet (vidage du registre précédent, disposition des bundles, requêtes obsolètes ignorées par projectId/révision); PluginLayoutCellView ignore les références de layout devenues hors périmètre via onChooseAnotherLayout et ne rend rien pendant le rechargement (aucune fuite inter-projets) - Tests: plugin_install_load (infrastructure), dto slash commands, plugin.test, PluginRuntimeProvider.test, PluginLayoutCellView.test QA verte sur feature/321-plugin-contributions-linked-projects. Aucun push.
532 lines
17 KiB
Rust
532 lines
17 KiB
Rust
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<Vec<Project>, StoreError> {
|
|
Ok(vec![self.project.clone()])
|
|
}
|
|
|
|
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
|
(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<domain::Workspace, StoreError> {
|
|
Ok(domain::Workspace::default())
|
|
}
|
|
}
|
|
|
|
struct RuntimeProjectSettings {
|
|
settings: ProjectPluginSettings,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ProjectPluginSettingsStore for RuntimeProjectSettings {
|
|
async fn load(&self, _project_root: &ProjectPath) -> Result<ProjectPluginSettings, StoreError> {
|
|
Ok(self.settings.clone())
|
|
}
|
|
|
|
async fn save(
|
|
&self,
|
|
_project_root: &ProjectPath,
|
|
_settings: &ProjectPluginSettings,
|
|
) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn runtime_catalog_usecase(
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
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);
|
|
}
|