feat(application,backend,infrastructure,app-tauri,frontend): contributions UI/runtime des plugins limitées aux projets liés — #321 (QA verte)
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.
This commit is contained in:
@ -125,9 +125,14 @@ pub async fn list_slash_commands(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<SlashCommandListDto, ErrorDto> {
|
) -> Result<SlashCommandListDto, ErrorDto> {
|
||||||
let request = request.unwrap_or_default();
|
let request = request.unwrap_or_default();
|
||||||
|
let project_id = request
|
||||||
|
.project_id
|
||||||
|
.as_deref()
|
||||||
|
.map(parse_project_id)
|
||||||
|
.transpose()?;
|
||||||
let catalog = state
|
let catalog = state
|
||||||
.list_plugin_runtime_contributions
|
.list_plugin_runtime_contributions
|
||||||
.execute()
|
.execute(project_id)
|
||||||
.await
|
.await
|
||||||
.map_err(ErrorDto::from)?;
|
.map_err(ErrorDto::from)?;
|
||||||
let plugin_commands =
|
let plugin_commands =
|
||||||
@ -153,6 +158,11 @@ pub async fn execute_slash_command(
|
|||||||
request: ExecuteSlashCommandRequestDto,
|
request: ExecuteSlashCommandRequestDto,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<ExecuteSlashCommandResponseDto, ErrorDto> {
|
) -> Result<ExecuteSlashCommandResponseDto, ErrorDto> {
|
||||||
|
let project_id = request
|
||||||
|
.project_id
|
||||||
|
.as_deref()
|
||||||
|
.map(parse_project_id)
|
||||||
|
.transpose()?;
|
||||||
let session_id = request
|
let session_id = request
|
||||||
.session_id
|
.session_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@ -160,7 +170,7 @@ pub async fn execute_slash_command(
|
|||||||
.transpose()?;
|
.transpose()?;
|
||||||
let catalog = state
|
let catalog = state
|
||||||
.list_plugin_runtime_contributions
|
.list_plugin_runtime_contributions
|
||||||
.execute()
|
.execute(project_id)
|
||||||
.await
|
.await
|
||||||
.map_err(ErrorDto::from)?;
|
.map_err(ErrorDto::from)?;
|
||||||
let plugin_commands =
|
let plugin_commands =
|
||||||
|
|||||||
@ -283,11 +283,15 @@ pub async fn plugin_uninstall(
|
|||||||
/// Lists active runtime contributions for frontend bootstrap.
|
/// Lists active runtime contributions for frontend bootstrap.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn plugin_list_runtime_contributions(
|
pub async fn plugin_list_runtime_contributions(
|
||||||
|
project_id: Option<String>,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<PluginRuntimeContributionCatalogDto, ErrorDto> {
|
) -> Result<PluginRuntimeContributionCatalogDto, ErrorDto> {
|
||||||
|
let project_id = project_id
|
||||||
|
.map(|project_id| parse_project_id(&project_id))
|
||||||
|
.transpose()?;
|
||||||
state
|
state
|
||||||
.list_plugin_runtime_contributions
|
.list_plugin_runtime_contributions
|
||||||
.execute()
|
.execute(project_id)
|
||||||
.await
|
.await
|
||||||
.map(PluginRuntimeContributionCatalogDto::from)
|
.map(PluginRuntimeContributionCatalogDto::from)
|
||||||
.map_err(ErrorDto::from)
|
.map_err(ErrorDto::from)
|
||||||
|
|||||||
@ -15,8 +15,8 @@ use domain::{
|
|||||||
AgentId, BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, ContentHash,
|
AgentId, BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, ContentHash,
|
||||||
DomainEvent, PluginContributionSet, PluginDescriptor, PluginId, PluginInstallSource,
|
DomainEvent, PluginContributionSet, PluginDescriptor, PluginId, PluginInstallSource,
|
||||||
PluginLayoutType, PluginLifecycleState, PluginManifest, PluginMcpServerSpec,
|
PluginLayoutType, PluginLifecycleState, PluginManifest, PluginMcpServerSpec,
|
||||||
PluginRegistryEntry, PluginTrustLevel, Project, ProjectId, ProjectPath, RemovalOutcome,
|
PluginRegistryEntry, PluginTrustLevel, Project, ProjectId, ProjectPath,
|
||||||
StagedPluginPackage, TaskId,
|
ProjectPluginSettingsStore, RemovalOutcome, StagedPluginPackage, TaskId,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -2948,6 +2948,8 @@ pub struct ListPluginRuntimeContributions {
|
|||||||
packages: Arc<dyn PluginPackageStore>,
|
packages: Arc<dyn PluginPackageStore>,
|
||||||
registry: Arc<dyn PluginRegistryStore>,
|
registry: Arc<dyn PluginRegistryStore>,
|
||||||
validator: Arc<dyn PluginManifestValidator>,
|
validator: Arc<dyn PluginManifestValidator>,
|
||||||
|
projects: Arc<dyn ProjectStore>,
|
||||||
|
project_settings: Arc<dyn ProjectPluginSettingsStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ListPluginRuntimeContributions {
|
impl ListPluginRuntimeContributions {
|
||||||
@ -2957,21 +2959,45 @@ impl ListPluginRuntimeContributions {
|
|||||||
packages: Arc<dyn PluginPackageStore>,
|
packages: Arc<dyn PluginPackageStore>,
|
||||||
registry: Arc<dyn PluginRegistryStore>,
|
registry: Arc<dyn PluginRegistryStore>,
|
||||||
validator: Arc<dyn PluginManifestValidator>,
|
validator: Arc<dyn PluginManifestValidator>,
|
||||||
|
projects: Arc<dyn ProjectStore>,
|
||||||
|
project_settings: Arc<dyn ProjectPluginSettingsStore>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
packages,
|
packages,
|
||||||
registry,
|
registry,
|
||||||
validator,
|
validator,
|
||||||
|
projects,
|
||||||
|
project_settings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Executes the use case.
|
/// Executes the use case.
|
||||||
pub async fn execute(&self) -> Result<PluginRuntimeCatalog, AppError> {
|
///
|
||||||
|
/// Without a project scope, no project-visible contribution is exposed.
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
project_id: Option<ProjectId>,
|
||||||
|
) -> Result<PluginRuntimeCatalog, AppError> {
|
||||||
|
let Some(project_id) = project_id else {
|
||||||
|
return Ok(PluginRuntimeCatalog {
|
||||||
|
plugins: Vec::new(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let project = self
|
||||||
|
.projects
|
||||||
|
.load_project(project_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
let settings = self
|
||||||
|
.project_settings
|
||||||
|
.load(&project.root)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
let mut registry = self.registry.load_registry().await.map_err(map_registry)?;
|
let mut registry = self.registry.load_registry().await.map_err(map_registry)?;
|
||||||
let mut plugins = Vec::new();
|
let mut plugins = Vec::new();
|
||||||
let mut invalid_plugins = Vec::new();
|
let mut invalid_plugins = Vec::new();
|
||||||
for entry in registry.plugins.clone() {
|
for entry in registry.plugins.clone() {
|
||||||
if !entry.lifecycle_state.is_runtime_active() {
|
if !entry.lifecycle_state.is_runtime_active() || !settings.is_enabled(&entry.id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match runtime_plugin_from_entry(
|
match runtime_plugin_from_entry(
|
||||||
@ -3797,6 +3823,60 @@ mod tests {
|
|||||||
PluginId::new("dev.acme.gitgraph").unwrap()
|
PluginId::new("dev.acme.gitgraph").unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn runtime_project_id() -> ProjectId {
|
||||||
|
ProjectId::from_uuid(Uuid::from_u128(0x321))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_project() -> Project {
|
||||||
|
Project::new(
|
||||||
|
runtime_project_id(),
|
||||||
|
"Runtime project",
|
||||||
|
ProjectPath::new("/runtime-project").unwrap(),
|
||||||
|
RemoteRef::Local,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_usecase(
|
||||||
|
packages: Arc<dyn PluginPackageStore>,
|
||||||
|
registry: Arc<dyn PluginRegistryStore>,
|
||||||
|
validator: Arc<dyn PluginManifestValidator>,
|
||||||
|
) -> ListPluginRuntimeContributions {
|
||||||
|
runtime_usecase_with_settings(
|
||||||
|
packages,
|
||||||
|
registry,
|
||||||
|
validator,
|
||||||
|
domain::ProjectPluginSettings {
|
||||||
|
plugins: vec![domain::ProjectPluginActivation {
|
||||||
|
plugin_id: plugin_id(),
|
||||||
|
enabled: true,
|
||||||
|
}],
|
||||||
|
..domain::ProjectPluginSettings::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_usecase_with_settings(
|
||||||
|
packages: Arc<dyn PluginPackageStore>,
|
||||||
|
registry: Arc<dyn PluginRegistryStore>,
|
||||||
|
validator: Arc<dyn PluginManifestValidator>,
|
||||||
|
settings: domain::ProjectPluginSettings,
|
||||||
|
) -> ListPluginRuntimeContributions {
|
||||||
|
let project = runtime_project();
|
||||||
|
let projects = Arc::new(FakeProjectStore {
|
||||||
|
projects: Mutex::new(HashMap::from([(project.id, project)])),
|
||||||
|
});
|
||||||
|
let project_settings = Arc::new(FakeRuntimeProjectSettings { settings });
|
||||||
|
ListPluginRuntimeContributions::new(
|
||||||
|
packages,
|
||||||
|
registry,
|
||||||
|
validator,
|
||||||
|
projects,
|
||||||
|
project_settings,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn content_hash(raw: &str) -> ContentHash {
|
fn content_hash(raw: &str) -> ContentHash {
|
||||||
ContentHash::new(raw).unwrap()
|
ContentHash::new(raw).unwrap()
|
||||||
}
|
}
|
||||||
@ -4296,25 +4376,61 @@ mod tests {
|
|||||||
let registry = Arc::new(FakeRegistry {
|
let registry = Arc::new(FakeRegistry {
|
||||||
registry: Mutex::new(registry_with(state)),
|
registry: Mutex::new(registry_with(state)),
|
||||||
});
|
});
|
||||||
let usecase =
|
let usecase = runtime_usecase(packages, registry, Arc::new(validator()));
|
||||||
ListPluginRuntimeContributions::new(packages, registry, Arc::new(validator()));
|
|
||||||
|
|
||||||
let catalog = usecase.execute().await.unwrap();
|
let catalog = usecase.execute(Some(runtime_project_id())).await.unwrap();
|
||||||
|
|
||||||
assert!(catalog.plugins.is_empty(), "{state:?} must not be active");
|
assert!(catalog.plugins.is_empty(), "{state:?} must not be active");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn runtime_catalog_without_project_scope_exposes_no_contributions() {
|
||||||
|
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
|
||||||
|
let registry = Arc::new(FakeRegistry {
|
||||||
|
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
||||||
|
});
|
||||||
|
let usecase = runtime_usecase(packages, registry, Arc::new(validator()));
|
||||||
|
|
||||||
|
let catalog = usecase.execute(None).await.unwrap();
|
||||||
|
|
||||||
|
assert!(catalog.plugins.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn runtime_catalog_requires_explicit_project_activation_not_agent_assignment() {
|
||||||
|
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
|
||||||
|
let registry = Arc::new(FakeRegistry {
|
||||||
|
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
||||||
|
});
|
||||||
|
let settings = domain::ProjectPluginSettings {
|
||||||
|
agents: vec![domain::AgentPluginAssignments {
|
||||||
|
agent_id: AgentId::from_uuid(Uuid::from_u128(7)),
|
||||||
|
skills: vec![domain::PluginSkillRef {
|
||||||
|
plugin_id: plugin_id(),
|
||||||
|
skill_id: domain::PluginSkillId::new("build-debug").unwrap(),
|
||||||
|
}],
|
||||||
|
tools: Vec::new(),
|
||||||
|
}],
|
||||||
|
..domain::ProjectPluginSettings::default()
|
||||||
|
};
|
||||||
|
let usecase =
|
||||||
|
runtime_usecase_with_settings(packages, registry, Arc::new(validator()), settings);
|
||||||
|
|
||||||
|
let catalog = usecase.execute(Some(runtime_project_id())).await.unwrap();
|
||||||
|
|
||||||
|
assert!(catalog.plugins.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn runtime_catalog_carries_manifest_capabilities() {
|
async fn runtime_catalog_carries_manifest_capabilities() {
|
||||||
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
|
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
|
||||||
let registry = Arc::new(FakeRegistry {
|
let registry = Arc::new(FakeRegistry {
|
||||||
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
||||||
});
|
});
|
||||||
let usecase =
|
let usecase = runtime_usecase(packages, registry, Arc::new(validator()));
|
||||||
ListPluginRuntimeContributions::new(packages, registry, Arc::new(validator()));
|
|
||||||
|
|
||||||
let catalog = usecase.execute().await.unwrap();
|
let catalog = usecase.execute(Some(runtime_project_id())).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(catalog.plugins.len(), 1);
|
assert_eq!(catalog.plugins.len(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@ -4338,10 +4454,9 @@ mod tests {
|
|||||||
let registry = Arc::new(FakeRegistry {
|
let registry = Arc::new(FakeRegistry {
|
||||||
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
||||||
});
|
});
|
||||||
let usecase =
|
let usecase = runtime_usecase(packages, registry.clone(), Arc::new(validator()));
|
||||||
ListPluginRuntimeContributions::new(packages, registry.clone(), Arc::new(validator()));
|
|
||||||
|
|
||||||
let catalog = usecase.execute().await.unwrap();
|
let catalog = usecase.execute(Some(runtime_project_id())).await.unwrap();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
catalog.plugins.is_empty(),
|
catalog.plugins.is_empty(),
|
||||||
@ -4673,11 +4788,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
)));
|
)));
|
||||||
|
|
||||||
let runtime =
|
let runtime = runtime_usecase(packages, registry, Arc::new(validator()))
|
||||||
ListPluginRuntimeContributions::new(packages, registry, Arc::new(validator()))
|
.execute(Some(runtime_project_id()))
|
||||||
.execute()
|
.await
|
||||||
.await
|
.unwrap();
|
||||||
.unwrap();
|
|
||||||
assert!(runtime.plugins.is_empty());
|
assert!(runtime.plugins.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4838,14 +4952,10 @@ mod tests {
|
|||||||
assert!(registry.load_registry().await.unwrap().plugins.is_empty());
|
assert!(registry.load_registry().await.unwrap().plugins.is_empty());
|
||||||
|
|
||||||
let result = install.execute("/source/plugin".to_owned()).await.unwrap();
|
let result = install.execute("/source/plugin".to_owned()).await.unwrap();
|
||||||
let runtime = ListPluginRuntimeContributions::new(
|
let runtime = runtime_usecase(packages.clone(), registry.clone(), Arc::new(validator()))
|
||||||
packages.clone(),
|
.execute(Some(runtime_project_id()))
|
||||||
registry.clone(),
|
.await
|
||||||
Arc::new(validator()),
|
.unwrap();
|
||||||
)
|
|
||||||
.execute()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(result.plugin.lifecycle_state, PluginLifecycleState::Enabled);
|
assert_eq!(result.plugin.lifecycle_state, PluginLifecycleState::Enabled);
|
||||||
assert_eq!(runtime.plugins.len(), 1);
|
assert_eq!(runtime.plugins.len(), 1);
|
||||||
@ -4957,6 +5067,28 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct FakeRuntimeProjectSettings {
|
||||||
|
settings: domain::ProjectPluginSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ProjectPluginSettingsStore for FakeRuntimeProjectSettings {
|
||||||
|
async fn load(
|
||||||
|
&self,
|
||||||
|
_project_root: &ProjectPath,
|
||||||
|
) -> Result<domain::ProjectPluginSettings, StoreError> {
|
||||||
|
Ok(self.settings.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(
|
||||||
|
&self,
|
||||||
|
_project_root: &ProjectPath,
|
||||||
|
_settings: &domain::ProjectPluginSettings,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct FakeWorkspaceFs {
|
struct FakeWorkspaceFs {
|
||||||
files: Mutex<HashMap<String, Vec<u8>>>,
|
files: Mutex<HashMap<String, Vec<u8>>>,
|
||||||
|
|||||||
@ -917,6 +917,9 @@ impl From<HealthReport> for HealthResponseDto {
|
|||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ListSlashCommandsRequestDto {
|
pub struct ListSlashCommandsRequestDto {
|
||||||
|
/// Project whose explicitly enabled plugin contributions may be listed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub project_id: Option<String>,
|
||||||
/// Optional typed prefix, with or without the leading `/`.
|
/// Optional typed prefix, with or without the leading `/`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub prefix: Option<String>,
|
pub prefix: Option<String>,
|
||||||
@ -945,6 +948,9 @@ impl From<ListSlashCommandsOutput> for SlashCommandListDto {
|
|||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ExecuteSlashCommandRequestDto {
|
pub struct ExecuteSlashCommandRequestDto {
|
||||||
|
/// Project whose explicitly enabled plugin contribution may be executed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub project_id: Option<String>,
|
||||||
/// Command name, with or without the leading `/`.
|
/// Command name, with or without the leading `/`.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Current structured chat session id, required by `/clean` and future
|
/// Current structured chat session id, required by `/clean` and future
|
||||||
@ -6251,6 +6257,28 @@ mod tests {
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slash_command_requests_accept_optional_project_scope() {
|
||||||
|
let project_id = Uuid::from_u128(321).to_string();
|
||||||
|
let list: ListSlashCommandsRequestDto = serde_json::from_value(json!({
|
||||||
|
"projectId": project_id,
|
||||||
|
"prefix": "/graph"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(list.project_id.as_deref(), Some(project_id.as_str()));
|
||||||
|
|
||||||
|
let execute: ExecuteSlashCommandRequestDto = serde_json::from_value(json!({
|
||||||
|
"projectId": project_id,
|
||||||
|
"name": "/graph",
|
||||||
|
"arguments": []
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(execute.project_id.as_deref(), Some(project_id.as_str()));
|
||||||
|
|
||||||
|
let unscoped: ListSlashCommandsRequestDto = serde_json::from_value(json!({})).unwrap();
|
||||||
|
assert!(unscoped.project_id.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -3831,6 +3831,8 @@ impl BackendCore {
|
|||||||
Arc::clone(&plugin_package_store),
|
Arc::clone(&plugin_package_store),
|
||||||
Arc::clone(&plugin_registry_store),
|
Arc::clone(&plugin_registry_store),
|
||||||
Arc::clone(&plugin_manifest_validator),
|
Arc::clone(&plugin_manifest_validator),
|
||||||
|
Arc::clone(&store_port),
|
||||||
|
Arc::clone(&project_plugin_settings_store),
|
||||||
));
|
));
|
||||||
let reconcile_plugin_mcp_servers = Arc::new(ReconcilePluginMcpServers::new(
|
let reconcile_plugin_mcp_servers = Arc::new(ReconcilePluginMcpServers::new(
|
||||||
Arc::clone(&plugin_package_store),
|
Arc::clone(&plugin_package_store),
|
||||||
|
|||||||
@ -7,6 +7,12 @@ use application::{
|
|||||||
InstallPluginFromDirectory, JsonPluginManifestValidator, ListPluginRuntimeContributions,
|
InstallPluginFromDirectory, JsonPluginManifestValidator, ListPluginRuntimeContributions,
|
||||||
ListPlugins, UninstallPlugin, UninstallPluginInput,
|
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::{
|
use infrastructure::{
|
||||||
ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore,
|
ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore,
|
||||||
TokioBroadcastEventBus,
|
TokioBroadcastEventBus,
|
||||||
@ -14,6 +20,88 @@ use infrastructure::{
|
|||||||
|
|
||||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
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 {
|
fn temp_dir(label: &str) -> PathBuf {
|
||||||
let n = TEMP_COUNTER.fetch_add(1, Ordering::SeqCst);
|
let n = TEMP_COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||||
let path = std::env::temp_dir().join(format!(
|
let path = std::env::temp_dir().join(format!(
|
||||||
@ -124,10 +212,13 @@ async fn installs_reference_fixture_and_loads_runtime_catalog() {
|
|||||||
domain::PluginLifecycleState::Enabled
|
domain::PluginLifecycleState::Enabled
|
||||||
);
|
);
|
||||||
|
|
||||||
let catalog = ListPluginRuntimeContributions::new(packages, registry, validator)
|
let (runtime, project_id) = runtime_catalog_usecase(
|
||||||
.execute()
|
packages,
|
||||||
.await
|
registry,
|
||||||
.unwrap();
|
validator,
|
||||||
|
"dev.idea.fixtures.reference-minimal",
|
||||||
|
);
|
||||||
|
let catalog = runtime.execute(Some(project_id)).await.unwrap();
|
||||||
assert_eq!(catalog.plugins.len(), 1);
|
assert_eq!(catalog.plugins.len(), 1);
|
||||||
let plugin = &catalog.plugins[0];
|
let plugin = &catalog.plugins[0];
|
||||||
assert_eq!(plugin.id, "dev.idea.fixtures.reference-minimal");
|
assert_eq!(plugin.id, "dev.idea.fixtures.reference-minimal");
|
||||||
@ -179,10 +270,9 @@ async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() {
|
|||||||
.join("plugins/installed/com.example.hello-plugin/dist/index.js")
|
.join("plugins/installed/com.example.hello-plugin/dist/index.js")
|
||||||
.is_file());
|
.is_file());
|
||||||
|
|
||||||
let catalog = ListPluginRuntimeContributions::new(packages, registry, validator)
|
let (runtime, project_id) =
|
||||||
.execute()
|
runtime_catalog_usecase(packages, registry, validator, "com.example.hello-plugin");
|
||||||
.await
|
let catalog = runtime.execute(Some(project_id)).await.unwrap();
|
||||||
.unwrap();
|
|
||||||
assert_eq!(catalog.plugins.len(), 1);
|
assert_eq!(catalog.plugins.len(), 1);
|
||||||
let plugin = &catalog.plugins[0];
|
let plugin = &catalog.plugins[0];
|
||||||
assert_eq!(plugin.id, "com.example.hello-plugin");
|
assert_eq!(plugin.id, "com.example.hello-plugin");
|
||||||
@ -261,16 +351,18 @@ async fn uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue() {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.is_empty()
|
.is_empty()
|
||||||
);
|
);
|
||||||
assert!(ListPluginRuntimeContributions::new(
|
let (runtime, project_id) = runtime_catalog_usecase(
|
||||||
packages.clone(),
|
packages.clone(),
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
validator.clone()
|
validator.clone(),
|
||||||
)
|
"com.example.hello-plugin",
|
||||||
.execute()
|
);
|
||||||
.await
|
assert!(runtime
|
||||||
.unwrap()
|
.execute(Some(project_id))
|
||||||
.plugins
|
.await
|
||||||
.is_empty());
|
.unwrap()
|
||||||
|
.plugins
|
||||||
|
.is_empty());
|
||||||
|
|
||||||
let reinstall = install
|
let reinstall = install
|
||||||
.execute(hello_plugin.to_string_lossy().into_owned())
|
.execute(hello_plugin.to_string_lossy().into_owned())
|
||||||
@ -281,10 +373,9 @@ async fn uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue() {
|
|||||||
reinstall.plugin.lifecycle_state,
|
reinstall.plugin.lifecycle_state,
|
||||||
domain::PluginLifecycleState::Enabled
|
domain::PluginLifecycleState::Enabled
|
||||||
);
|
);
|
||||||
let catalog = ListPluginRuntimeContributions::new(packages, registry, validator)
|
let (runtime, project_id) =
|
||||||
.execute()
|
runtime_catalog_usecase(packages, registry, validator, "com.example.hello-plugin");
|
||||||
.await
|
let catalog = runtime.execute(Some(project_id)).await.unwrap();
|
||||||
.unwrap();
|
|
||||||
assert_eq!(catalog.plugins.len(), 1);
|
assert_eq!(catalog.plugins.len(), 1);
|
||||||
assert_eq!(catalog.plugins[0].id, "com.example.hello-plugin");
|
assert_eq!(catalog.plugins[0].id, "com.example.hello-plugin");
|
||||||
assert!(catalog.plugins[0].bundle_url.ends_with("/dist/index.js"));
|
assert!(catalog.plugins[0].bundle_url.ends_with("/dist/index.js"));
|
||||||
@ -349,14 +440,14 @@ async fn uninstall_multifile_plugin_removes_package_registry_and_runtime_residue
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.is_empty()
|
.is_empty()
|
||||||
);
|
);
|
||||||
assert!(
|
let (runtime, project_id) =
|
||||||
ListPluginRuntimeContributions::new(packages, registry, validator)
|
runtime_catalog_usecase(packages, registry, validator, "dev.acme.multifile");
|
||||||
.execute()
|
assert!(runtime
|
||||||
.await
|
.execute(Some(project_id))
|
||||||
.unwrap()
|
.await
|
||||||
.plugins
|
.unwrap()
|
||||||
.is_empty()
|
.plugins
|
||||||
);
|
.is_empty());
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(app_data);
|
let _ = fs::remove_dir_all(app_data);
|
||||||
let _ = fs::remove_dir_all(source);
|
let _ = fs::remove_dir_all(source);
|
||||||
@ -409,10 +500,13 @@ async fn installed_plugin_with_missing_main_is_isolated_from_runtime_catalog() {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.contains("not servable"));
|
.contains("not servable"));
|
||||||
|
|
||||||
let catalog = ListPluginRuntimeContributions::new(packages, registry.clone(), validator)
|
let (runtime, project_id) = runtime_catalog_usecase(
|
||||||
.execute()
|
packages,
|
||||||
.await
|
registry.clone(),
|
||||||
.unwrap();
|
validator,
|
||||||
|
"dev.idea.fixtures.missing-main",
|
||||||
|
);
|
||||||
|
let catalog = runtime.execute(Some(project_id)).await.unwrap();
|
||||||
assert!(catalog.plugins.is_empty());
|
assert!(catalog.plugins.is_empty());
|
||||||
let admin = ListPlugins::new(
|
let admin = ListPlugins::new(
|
||||||
Arc::new(FsPluginPackageStore::new(&app_data)),
|
Arc::new(FsPluginPackageStore::new(&app_data)),
|
||||||
|
|||||||
@ -191,7 +191,7 @@ export class WebPluginGateway implements PluginGateway {
|
|||||||
async uninstall(_pluginId: string): Promise<PluginUninstallResult> {
|
async uninstall(_pluginId: string): Promise<PluginUninstallResult> {
|
||||||
return unsupportedOnWeb("Plugin management");
|
return unsupportedOnWeb("Plugin management");
|
||||||
}
|
}
|
||||||
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
|
async listRuntimeContributions(_projectId?: string): Promise<PluginRuntimeContributionCatalog> {
|
||||||
// No plugin bundles are ever loaded on the web client — an empty catalog
|
// No plugin bundles are ever loaded on the web client — an empty catalog
|
||||||
// lets the bootstrap loader run unconditionally without a transport check.
|
// lets the bootstrap loader run unconditionally without a transport check.
|
||||||
return { plugins: [] };
|
return { plugins: [] };
|
||||||
|
|||||||
@ -4424,7 +4424,7 @@ export class MockPluginGateway implements PluginGateway {
|
|||||||
return { pluginId, removalOutcome: "removed", restartRequired: true };
|
return { pluginId, removalOutcome: "removed", restartRequired: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
|
async listRuntimeContributions(_projectId?: string): Promise<PluginRuntimeContributionCatalog> {
|
||||||
// Only enabled, non-pending-uninstall plugins are loadable at bootstrap
|
// Only enabled, non-pending-uninstall plugins are loadable at bootstrap
|
||||||
// (carnet §1.3) — the mock has no bundle to actually import, so this
|
// (carnet §1.3) — the mock has no bundle to actually import, so this
|
||||||
// starts empty; tests seed `plugins` on `PluginRuntimeContributionCatalog`
|
// starts empty; tests seed `plugins` on `PluginRuntimeContributionCatalog`
|
||||||
|
|||||||
@ -82,4 +82,14 @@ describe("TauriPluginGateway invoke payloads", () => {
|
|||||||
await gateway.unassignAgentTool(toolInput);
|
await gateway.unassignAgentTool(toolInput);
|
||||||
expect(invoke).toHaveBeenLastCalledWith("plugin_unassign_agent_tool", { input: toolInput });
|
expect(invoke).toHaveBeenLastCalledWith("plugin_unassign_agent_tool", { input: toolInput });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("scopes runtime contributions to the focused project", async () => {
|
||||||
|
const gateway = new TauriPluginGateway();
|
||||||
|
|
||||||
|
await gateway.listRuntimeContributions("project-1");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("plugin_list_runtime_contributions", {
|
||||||
|
projectId: "project-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -70,8 +70,10 @@ export class TauriPluginGateway implements PluginGateway {
|
|||||||
return invoke<PluginUninstallResult>("plugin_uninstall", { pluginId });
|
return invoke<PluginUninstallResult>("plugin_uninstall", { pluginId });
|
||||||
}
|
}
|
||||||
|
|
||||||
listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
|
listRuntimeContributions(projectId?: string): Promise<PluginRuntimeContributionCatalog> {
|
||||||
return invoke<PluginRuntimeContributionCatalog>("plugin_list_runtime_contributions");
|
return invoke<PluginRuntimeContributionCatalog>("plugin_list_runtime_contributions", {
|
||||||
|
projectId: projectId ?? null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
listProjectSettings(projectId: string): Promise<ProjectPluginSettingsView> {
|
listProjectSettings(projectId: string): Promise<ProjectPluginSettingsView> {
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* F4 — `PluginLayoutCellView` (ticket #43, carnet §10 F4 acceptance criteria:
|
* F4 — `PluginLayoutCellView` (ticket #43, carnet §10 F4 acceptance criteria:
|
||||||
* "rendu composant mock, state roundtrip, fallback sans mutation du layout").
|
* "rendu composant mock, state roundtrip, isolation des surfaces indisponibles").
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
import type { CustomPluginLayoutCell } from "@/domain";
|
import type { CustomPluginLayoutCell } from "@/domain";
|
||||||
import type { Gateways } from "@/ports";
|
import type { Gateways } from "@/ports";
|
||||||
@ -68,7 +68,11 @@ function stubPlugin(component = MockLayoutComponent): LoadedPlugin {
|
|||||||
|
|
||||||
function renderCell(
|
function renderCell(
|
||||||
registry: PluginRuntimeRegistry,
|
registry: PluginRuntimeRegistry,
|
||||||
props: Partial<{ cell: CustomPluginLayoutCell; onStateChange: (s: unknown) => void }> = {},
|
props: Partial<{
|
||||||
|
cell: CustomPluginLayoutCell;
|
||||||
|
onStateChange: (s: unknown) => void;
|
||||||
|
onChooseAnotherLayout: () => void;
|
||||||
|
}> = {},
|
||||||
) {
|
) {
|
||||||
const gateways: Gateways = createMockGateways();
|
const gateways: Gateways = createMockGateways();
|
||||||
return render(
|
return render(
|
||||||
@ -79,7 +83,7 @@ function renderCell(
|
|||||||
cell={props.cell ?? cell()}
|
cell={props.cell ?? cell()}
|
||||||
onStateChange={props.onStateChange ?? (() => {})}
|
onStateChange={props.onStateChange ?? (() => {})}
|
||||||
onOpenPlugins={() => {}}
|
onOpenPlugins={() => {}}
|
||||||
onChooseAnotherLayout={() => {}}
|
onChooseAnotherLayout={props.onChooseAnotherLayout ?? (() => {})}
|
||||||
/>
|
/>
|
||||||
</PluginRuntimeProvider>
|
</PluginRuntimeProvider>
|
||||||
</DIProvider>,
|
</DIProvider>,
|
||||||
@ -111,13 +115,14 @@ describe("PluginLayoutCellView", () => {
|
|||||||
expect(lastState).toEqual({ commits: 4 });
|
expect(lastState).toEqual({ commits: 4 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the non-destructive fallback when the provider is not loaded, without mutating the cell", () => {
|
it("ignores an unavailable layout reference without rendering a disabled surface", async () => {
|
||||||
const registry = new PluginRuntimeRegistry(); // empty — provider not loaded
|
const registry = new PluginRuntimeRegistry(); // empty — provider not loaded
|
||||||
const testCell = cell();
|
const testCell = cell();
|
||||||
renderCell(registry, { cell: testCell });
|
const onChooseAnotherLayout = vi.fn();
|
||||||
|
renderCell(registry, { cell: testCell, onChooseAnotherLayout });
|
||||||
|
|
||||||
expect(screen.getByText("Layout indisponible")).toBeTruthy();
|
expect(screen.queryByText("Layout indisponible")).toBeNull();
|
||||||
// The cell object itself is untouched — the domain identity/state survive.
|
await waitFor(() => expect(onChooseAnotherLayout).toHaveBeenCalledOnce());
|
||||||
expect(testCell).toEqual(cell());
|
expect(testCell).toEqual(cell());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
* calls from inside a plugin component).
|
* calls from inside a plugin component).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
import { Component, useEffect, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
|
||||||
import type { CustomPluginLayoutCell, PluginAdmin } from "@/domain";
|
import type { CustomPluginLayoutCell, PluginAdmin } from "@/domain";
|
||||||
import { usePluginRuntime } from "./PluginRuntimeProvider";
|
import { usePluginRuntime } from "./PluginRuntimeProvider";
|
||||||
@ -66,23 +66,21 @@ export function PluginLayoutCellView({
|
|||||||
onOpenPlugins,
|
onOpenPlugins,
|
||||||
onChooseAnotherLayout,
|
onChooseAnotherLayout,
|
||||||
}: PluginLayoutCellViewProps) {
|
}: PluginLayoutCellViewProps) {
|
||||||
const { registry } = usePluginRuntime();
|
const { registry, loading } = usePluginRuntime();
|
||||||
const availability = resolvePluginLayoutAvailability(registry, cell, installedPlugins);
|
const availability = resolvePluginLayoutAvailability(registry, cell, installedPlugins);
|
||||||
const providerDisplayName =
|
const providerDisplayName =
|
||||||
registry.get(cell.pluginId)?.displayName ??
|
registry.get(cell.pluginId)?.displayName ??
|
||||||
installedPlugins?.find((p) => p.id === cell.pluginId)?.displayName;
|
installedPlugins?.find((p) => p.id === cell.pluginId)?.displayName;
|
||||||
|
|
||||||
if (availability !== "available") {
|
useEffect(() => {
|
||||||
return (
|
if (!loading && availability !== "available") onChooseAnotherLayout();
|
||||||
<PluginLayoutFallback
|
}, [availability, loading, onChooseAnotherLayout]);
|
||||||
cell={cell}
|
|
||||||
availability={availability}
|
// A layout contribution absent from the current project's runtime catalog
|
||||||
providerDisplayName={providerDisplayName}
|
// is not a disabled surface: its stale reference is ignored/closed by the
|
||||||
onOpenPlugins={onOpenPlugins}
|
// owner callback. While the project-scoped catalog is changing, render
|
||||||
onChooseAnotherLayout={onChooseAnotherLayout}
|
// nothing so contributions from the previous project cannot leak through.
|
||||||
/>
|
if (loading || availability !== "available") return null;
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const Component = registry.layoutComponent(cell.pluginId, cell.layoutType)!;
|
const Component = registry.layoutComponent(cell.pluginId, cell.layoutType)!;
|
||||||
const fallback = (
|
const fallback = (
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { createMockGateways, MockPluginGateway, MockSystemGateway } from "@/adapters/mock";
|
import {
|
||||||
|
createMockGateways,
|
||||||
|
MockFocusedProjectGateway,
|
||||||
|
MockPluginGateway,
|
||||||
|
MockSystemGateway,
|
||||||
|
} from "@/adapters/mock";
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import type {
|
import type {
|
||||||
CustomPluginLayoutCell,
|
CustomPluginLayoutCell,
|
||||||
@ -33,10 +38,26 @@ function runtimePlugin(overrides: Partial<PluginRuntimePlugin> = {}): PluginRunt
|
|||||||
class MutableRuntimeCatalogGateway extends MockPluginGateway {
|
class MutableRuntimeCatalogGateway extends MockPluginGateway {
|
||||||
calls = 0;
|
calls = 0;
|
||||||
catalog: PluginRuntimeContributionCatalog = { plugins: [] };
|
catalog: PluginRuntimeContributionCatalog = { plugins: [] };
|
||||||
|
catalogs = new Map<string, PluginRuntimeContributionCatalog>();
|
||||||
|
projectIds: Array<string | undefined> = [];
|
||||||
|
|
||||||
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
|
async listRuntimeContributions(projectId?: string): Promise<PluginRuntimeContributionCatalog> {
|
||||||
this.calls += 1;
|
this.calls += 1;
|
||||||
return this.catalog;
|
this.projectIds.push(projectId);
|
||||||
|
return (projectId ? this.catalogs.get(projectId) : undefined) ?? this.catalog;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeferredRuntimeCatalogGateway extends MockPluginGateway {
|
||||||
|
requests: Array<{
|
||||||
|
projectId: string | undefined;
|
||||||
|
resolve: (catalog: PluginRuntimeContributionCatalog) => void;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
async listRuntimeContributions(projectId?: string): Promise<PluginRuntimeContributionCatalog> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.requests.push({ projectId, resolve });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,7 +82,9 @@ describe("PluginRuntimeProvider soft refresh (#279)", () => {
|
|||||||
it("reloads contributions after install and reload events without remounting its workspace", async () => {
|
it("reloads contributions after install and reload events without remounting its workspace", async () => {
|
||||||
const plugin = new MutableRuntimeCatalogGateway();
|
const plugin = new MutableRuntimeCatalogGateway();
|
||||||
const system = new MockSystemGateway();
|
const system = new MockSystemGateway();
|
||||||
const gateways = { ...createMockGateways(), plugin, system };
|
const focusedProject = new MockFocusedProjectGateway();
|
||||||
|
await focusedProject.setFocusedProject({ id: "project-1", name: "Alpha", root: "/alpha" });
|
||||||
|
const gateways = { ...createMockGateways(), plugin, system, focusedProject };
|
||||||
let mounts = 0;
|
let mounts = 0;
|
||||||
const onMount = () => {
|
const onMount = () => {
|
||||||
mounts += 1;
|
mounts += 1;
|
||||||
@ -108,12 +131,16 @@ describe("PluginRuntimeProvider soft refresh (#279)", () => {
|
|||||||
expect(screen.getByTestId("workspace-state").textContent).toBe("preserved");
|
expect(screen.getByTestId("workspace-state").textContent).toBe("preserved");
|
||||||
expect(mounts).toBe(1);
|
expect(mounts).toBe(1);
|
||||||
expect(plugin.calls).toBe(3);
|
expect(plugin.calls).toBe(3);
|
||||||
|
expect(plugin.projectIds).toEqual(["project-1", "project-1", "project-1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a custom plugin layout cell mounted as a fallback when its contribution disappears", async () => {
|
it("ignores a custom plugin layout ref when its contribution disappears", async () => {
|
||||||
const plugin = new MutableRuntimeCatalogGateway();
|
const plugin = new MutableRuntimeCatalogGateway();
|
||||||
const system = new MockSystemGateway();
|
const system = new MockSystemGateway();
|
||||||
const gateways = { ...createMockGateways(), plugin, system };
|
const focusedProject = new MockFocusedProjectGateway();
|
||||||
|
await focusedProject.setFocusedProject({ id: "project-1", name: "Alpha", root: "/alpha" });
|
||||||
|
const gateways = { ...createMockGateways(), plugin, system, focusedProject };
|
||||||
|
const onChooseAnotherLayout = vi.fn();
|
||||||
const testCell: CustomPluginLayoutCell = {
|
const testCell: CustomPluginLayoutCell = {
|
||||||
id: "plugin-cell-1",
|
id: "plugin-cell-1",
|
||||||
pluginId: "dev.acme.refreshable",
|
pluginId: "dev.acme.refreshable",
|
||||||
@ -155,7 +182,7 @@ describe("PluginRuntimeProvider soft refresh (#279)", () => {
|
|||||||
cell={testCell}
|
cell={testCell}
|
||||||
onStateChange={() => {}}
|
onStateChange={() => {}}
|
||||||
onOpenPlugins={() => {}}
|
onOpenPlugins={() => {}}
|
||||||
onChooseAnotherLayout={() => {}}
|
onChooseAnotherLayout={onChooseAnotherLayout}
|
||||||
/>
|
/>
|
||||||
</PluginRuntimeProvider>
|
</PluginRuntimeProvider>
|
||||||
</DIProvider>,
|
</DIProvider>,
|
||||||
@ -171,7 +198,8 @@ describe("PluginRuntimeProvider soft refresh (#279)", () => {
|
|||||||
changed: true,
|
changed: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await screen.findByText("Layout indisponible")).toBeTruthy();
|
await waitFor(() => expect(onChooseAnotherLayout).toHaveBeenCalledOnce());
|
||||||
|
expect(screen.queryByText("Layout indisponible")).toBeNull();
|
||||||
expect(testCell).toEqual({
|
expect(testCell).toEqual({
|
||||||
id: "plugin-cell-1",
|
id: "plugin-cell-1",
|
||||||
pluginId: "dev.acme.refreshable",
|
pluginId: "dev.acme.refreshable",
|
||||||
@ -180,4 +208,71 @@ describe("PluginRuntimeProvider soft refresh (#279)", () => {
|
|||||||
});
|
});
|
||||||
await waitFor(() => expect(plugin.calls).toBe(2));
|
await waitFor(() => expect(plugin.calls).toBe(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("replaces the runtime registry when the focused project changes", async () => {
|
||||||
|
const plugin = new MutableRuntimeCatalogGateway();
|
||||||
|
const focusedProject = new MockFocusedProjectGateway();
|
||||||
|
await focusedProject.setFocusedProject({ id: "project-a", name: "Alpha", root: "/alpha" });
|
||||||
|
plugin.catalogs.set("project-a", {
|
||||||
|
plugins: [runtimePlugin({ displayName: "Plugin Alpha" })],
|
||||||
|
});
|
||||||
|
plugin.catalogs.set("project-b", { plugins: [] });
|
||||||
|
const gateways = { ...createMockGateways(), plugin, focusedProject };
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<PluginRuntimeProvider>
|
||||||
|
<RuntimeProbe onMount={() => {}} />
|
||||||
|
</PluginRuntimeProvider>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Plugin Alpha")).toBeTruthy();
|
||||||
|
await focusedProject.setFocusedProject({ id: "project-b", name: "Beta", root: "/beta" });
|
||||||
|
|
||||||
|
expect(screen.queryByText("Plugin Alpha")).toBeNull();
|
||||||
|
expect(await screen.findByText("none")).toBeTruthy();
|
||||||
|
expect(plugin.projectIds).toEqual(["project-a", "project-b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a stale runtime catalog response after the focused project changes", async () => {
|
||||||
|
const plugin = new DeferredRuntimeCatalogGateway();
|
||||||
|
const focusedProject = new MockFocusedProjectGateway();
|
||||||
|
await focusedProject.setFocusedProject({ id: "project-a", name: "Alpha", root: "/alpha" });
|
||||||
|
const gateways = { ...createMockGateways(), plugin, focusedProject };
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<PluginRuntimeProvider>
|
||||||
|
<RuntimeProbe onMount={() => {}} />
|
||||||
|
</PluginRuntimeProvider>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(plugin.requests).toHaveLength(1));
|
||||||
|
expect(plugin.requests[0].projectId).toBe("project-a");
|
||||||
|
|
||||||
|
await focusedProject.setFocusedProject({ id: "project-b", name: "Beta", root: "/beta" });
|
||||||
|
|
||||||
|
await waitFor(() => expect(plugin.requests).toHaveLength(2));
|
||||||
|
expect(plugin.requests[1].projectId).toBe("project-b");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
plugin.requests[1].resolve({
|
||||||
|
plugins: [runtimePlugin({ displayName: "Plugin Beta" })],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("Plugin Beta")).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
plugin.requests[0].resolve({
|
||||||
|
plugins: [runtimePlugin({ displayName: "Plugin Alpha" })],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("runtime-plugin").textContent).toBe("Plugin Beta"),
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("Plugin Alpha")).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* `PluginRuntimeProvider` — owns the refreshable plugin runtime for one window.
|
* `PluginRuntimeProvider` — owns the refreshable plugin runtime for one window.
|
||||||
* It fetches the runtime catalog (already filtered
|
* It fetches the runtime catalog (already filtered to plugins linked to the
|
||||||
* to `enabled && !pendingUninstall` by the backend) and loads every bundle via
|
* focused project and runtime-active by the backend) and loads every bundle via
|
||||||
* {@link loadPlugins}, then exposes the resulting {@link PluginRuntimeRegistry}
|
* {@link loadPlugins}, then exposes the resulting {@link PluginRuntimeRegistry}
|
||||||
* to the rest of the tree (menus in F3, layouts in F4).
|
* to the rest of the tree (menus in F3, layouts in F4).
|
||||||
*
|
*
|
||||||
@ -19,7 +19,7 @@ import {
|
|||||||
type PluginLoadPending,
|
type PluginLoadPending,
|
||||||
} from "@/plugins/runtime";
|
} from "@/plugins/runtime";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import type { PluginRuntimePlugin, Unsubscribe } from "@/domain";
|
import type { Unsubscribe } from "@/domain";
|
||||||
|
|
||||||
export interface PluginRuntimeContextValue {
|
export interface PluginRuntimeContextValue {
|
||||||
registry: PluginRuntimeRegistry;
|
registry: PluginRuntimeRegistry;
|
||||||
@ -89,10 +89,11 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
|||||||
pluginConfig: gateways.pluginConfig,
|
pluginConfig: gateways.pluginConfig,
|
||||||
pluginStorage: gateways.pluginStorage,
|
pluginStorage: gateways.pluginStorage,
|
||||||
};
|
};
|
||||||
let pendingProjectPlugins: PluginRuntimePlugin[] = [];
|
|
||||||
let unsubscribeFocus: Unsubscribe | undefined;
|
let unsubscribeFocus: Unsubscribe | undefined;
|
||||||
let unsubscribeDomainEvents: Unsubscribe | undefined;
|
let unsubscribeDomainEvents: Unsubscribe | undefined;
|
||||||
let activeRegistry = value.registry;
|
let activeRegistry = value.registry;
|
||||||
|
let currentProjectId: string | undefined;
|
||||||
|
let focusRevision = 0;
|
||||||
|
|
||||||
async function disposeRegistry(registry: PluginRuntimeRegistry): Promise<void> {
|
async function disposeRegistry(registry: PluginRuntimeRegistry): Promise<void> {
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
@ -100,18 +101,32 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearRuntimeForProjectChange(): void {
|
||||||
|
const previousRegistry = activeRegistry;
|
||||||
|
activeRegistry = new PluginRuntimeRegistry();
|
||||||
|
setValue({
|
||||||
|
registry: activeRegistry,
|
||||||
|
failures: [],
|
||||||
|
pending: [],
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
if (previousRegistry !== activeRegistry) void disposeRegistry(previousRegistry);
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshRuntime(initial: boolean): Promise<void> {
|
async function refreshRuntime(initial: boolean): Promise<void> {
|
||||||
const revision = ++refreshRevision;
|
const revision = ++refreshRevision;
|
||||||
|
const projectId = currentProjectId;
|
||||||
try {
|
try {
|
||||||
const catalog = await gateways.plugin.listRuntimeContributions();
|
const catalog = await gateways.plugin.listRuntimeContributions(projectId);
|
||||||
const result = await loadPlugins(catalog.plugins, pluginGateways);
|
const result = await loadPlugins(catalog.plugins, pluginGateways);
|
||||||
if (cancelled || revision !== refreshRevision) {
|
if (
|
||||||
|
cancelled ||
|
||||||
|
revision !== refreshRevision ||
|
||||||
|
projectId !== currentProjectId
|
||||||
|
) {
|
||||||
await disposeRegistry(result.registry);
|
await disposeRegistry(result.registry);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pendingProjectPlugins = catalog.plugins.filter((entry) =>
|
|
||||||
result.pending.some((pending) => pending.pluginId === entry.id),
|
|
||||||
);
|
|
||||||
const previousRegistry = activeRegistry;
|
const previousRegistry = activeRegistry;
|
||||||
activeRegistry = result.registry;
|
activeRegistry = result.registry;
|
||||||
setValue({
|
setValue({
|
||||||
@ -133,16 +148,35 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function focusProject(projectId: string | undefined, initial = false): void {
|
||||||
|
if (projectId === currentProjectId && refreshRevision > 0) return;
|
||||||
|
currentProjectId = projectId;
|
||||||
|
clearRuntimeForProjectChange();
|
||||||
|
void refreshRuntime(initial);
|
||||||
|
}
|
||||||
|
|
||||||
void gateways.focusedProject
|
void gateways.focusedProject
|
||||||
.onFocusedProjectChanged((project) => {
|
.onFocusedProjectChanged((project) => {
|
||||||
if (!project || pendingProjectPlugins.length === 0) return;
|
focusRevision += 1;
|
||||||
void refreshRuntime(false);
|
focusProject(project?.id);
|
||||||
})
|
})
|
||||||
.then((unsubscribe) => {
|
.then((unsubscribe) => {
|
||||||
if (cancelled) unsubscribe();
|
if (cancelled) unsubscribe();
|
||||||
else unsubscribeFocus = unsubscribe;
|
else unsubscribeFocus = unsubscribe;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const initialFocusRevision = focusRevision;
|
||||||
|
void gateways.focusedProject
|
||||||
|
.getFocusedProject()
|
||||||
|
.then((project) => {
|
||||||
|
if (!cancelled && focusRevision === initialFocusRevision) {
|
||||||
|
focusProject(project?.id, true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled && focusRevision === initialFocusRevision) focusProject(undefined, true);
|
||||||
|
});
|
||||||
|
|
||||||
void gateways.system
|
void gateways.system
|
||||||
.onDomainEvent((event) => {
|
.onDomainEvent((event) => {
|
||||||
if (event.type === "pluginInstalled" || event.type === "pluginReloaded") {
|
if (event.type === "pluginInstalled" || event.type === "pluginReloaded") {
|
||||||
@ -154,7 +188,6 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
|||||||
else unsubscribeDomainEvents = unsubscribe;
|
else unsubscribeDomainEvents = unsubscribe;
|
||||||
});
|
});
|
||||||
|
|
||||||
void refreshRuntime(true);
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
refreshRevision += 1;
|
refreshRevision += 1;
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* At UI bootstrap and after an install/reload event, the app calls
|
* At UI bootstrap and after an install/reload event, the app calls
|
||||||
* {@link loadPlugins} with the catalog
|
* {@link loadPlugins} with the catalog
|
||||||
* from `PluginGateway.listRuntimeContributions()` (already filtered by the
|
* from `PluginGateway.listRuntimeContributions(projectId)` (already filtered by the
|
||||||
* backend to `enabled && !pendingUninstall`, carnet §1.3) and the gateway set
|
* backend to `enabled && !pendingUninstall`, carnet §1.3) and the gateway set
|
||||||
* used to build the public service facade. For each entry it dynamically imports
|
* used to build the public service facade. For each entry it dynamically imports
|
||||||
* the bundle URL, validates the module shape, and calls `activate(ctx)`,
|
* the bundle URL, validates the module shape, and calls `activate(ctx)`,
|
||||||
|
|||||||
@ -1704,7 +1704,7 @@ export interface PluginGateway {
|
|||||||
installFromDirectory(path: string): Promise<PluginInstallResult>;
|
installFromDirectory(path: string): Promise<PluginInstallResult>;
|
||||||
setEnabled(pluginId: string, enabled: boolean): Promise<PluginAdmin>;
|
setEnabled(pluginId: string, enabled: boolean): Promise<PluginAdmin>;
|
||||||
uninstall(pluginId: string): Promise<PluginUninstallResult>;
|
uninstall(pluginId: string): Promise<PluginUninstallResult>;
|
||||||
listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog>;
|
listRuntimeContributions(projectId?: string): Promise<PluginRuntimeContributionCatalog>;
|
||||||
listProjectSettings(projectId: string): Promise<ProjectPluginSettingsView>;
|
listProjectSettings(projectId: string): Promise<ProjectPluginSettingsView>;
|
||||||
setProjectEnabled(input: SetProjectPluginEnabledInput): Promise<ProjectPluginSettings>;
|
setProjectEnabled(input: SetProjectPluginEnabledInput): Promise<ProjectPluginSettings>;
|
||||||
assignAgentSkill(input: MutateAgentPluginSkillInput): Promise<ProjectPluginSettings>;
|
assignAgentSkill(input: MutateAgentPluginSkillInput): Promise<ProjectPluginSettings>;
|
||||||
|
|||||||
Reference in New Issue
Block a user