feat(layout): supporte la création de layouts plugins (customPluginLayout)

Étend le flux de création de layout backend (DTO, usecases, store) et le
frontend (sélecteur, adaptateurs, LayoutTabs/LayoutGrid) pour permettre
d'ouvrir un layout déclaré par un plugin installé (ex. Android Health),
sans passer par le message bloquant "extension backend pas encore livrée".

Ticket #141 — QA vert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 18:34:21 +02:00
parent 168f93df78
commit 45617992ef
29 changed files with 907 additions and 99 deletions

View File

@ -13,19 +13,23 @@ use async_trait::async_trait;
use domain::events::DomainEvent;
use domain::layout::Workspace;
use domain::ports::{
DirEntry, EventBus, EventStream, FileSystem, FsError, IdGenerator, ProjectStore, RemotePath,
StoreError,
DirEntry, EventBus, EventStream, FileSystem, FsError, IdGenerator, PluginManifestBytes,
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError, ProjectStore,
RemotePath, StoreError,
};
use domain::{
AgentId, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, NodeId, Project, ProjectId,
ProjectPath, RemoteRef, SessionId,
AgentId, ContentHash, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, LocalPath, NodeId,
PluginBundleUrl, PluginId, PluginInstallSource, PluginLifecycleState, PluginPackageRef,
PluginRegistry, PluginRegistryEntry, Project, ProjectId, ProjectPath, RelativePath, RemoteRef,
RemovalOutcome, SessionId, StagedPluginPackage,
};
use uuid::Uuid;
use application::plugin::JsonPluginManifestValidator;
use application::{
CreateLayout, CreateLayoutInput, DeleteLayout, DeleteLayoutInput, LayoutKind, LayoutOperation,
ListLayouts, ListLayoutsInput, LoadLayout, LoadLayoutInput, MutateLayout, MutateLayoutInput,
RenameLayout, RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput,
PluginLayoutOrigin, RenameLayout, RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput,
};
// ---------------------------------------------------------------------------
@ -158,6 +162,152 @@ impl IdGenerator for SeqIds {
}
}
fn plugin_manifest() -> Vec<u8> {
br#"{
"ideaPluginManifestVersion": 1,
"id": "dev.idea.android-plugin",
"displayName": "Android",
"version": "1.0.0",
"engines": {"idea": ">=0.1.0 <1.0.0"},
"main": "dist/index.js",
"trustLevel": "full",
"capabilities": ["ui"],
"contributes": {
"layouts": [{"type":"idea-android.health","label":"Android Health","component":"AndroidHealth"}]
}
}"#.to_vec()
}
#[derive(Clone)]
struct FakePluginPackages {
manifest: Arc<Mutex<Vec<u8>>>,
}
impl FakePluginPackages {
fn new(manifest: Vec<u8>) -> Self {
Self {
manifest: Arc::new(Mutex::new(manifest)),
}
}
}
#[async_trait]
impl PluginPackageStore for FakePluginPackages {
async fn list_installed(&self) -> Result<Vec<PluginPackageRef>, PluginStoreError> {
Ok(Vec::new())
}
async fn read_manifest(
&self,
_package: &PluginPackageRef,
) -> Result<PluginManifestBytes, PluginStoreError> {
Ok(PluginManifestBytes {
bytes: self.manifest.lock().unwrap().clone(),
})
}
async fn install_from_archive(
&self,
_archive: &LocalPath,
) -> Result<StagedPluginPackage, PluginStoreError> {
Err(PluginStoreError::Invalid("unused".to_owned()))
}
async fn install_from_directory(
&self,
_dir: &LocalPath,
) -> Result<StagedPluginPackage, PluginStoreError> {
Err(PluginStoreError::Invalid("unused".to_owned()))
}
async fn commit_install(
&self,
staged: StagedPluginPackage,
plugin_id: &PluginId,
) -> Result<PluginPackageRef, PluginStoreError> {
Ok(PluginPackageRef {
plugin_id: Some(plugin_id.clone()),
root: staged.root,
})
}
async fn remove_package(
&self,
_plugin_id: &PluginId,
) -> Result<RemovalOutcome, PluginStoreError> {
Ok(RemovalOutcome::NotFound)
}
fn bundle_url(
&self,
plugin_id: &PluginId,
entry: &RelativePath,
hash: &ContentHash,
) -> Result<PluginBundleUrl, PluginStoreError> {
Ok(PluginBundleUrl::new(format!(
"idea-plugin://{}/current/{}/{}",
plugin_id.as_str(),
hash.as_str(),
entry.as_str()
)))
}
}
#[derive(Clone)]
struct FakePluginRegistry {
registry: Arc<Mutex<PluginRegistry>>,
}
impl FakePluginRegistry {
fn with_state(lifecycle_state: PluginLifecycleState) -> Self {
Self {
registry: Arc::new(Mutex::new(PluginRegistry {
version: 1,
plugins: vec![PluginRegistryEntry {
id: PluginId::new("dev.idea.android-plugin").unwrap(),
lifecycle_state,
source: PluginInstallSource::Directory {
path_label: "/plugin".to_owned(),
},
content_hash: ContentHash::new("abc123").unwrap(),
restart_required: false,
error: None,
}],
})),
}
}
}
#[async_trait]
impl PluginRegistryStore for FakePluginRegistry {
async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError> {
Ok(self.registry.lock().unwrap().clone())
}
async fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginRegistryError> {
*self.registry.lock().unwrap() = registry.clone();
Ok(())
}
}
fn create_layout(
store: FakeStore,
fs: FakeFs,
ids: SeqIds,
bus: SpyBus,
registry: FakePluginRegistry,
) -> CreateLayout {
CreateLayout::new(
Arc::new(store),
Arc::new(fs),
Arc::new(ids),
Arc::new(bus),
Arc::new(FakePluginPackages::new(plugin_manifest())),
Arc::new(registry),
Arc::new(JsonPluginManifestValidator::new("0.3.0")),
)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@ -803,11 +953,12 @@ async fn mgmt_env(project_id: ProjectId) -> (FakeStore, FakeFs, SpyBus) {
#[tokio::test]
async fn create_layout_appends_and_activates_it() {
let (store, fs, bus) = mgmt_env(pid(30)).await;
let create = CreateLayout::new(
Arc::new(store.clone()),
Arc::new(fs.clone()),
Arc::new(SeqIds::new(0xABC)),
Arc::new(bus.clone()),
let create = create_layout(
store.clone(),
fs.clone(),
SeqIds::new(0xABC),
bus.clone(),
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
);
let out = create
.execute(CreateLayoutInput {
@ -832,11 +983,12 @@ async fn create_layout_appends_and_activates_it() {
#[tokio::test]
async fn create_layout_rejects_empty_name() {
let (store, fs, bus) = mgmt_env(pid(31)).await;
let err = CreateLayout::new(
Arc::new(store),
Arc::new(fs),
Arc::new(SeqIds::new(1)),
Arc::new(bus),
let err = create_layout(
store,
fs,
SeqIds::new(1),
bus,
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
)
.execute(CreateLayoutInput {
project_id: pid(31),
@ -848,6 +1000,149 @@ async fn create_layout_rejects_empty_name() {
assert_eq!(err.code(), "INVALID", "got {err:?}");
}
#[tokio::test]
async fn create_layout_plugin_validates_active_runtime_contribution_and_persists_root() {
let (store, fs, bus) = mgmt_env(pid(131)).await;
let create = create_layout(
store.clone(),
fs.clone(),
SeqIds::new(0x141),
bus,
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
);
let out = create
.execute(CreateLayoutInput {
project_id: pid(131),
name: "Android Health".to_owned(),
kind: LayoutKind::Plugin {
plugin_origin: PluginLayoutOrigin {
plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
layout_type: domain::PluginLayoutType::new("idea-android.health").unwrap(),
},
state: serde_json::json!({ "deviceId": "pixel-8" }),
},
})
.await
.unwrap();
let list = ListLayouts::new(Arc::new(store), Arc::new(fs.clone()))
.execute(ListLayoutsInput {
project_id: pid(131),
})
.await
.unwrap();
assert_eq!(list.active_id, out.layout_id);
assert_eq!(
list.layouts[1].kind,
LayoutKind::Plugin {
plugin_origin: PluginLayoutOrigin {
plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
layout_type: domain::PluginLayoutType::new("idea-android.health").unwrap(),
},
state: serde_json::json!({ "deviceId": "pixel-8" }),
}
);
let tree_json = active_tree_json(&fs);
assert_eq!(tree_json["root"]["type"], "customPluginLayout");
assert_eq!(
tree_json["root"]["node"]["pluginId"],
"dev.idea.android-plugin"
);
assert_eq!(
tree_json["root"]["node"]["layoutType"],
"idea-android.health"
);
assert_eq!(tree_json["root"]["node"]["state"]["deviceId"], "pixel-8");
}
#[tokio::test]
async fn create_layout_plugin_rejects_inactive_runtime_contribution() {
let (store, fs, bus) = mgmt_env(pid(132)).await;
let err = create_layout(
store,
fs,
SeqIds::new(0x142),
bus,
FakePluginRegistry::with_state(PluginLifecycleState::Disabled),
)
.execute(CreateLayoutInput {
project_id: pid(132),
name: "Android Health".to_owned(),
kind: LayoutKind::Plugin {
plugin_origin: PluginLayoutOrigin {
plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
layout_type: domain::PluginLayoutType::new("idea-android.health").unwrap(),
},
state: serde_json::json!({}),
},
})
.await
.unwrap_err();
assert_eq!(err.code(), "INVALID", "got {err:?}");
}
#[tokio::test]
async fn mutate_set_plugin_layout_state_persists_opaque_state() {
let (store, fs, bus) = mgmt_env(pid(133)).await;
let create = create_layout(
store.clone(),
fs.clone(),
SeqIds::new(0x143),
bus.clone(),
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
);
create
.execute(CreateLayoutInput {
project_id: pid(133),
name: "Android Health".to_owned(),
kind: LayoutKind::Plugin {
plugin_origin: PluginLayoutOrigin {
plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
layout_type: domain::PluginLayoutType::new("idea-android.health").unwrap(),
},
state: serde_json::json!({ "deviceId": "pixel-8" }),
},
})
.await
.unwrap();
let loaded = LoadLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()))
.execute(LoadLayoutInput {
project_id: pid(133),
layout_id: None,
})
.await
.unwrap();
let target = match loaded.layout.root {
LayoutNode::CustomPluginLayout(cell) => cell.id,
other => panic!("expected custom plugin layout root, got {other:?}"),
};
let out = MutateLayout::new(Arc::new(store), Arc::new(fs.clone()), Arc::new(bus))
.execute(MutateLayoutInput {
project_id: pid(133),
layout_id: None,
operation: LayoutOperation::SetPluginLayoutState {
target,
state: serde_json::json!({ "deviceId": "pixel-8", "healthy": true }),
},
})
.await
.unwrap();
match out.layout.root {
LayoutNode::CustomPluginLayout(cell) => {
assert_eq!(cell.state["healthy"], true);
}
other => panic!("expected custom plugin layout root, got {other:?}"),
}
assert_eq!(
active_tree_json(&fs)["root"]["node"]["state"]["healthy"],
true
);
}
#[tokio::test]
async fn rename_layout_changes_the_name() {
let (store, fs, bus) = mgmt_env(pid(32)).await;
@ -886,11 +1181,12 @@ async fn delete_layout_rejects_the_last_one() {
async fn delete_active_layout_reassigns_active() {
let (store, fs, bus) = mgmt_env(pid(34)).await;
// Add a second layout (becomes active), then delete it → active falls back.
let created = CreateLayout::new(
Arc::new(store.clone()),
Arc::new(fs.clone()),
Arc::new(SeqIds::new(0xD)),
Arc::new(bus.clone()),
let created = create_layout(
store.clone(),
fs.clone(),
SeqIds::new(0xD),
bus.clone(),
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
)
.execute(CreateLayoutInput {
project_id: pid(34),
@ -925,11 +1221,12 @@ async fn delete_active_layout_reassigns_active() {
#[tokio::test]
async fn set_active_layout_switches_and_load_follows() {
let (store, fs, bus) = mgmt_env(pid(35)).await;
let created = CreateLayout::new(
Arc::new(store.clone()),
Arc::new(fs.clone()),
Arc::new(SeqIds::new(0xE)),
Arc::new(bus.clone()),
let created = create_layout(
store.clone(),
fs.clone(),
SeqIds::new(0xE),
bus.clone(),
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
)
.execute(CreateLayoutInput {
project_id: pid(35),