Files
IdeA/crates/infrastructure/tests/plugin_install_load.rs
Blomios 171c6c923c feat(wave): #119/#122/#131/#132 verts + sprint plugins ESM/persistance #135/#136/#139
État d'intégration confiné à la branche batch. Les tickets #119 (skills →
capacités agent découvrables), #122 (override permissions par défaut), #131
(effort par agent/presets) et #132 (outil MCP d'édition du contexte projet)
sont verts en périmètre. Le sprint plugins multi-fichiers ESM / persistance
plugin-owned (#135/#136/#139) est co-implémenté dans les MÊMES fichiers de
câblage (frontend/src/ports/index.ts, backend/src/lib.rs, domain/ports.rs,
backend/dto.rs), inséparable sans staging interactif (indisponible ici).

Commit unique volontaire : préserve l'état vert QA sans découpe hunk risquée.
NON mergé vers develop tant que #137 (QA e2e plugins) n'est pas vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 11:06:23 +02:00

417 lines
14 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 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);
}