Files
IdeA/crates/application/tests/window_usecases.rs
Blomios 551eb09ad2 feat(window): support des fenêtres plugin-hébergées (backend)
Étend le port/usecases window et layout.customPluginLayout pour ouvrir et
piloter des fenêtres OS hébergeant un layout plugin, en réutilisant
contributes.layouts plutôt qu'un système de fenêtres parallèle. Câble la
commande Tauri et l'état app-tauri correspondants.

Cargo test -p domain --test window : 5/5
Cargo test -p application --test window_usecases : 7/7
Cargo test -p infrastructure --test window_state_store : 1/1
Cargo test -p app-tauri view_window_tests : 8/8

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 00:39:42 +02:00

534 lines
16 KiB
Rust

//! L10 tests for [`MoveTabToNewWindow`] with a fake [`ProjectStore`]: the tab is
//! detached and the workspace is persisted (load returns the new state).
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::ids::{ProjectId, TabId, WindowId};
use domain::layout::{
LayoutNode, LayoutTree, LeafCell, PersistedPluginLayoutWindow, PersistedWindowKind,
PersistedWindowState, Tab, Window, WindowStateSnapshot, Workspace,
};
use domain::ports::{
IdGenerator, PluginManifestBytes, PluginPackageStore, PluginRegistryError, PluginRegistryStore,
PluginStoreError, ProjectStore, StoreError, WindowStateStore,
};
use domain::project::{Project, ProjectPath};
use domain::{
ContentHash, LocalPath, NodeId, PluginBundleUrl, PluginId, PluginInstallSource,
PluginLayoutType, PluginLifecycleState, PluginPackageRef, PluginRegistry, PluginRegistryEntry,
RelativePath, RemoteRef, RemovalOutcome, StagedPluginPackage,
};
use uuid::Uuid;
use application::plugin::JsonPluginManifestValidator;
use application::{
MoveTabToNewWindow, MoveTabToNewWindowInput, OpenPluginLayoutWindow,
OpenPluginLayoutWindowInput, RestoreOpenWindows, SnapshotOpenWindows, SnapshotOpenWindowsInput,
};
/// A `ProjectStore` fake that only implements the workspace persistence the use
/// case needs (the project methods are never called here).
#[derive(Clone)]
struct FakeStore(Arc<Mutex<Workspace>>);
#[async_trait]
impl ProjectStore for FakeStore {
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
unreachable!()
}
async fn load_project(&self, _id: ProjectId) -> Result<Project, StoreError> {
unreachable!()
}
async fn save_project(&self, _p: &Project) -> Result<(), StoreError> {
unreachable!()
}
async fn save_workspace(&self, ws: &Workspace) -> Result<(), StoreError> {
*self.0.lock().unwrap() = ws.clone();
Ok(())
}
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
Ok(self.0.lock().unwrap().clone())
}
}
struct SeqIds(Mutex<u128>);
impl IdGenerator for SeqIds {
fn new_uuid(&self) -> Uuid {
let mut n = self.0.lock().unwrap();
let id = Uuid::from_u128(*n);
*n += 1;
id
}
}
#[derive(Clone, Default)]
struct FakeWindowStateStore(Arc<Mutex<WindowStateSnapshot>>);
#[async_trait]
impl WindowStateStore for FakeWindowStateStore {
async fn save_window_state(&self, snapshot: &WindowStateSnapshot) -> Result<(), StoreError> {
*self.0.lock().unwrap() = snapshot.clone();
Ok(())
}
async fn load_window_state(&self) -> Result<WindowStateSnapshot, StoreError> {
Ok(self.0.lock().unwrap().clone())
}
}
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(())
}
}
#[derive(Clone)]
struct FakeProjectRegistry {
existing: Arc<Mutex<Vec<ProjectId>>>,
}
impl FakeProjectRegistry {
fn new(existing: Vec<ProjectId>) -> Self {
Self {
existing: Arc::new(Mutex::new(existing)),
}
}
}
#[async_trait]
impl ProjectStore for FakeProjectRegistry {
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
unreachable!()
}
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
if !self.existing.lock().unwrap().contains(&id) {
return Err(StoreError::NotFound);
}
Ok(Project {
id,
name: format!("project-{id}"),
root: ProjectPath::new(format!("/tmp/{id}")).unwrap(),
remote: RemoteRef::Local,
created_at: 0,
})
}
async fn save_project(&self, _p: &Project) -> Result<(), StoreError> {
unreachable!()
}
async fn save_workspace(&self, _ws: &Workspace) -> Result<(), StoreError> {
unreachable!()
}
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
unreachable!()
}
}
fn tid(n: u128) -> TabId {
TabId::from_uuid(Uuid::from_u128(n))
}
fn wid(n: u128) -> WindowId {
WindowId::from_uuid(Uuid::from_u128(n))
}
fn tab(n: u128) -> Tab {
Tab {
id: tid(n),
project_id: ProjectId::from_uuid(Uuid::from_u128(1000 + n)),
layout: LayoutTree::new(LayoutNode::Leaf(LeafCell {
id: NodeId::from_uuid(Uuid::from_u128(900 + n)),
session: None,
agent: None,
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
})),
}
}
fn seeded() -> FakeStore {
let ws = Workspace {
windows: vec![Window::new(wid(1), vec![tab(1), tab(2)], tid(1)).unwrap()],
};
FakeStore(Arc::new(Mutex::new(ws)))
}
fn persisted_main() -> PersistedWindowState {
PersistedWindowState {
label: "main".to_owned(),
kind: PersistedWindowKind::Main,
panel: None,
project_id: None,
plugin_layout: None,
url: None,
visible: true,
maximized: false,
fullscreen: false,
outer_position: None,
outer_size: None,
monitor: None,
last_focused_at: None,
}
}
fn persisted_view(label: &str, project_id: ProjectId) -> PersistedWindowState {
PersistedWindowState {
label: label.to_owned(),
kind: PersistedWindowKind::View,
panel: Some("tickets".to_owned()),
project_id: Some(project_id),
plugin_layout: None,
url: Some(format!("index.html?panel=tickets&project={project_id}")),
visible: true,
maximized: false,
fullscreen: false,
outer_position: None,
outer_size: None,
monitor: None,
last_focused_at: None,
}
}
fn persisted_plugin_layout(
label: &str,
plugin_id: &str,
layout_type: &str,
) -> PersistedWindowState {
PersistedWindowState {
label: label.to_owned(),
kind: PersistedWindowKind::PluginLayout,
panel: None,
project_id: None,
plugin_layout: Some(PersistedPluginLayoutWindow {
plugin_id: PluginId::new(plugin_id).unwrap(),
layout_type: PluginLayoutType::new(layout_type).unwrap(),
state: serde_json::json!({ "from": "test" }),
}),
url: Some(format!(
"index.html?pluginLayout=1&pluginId={plugin_id}&layoutType={layout_type}"
)),
visible: true,
maximized: false,
fullscreen: false,
outer_position: None,
outer_size: None,
monitor: None,
last_focused_at: None,
}
}
fn restore_uc(store: FakeWindowStateStore, registry: FakePluginRegistry) -> RestoreOpenWindows {
RestoreOpenWindows::new(
Arc::new(store),
Arc::new(FakeProjectRegistry::new(vec![])),
Arc::new(FakePluginPackages::new(plugin_manifest())),
Arc::new(registry),
Arc::new(JsonPluginManifestValidator::new("0.3.0")),
)
}
fn open_plugin_layout_uc(registry: FakePluginRegistry) -> OpenPluginLayoutWindow {
OpenPluginLayoutWindow::new(
Arc::new(FakePluginPackages::new(plugin_manifest())),
Arc::new(registry),
Arc::new(JsonPluginManifestValidator::new("0.3.0")),
)
}
#[tokio::test]
async fn detaches_tab_and_persists_workspace() {
let store = seeded();
// The id generator's first uuid (from_u128(7)) becomes the new window id.
let ids = Arc::new(SeqIds(Mutex::new(7)));
let uc = MoveTabToNewWindow::new(Arc::new(store.clone()), ids);
let out = uc
.execute(MoveTabToNewWindowInput { tab_id: tid(1) })
.await
.unwrap();
assert_eq!(out.new_window_id, WindowId::from_uuid(Uuid::from_u128(7)));
assert_eq!(out.workspace.windows.len(), 2);
// Persisted: reloading the store yields the detached layout.
let reloaded = store.load_workspace().await.unwrap();
assert_eq!(reloaded, out.workspace);
let detached = reloaded
.windows
.iter()
.find(|w| w.id == out.new_window_id)
.unwrap();
assert_eq!(detached.tabs.len(), 1);
assert_eq!(detached.tabs[0].id, tid(1));
}
#[tokio::test]
async fn unknown_tab_is_not_found() {
let store = seeded();
let uc = MoveTabToNewWindow::new(Arc::new(store), Arc::new(SeqIds(Mutex::new(7))));
let err = uc
.execute(MoveTabToNewWindowInput { tab_id: tid(404) })
.await
.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}
#[tokio::test]
async fn snapshot_open_windows_persists_the_supplied_snapshot() {
let store = FakeWindowStateStore::default();
let uc = SnapshotOpenWindows::new(Arc::new(store.clone()));
let windows = vec![persisted_main()];
uc.execute(SnapshotOpenWindowsInput {
windows: windows.clone(),
})
.await
.unwrap();
let saved = store.load_window_state().await.unwrap();
assert_eq!(saved.version, domain::WINDOW_STATE_SNAPSHOT_VERSION);
assert_eq!(saved.windows, windows);
}
#[tokio::test]
async fn restore_open_windows_keeps_panel_views_without_reopening_projects() {
let existing = ProjectId::from_uuid(Uuid::from_u128(42));
let missing = ProjectId::from_uuid(Uuid::from_u128(43));
let valid_label = "view-tickets-0000000000000000000000000000002a";
let mut url_missing = persisted_view("view-tickets-0000000000000000000000000000002c", existing);
url_missing.url = None;
let mut no_panel = persisted_view("view-tickets-0000000000000000000000000000002d", existing);
no_panel.panel = None;
let store = FakeWindowStateStore(Arc::new(Mutex::new(WindowStateSnapshot::new(vec![
persisted_main(),
persisted_main(),
persisted_view(valid_label, existing),
persisted_view(valid_label, existing),
persisted_view("view-tickets-0000000000000000000000000000002b", missing),
url_missing,
no_panel,
]))));
let uc = restore_uc(
store,
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
);
let out = uc.execute().await.unwrap();
assert_eq!(
out.windows
.iter()
.map(|w| w.label.as_str())
.collect::<Vec<_>>(),
vec![
"main",
valid_label,
"view-tickets-0000000000000000000000000000002b",
"view-tickets-0000000000000000000000000000002c"
]
);
}
#[tokio::test]
async fn open_plugin_layout_window_validates_active_runtime_contribution() {
let uc = open_plugin_layout_uc(FakePluginRegistry::with_state(
PluginLifecycleState::Enabled,
));
let out = uc
.execute(OpenPluginLayoutWindowInput {
plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
layout_type: PluginLayoutType::new("idea-android.health").unwrap(),
state: serde_json::json!({ "deviceId": "pixel-8" }),
})
.await
.unwrap();
assert_eq!(out.contribution.provider_plugin_display_name, "Android");
assert_eq!(out.contribution.label, "Android Health");
assert_eq!(out.surface.plugin_id.as_str(), "dev.idea.android-plugin");
assert_eq!(out.surface.layout_type.as_str(), "idea-android.health");
assert_eq!(out.surface.state["deviceId"], "pixel-8");
}
#[tokio::test]
async fn open_plugin_layout_window_rejects_inactive_plugin() {
let uc = open_plugin_layout_uc(FakePluginRegistry::with_state(
PluginLifecycleState::Disabled,
));
let err = uc
.execute(OpenPluginLayoutWindowInput {
plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
layout_type: PluginLayoutType::new("idea-android.health").unwrap(),
state: serde_json::Value::Null,
})
.await
.unwrap_err();
assert_eq!(err.code(), "INVALID", "got {err:?}");
}
#[tokio::test]
async fn restore_open_windows_keeps_valid_plugin_layout_windows_only() {
let mut missing_surface = persisted_plugin_layout(
"view-plugin-layout-missing",
"dev.idea.android-plugin",
"idea-android.health",
);
missing_surface.plugin_layout = None;
let store = FakeWindowStateStore(Arc::new(Mutex::new(WindowStateSnapshot::new(vec![
persisted_plugin_layout(
"view-plugin-layout-valid",
"dev.idea.android-plugin",
"idea-android.health",
),
persisted_plugin_layout(
"view-plugin-layout-unknown-layout",
"dev.idea.android-plugin",
"idea-android.missing",
),
missing_surface,
]))));
let uc = restore_uc(
store,
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
);
let out = uc.execute().await.unwrap();
assert_eq!(
out.windows
.iter()
.map(|w| w.label.as_str())
.collect::<Vec<_>>(),
vec!["view-plugin-layout-valid"]
);
}