test(backend,plugins): ajoute un test de non-résidu runtime après uninstall/reinstall (#120)

- plugin_install_load.rs: test uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue()
- application/src/plugin/mod.rs: refactor FakePackages pour supporter plusieurs staged packages et nettoyer les manifests sur uninstall
- Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 19:11:54 +02:00
parent 0061685b08
commit 5baf5821d4
2 changed files with 148 additions and 11 deletions

View File

@ -1360,7 +1360,7 @@ fn parse_version_tuple(raw: &str) -> Option<(u64, u64, u64)> {
mod tests {
use super::*;
use domain::ports::{EventStream, PluginPackageStore, PluginRegistryStore, PluginStoreError};
use std::collections::HashMap;
use std::collections::{HashMap, VecDeque};
use std::sync::Mutex;
fn validator() -> JsonPluginManifestValidator {
@ -1397,23 +1397,32 @@ mod tests {
struct FakePackages {
manifests: Mutex<HashMap<String, Vec<u8>>>,
staged: Mutex<Option<StagedPluginPackage>>,
staged_manifest: Vec<u8>,
staged: Mutex<VecDeque<StagedPluginPackage>>,
removed: Mutex<Vec<String>>,
}
impl FakePackages {
fn with_manifest(bytes: Vec<u8>) -> Self {
Self::with_manifest_and_staged_count(bytes, 1)
}
fn with_manifest_and_staged_count(bytes: Vec<u8>, staged_count: usize) -> Self {
let mut manifests = HashMap::new();
manifests.insert("dev.acme.gitgraph".to_owned(), bytes);
Self {
manifests: Mutex::new(manifests),
staged: Mutex::new(Some(StagedPluginPackage {
root: "/stage/plugin".to_owned(),
let staged = (0..staged_count)
.map(|index| StagedPluginPackage {
root: format!("/stage/plugin-{index}"),
source: PluginInstallSource::Directory {
path_label: "/source/plugin".to_owned(),
},
content_hash: content_hash("abc123"),
})),
})
.collect();
Self {
manifests: Mutex::new(manifests),
staged_manifest: valid_manifest(),
staged: Mutex::new(staged),
removed: Mutex::new(Vec::new()),
}
}
@ -1442,6 +1451,11 @@ mod tests {
.plugin_id
.as_ref()
.map_or("dev.acme.gitgraph", PluginId::as_str);
if package.plugin_id.is_none() {
return Ok(PluginManifestBytes {
bytes: self.staged_manifest.clone(),
});
}
self.manifests
.lock()
.unwrap()
@ -1458,7 +1472,7 @@ mod tests {
self.staged
.lock()
.unwrap()
.take()
.pop_front()
.ok_or_else(|| PluginStoreError::Invalid("missing staged package".to_owned()))
}
@ -1469,7 +1483,7 @@ mod tests {
self.staged
.lock()
.unwrap()
.take()
.pop_front()
.ok_or_else(|| PluginStoreError::Invalid("missing staged package".to_owned()))
}
@ -1481,7 +1495,7 @@ mod tests {
self.manifests
.lock()
.unwrap()
.insert(plugin_id.as_str().to_owned(), valid_manifest());
.insert(plugin_id.as_str().to_owned(), self.staged_manifest.clone());
Ok(domain::PluginPackageRef {
plugin_id: Some(plugin_id.clone()),
root: staged.root,
@ -1496,6 +1510,7 @@ mod tests {
.lock()
.unwrap()
.push(plugin_id.as_str().to_owned());
self.manifests.lock().unwrap().remove(plugin_id.as_str());
Ok(RemovalOutcome::Removed)
}
@ -1910,4 +1925,53 @@ mod tests {
}
)));
}
#[tokio::test]
async fn uninstall_then_reinstall_leaves_runtime_catalog_active_without_residue() {
let packages = Arc::new(FakePackages::with_manifest_and_staged_count(
valid_manifest(),
2,
));
let registry = Arc::new(FakeRegistry::default());
let events = Arc::new(FakeEvents::default());
let mcp = Arc::new(FakeMcp::default());
let install = InstallPluginFromDirectory::new(
packages.clone(),
registry.clone(),
Arc::new(validator()),
events.clone(),
mcp.clone(),
);
let uninstall =
UninstallPlugin::new(packages.clone(), registry.clone(), events, mcp.clone());
install.execute("/source/plugin".to_owned()).await.unwrap();
uninstall
.execute(UninstallPluginInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
})
.await
.unwrap();
assert!(registry.load_registry().await.unwrap().plugins.is_empty());
let result = install.execute("/source/plugin".to_owned()).await.unwrap();
let runtime = ListPluginRuntimeContributions::new(
packages.clone(),
registry.clone(),
Arc::new(validator()),
)
.execute()
.await
.unwrap();
assert_eq!(result.plugin.lifecycle_state, PluginLifecycleState::Enabled);
assert_eq!(runtime.plugins.len(), 1);
assert_eq!(runtime.plugins[0].id, "dev.acme.gitgraph");
let saved = registry.load_registry().await.unwrap();
let entry = saved.find(&plugin_id()).unwrap();
assert_eq!(entry.lifecycle_state, PluginLifecycleState::Enabled);
assert!(entry.error.is_none());
assert_eq!(&*packages.removed.lock().unwrap(), &["dev.acme.gitgraph"]);
assert_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]);
}
}

View File

@ -5,7 +5,7 @@ use std::sync::Arc;
use application::{
InstallPluginFromDirectory, JsonPluginManifestValidator, ListPluginRuntimeContributions,
ListPlugins,
ListPlugins, UninstallPlugin, UninstallPluginInput,
};
use infrastructure::{
ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore,
@ -154,6 +154,79 @@ async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() {
let _ = fs::remove_dir_all(app_data);
}
#[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 = sdk_hello_plugin_path();
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.clone(),
mcp.clone(),
);
let uninstall = UninstallPlugin::new(packages.clone(), 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);
}
#[tokio::test]
async fn installed_plugin_with_missing_main_is_isolated_from_runtime_catalog() {
let app_data = temp_dir("missing-main-app-data");