//! 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>); #[async_trait] impl ProjectStore for FakeStore { async fn list_projects(&self) -> Result, StoreError> { unreachable!() } async fn load_project(&self, _id: ProjectId) -> Result { 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 { Ok(self.0.lock().unwrap().clone()) } } struct SeqIds(Mutex); 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>); #[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 { Ok(self.0.lock().unwrap().clone()) } } fn plugin_manifest() -> Vec { 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>>, } impl FakePluginPackages { fn new(manifest: Vec) -> Self { Self { manifest: Arc::new(Mutex::new(manifest)), } } } #[async_trait] impl PluginPackageStore for FakePluginPackages { async fn list_installed(&self) -> Result, PluginStoreError> { Ok(Vec::new()) } async fn read_manifest( &self, _package: &PluginPackageRef, ) -> Result { Ok(PluginManifestBytes { bytes: self.manifest.lock().unwrap().clone(), }) } async fn install_from_archive( &self, _archive: &LocalPath, ) -> Result { Err(PluginStoreError::Invalid("unused".to_owned())) } async fn install_from_directory( &self, _dir: &LocalPath, ) -> Result { Err(PluginStoreError::Invalid("unused".to_owned())) } async fn commit_install( &self, staged: StagedPluginPackage, plugin_id: &PluginId, ) -> Result { Ok(PluginPackageRef { plugin_id: Some(plugin_id.clone()), root: staged.root, }) } async fn remove_package( &self, _plugin_id: &PluginId, ) -> Result { Ok(RemovalOutcome::NotFound) } fn bundle_url( &self, plugin_id: &PluginId, entry: &RelativePath, hash: &ContentHash, ) -> Result { Ok(PluginBundleUrl::new(format!( "idea-plugin://{}/current/{}/{}", plugin_id.as_str(), hash.as_str(), entry.as_str() ))) } } #[derive(Clone)] struct FakePluginRegistry { registry: Arc>, } 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 { 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>>, } impl FakeProjectRegistry { fn new(existing: Vec) -> Self { Self { existing: Arc::new(Mutex::new(existing)), } } } #[async_trait] impl ProjectStore for FakeProjectRegistry { async fn list_projects(&self) -> Result, StoreError> { unreachable!() } async fn load_project(&self, id: ProjectId) -> Result { 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 { 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, preferred_view: domain::PreferredView::Tui, })), } } 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![ "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!["view-plugin-layout-valid"] ); }