mod context; mod env; mod manifest; mod panes; mod runtime; use super::responses::{encode_error, encode_success}; use crate::api::schema::{ InstalledPluginInfo, PaneLinkActivateParams, PluginActionInfo, PluginActionInvokeParams, PluginActionListParams, PluginLinkParams, PluginListParams, PluginLogListParams, PluginManifestAction, PluginManifestLinkHandler, PluginPaneCloseParams, PluginPaneFocusParams, PluginPaneInfo, PluginPaneOpenParams, PluginPanePlacement, PluginSetEnabledParams, PluginUnlinkParams, ResponseResult, }; use crate::app::App; pub(super) use manifest::normalize_plugin_id; use manifest::{ effective_platforms, ensure_platform_supported, normalize_action_id, normalize_plugin_source, }; #[cfg(test)] use crate::api::schema::{PluginCommandStatus, PluginInvocationContext}; pub(crate) use manifest::load_plugin_manifest; #[cfg(test)] use runtime::{read_capped_plugin_output, MAX_PLUGIN_COMMANDS_IN_FLIGHT}; impl App { fn replace_installed_plugins(&mut self, entries: Vec) { let entries = crate::persist::plugin_registry::reload_manifests(entries, |path, enabled| { load_plugin_manifest(path, enabled).map_err(|(_, message)| message) }); self.state.installed_plugins = entries .into_iter() .map(|plugin| (plugin.plugin_id.clone(), plugin)) .collect(); } fn refresh_installed_plugins(&mut self) -> std::io::Result<()> { if !self.policy.persist_plugin_registry { return Ok(()); } let entries = crate::persist::plugin_registry::try_load()?; self.replace_installed_plugins(entries); Ok(()) } fn update_installed_plugins( &mut self, mutation: impl FnOnce(&mut crate::app::state::InstalledPluginRegistry) -> T, ) -> std::io::Result { if !self.policy.persist_plugin_registry { return Ok(mutation(&mut self.state.installed_plugins)); } let (result, entries) = crate::persist::plugin_registry::update(|entries| { let mut registry = entries .drain(..) .map(|plugin| (plugin.plugin_id.clone(), plugin)) .collect(); let result = mutation(&mut registry); *entries = registry.into_values().collect(); result })?; self.replace_installed_plugins(entries); Ok(result) } pub(super) fn handle_plugin_link(&mut self, id: String, params: PluginLinkParams) -> String { let mut plugin = match load_plugin_manifest(¶ms.path, params.enabled) { Ok(plugin) => plugin, Err((code, message)) => return encode_error(id, code, message), }; if let Some(source) = params.source { match normalize_plugin_source(&plugin, source) { Ok(source) => plugin.source = source, Err((code, message)) => return encode_error(id, code, message), } } if let Err(err) = env::ensure_plugin_user_dirs(&plugin) { return encode_error(id, "plugin_user_dir_create_failed", err.to_string()); } if let Err(err) = self.update_installed_plugins(|plugins| { plugins.insert(plugin.plugin_id.clone(), plugin.clone()); }) { return encode_error(id, "plugin_registry_save_failed", err.to_string()); } encode_success(id, ResponseResult::PluginLinked { plugin }) } pub(super) fn handle_plugin_list(&mut self, id: String, params: PluginListParams) -> String { let plugin_id = match normalize_optional_plugin_id(&id, params.plugin_id) { Ok(plugin_id) => plugin_id, Err(response) => return response, }; if let Err(err) = self.refresh_installed_plugins() { return encode_error(id, "plugin_registry_load_failed", err.to_string()); } let mut plugins = self .state .installed_plugins .values() .filter(|plugin| { plugin_id .as_deref() .is_none_or(|plugin_id| plugin.plugin_id == plugin_id) }) .cloned() .collect::>(); plugins.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id)); encode_success(id, ResponseResult::PluginList { plugins }) } pub(super) fn handle_plugin_unlink( &mut self, id: String, params: PluginUnlinkParams, ) -> String { let Some(plugin_id) = normalize_plugin_id(¶ms.plugin_id) else { return invalid_plugin_id(id); }; let removed = match self.update_installed_plugins(|plugins| plugins.remove(&plugin_id).is_some()) { Ok(removed) => removed, Err(err) => { return encode_error(id, "plugin_registry_save_failed", err.to_string()); } }; if removed { // Drop plugin_panes records for this plugin (panes keep running). self.state .plugin_panes .retain(|_, record| record.plugin_id != plugin_id); self.clear_agent_view_for_source(&format!("plugin:{plugin_id}")); } encode_success(id, ResponseResult::PluginUnlinked { plugin_id, removed }) } pub(super) fn handle_plugin_enable( &mut self, id: String, params: PluginSetEnabledParams, ) -> String { self.set_plugin_enabled(id, params.plugin_id, true) } pub(super) fn handle_plugin_disable( &mut self, id: String, params: PluginSetEnabledParams, ) -> String { self.set_plugin_enabled(id, params.plugin_id, false) } pub(super) fn handle_plugin_action_list( &mut self, id: String, params: PluginActionListParams, ) -> String { let plugin_id = match normalize_optional_plugin_id(&id, params.plugin_id) { Ok(plugin_id) => plugin_id, Err(response) => return response, }; if let Err(err) = self.refresh_installed_plugins() { return encode_error(id, "plugin_registry_load_failed", err.to_string()); } let mut actions = manifest_actions(&self.state.installed_plugins) .filter(|action| { plugin_id .as_deref() .is_none_or(|plugin_id| action.plugin_id == plugin_id) }) .collect::>(); actions.sort_by_key(|action| action.qualified_id()); encode_success(id, ResponseResult::PluginActionList { actions }) } pub(super) fn handle_plugin_action_invoke( &mut self, id: String, params: PluginActionInvokeParams, ) -> String { if let Err(err) = self.refresh_installed_plugins() { return encode_error(id, "plugin_registry_load_failed", err.to_string()); } let (plugin, action) = match self.find_plugin_action(params.plugin_id.as_deref(), ¶ms.action_id) { Ok(pair) => pair, Err((code, message)) => return encode_error(id, code, message), }; if !plugin.enabled { return encode_error( id, "plugin_disabled", format!("plugin {} is disabled", plugin.plugin_id), ); } if let Err((code, message)) = ensure_platform_supported( effective_platforms(&action.platforms, &plugin.platforms), &format!("action '{}'", action.qualified_id()), ) { return encode_error(id, code, message); } let context = self.merge_plugin_context(params.context, &id); let log = match self.start_plugin_command( &plugin, Some(action.action_id.clone()), None, action.command.clone(), &context, None, ) { Ok(log) => log, Err((code, message)) => return encode_error(id, code, message), }; encode_success( id, ResponseResult::PluginActionInvoked { action, context, log, }, ) } pub(crate) fn invoke_plugin_action_from_keybind( &mut self, action_id: String, selected_text: Option, ) -> Result<(), String> { self.refresh_installed_plugins() .map_err(|err| format!("failed to load plugin registry: {err}"))?; let (plugin, action) = self .find_plugin_action(None, &action_id) .map_err(|(_, message)| message)?; if !plugin.enabled { return Err(format!("plugin {} is disabled", plugin.plugin_id)); } ensure_platform_supported( effective_platforms(&action.platforms, &plugin.platforms), &action.qualified_id(), ) .map_err(|(_, message)| message)?; let mut context = self.current_plugin_context("keybinding"); context.invocation_source = Some("keybinding".to_string()); context.selected_text = selected_text; self.start_plugin_command( &plugin, Some(action.action_id), None, action.command, &context, None, ) .map(|_| ()) .map_err(|(_, message)| message) } fn read_checked_pane_link( &self, id: &str, params: &PaneLinkActivateParams, operation: &str, read: impl FnOnce(&crate::terminal::TerminalRuntime, u16, u16) -> T, ) -> Result<(crate::layout::PaneId, T), String> { let error = |code, message: &str| encode_error(id.to_owned(), code, message); let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return Err(error("pane_not_found", "pane not found")); }; if !self.state.pane_visible_on_active_surface(ws_idx, pane_id) { return Err(error("stale_target", "pane is no longer visible")); } let Some(runtime) = self.state .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) else { return Err(error("pane_not_found", "pane runtime not found")); }; let current_offset = runtime .scroll_metrics() .map(|metrics| metrics.offset_from_bottom as u64); if params .offset_from_bottom .is_some_and(|expected| current_offset != Some(expected)) { return Err(error( "stale_content", &format!("pane viewport changed before link {operation}"), )); } let content_revision = runtime.content_seq(); if content_revision % 2 != 0 || params .content_revision .is_some_and(|expected| expected != content_revision) { return Err(error( "stale_content", &format!("pane content changed before link {operation}"), )); } let value = read(runtime, params.col, params.viewport_row); if runtime.content_seq() != content_revision || runtime .scroll_metrics() .map(|metrics| metrics.offset_from_bottom as u64) != current_offset { return Err(error( "stale_content", &format!("pane content or viewport changed during link {operation}"), )); } Ok((pane_id, value)) } pub(super) fn handle_pane_link_resolve( &mut self, id: String, params: PaneLinkActivateParams, ) -> String { match self.read_checked_pane_link(&id, ¶ms, "resolution", |runtime, col, row| { runtime.link_regions_at(col, row, crate::app::actions::url_byte_range) }) { Ok((_, regions)) => encode_success(id, ResponseResult::PaneLinkResolved { regions }), Err(error) => error, } } pub(super) fn handle_pane_link_activate( &mut self, id: String, params: PaneLinkActivateParams, ) -> String { let (pane_id, url) = match self.read_checked_pane_link(&id, ¶ms, "activation", |runtime, col, row| { runtime .link_target_at(col, row) .and_then(crate::app::actions::url_from_link_target) }) { Ok(value) => value, Err(error) => return error, }; let handled = match url.as_deref() { Some(url) => match self.invoke_plugin_link_handler_for_url(url, pane_id) { Ok(handled) => handled, Err(err) => { tracing::warn!(err = %err, url = %url, "failed to invoke plugin link handler"); false } }, None => false, }; encode_success(id, ResponseResult::PaneLinkActivated { url, handled }) } pub(crate) fn invoke_plugin_link_handler_for_url( &mut self, url: &str, pane_id: crate::layout::PaneId, ) -> Result { self.refresh_installed_plugins() .map_err(|err| format!("failed to load plugin registry: {err}"))?; let Some((plugin, handler)) = self.find_plugin_link_handler(url) else { return Ok(false); }; if ensure_platform_supported( &effective_platforms(&handler.platforms, &plugin.platforms).clone(), &handler.id, ) .is_err() { return Ok(false); } let action = plugin .actions .iter() .find(|action| action.id == handler.action) .cloned() .ok_or_else(|| { format!( "plugin {} link handler {} references missing action {}", plugin.plugin_id, handler.id, handler.action ) })?; ensure_platform_supported( &effective_platforms(&action.platforms, &plugin.platforms).clone(), &action.id, ) .map_err(|(_, message)| message)?; let Some(ws_idx) = self.state.active else { return Ok(false); }; let mut context = self.plugin_context_for_pane(ws_idx, pane_id, "link_click"); context.invocation_source = Some("link_click".to_string()); context.clicked_url = Some(url.to_string()); context.link_handler_id = Some(handler.id); self.start_plugin_command( &plugin, Some(action.id), None, action.command, &context, None, ) .map(|_| true) .map_err(|(_, message)| message) } pub(super) fn handle_plugin_log_list( &mut self, id: String, params: PluginLogListParams, ) -> String { let plugin_id = match normalize_optional_plugin_id(&id, params.plugin_id) { Ok(plugin_id) => plugin_id, Err(response) => return response, }; let limit = params.limit.unwrap_or(50).clamp(1, 200); let mut logs = self .state .plugin_command_logs .iter() .filter(|log| { plugin_id .as_deref() .is_none_or(|plugin_id| log.plugin_id == plugin_id) }) .rev() .take(limit) .cloned() .collect::>(); logs.reverse(); encode_success(id, ResponseResult::PluginLogList { logs }) } pub(super) fn handle_plugin_pane_open( &mut self, id: String, params: PluginPaneOpenParams, ) -> String { if let Err(err) = self.refresh_installed_plugins() { return encode_error(id, "plugin_registry_load_failed", err.to_string()); } let Some(plugin_id) = normalize_plugin_id(¶ms.plugin_id) else { return invalid_plugin_id(id); }; let Some(plugin) = self.state.installed_plugins.get(&plugin_id).cloned() else { return encode_error(id, "plugin_not_found", "plugin not found"); }; if !plugin_manifest_available(&plugin) { return encode_error( id, "plugin_manifest_unavailable", format!("plugin {plugin_id} manifest is unavailable"), ); } if !plugin.enabled { return encode_error( id, "plugin_disabled", format!("plugin {plugin_id} is disabled"), ); } let Some(entrypoint) = normalize_action_id(¶ms.entrypoint) else { return encode_error(id, "invalid_plugin_entrypoint", "invalid entrypoint id"); }; let Some(mut pane) = plugin .panes .iter() .find(|pane| pane.id == entrypoint) .cloned() else { return encode_error( id, "plugin_pane_not_found", format!("plugin pane entrypoint '{entrypoint}' not found"), ); }; if let Err((code, message)) = ensure_platform_supported( effective_platforms(&pane.platforms, &plugin.platforms), "plugin pane", ) { return encode_error(id, code, message); } pane.command[0] = crate::plugin_command::program_for_cwd( &pane.command[0], std::path::Path::new(&plugin.plugin_root), ) .display() .to_string(); let placement = params.placement.unwrap_or(pane.placement); if placement != PluginPanePlacement::Popup && (params.width.is_some() || params.height.is_some()) { return encode_error( id, "invalid_params", "width and height are only supported when placement is popup", ); } if placement == PluginPanePlacement::Popup && self.state.popup_pane.is_some() { return encode_error(id, "ui_busy", "a popup pane is already open"); } match placement { PluginPanePlacement::Overlay | PluginPanePlacement::Popup => { if params.workspace_id.is_some() || params.target_pane_id.is_some() || params.direction.is_some() { return encode_error( id, "invalid_params", "overlay and popup plugin panes target the active pane", ); } } PluginPanePlacement::Split | PluginPanePlacement::Zoomed => { if params.workspace_id.is_some() { return encode_error( id, "invalid_params", "split and zoomed plugin panes target an existing pane; use target_pane_id", ); } } PluginPanePlacement::Tab => { if params.target_pane_id.is_some() || params.direction.is_some() { return encode_error( id, "invalid_params", "tab plugin panes support workspace_id but not target_pane_id or direction", ); } } } match placement { PluginPanePlacement::Overlay => { self.open_plugin_overlay_pane(id, params, &plugin, pane) } PluginPanePlacement::Popup => self.open_plugin_popup_pane(id, params, &plugin, pane), PluginPanePlacement::Split | PluginPanePlacement::Zoomed => { self.open_plugin_split_pane(id, params, &plugin, pane, placement) } PluginPanePlacement::Tab => self.open_plugin_tab(id, params, &plugin, pane), } } pub(super) fn handle_plugin_pane_focus( &mut self, id: String, params: PluginPaneFocusParams, ) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); }; if !self.state.plugin_panes.contains_key(&pane_id) { return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); } self.state.focus_pane_in_workspace(ws_idx, pane_id); self.state.mode = crate::app::Mode::Terminal; let Some(record) = self.state.plugin_panes.get(&pane_id).cloned() else { return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); }; let Some(pane) = self.pane_info(ws_idx, pane_id) else { return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); }; encode_success( id, ResponseResult::PluginPaneFocused { plugin_pane: PluginPaneInfo { plugin_id: record.plugin_id, entrypoint: record.entrypoint, pane, }, }, ) } pub(super) fn handle_plugin_pane_close( &mut self, id: String, params: PluginPaneCloseParams, ) -> String { let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); }; if !self.state.plugin_panes.contains_key(&pane_id) { return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); } let pane_id = params.pane_id; if let Err(response) = self.close_pane( id.clone(), &crate::api::schema::PaneTarget { pane_id: pane_id.clone(), }, ) { return response; } encode_success(id, ResponseResult::PluginPaneClosed { pane_id }) } fn find_plugin_action( &self, plugin_id: Option<&str>, action_id: &str, ) -> Result<(crate::api::schema::InstalledPluginInfo, PluginActionInfo), (&'static str, String)> { if let Some(plugin_id) = plugin_id { let plugin_id = normalize_plugin_id(plugin_id) .ok_or_else(|| ("invalid_plugin_id", "invalid plugin id".to_string()))?; let action_id = normalize_action_id(action_id) .ok_or_else(|| ("invalid_plugin_action_id", "invalid action id".to_string()))?; let plugin = self .state .installed_plugins .get(&plugin_id) .ok_or_else(|| ("plugin_not_found", "plugin not found".to_string()))? .clone(); if !plugin_manifest_available(&plugin) { return Err(( "plugin_manifest_unavailable", format!("plugin {plugin_id} manifest is unavailable"), )); } let action_info = plugin .actions .iter() .find(|a| a.id == action_id) .map(|a| manifest_action_info(&plugin_id, &plugin.platforms, a)) .ok_or_else(|| { ( "plugin_action_not_found", "plugin action not found".to_string(), ) })?; return Ok((plugin, action_info)); } let action_id = action_id.trim(); let matches = manifest_actions(&self.state.installed_plugins) .filter(|action| action.action_id == action_id || action.qualified_id() == action_id) .collect::>(); match matches.as_slice() { [action] => { let plugin = self .state .installed_plugins .get(&action.plugin_id) .cloned() .ok_or_else(|| ("plugin_not_found", "plugin not found".to_string()))?; Ok((plugin, action.clone())) } [] => Err(( "plugin_action_not_found", "plugin action not found".to_string(), )), _ => Err(( "ambiguous_plugin_action", "plugin action id matches more than one action; include plugin_id".to_string(), )), } } fn find_plugin_link_handler( &self, url: &str, ) -> Option<(InstalledPluginInfo, PluginManifestLinkHandler)> { let mut plugins = self .state .installed_plugins .values() .filter(|plugin| plugin.enabled && plugin_manifest_available(plugin)) .cloned() .collect::>(); plugins.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id)); for plugin in plugins { for handler in &plugin.link_handlers { if ensure_platform_supported( &effective_platforms(&handler.platforms, &plugin.platforms).clone(), &handler.id, ) .is_err() { continue; } let Some(action) = plugin .actions .iter() .find(|action| action.id == handler.action) else { continue; }; if ensure_platform_supported( &effective_platforms(&action.platforms, &plugin.platforms).clone(), &action.id, ) .is_err() { continue; } let Ok(regex) = regex::Regex::new(&handler.pattern) else { continue; }; if regex.is_match(url) { return Some((plugin.clone(), handler.clone())); } } } None } fn set_plugin_enabled(&mut self, id: String, plugin_id: String, enabled: bool) -> String { let Some(plugin_id) = normalize_plugin_id(&plugin_id) else { return invalid_plugin_id(id); }; let found = match self.update_installed_plugins(|plugins| { if let Some(plugin) = plugins.get_mut(&plugin_id) { plugin.enabled = enabled; true } else { false } }) { Ok(found) => found, Err(err) => { return encode_error(id, "plugin_registry_save_failed", err.to_string()); } }; if !found { return encode_error(id, "plugin_not_found", "plugin not found"); } let Some(plugin) = self.state.installed_plugins.get(&plugin_id).cloned() else { return encode_error(id, "plugin_not_found", "plugin not found"); }; if !enabled { self.clear_agent_view_for_source(&format!("plugin:{plugin_id}")); } if enabled { encode_success(id, ResponseResult::PluginEnabled { plugin }) } else { encode_success(id, ResponseResult::PluginDisabled { plugin }) } } } fn invalid_plugin_id(id: String) -> String { encode_error( id, "invalid_plugin_id", "plugin id must be non-empty, <= 120 characters, and contain only ASCII letters, digits, colon, dot, underscore, or hyphen", ) } /// Normalize an optional plugin id filter; `Err` carries the encoded /// `invalid_plugin_id` error response. fn normalize_optional_plugin_id( id: &str, plugin_id: Option, ) -> Result, String> { match plugin_id { Some(plugin_id) => match normalize_plugin_id(&plugin_id) { Some(plugin_id) => Ok(Some(plugin_id)), None => Err(invalid_plugin_id(id.to_string())), }, None => Ok(None), } } fn plugin_manifest_available(plugin: &InstalledPluginInfo) -> bool { !plugin.warnings.iter().any(|warning| { warning.starts_with(crate::persist::plugin_registry::MANIFEST_UNAVAILABLE_WARNING_PREFIX) }) } fn manifest_action_info( plugin_id: &str, plugin_platforms: &Option>, action: &PluginManifestAction, ) -> PluginActionInfo { PluginActionInfo { plugin_id: plugin_id.to_string(), action_id: action.id.clone(), title: action.title.clone(), description: action.description.clone(), contexts: action.contexts.clone(), command: action.command.clone(), platforms: effective_platforms(&action.platforms, plugin_platforms).clone(), } } fn manifest_actions( plugins: &crate::app::state::InstalledPluginRegistry, ) -> impl Iterator + '_ { plugins .values() .filter(|plugin| plugin_manifest_available(plugin)) .flat_map(|plugin| { plugin .actions .iter() .map(|action| manifest_action_info(&plugin.plugin_id, &plugin.platforms, action)) }) } #[cfg(test)] mod tests { use super::*; #[cfg(unix)] use crate::api::schema::PaneListParams; use crate::api::schema::{ Method, PluginSourceInfo, PluginSourceKind, Request, SuccessResponse, }; use std::time::{SystemTime, UNIX_EPOCH}; #[tokio::test] async fn pane_link_resolve_checks_staleness_without_side_effects() { let mut app = test_app(); app.state.workspaces = vec![crate::workspace::Workspace::test_new("hover")]; app.state.ensure_test_terminals(); app.state.active = Some(0); let pane_id = app.state.workspaces[0].tabs[0].root_pane; let public_id = app.public_pane_id(0, pane_id).unwrap(); let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] .attached_terminal_id .clone(); let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 4); runtime.test_process_pty_bytes(b"https://example.com"); let revision = runtime.content_seq(); app.terminal_runtimes.insert(terminal_id, runtime); let params = PaneLinkActivateParams { pane_id: public_id, viewport_row: 0, col: 1, content_revision: Some(revision), offset_from_bottom: Some(0), }; let response = app.handle_api_request(Request { id: "hover".into(), method: Method::PaneLinkResolve(params.clone()), }); assert!( matches!(response_result(&response), ResponseResult::PaneLinkResolved { regions } if regions.len() == 1) ); assert!( rx.try_recv().is_err(), "resolution must not write to the PTY" ); for stale in [ PaneLinkActivateParams { content_revision: Some(revision + 2), ..params.clone() }, PaneLinkActivateParams { offset_from_bottom: Some(1), ..params.clone() }, ] { let response = app.handle_pane_link_resolve("hover".into(), stale); let response: serde_json::Value = serde_json::from_str(&response).unwrap(); assert_eq!(response["error"]["code"], "stale_content"); } let changed = app.read_checked_pane_link("hover", ¶ms, "resolution", |runtime, _, _| { runtime.test_process_pty_bytes(b"changed"); }); let response: serde_json::Value = serde_json::from_str(&changed.unwrap_err()).unwrap(); assert_eq!(response["error"]["code"], "stale_content"); assert_eq!( response["error"]["message"], "pane content or viewport changed during link resolution" ); assert!(rx.try_recv().is_err()); app.state.active = None; let response: serde_json::Value = serde_json::from_str(&app.handle_pane_link_resolve("hover".into(), params)).unwrap(); assert_eq!(response["error"]["code"], "stale_target"); } fn test_app() -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ) } fn response_result(response: &str) -> ResponseResult { serde_json::from_str::(response) .expect("success response") .result } fn unique_temp_path(name: &str) -> std::path::PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id())) } fn canonical_path_string(path: &std::path::Path) -> String { path.canonicalize() .unwrap_or_else(|_| path.to_path_buf()) .display() .to_string() } /// Wait for non-empty contents at `path`. Shell `>` creates the file empty /// before the command writes, so waiting on existence alone can read EOF. /// `pump` advances any event loop the command depends on. fn read_capture_when_ready(path: &std::path::Path, mut pump: impl FnMut()) -> String { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); loop { pump(); if let Ok(contents) = std::fs::read_to_string(path) { if !contents.is_empty() { return contents; } } assert!( std::time::Instant::now() < deadline, "plugin command did not write {} within deadline", path.display() ); std::thread::sleep(std::time::Duration::from_millis(10)); } } fn write_manifest(root: &std::path::Path) -> std::path::PathBuf { std::fs::create_dir_all(root).unwrap(); let manifest = root.join("herdr-plugin.toml"); std::fs::write( &manifest, r#" id = "example.worktree-bootstrap" name = "Worktree Bootstrap" version = "0.1.0" min_herdr_version = "0.6.10" description = "Prepare new worktrees" platforms = ["linux", "macos", "windows"] [[build]] command = ["bun", "install"] [[actions]] id = "bootstrap" title = "Bootstrap worktree" contexts = ["workspace"] command = ["bun", "run", "bootstrap.ts"] [[events]] on = "worktree.created" command = ["bun", "run", "bootstrap.ts"] [[panes]] id = "board" title = "Worktree board" command = ["bun", "run", "board.ts"] [[link_handlers]] id = "github-pr" title = "Open GitHub PR" pattern = "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$" action = "bootstrap" "#, ) .unwrap(); manifest } fn write_manifest_content(root: &std::path::Path, content: &str) -> std::path::PathBuf { std::fs::create_dir_all(root).unwrap(); let manifest = root.join("herdr-plugin.toml"); std::fs::write(&manifest, content).unwrap(); manifest } fn link_manifest(app: &mut App, root: &std::path::Path) { let result = app.handle_api_request(Request { id: "link".into(), method: Method::PluginLink(PluginLinkParams { path: root.display().to_string(), enabled: true, source: None, }), }); assert!( result.contains("plugin_linked"), "expected plugin_linked: {result}" ); } #[test] fn plugin_link_creates_stable_config_and_state_dirs() { let mut app = test_app(); let root = unique_temp_path("plugin-link-dirs"); let config_dir = super::env::plugin_config_dir("example.config-dirs"); let state_dir = super::env::plugin_state_dir("example.config-dirs"); let _ = std::fs::remove_dir_all(&config_dir); let _ = std::fs::remove_dir_all(&state_dir); write_manifest_content( &root, r#" id = "example.config-dirs" name = "Config Dirs" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos", "windows"] "#, ); link_manifest(&mut app, &root); assert!(config_dir.is_dir()); assert!(state_dir.is_dir()); let _ = std::fs::remove_dir_all(root); let _ = std::fs::remove_dir_all(config_dir); let _ = std::fs::remove_dir_all(state_dir); } #[test] fn plugin_link_seeds_stable_config_dir_from_legacy_unhashed_dir() { let mut app = test_app(); let root = unique_temp_path("plugin-link-legacy-config"); let config_dir = super::env::plugin_config_dir("example.legacy-config"); let state_dir = super::env::plugin_state_dir("example.legacy-config"); let legacy_dir = crate::config::config_dir() .join("plugins") .join("example.legacy-config"); let _ = std::fs::remove_dir_all(&config_dir); let _ = std::fs::remove_dir_all(&state_dir); let _ = std::fs::remove_dir_all(&legacy_dir); std::fs::create_dir_all(&legacy_dir).unwrap(); std::fs::write(legacy_dir.join(".env"), "TELEGRAM_BOT_TOKEN=test\n").unwrap(); write_manifest_content( &root, r#" id = "example.legacy-config" name = "Legacy Config" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos", "windows"] "#, ); link_manifest(&mut app, &root); assert_eq!( std::fs::read_to_string(config_dir.join(".env")).unwrap(), "TELEGRAM_BOT_TOKEN=test\n" ); let _ = std::fs::remove_dir_all(root); let _ = std::fs::remove_dir_all(config_dir); let _ = std::fs::remove_dir_all(state_dir); let _ = std::fs::remove_dir_all(legacy_dir); } #[test] fn plugin_link_lists_and_unlinks_manifest() { let mut app = test_app(); let root = unique_temp_path("plugin-link"); write_manifest(&root); let link = app.handle_api_request(Request { id: "link".into(), method: Method::PluginLink(PluginLinkParams { path: root.display().to_string(), enabled: true, source: None, }), }); let ResponseResult::PluginLinked { plugin } = response_result(&link) else { panic!("expected plugin linked response: {link}"); }; assert_eq!(plugin.plugin_id, "example.worktree-bootstrap"); assert_eq!(plugin.name, "Worktree Bootstrap"); assert_eq!(plugin.version, "0.1.0"); assert_eq!( plugin.manifest_path, canonical_path_string(&root.join("herdr-plugin.toml")) ); assert_eq!( plugin.plugin_root, crate::platform::plugin_runtime_path(&root.canonicalize().unwrap()) .display() .to_string() ); assert!(plugin.enabled); assert_eq!(plugin.build.len(), 1); assert_eq!(plugin.build[0].command, ["bun", "install"]); assert_eq!(plugin.actions.len(), 1); assert_eq!(plugin.actions[0].id, "bootstrap"); assert_eq!(plugin.actions[0].command, ["bun", "run", "bootstrap.ts"]); assert_eq!(plugin.events.len(), 1); assert_eq!(plugin.events[0].on, "worktree.created"); assert_eq!(plugin.panes.len(), 1); assert_eq!(plugin.panes[0].id, "board"); assert_eq!(plugin.panes[0].placement, PluginPanePlacement::Overlay); assert_eq!(plugin.link_handlers.len(), 1); assert_eq!(plugin.link_handlers[0].id, "github-pr"); assert_eq!(plugin.link_handlers[0].action, "bootstrap"); let list = app.handle_api_request(Request { id: "list".into(), method: Method::PluginList(PluginListParams { plugin_id: None }), }); let ResponseResult::PluginList { plugins } = response_result(&list) else { panic!("expected plugin list response: {list}"); }; assert_eq!(plugins.len(), 1); assert_eq!(plugins[0].plugin_id, "example.worktree-bootstrap"); let unlink = app.handle_api_request(Request { id: "unlink".into(), method: Method::PluginUnlink(PluginUnlinkParams { plugin_id: "example.worktree-bootstrap".into(), }), }); assert!(matches!( response_result(&unlink), ResponseResult::PluginUnlinked { plugin_id, removed: true } if plugin_id == "example.worktree-bootstrap" )); let list = app.handle_api_request(Request { id: "list-empty".into(), method: Method::PluginList(PluginListParams { plugin_id: None }), }); let ResponseResult::PluginList { plugins } = response_result(&list) else { panic!("expected plugin list response: {list}"); }; assert!(plugins.is_empty()); let _ = std::fs::remove_dir_all(root); } #[test] fn plugin_manifest_preserves_whitespace_only_command_arguments() { let root = unique_temp_path("plugin-whitespace-argv"); write_manifest_content( &root, r#" id = "example.whitespace-argv" name = "Whitespace argv" version = "0.1.0" min_herdr_version = "0.7.0" platforms = ["linux", "macos"] [[panes]] id = "cut" title = "Cut on tab" command = ["awk", "-F", "\t", " {print $1} "] "#, ); let plugin = load_plugin_manifest(&root.display().to_string(), true) .expect("literal whitespace argv elements should be valid"); assert_eq!(plugin.panes[0].command, ["awk", "-F", "\t", " {print $1} "]); let _ = std::fs::remove_dir_all(root); } #[test] fn plugin_manifest_rejects_empty_command_elements() { for (name, command) in [("array", "[]"), ("element", r#"["echo", ""]"#)] { let root = unique_temp_path(&format!("plugin-empty-command-{name}")); write_manifest_content( &root, &format!( r#" id = "example.empty-command-{name}" name = "Empty command {name}" version = "0.1.0" min_herdr_version = "0.7.0" platforms = ["linux", "macos"] [[panes]] id = "empty" title = "Empty command" command = {command} "# ), ); let result = load_plugin_manifest(&root.display().to_string(), true); assert!(matches!(result, Err(("invalid_plugin_command", _)))); let _ = std::fs::remove_dir_all(root); } } #[test] fn plugin_manifest_preserves_legacy_event_order_with_exact_argv() { let root = unique_temp_path("plugin-event-whitespace-order"); write_manifest_content( &root, r#" id = "example.event-whitespace-order" name = "Event whitespace order" version = "0.1.0" min_herdr_version = "0.7.0" platforms = ["linux", "macos"] [[events]] on = "workspace.created" command = ["echo", "!"] [[events]] on = "workspace.created" command = ["echo", " a"] [[events]] on = "workspace.created" command = ["echo", "a ", "first"] [[events]] on = "workspace.created" command = ["echo", " a", "first "] "#, ); let plugin = load_plugin_manifest(&root.display().to_string(), true) .expect("event commands with whitespace should load"); assert_eq!(plugin.events[0].command, ["echo", "!"]); assert_eq!(plugin.events[1].command, ["echo", " a"]); assert_eq!(plugin.events[2].command, ["echo", "a ", "first"]); assert_eq!(plugin.events[3].command, ["echo", " a", "first "]); let _ = std::fs::remove_dir_all(root); } #[test] fn plugin_link_rejects_invalid_github_source_path() { let mut app = test_app(); let root = unique_temp_path("plugin-invalid-source"); write_manifest(&root); let response = app.handle_api_request(Request { id: "link-invalid-source".into(), method: Method::PluginLink(PluginLinkParams { path: root.display().to_string(), enabled: true, source: Some(PluginSourceInfo { kind: PluginSourceKind::Github, owner: Some("ogulcancelik".into()), repo: Some("herdr-plugin-examples".into()), subdir: Some("worktree-bootstrap".into()), requested_ref: None, resolved_commit: Some("abc123".into()), managed_path: Some(root.display().to_string()), installed_unix_ms: Some(42), }), }), }); let value: serde_json::Value = serde_json::from_str(&response).unwrap(); assert_eq!(value["error"]["code"], "invalid_plugin_source"); let _ = std::fs::remove_dir_all(root); } #[test] fn link_rejects_invalid_min_herdr_versions() { let cases = [ ( "plugin-missing-min-herdr", r#" id = "example.missing-min-herdr" name = "Missing Min Herdr" version = "0.1.0" platforms = ["linux", "macos", "windows"] "#, "invalid_plugin_min_herdr_version", ), ( "plugin-invalid-min-herdr", r#" id = "example.invalid-min-herdr" name = "Invalid Min Herdr" version = "0.1.0" min_herdr_version = "soon" platforms = ["linux", "macos", "windows"] "#, "invalid_plugin_min_herdr_version", ), ( "plugin-future-min-herdr", r#" id = "example.future-min-herdr" name = "Future Min Herdr" version = "0.1.0" min_herdr_version = "999.0.0" platforms = ["linux", "macos", "windows"] "#, "plugin_requires_newer_herdr", ), ( "plugin-non-popup-size", r#" id = "example.non-popup-size" name = "Non Popup Size" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos", "windows"] [[panes]] id = "board" title = "Board" placement = "split" width = "80%" command = ["echo", "board"] "#, "invalid_plugin_pane_size", ), ]; for (name, manifest, expected_code) in cases { let root = unique_temp_path(name); write_manifest_content(&root, manifest); let result = load_plugin_manifest(&root.display().to_string(), true); assert!( matches!(result, Err((code, _)) if code == expected_code), "{name}: expected {expected_code}, got {result:?}" ); let _ = std::fs::remove_dir_all(root); } } #[test] fn link_rejects_duplicate_action_ids() { let root = unique_temp_path("plugin-duplicate-action"); write_manifest_content( &root, r#" id = "example.duplicate" name = "Duplicate" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos", "windows"] [[actions]] id = "run" title = "Run" command = ["echo", "a"] [[actions]] id = "run" title = "Run again" command = ["echo", "b"] "#, ); let result = load_plugin_manifest(&root.display().to_string(), true); assert!(matches!(result, Err(("duplicate_plugin_action_id", _)))); let _ = std::fs::remove_dir_all(root); } #[test] fn link_rejects_dotted_action_ids() { let root = unique_temp_path("plugin-dotted-action"); write_manifest_content( &root, r#" id = "example.dotted-action" name = "Dotted Action" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos", "windows"] [[actions]] id = "build.release" title = "Build" command = ["echo", "build"] "#, ); let result = load_plugin_manifest(&root.display().to_string(), true); assert!(matches!(result, Err(("invalid_plugin_action_id", _)))); let _ = std::fs::remove_dir_all(root); } #[test] fn link_rejects_duplicate_pane_ids() { let root = unique_temp_path("plugin-duplicate-pane"); write_manifest_content( &root, r#" id = "example.duplicate-pane" name = "Duplicate Pane" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos", "windows"] [[panes]] id = "ui" title = "UI" command = ["echo", "a"] [[panes]] id = "ui" title = "UI again" command = ["echo", "b"] "#, ); let result = load_plugin_manifest(&root.display().to_string(), true); assert!(matches!(result, Err(("duplicate_plugin_pane_id", _)))); let _ = std::fs::remove_dir_all(root); } #[test] fn startup_hook_manifest_loads() { let root = unique_temp_path("plugin-startup-manifest"); write_manifest_content( &root, r#" id = "example.startup-manifest" name = "Startup Manifest" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos", "windows"] [[startup]] command = ["node", "restore.js"] platforms = ["linux", "macos"] "#, ); let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap(); assert_eq!(plugin.startup.len(), 1); assert_eq!(plugin.startup[0].command, ["node", "restore.js"]); assert_eq!( plugin.startup[0].platforms, Some(vec![ crate::api::schema::PluginPlatform::Linux, crate::api::schema::PluginPlatform::Macos, ]) ); let _ = std::fs::remove_dir_all(root); } #[test] fn smoke_fixture_manifest_loads() { let plugin = load_plugin_manifest("tests/fixtures/plugin-smoke", true) .expect("smoke fixture should load"); assert_eq!(plugin.plugin_id, "example.smoke"); assert_eq!(plugin.actions.len(), 1); assert_eq!(plugin.events.len(), 1); assert_eq!(plugin.panes.len(), 1); assert!(plugin.warnings.is_empty()); } #[test] fn plugin_command_output_reader_caps_and_marks_truncation() { let output = read_capped_plugin_output("abcdef".as_bytes(), 3); assert_eq!(output, "abc\n[herdr truncated plugin output after 3 bytes]"); } #[test] fn plugin_enable_disable_updates_registry_state() { let mut app = test_app(); let root = unique_temp_path("plugin-enable-disable"); write_manifest(&root); link_manifest(&mut app, &root); app.state.agent_view_override = Some(crate::api::schema::AgentViewSetParams { source: "plugin:example.worktree-bootstrap".into(), label: None, filter: None, sort: Vec::new(), }); let disabled = app.handle_api_request(Request { id: "disable".into(), method: Method::PluginDisable(PluginSetEnabledParams { plugin_id: "example.worktree-bootstrap".into(), }), }); let ResponseResult::PluginDisabled { plugin } = response_result(&disabled) else { panic!("expected disabled response: {disabled}"); }; assert!(!plugin.enabled); assert!(app.state.agent_view_override.is_none()); let delayed_restore = app.handle_api_request(Request { id: "delayed-startup-restore".into(), method: Method::AgentViewSet(crate::api::schema::AgentViewSetParams { source: "plugin:example.worktree-bootstrap".into(), label: None, filter: None, sort: Vec::new(), }), }); let delayed_restore: crate::api::schema::ErrorResponse = serde_json::from_str(&delayed_restore).unwrap(); assert_eq!(delayed_restore.error.code, "plugin_disabled"); let enabled = app.handle_api_request(Request { id: "enable".into(), method: Method::PluginEnable(PluginSetEnabledParams { plugin_id: "example.worktree-bootstrap".into(), }), }); let ResponseResult::PluginEnabled { plugin } = response_result(&enabled) else { panic!("expected enabled response: {enabled}"); }; assert!(plugin.enabled); let _ = std::fs::remove_dir_all(root); } #[test] fn plugin_pane_open_requires_installed_plugin() { let mut app = test_app(); let response = app.handle_api_request(Request { id: "pane-open".into(), method: Method::PluginPaneOpen(PluginPaneOpenParams { plugin_id: "example.missing".into(), entrypoint: "ui".into(), placement: Some(PluginPanePlacement::Split), width: None, height: None, workspace_id: None, target_pane_id: None, direction: None, cwd: None, focus: false, env: std::collections::HashMap::new(), }), }); let value: serde_json::Value = serde_json::from_str(&response).unwrap(); assert_eq!(value["error"]["code"], "plugin_not_found"); } #[test] fn plugin_pane_open_rejects_popup_size_for_non_popup_placement() { let mut app = test_app(); let root = unique_temp_path("plugin-pane-non-popup-size-param"); write_manifest(&root); link_manifest(&mut app, &root); let response = app.handle_api_request(Request { id: "pane-open-size".into(), method: Method::PluginPaneOpen(PluginPaneOpenParams { plugin_id: "example.worktree-bootstrap".into(), entrypoint: "board".into(), placement: Some(PluginPanePlacement::Split), width: Some(crate::popup_size::PopupSize::Percent(80)), height: None, workspace_id: None, target_pane_id: None, direction: Some(crate::api::schema::SplitDirection::Right), cwd: None, focus: false, env: std::collections::HashMap::new(), }), }); let value: serde_json::Value = serde_json::from_str(&response).unwrap(); assert_eq!(value["error"]["code"], "invalid_params"); let _ = std::fs::remove_dir_all(root); } #[cfg(windows)] #[tokio::test] async fn windows_plugin_pane_commands_resolve_from_plugin_root_with_cwd_override() { use std::os::windows::ffi::OsStrExt; let mut app = test_app(); app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-pane-paths")]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; app.state.mode = crate::app::Mode::Terminal; let root = unique_temp_path("plugin-pane-paths"); let child_cwd = root.join("child-cwd"); std::fs::create_dir_all(&child_cwd).unwrap(); let cleanup_root = root.canonicalize().unwrap(); let tool = root.join("tool.exe"); let long_where_relative = format!("bin/{}/where.exe", "x".repeat(220)); let long_where = root.join(&long_where_relative); std::fs::create_dir_all(long_where.parent().unwrap()).unwrap(); let shell = std::env::var_os("ComSpec").expect("ComSpec"); std::fs::copy(&shell, &tool).unwrap(); let where_exe = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()) .join("System32/where.exe"); std::fs::copy(where_exe, &long_where).unwrap(); let where_probe = "herdr-long-path-probe.exe"; std::fs::write(child_cwd.join(where_probe), b"").unwrap(); assert!( root.as_os_str().encode_wide().count() < windows_sys::Win32::Foundation::MAX_PATH as usize ); assert!( long_where.as_os_str().encode_wide().count() >= windows_sys::Win32::Foundation::MAX_PATH as usize ); let script = "@echo off\r\n(echo %1&cd)>capture-%1.tmp\r\nmove /y capture-%1.tmp capture-%1.txt >nul\r\n"; std::fs::write(root.join("slot.cmd"), script).unwrap(); std::fs::write(child_cwd.join("slot.cmd"), script).unwrap(); write_manifest_content( &root, &format!( r#" id = "example.pane-paths" name = "Pane Paths" version = "0.1.0" min_herdr_version = "0.7.0" platforms = ["windows"] [[panes]] id = "explicit" title = "Explicit" command = ["./tool.exe", "/d", "/c", "slot.cmd", "explicit"] [[panes]] id = "bare" title = "Bare" command = ["tool.exe", "/d", "/c", "slot.cmd", "bare"] [[panes]] id = "path" title = "Path" command = ["cmd.exe", "/d", "/c", "slot.cmd", "path"] [[panes]] id = "absolute" title = "Absolute" command = ['{}', "/d", "/c", "slot.cmd", "absolute"] [[panes]] id = "long" title = "Long executable" command = ['./{}', "{}"] [[panes]] id = "default" title = "Default cwd" command = ["cmd.exe", "/d", "/c", "slot.cmd", "default"] "#, tool.display(), long_where_relative, where_probe ), ); link_manifest(&mut app, &root); for (entrypoint, cwd) in [ ("explicit", Some(&child_cwd)), ("bare", Some(&child_cwd)), ("path", Some(&child_cwd)), ("absolute", Some(&child_cwd)), ("long", Some(&child_cwd)), ("default", None), ] { let expected_cwd = cwd.unwrap_or(&root); let open = app.handle_api_request(Request { id: format!("pane-open-{entrypoint}"), method: Method::PluginPaneOpen(PluginPaneOpenParams { plugin_id: "example.pane-paths".into(), entrypoint: entrypoint.into(), placement: Some(PluginPanePlacement::Split), width: None, height: None, workspace_id: None, target_pane_id: None, direction: None, cwd: cwd.map(|path| path.display().to_string()), focus: false, env: std::collections::HashMap::new(), }), }); let ResponseResult::PluginPaneOpened { plugin_pane } = response_result(&open) else { panic!("failed to open {entrypoint} plugin pane: {open}"); }; if entrypoint == "long" { let Some((ws_idx, pane_id)) = app.parse_pane_id(&plugin_pane.pane.pane_id) else { panic!("opened pane id should parse"); }; let runtime = app .state .runtime_for_pane_in_workspace(&app.terminal_runtimes, ws_idx, pane_id) .expect("long executable pane runtime"); let expected = child_cwd.join(where_probe).canonicalize().unwrap(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); loop { let text = runtime.recent_unwrapped_text(20); if text.lines().any(|line| { std::fs::canonicalize(line.trim()).is_ok_and(|path| path == expected) }) { break; } assert!( std::time::Instant::now() < deadline, "long where.exe output missing from pane: {text:?}" ); std::thread::sleep(std::time::Duration::from_millis(10)); } continue; } let capture = read_capture_when_ready( &expected_cwd.join(format!("capture-{entrypoint}.txt")), || {}, ); let mut lines = capture.lines(); assert_eq!(lines.next(), Some(entrypoint)); let actual_cwd = std::fs::canonicalize(lines.next().expect("captured cwd")).unwrap(); assert_eq!(actual_cwd, expected_cwd.canonicalize().unwrap()); } for (_, runtime) in app.terminal_runtimes.drain() { runtime.shutdown(); } // PTY actor shutdown is queued, so its Windows handles can outlive `shutdown()`. let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); loop { match std::fs::remove_dir_all(&cleanup_root) { Ok(()) => break, Err(error) => { assert!( std::time::Instant::now() < deadline, "failed to remove {} after runtime shutdown: {error}", cleanup_root.display() ); std::thread::sleep(std::time::Duration::from_millis(10)); } } } } #[cfg(target_os = "linux")] #[tokio::test] async fn plugin_launch_survives_executable_replacement() { const CHILD_ROOT: &str = "HERDR_TEST_PLUGIN_REPLACEMENT_ROOT"; if let Some(root) = std::env::var_os(CHILD_ROOT) { let root = std::path::PathBuf::from(root); let executable = std::env::current_exe().unwrap(); let replacement = root.join("replacement"); std::fs::copy(&executable, &replacement).unwrap(); std::fs::rename(&replacement, &executable).unwrap(); assert!(!std::env::current_exe().unwrap().exists()); let mut app = test_app(); app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-update")]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; app.state.mode = crate::app::Mode::Terminal; let plugin_root = root.join("plugin"); write_manifest_content( &plugin_root, r#" id = "example.update" name = "Update Probe" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux"] [[actions]] id = "probe" title = "Probe executable" command = ["sh", "-c", '"$HERDR_BIN_PATH" --list >/dev/null; printf "%s\n" "$?" > action-status'] [[panes]] id = "probe" title = "Probe executable" command = ["sh", "-c", '"$HERDR_BIN_PATH" --list >/dev/null; printf "%s\n" "$?" > pane-status'] "#, ); link_manifest(&mut app, &plugin_root); app.invoke_plugin_action_from_keybind("example.update.probe".into(), None) .unwrap(); let action_status = read_capture_when_ready(&plugin_root.join("action-status"), || { app.drain_all_internal_events(); }); let open = app.handle_api_request(Request { id: "update-pane".into(), method: Method::PluginPaneOpen(PluginPaneOpenParams { plugin_id: "example.update".into(), entrypoint: "probe".into(), placement: Some(PluginPanePlacement::Overlay), width: None, height: None, workspace_id: None, target_pane_id: None, direction: None, cwd: None, focus: true, env: std::collections::HashMap::new(), }), }); assert!(matches!( response_result(&open), ResponseResult::PluginPaneOpened { .. } )); let pane_status = read_capture_when_ready(&plugin_root.join("pane-status"), || {}); for (_, runtime) in app.terminal_runtimes.drain() { runtime.shutdown(); } assert_eq!( (action_status.trim(), pane_status.trim()), ("0", "0"), "plugin action and pane must launch Herdr after its executable is replaced" ); return; } let root = std::path::PathBuf::from("/var/tmp").join(format!( "herdr-plugin-update-{}-{}", std::process::id(), SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() )); std::fs::create_dir_all(&root).unwrap(); let executable = root.join("herdr test"); std::fs::copy(std::env::current_exe().unwrap(), &executable).unwrap(); let result = std::process::Command::new(&executable) .args([ "--exact", "app::api::plugins::tests::plugin_launch_survives_executable_replacement", "--nocapture", ]) .env(CHILD_ROOT, &root) .output(); std::fs::remove_dir_all(&root).unwrap(); let output = result.unwrap(); assert!( output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); } #[cfg(unix)] #[tokio::test] async fn plugin_pane_open_uses_plugin_root_title_env_and_target_context() { let mut app = test_app(); let mut workspace = crate::workspace::Workspace::test_new("plugin-target"); workspace.custom_name = None; let root_pane = workspace.tabs[0].root_pane; let root_terminal = workspace.terminal_id(root_pane).cloned().unwrap(); app.state.workspaces = vec![workspace]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; app.state.mode = crate::app::Mode::Terminal; app.state.kitty_graphics_enabled = true; app.state.host_cell_size = crate::kitty_graphics::HostCellSize { width_px: 11, height_px: 22, }; app.state.terminals.get_mut(&root_terminal).unwrap().cwd = "/tmp".into(); let target_public_pane_id = app.public_pane_id(0, root_pane).unwrap(); let root = unique_temp_path("plugin-pane-open"); let capture = root.join("capture.txt"); write_manifest_content( &root, &format!( r#" id = "example.pane" name = "Pane Plugin" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos"] [[panes]] id = "board" title = "Plugin Board" command = ["sh", "-c", "printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \"$PWD\" \"$HERDR_PLUGIN_ID\" \"$HERDR_PLUGIN_ENTRYPOINT_ID\" \"$HERDR_WORKSPACE_ID\" \"$HERDR_PANE_ID\" \"$HERDR_BIN_PATH\" \"$HERDR_PLUGIN_CONTEXT_JSON\" \"${{HERDR_CELL_WIDTH_PX-unset}}\" \"${{HERDR_CELL_HEIGHT_PX-unset}}\" > {}"] "#, capture.display() ), ); link_manifest(&mut app, &root); let open = app.handle_api_request(Request { id: "pane-open".into(), method: Method::PluginPaneOpen(PluginPaneOpenParams { plugin_id: "example.pane".into(), entrypoint: "board".into(), placement: Some(PluginPanePlacement::Overlay), width: None, height: None, workspace_id: None, target_pane_id: None, direction: None, cwd: None, focus: true, env: std::collections::HashMap::from([ ("HERDR_PLUGIN_ID".to_string(), "spoofed-plugin".to_string()), ( "HERDR_PLUGIN_ENTRYPOINT_ID".to_string(), "spoofed-entrypoint".to_string(), ), ( "HERDR_PLUGIN_CONTEXT_JSON".to_string(), "{\"spoofed\":true}".to_string(), ), ( "HERDR_BIN_PATH".to_string(), "/tmp/spoofed-herdr".to_string(), ), ]), }), }); let ResponseResult::PluginPaneOpened { plugin_pane } = response_result(&open) else { panic!("expected plugin pane opened response: {open}"); }; assert_eq!(plugin_pane.plugin_id, "example.pane"); assert_eq!(plugin_pane.entrypoint, "board"); assert_eq!(plugin_pane.pane.label.as_deref(), Some("Plugin Board")); let Some((_, opened_pane_id)) = app.parse_pane_id(&plugin_pane.pane.pane_id) else { panic!("opened pane id should parse"); }; assert!(app.state.plugin_panes.contains_key(&opened_pane_id)); let text = read_capture_when_ready(&capture, || {}); let mut lines = text.lines(); assert_eq!(lines.next(), Some(canonical_path_string(&root).as_str())); assert_eq!(lines.next(), Some("example.pane")); assert_eq!(lines.next(), Some("board")); assert_eq!(lines.next(), Some(plugin_pane.pane.workspace_id.as_str())); assert_eq!(lines.next(), Some(plugin_pane.pane.pane_id.as_str())); let bin_path = lines.next().expect("bin path"); assert_ne!(bin_path, "/tmp/spoofed-herdr"); assert_eq!( bin_path, std::env::current_exe() .expect("current exe should resolve") .display() .to_string() ); let context: PluginInvocationContext = serde_json::from_str(lines.next().expect("context json")).unwrap(); assert_eq!( context.workspace_id.as_deref(), Some(plugin_pane.pane.workspace_id.as_str()) ); assert_eq!( context.focused_pane_id.as_deref(), Some(target_public_pane_id.as_str()) ); assert_eq!(lines.next(), Some("unset")); assert_eq!(lines.next(), Some("unset")); for (_, runtime) in app.terminal_runtimes.drain() { runtime.shutdown(); } let _ = std::fs::remove_dir_all(root); } #[cfg(unix)] #[tokio::test] async fn plugin_pane_open_injects_plugin_paths_and_protects_overrides() { let mut app = test_app(); app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-path-env")]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; app.state.mode = crate::app::Mode::Terminal; let root = unique_temp_path("plugin-pane-path-env"); let capture = root.join("capture.txt"); write_manifest_content( &root, &format!( r#" id = "example.path-env" name = "Path Env" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos"] [[panes]] id = "board" title = "Plugin Board" command = ["sh", "-c", "printf '%s\n%s\n%s\n' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUGIN_CONFIG_DIR\" \"$HERDR_PLUGIN_STATE_DIR\" > {}"] "#, capture.display() ), ); link_manifest(&mut app, &root); let open = app.handle_api_request(Request { id: "pane-open".into(), method: Method::PluginPaneOpen(PluginPaneOpenParams { plugin_id: "example.path-env".into(), entrypoint: "board".into(), placement: Some(PluginPanePlacement::Overlay), width: None, height: None, workspace_id: None, target_pane_id: None, direction: None, cwd: None, focus: true, env: std::collections::HashMap::from([ ( "HERDR_PLUGIN_ROOT".to_string(), "/tmp/spoofed-root".to_string(), ), ( "HERDR_PLUGIN_CONFIG_DIR".to_string(), "/tmp/spoofed-config".to_string(), ), ( "HERDR_PLUGIN_STATE_DIR".to_string(), "/tmp/spoofed-state".to_string(), ), ]), }), }); let ResponseResult::PluginPaneOpened { .. } = response_result(&open) else { panic!("expected plugin pane opened response: {open}"); }; let text = read_capture_when_ready(&capture, || {}); let mut lines = text.lines(); assert_eq!(lines.next(), Some(canonical_path_string(&root).as_str())); assert_eq!( lines.next(), Some( crate::config::config_dir() .join("plugins") .join("config") .join("example.path-env") .display() .to_string() .as_str() ) ); assert_eq!( lines.next(), Some( crate::config::state_dir() .join("plugins") .join("example.path-env") .display() .to_string() .as_str() ) ); for (_, runtime) in app.terminal_runtimes.drain() { runtime.shutdown(); } let _ = std::fs::remove_dir_all(root); } #[cfg(unix)] #[tokio::test] async fn plugin_pane_open_tab_emits_tab_created_before_pane_created() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), ); app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-tab")]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; app.state.mode = crate::app::Mode::Terminal; let root = unique_temp_path("plugin-pane-tab-events"); write_manifest_content( &root, r#" id = "example.tab" name = "Tab Plugin" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos"] [[panes]] id = "board" title = "Plugin Board" placement = "tab" command = ["sh", "-c", "sleep 1"] "#, ); link_manifest(&mut app, &root); let open = app.handle_api_request(Request { id: "pane-open-tab".into(), method: Method::PluginPaneOpen(PluginPaneOpenParams { plugin_id: "example.tab".into(), entrypoint: "board".into(), placement: None, width: None, height: None, workspace_id: None, target_pane_id: None, direction: None, cwd: None, focus: true, env: std::collections::HashMap::new(), }), }); let ResponseResult::PluginPaneOpened { .. } = response_result(&open) else { panic!("expected plugin pane opened response: {open}"); }; let events = event_hub .events_after(0) .into_iter() .map(|(_, event)| event.event) .collect::>(); let tab_created = events .iter() .position(|event| *event == crate::api::schema::EventKind::TabCreated) .expect("tab.created should be emitted"); let pane_created = events .iter() .position(|event| *event == crate::api::schema::EventKind::PaneCreated) .expect("pane.created should be emitted"); let layout_updated = events .iter() .position(|event| *event == crate::api::schema::EventKind::LayoutUpdated) .expect("layout.updated should be emitted"); assert!(tab_created < pane_created); assert!(pane_created < layout_updated); for (_, runtime) in app.terminal_runtimes.drain() { runtime.shutdown(); } let _ = std::fs::remove_dir_all(root); } #[cfg(unix)] #[tokio::test] async fn plugin_pane_open_zoomed_split_emits_layout_updated() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), ); app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-split")]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; app.state.mode = crate::app::Mode::Terminal; let root = unique_temp_path("plugin-pane-split-layout-event"); write_manifest_content( &root, r#" id = "example.split" name = "Split Plugin" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos"] [[panes]] id = "board" title = "Plugin Board" placement = "split" command = ["sh", "-c", "sleep 1"] "#, ); link_manifest(&mut app, &root); let open = app.handle_api_request(Request { id: "pane-open-split".into(), method: Method::PluginPaneOpen(PluginPaneOpenParams { plugin_id: "example.split".into(), entrypoint: "board".into(), placement: Some(PluginPanePlacement::Zoomed), width: None, height: None, workspace_id: None, target_pane_id: None, direction: Some(crate::api::schema::SplitDirection::Right), cwd: None, focus: true, env: std::collections::HashMap::new(), }), }); let ResponseResult::PluginPaneOpened { .. } = response_result(&open) else { panic!("expected plugin pane opened response: {open}"); }; let events = event_hub.events_after(0); let pane_created = events .iter() .position(|(_, event)| event.event == crate::api::schema::EventKind::PaneCreated) .expect("pane.created should be emitted"); let layout_updated = events .iter() .position(|(_, event)| event.event == crate::api::schema::EventKind::LayoutUpdated) .expect("layout.updated should be emitted"); assert!(pane_created < layout_updated); assert!(matches!( &events[layout_updated].1.data, crate::api::schema::EventData::LayoutUpdated { layout } if layout.zoomed && layout.panes.len() == 2 )); for (_, runtime) in app.terminal_runtimes.drain() { runtime.shutdown(); } let _ = std::fs::remove_dir_all(root); } #[cfg(unix)] #[tokio::test] async fn plugin_pane_open_overlay_emits_layout_updated() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), ); app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-overlay")]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; app.state.mode = crate::app::Mode::Terminal; let root = unique_temp_path("plugin-pane-overlay-layout-event"); write_manifest_content( &root, r#" id = "example.overlay" name = "Overlay Plugin" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos"] [[panes]] id = "board" title = "Plugin Board" placement = "overlay" command = ["sh", "-c", "sleep 1"] "#, ); link_manifest(&mut app, &root); let open = app.handle_api_request(Request { id: "pane-open-overlay".into(), method: Method::PluginPaneOpen(PluginPaneOpenParams { plugin_id: "example.overlay".into(), entrypoint: "board".into(), placement: None, width: None, height: None, workspace_id: None, target_pane_id: None, direction: None, cwd: None, focus: true, env: std::collections::HashMap::new(), }), }); let ResponseResult::PluginPaneOpened { .. } = response_result(&open) else { panic!("expected plugin pane opened response: {open}"); }; let events = event_hub.events_after(0); let pane_created = events .iter() .position(|(_, event)| event.event == crate::api::schema::EventKind::PaneCreated) .expect("pane.created should be emitted"); let layout_updated = events .iter() .position(|(_, event)| event.event == crate::api::schema::EventKind::LayoutUpdated) .expect("layout.updated should be emitted"); assert!(pane_created < layout_updated); assert!(matches!( &events[layout_updated].1.data, crate::api::schema::EventData::LayoutUpdated { layout } if layout.zoomed && layout.panes.len() == 2 )); for (_, runtime) in app.terminal_runtimes.drain() { runtime.shutdown(); } let _ = std::fs::remove_dir_all(root); } #[cfg(unix)] #[tokio::test] async fn plugin_pane_open_popup_is_layout_neutral() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), ); app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-popup")]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; app.state.mode = crate::app::Mode::Terminal; let root_pane = app.state.workspaces[0].tabs[0].root_pane; let root_public = app.public_pane_id(0, root_pane).unwrap(); let root = unique_temp_path("plugin-pane-popup"); let env_capture = root.join("popup-env.txt"); let manifest = format!( r#" id = "example.popup" name = "Popup Plugin" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos"] [[panes]] id = "board" title = "Plugin Popup" placement = "popup" width = "80%" height = "40%" command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] "#, env_capture.display() ); write_manifest_content(&root, &manifest); link_manifest(&mut app, &root); let popup_request = |id: &str| Request { id: id.into(), method: Method::PluginPaneOpen(PluginPaneOpenParams { plugin_id: "example.popup".into(), entrypoint: "board".into(), placement: None, width: None, height: None, workspace_id: None, target_pane_id: None, direction: None, cwd: None, focus: true, env: std::collections::HashMap::new(), }), }; app.state.mode = crate::app::Mode::Navigate; let open = app.handle_api_request(popup_request("pane-open-popup")); assert_eq!(response_result(&open), ResponseResult::Ok {}); let duplicate = app.handle_api_request(popup_request("pane-open-popup-duplicate")); let duplicate: crate::api::schema::ErrorResponse = serde_json::from_str(&duplicate).unwrap(); assert_eq!(duplicate.error.code, "ui_busy"); assert_eq!( read_capture_when_ready(&env_capture, || { app.drain_internal_events(); }), "unset" ); let opened_pane_id = app.state.popup_pane.as_ref().unwrap().pane_id; assert!(!app.state.plugin_panes.contains_key(&opened_pane_id)); app.state.assert_invariants_for_test(); app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 100, 30); let (outer, inner) = crate::ui::popup_pane_rects(&app.state, app.state.view.terminal_area) .expect("popup rects"); assert_eq!((outer.width, outer.height), (80, 12)); assert_eq!((inner.width, inner.height), (77, 10)); assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); assert!(!app.state.workspaces[0].tabs[0].zoomed); let pane_list = app.handle_api_request(Request { id: "pane-list-popup".into(), method: Method::PaneList(PaneListParams { workspace_id: Some(app.public_workspace_id(0)), }), }); let ResponseResult::PaneList { panes } = response_result(&pane_list) else { panic!("expected pane list response: {pane_list}"); }; assert_eq!(panes.len(), 1); assert_eq!(panes[0].pane_id, root_public); assert!(panes[0].focused); assert_eq!( app.current_plugin_context("popup-open").focused_pane_id, Some(root_public) ); assert!(event_hub.events_after(0).is_empty()); app.handle_internal_event(crate::events::AppEvent::PaneDied { pane_id: opened_pane_id, exit_reason: crate::platform::ChildExitReason::Exited, }); assert!(app.state.popup_pane.is_none()); assert!(event_hub.events_after(0).is_empty()); for (_, runtime) in app.terminal_runtimes.drain() { runtime.shutdown(); } let _ = std::fs::remove_dir_all(root); } #[test] fn manifest_action_list_and_invoke_with_context() { let mut app = test_app(); let root = unique_temp_path("plugin-action-list"); write_manifest(&root); link_manifest(&mut app, &root); let list = app.handle_api_request(Request { id: "list".into(), method: Method::PluginActionList(PluginActionListParams { plugin_id: None }), }); let ResponseResult::PluginActionList { actions } = response_result(&list) else { panic!("expected plugin action list: {list}"); }; assert_eq!(actions.len(), 1); assert_eq!( actions[0].qualified_id(), "example.worktree-bootstrap.bootstrap" ); assert_eq!(actions[0].command, ["bun", "run", "bootstrap.ts"]); assert_eq!( actions[0].platforms, Some(vec![ crate::api::schema::PluginPlatform::Linux, crate::api::schema::PluginPlatform::Macos, crate::api::schema::PluginPlatform::Windows, ]) ); let invoke = app.handle_api_request(Request { id: "invoke".into(), method: Method::PluginActionInvoke(PluginActionInvokeParams { plugin_id: Some("example.worktree-bootstrap".into()), action_id: "bootstrap".into(), context: Some(PluginInvocationContext { workspace_id: Some("1".into()), workspace_label: None, workspace_cwd: None, worktree: None, tab_id: None, tab_label: None, focused_pane_id: None, focused_pane_cwd: None, focused_pane_agent: None, focused_pane_status: None, selected_text: None, invocation_source: Some("test".into()), correlation_id: Some("external-correlation".into()), clicked_url: None, link_handler_id: None, }), }), }); let ResponseResult::PluginActionInvoked { action, context, log, } = response_result(&invoke) else { panic!("expected plugin action invocation: {invoke}"); }; assert_eq!( action.qualified_id(), "example.worktree-bootstrap.bootstrap" ); assert_eq!(action.command, ["bun", "run", "bootstrap.ts"]); assert_eq!(log.plugin_id, "example.worktree-bootstrap"); assert_eq!(log.action_id.as_deref(), Some("bootstrap")); assert_eq!(context.workspace_id.as_deref(), Some("1")); assert_eq!(context.invocation_source.as_deref(), Some("test")); assert_eq!( context.correlation_id.as_deref(), Some("external-correlation") ); let _ = std::fs::remove_dir_all(root); } #[test] fn stale_registry_entries_are_visible_but_not_runnable() { let mut app = test_app(); let root = unique_temp_path("plugin-stale-registry"); write_manifest(&root); let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap(); let _ = std::fs::remove_dir_all(&root); let reloaded = crate::persist::plugin_registry::reload_manifests(vec![plugin], |path, enabled| { load_plugin_manifest(path, enabled).map_err(|(_, msg)| msg) }); assert_eq!(reloaded.len(), 1); assert!(reloaded[0].warnings.iter().any(|warning| warning .starts_with(crate::persist::plugin_registry::MANIFEST_UNAVAILABLE_WARNING_PREFIX))); app.state .installed_plugins .insert(reloaded[0].plugin_id.clone(), reloaded[0].clone()); let list = app.handle_api_request(Request { id: "plugin-list".into(), method: Method::PluginList(PluginListParams { plugin_id: None }), }); let ResponseResult::PluginList { plugins } = response_result(&list) else { panic!("expected plugin list: {list}"); }; assert_eq!(plugins.len(), 1); let actions = app.handle_api_request(Request { id: "action-list".into(), method: Method::PluginActionList(PluginActionListParams { plugin_id: None }), }); let ResponseResult::PluginActionList { actions } = response_result(&actions) else { panic!("expected action list: {actions}"); }; assert!(actions.is_empty()); let invoke = app.handle_api_request(Request { id: "invoke".into(), method: Method::PluginActionInvoke(PluginActionInvokeParams { plugin_id: Some("example.worktree-bootstrap".into()), action_id: "bootstrap".into(), context: None, }), }); let value: serde_json::Value = serde_json::from_str(&invoke).unwrap(); assert_eq!(value["error"]["code"], "plugin_manifest_unavailable"); let pane = app.handle_api_request(Request { id: "pane-open".into(), method: Method::PluginPaneOpen(PluginPaneOpenParams { plugin_id: "example.worktree-bootstrap".into(), entrypoint: "board".into(), placement: None, width: None, height: None, workspace_id: None, target_pane_id: None, direction: None, cwd: None, focus: true, env: std::collections::HashMap::new(), }), }); let value: serde_json::Value = serde_json::from_str(&pane).unwrap(); assert_eq!(value["error"]["code"], "plugin_manifest_unavailable"); } #[test] fn non_cli_plugin_consumers_refresh_global_enabled_state() { let _guard = crate::config::test_config_env_lock().lock().unwrap(); let previous_config_home = std::env::var_os("XDG_CONFIG_HOME"); let base = unique_temp_path("plugin-global-refresh"); std::env::set_var("XDG_CONFIG_HOME", &base); let root = base.join("plugin"); write_manifest(&root); let plugin = load_plugin_manifest(&root.display().to_string(), false).unwrap(); crate::persist::plugin_registry::update(|plugins| { plugins.retain(|entry| entry.plugin_id != plugin.plugin_id); plugins.push(plugin.clone()); }) .unwrap(); let mut app = test_app(); app.policy.persist_plugin_registry = true; let workspace = crate::workspace::Workspace::test_new("plugin-refresh"); let pane_id = workspace.tabs[0].root_pane; app.state.workspaces = vec![workspace]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; let make_stale = |app: &mut App| { let mut stale = plugin.clone(); stale.enabled = true; app.state .installed_plugins .insert(stale.plugin_id.clone(), stale); }; make_stale(&mut app); assert!(app .invoke_plugin_action_from_keybind("bootstrap".into(), None) .unwrap_err() .contains("disabled")); make_stale(&mut app); assert!(!app .invoke_plugin_link_handler_for_url( "https://github.com/herdrdev/herdr/issues/1174", pane_id, ) .unwrap()); make_stale(&mut app); let pane = app.handle_api_request(Request { id: "pane-disabled".into(), method: Method::PluginPaneOpen(PluginPaneOpenParams { plugin_id: "example.worktree-bootstrap".into(), entrypoint: "board".into(), placement: Some(PluginPanePlacement::Overlay), width: None, height: None, workspace_id: None, target_pane_id: None, direction: None, cwd: None, focus: true, env: std::collections::HashMap::new(), }), }); let pane: serde_json::Value = serde_json::from_str(&pane).unwrap(); assert_eq!(pane["error"]["code"], "plugin_disabled"); make_stale(&mut app); let logs_before = app.state.plugin_command_logs.len(); let workspace = app.workspace_info(0); app.run_plugin_event_hooks(&crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::WorktreeCreated, data: crate::api::schema::EventData::WorktreeCreated { workspace: workspace.clone(), worktree: crate::api::schema::WorktreeInfo { path: "/tmp/repo".into(), branch: Some("feature".into()), is_bare: false, is_detached: false, is_prunable: false, is_linked_worktree: true, open_workspace_id: Some(workspace.workspace_id), label: "feature".into(), }, }, }); assert_eq!(app.state.plugin_command_logs.len(), logs_before); let _ = std::fs::remove_dir_all(&base); match previous_config_home { Some(previous) => std::env::set_var("XDG_CONFIG_HOME", previous), None => std::env::remove_var("XDG_CONFIG_HOME"), } } #[cfg(unix)] #[test] fn manifest_action_invoke_runs_command_and_captures_log() { let mut app = test_app(); let root = unique_temp_path("plugin-action-runner"); write_manifest_content( &root, r#" id = "example.runner" name = "Runner" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos"] [[actions]] id = "run" title = "Run" command = ["sh", "-c", "printf '%s' \"$HERDR_PLUGIN_ACTION_ID\""] "#, ); link_manifest(&mut app, &root); let invoke = app.handle_api_request(Request { id: "invoke-runner".into(), method: Method::PluginActionInvoke(PluginActionInvokeParams { plugin_id: Some("example.runner".into()), action_id: "run".into(), context: None, }), }); let ResponseResult::PluginActionInvoked { log, .. } = response_result(&invoke) else { panic!("expected plugin action invocation: {invoke}"); }; assert_eq!(log.status, PluginCommandStatus::Running); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); while std::time::Instant::now() < deadline { app.drain_all_internal_events(); if app.state.plugin_command_logs.iter().any(|entry| { entry.log_id == log.log_id && entry.status != PluginCommandStatus::Running }) { break; } std::thread::sleep(std::time::Duration::from_millis(10)); } let logs = app.handle_api_request(Request { id: "logs".into(), method: Method::PluginLogList(PluginLogListParams { plugin_id: Some("example.runner".into()), limit: Some(10), }), }); let ResponseResult::PluginLogList { logs } = response_result(&logs) else { panic!("expected plugin logs: {logs}"); }; let finished = logs .iter() .find(|entry| entry.log_id == log.log_id) .expect("log should exist"); assert_eq!(finished.status, PluginCommandStatus::Succeeded); assert_eq!(finished.stdout.as_deref(), Some("run")); assert_eq!(finished.exit_code, Some(0)); let _ = std::fs::remove_dir_all(root); } #[cfg(unix)] #[test] fn manifest_action_invoke_injects_plugin_paths() { let mut app = test_app(); let root = unique_temp_path("plugin-action-path-env"); write_manifest_content( &root, r#" id = "example.action-paths" name = "Action Paths" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos"] [[actions]] id = "run" title = "Run" command = ["sh", "-c", "printf '%s\n%s\n%s' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUGIN_CONFIG_DIR\" \"$HERDR_PLUGIN_STATE_DIR\""] "#, ); link_manifest(&mut app, &root); let invoke = app.handle_api_request(Request { id: "invoke-runner".into(), method: Method::PluginActionInvoke(PluginActionInvokeParams { plugin_id: Some("example.action-paths".into()), action_id: "run".into(), context: None, }), }); let ResponseResult::PluginActionInvoked { log, .. } = response_result(&invoke) else { panic!("expected plugin action invocation: {invoke}"); }; let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); while std::time::Instant::now() < deadline { app.drain_all_internal_events(); if app.state.plugin_command_logs.iter().any(|entry| { entry.log_id == log.log_id && entry.status != PluginCommandStatus::Running }) { break; } std::thread::sleep(std::time::Duration::from_millis(10)); } let logs = app.handle_api_request(Request { id: "logs".into(), method: Method::PluginLogList(PluginLogListParams { plugin_id: Some("example.action-paths".into()), limit: Some(10), }), }); let ResponseResult::PluginLogList { logs } = response_result(&logs) else { panic!("expected plugin logs: {logs}"); }; let finished = logs .iter() .find(|entry| entry.log_id == log.log_id) .expect("log should exist"); assert_eq!(finished.status, PluginCommandStatus::Succeeded); let mut lines = finished.stdout.as_deref().unwrap_or_default().lines(); assert_eq!(lines.next(), Some(canonical_path_string(&root).as_str())); assert_eq!( lines.next(), Some( crate::config::config_dir() .join("plugins") .join("config") .join("example.action-paths") .display() .to_string() .as_str() ) ); assert_eq!( lines.next(), Some( crate::config::state_dir() .join("plugins") .join("example.action-paths") .display() .to_string() .as_str() ) ); let _ = std::fs::remove_dir_all(root); } #[tokio::test] async fn current_plugin_context_leaves_client_owned_selection_empty() { let mut app = test_app(); let workspace = crate::workspace::Workspace::test_new("plugin-selection"); let pane_id = workspace.tabs[0].root_pane; let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); app.state.workspaces = vec![workspace]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; app.state.mode = crate::app::Mode::Terminal; app.terminal_runtimes.insert( terminal_id, crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"hello plugin\n"), ); let context = app.current_plugin_context("selection-test"); assert_eq!(context.selected_text, None); } #[cfg(unix)] #[test] fn startup_hooks_run_once_with_plugin_environment() { let mut app = test_app(); let root = unique_temp_path("plugin-startup-hook"); let capture = root.join("startup.txt"); write_manifest_content( &root, &format!( r#" id = "example.startup" name = "Startup" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos"] [[startup]] command = ["sh", "-c", "printf '%s:%s' \"$HERDR_PLUGIN_ID\" \"$HERDR_PLUGIN_EVENT\" > {}"] "#, capture.display() ), ); link_manifest(&mut app, &root); app.run_plugin_startup_hooks(); assert_eq!( read_capture_when_ready(&capture, || { app.drain_all_internal_events(); }), "example.startup:startup" ); let plugin = app.state.installed_plugins.get("example.startup").unwrap(); assert_eq!(plugin.startup.len(), 1); let _ = std::fs::remove_dir_all(root); } #[cfg(unix)] #[test] fn event_hooks_use_event_target_context() { let mut app = test_app(); app.state.workspaces = vec![ crate::workspace::Workspace::test_new("active"), crate::workspace::Workspace::test_new("event-target"), ]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; let active_workspace_id = app.public_workspace_id(0); let target_workspace = app.workspace_info(1); let root = unique_temp_path("plugin-event-context"); let capture = root.join("context.json"); write_manifest_content( &root, &format!( r#" id = "example.event-context" name = "Event Context" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos"] [[events]] on = "worktree.created" command = ["sh", "-c", "printf '%s' \"$HERDR_PLUGIN_CONTEXT_JSON\" > {}"] "#, capture.display() ), ); link_manifest(&mut app, &root); app.run_plugin_event_hooks(&crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::WorktreeCreated, data: crate::api::schema::EventData::WorktreeCreated { workspace: target_workspace.clone(), worktree: crate::api::schema::WorktreeInfo { path: "/tmp/repo".into(), branch: Some("feature".into()), is_bare: false, is_detached: false, is_prunable: false, is_linked_worktree: true, open_workspace_id: Some(target_workspace.workspace_id.clone()), label: "feature".into(), }, }, }); let context: PluginInvocationContext = serde_json::from_str(&read_capture_when_ready(&capture, || { app.drain_all_internal_events(); })) .unwrap(); assert_eq!( context.workspace_id.as_deref(), Some(target_workspace.workspace_id.as_str()) ); assert_ne!( context.workspace_id.as_deref(), Some(active_workspace_id.as_str()) ); let _ = std::fs::remove_dir_all(root); } #[test] fn plugin_command_limit_rejects_and_logs() { let mut app = test_app(); let root = unique_temp_path("plugin-command-limit"); write_manifest(&root); link_manifest(&mut app, &root); app.state.plugin_commands_in_flight = MAX_PLUGIN_COMMANDS_IN_FLIGHT; let invoke = app.handle_api_request(Request { id: "invoke-limit".into(), method: Method::PluginActionInvoke(PluginActionInvokeParams { plugin_id: Some("example.worktree-bootstrap".into()), action_id: "bootstrap".into(), context: None, }), }); let value: serde_json::Value = serde_json::from_str(&invoke).unwrap(); assert_eq!(value["error"]["code"], "plugin_command_limit_reached"); let log = app .state .plugin_command_logs .last() .expect("rejected command should be logged"); assert_eq!(log.status, PluginCommandStatus::Failed); assert!(log .error .as_deref() .is_some_and(|error| error.contains("maximum concurrent plugin commands"))); let _ = std::fs::remove_dir_all(root); } #[test] fn closed_event_context_uses_closed_target_ids() { let mut app = test_app(); app.state.workspaces = vec![crate::workspace::Workspace::test_new("closed-events")]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; let active_pane_id = app.state.workspaces[0].tabs[0].root_pane; let active_public_pane_id = app.public_pane_id(0, active_pane_id).unwrap(); let workspace_id = app.public_workspace_id(0); let closed_tab_id = format!("{workspace_id}:t99"); let closed_pane_id = format!("{workspace_id}:p99"); let tab_context = app.plugin_context_for_event( &crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::TabClosed, data: crate::api::schema::EventData::TabClosed { tab_id: closed_tab_id.clone(), workspace_id: workspace_id.clone(), }, }, "tab.closed", ); assert_eq!( tab_context.workspace_id.as_deref(), Some(workspace_id.as_str()) ); assert_eq!(tab_context.tab_id.as_deref(), Some(closed_tab_id.as_str())); assert_eq!(tab_context.focused_pane_id, None); let pane_context = app.plugin_context_for_event( &crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::PaneClosed, data: crate::api::schema::EventData::PaneClosed { pane_id: closed_pane_id.clone(), workspace_id: workspace_id.clone(), }, }, "pane.closed", ); assert_eq!( pane_context.workspace_id.as_deref(), Some(workspace_id.as_str()) ); assert_eq!( pane_context.focused_pane_id.as_deref(), Some(closed_pane_id.as_str()) ); assert_ne!( pane_context.focused_pane_id.as_deref(), Some(active_public_pane_id.as_str()) ); app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { key: "repo-key".into(), label: "herdr".into(), repo_root: "/repo/herdr".into(), checkout_path: "/repo/herdr-issue".into(), is_linked_worktree: true, }); let workspace = app.workspace_info(0); let worktree = crate::api::schema::WorktreeInfo { path: "/repo/herdr-issue".into(), branch: Some("worktree/issue".into()), is_bare: false, is_detached: false, is_prunable: false, is_linked_worktree: true, open_workspace_id: None, label: "herdr".into(), }; app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { key: "repo-key".into(), label: "herdr".into(), repo_root: "/repo/herdr".into(), checkout_path: "/repo/herdr-other".into(), is_linked_worktree: true, }); let changed_context = app.plugin_context_for_event( &crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::WorktreeRemoved, data: crate::api::schema::EventData::WorktreeRemoved { workspace_id: workspace_id.clone(), workspace: Some(workspace.clone()), worktree: worktree.clone(), forced: true, }, }, "worktree.removed", ); assert_eq!( changed_context .worktree .as_ref() .map(|worktree| worktree.checkout_path.as_str()), Some("/repo/herdr-issue") ); app.state.workspaces.clear(); let removed_context = app.plugin_context_for_event( &crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::WorktreeRemoved, data: crate::api::schema::EventData::WorktreeRemoved { workspace_id: workspace_id.clone(), workspace: Some(workspace), worktree, forced: true, }, }, "worktree.removed", ); assert_eq!( removed_context.workspace_id.as_deref(), Some(workspace_id.as_str()) ); assert_eq!( removed_context .worktree .as_ref() .map(|worktree| worktree.checkout_path.as_str()), Some("/repo/herdr-issue") ); } #[cfg(unix)] #[test] fn plugin_link_handler_invokes_action_with_clicked_url_context() { let mut app = test_app(); app.state.workspaces = vec![crate::workspace::Workspace::test_new("link-handler")]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; let pane_id = app.state.workspaces[0].tabs[0].root_pane; let root = unique_temp_path("plugin-link-handler"); write_manifest_content( &root, r#" id = "example.links" name = "Links" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos"] [[actions]] id = "open" title = "Open link" command = ["sh", "-c", "printf '%s|%s' \"$HERDR_PLUGIN_LINK_HANDLER_ID\" \"$HERDR_PLUGIN_CLICKED_URL\""] [[link_handlers]] id = "github-issue" title = "Open GitHub issue" pattern = "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$" action = "open" "#, ); link_manifest(&mut app, &root); let handled = app .invoke_plugin_link_handler_for_url( "https://github.com/herdrdev/herdr/issues/398", pane_id, ) .expect("link handler should invoke"); assert!(handled); let log = app .state .plugin_command_logs .last() .expect("plugin command log should be recorded") .clone(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); while std::time::Instant::now() < deadline { app.drain_all_internal_events(); if app.state.plugin_command_logs.iter().any(|entry| { entry.log_id == log.log_id && entry.status != PluginCommandStatus::Running }) { break; } std::thread::sleep(std::time::Duration::from_millis(10)); } let finished = app .state .plugin_command_logs .iter() .find(|entry| entry.log_id == log.log_id) .expect("log should exist"); assert_eq!(finished.status, PluginCommandStatus::Succeeded); assert_eq!(finished.action_id.as_deref(), Some("open")); assert_eq!( finished.stdout.as_deref(), Some("github-issue|https://github.com/herdrdev/herdr/issues/398") ); let _ = std::fs::remove_dir_all(root); } #[test] fn plugin_link_handlers_keep_manifest_order_for_overlapping_patterns() { let mut app = test_app(); let root = unique_temp_path("plugin-link-handler-order"); write_manifest_content( &root, r#" id = "example.link-order" name = "Link Order" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos", "windows"] [[actions]] id = "specific" title = "Specific" command = ["true"] [[actions]] id = "generic" title = "Generic" command = ["true"] [[link_handlers]] id = "z-specific" title = "Specific GitHub issue" pattern = "^https://github\\.com/[^/]+/[^/]+/issues/[0-9]+$" action = "specific" [[link_handlers]] id = "a-generic" title = "Generic GitHub" pattern = "^https://github\\.com/" action = "generic" "#, ); link_manifest(&mut app, &root); let (_plugin, handler) = app .find_plugin_link_handler("https://github.com/herdrdev/herdr/issues/398") .expect("handler should match"); assert_eq!(handler.id, "z-specific"); assert_eq!(handler.action, "specific"); let _ = std::fs::remove_dir_all(root); } #[test] fn plugin_link_rejects_invalid_link_handler_pattern() { let mut app = test_app(); let root = unique_temp_path("plugin-bad-link-handler-pattern"); write_manifest_content( &root, r#" id = "example.bad-links" name = "Bad Links" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos", "windows"] [[actions]] id = "open" title = "Open link" command = ["true"] [[link_handlers]] id = "bad" title = "Bad" pattern = "[" action = "open" "#, ); let response = app.handle_api_request(Request { id: "link".into(), method: Method::PluginLink(PluginLinkParams { path: root.display().to_string(), enabled: true, source: None, }), }); let value: serde_json::Value = serde_json::from_str(&response).unwrap(); assert_eq!( value["error"]["code"], "invalid_plugin_link_handler_pattern" ); let _ = std::fs::remove_dir_all(root); } #[test] fn plugin_link_rejects_link_handler_unknown_action() { let mut app = test_app(); let root = unique_temp_path("plugin-bad-link-handler-action"); write_manifest_content( &root, r#" id = "example.bad-link-action" name = "Bad Link Action" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos", "windows"] [[actions]] id = "open" title = "Open link" command = ["true"] [[link_handlers]] id = "github" title = "GitHub" pattern = "^https://github\\.com/" action = "missing" "#, ); let response = app.handle_api_request(Request { id: "link".into(), method: Method::PluginLink(PluginLinkParams { path: root.display().to_string(), enabled: true, source: None, }), }); let value: serde_json::Value = serde_json::from_str(&response).unwrap(); assert_eq!(value["error"]["code"], "invalid_plugin_link_handler_action"); let _ = std::fs::remove_dir_all(root); } #[test] fn manifest_action_invoke_builds_default_workspace_context() { let mut app = test_app(); app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")]; app.state.workspaces[0].identity_cwd = "/tmp/issue".into(); app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; app.state.workspaces[0].custom_name = Some("Plugin Work".into()); app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { key: "repo-key".into(), label: "herdr".into(), repo_root: "/repo/herdr".into(), checkout_path: "/repo/herdr-issue".into(), is_linked_worktree: true, }); let pane_id = app.state.workspaces[0].tabs[0].root_pane; let pane_public = app.public_pane_id(0, pane_id).unwrap(); let tab_public = app.public_tab_id(0, 0).unwrap(); let workspace_public = app.public_workspace_id(0); let _ = app.handle_pane_report_agent( "report".into(), crate::api::schema::PaneReportAgentParams { pane_id: pane_public.clone(), source: "test".into(), agent: "codex".into(), state: crate::api::schema::PaneAgentState::Working, message: None, seq: None, agent_session_id: None, agent_session_path: None, resume_argv: None, }, ); let root = unique_temp_path("plugin-action-context"); // write a manifest with a "show" action in pane context std::fs::create_dir_all(&root).unwrap(); std::fs::write( root.join("herdr-plugin.toml"), r#" id = "example.context" name = "Context" version = "0.1.0" min_herdr_version = "0.6.10" [[actions]] id = "show" title = "Show Context" contexts = ["pane"] command = ["show-ctx"] "#, ) .unwrap(); link_manifest(&mut app, &root); let invoke = app.handle_api_request(Request { id: "invoke-context".into(), method: Method::PluginActionInvoke(PluginActionInvokeParams { plugin_id: Some("example.context".into()), action_id: "show".into(), context: None, }), }); let ResponseResult::PluginActionInvoked { context, .. } = response_result(&invoke) else { panic!("expected plugin action invocation: {invoke}"); }; assert_eq!( context.workspace_id.as_deref(), Some(workspace_public.as_str()) ); assert_eq!(context.workspace_label.as_deref(), Some("Plugin Work")); assert_eq!(context.workspace_cwd.as_deref(), Some("/tmp/issue")); assert_eq!(context.tab_id.as_deref(), Some(tab_public.as_str())); assert_eq!(context.tab_label.as_deref(), Some("1")); assert_eq!( context.focused_pane_id.as_deref(), Some(pane_public.as_str()) ); assert_eq!(context.focused_pane_cwd.as_deref(), Some("/tmp/issue")); assert_eq!(context.focused_pane_agent.as_deref(), Some("codex")); assert_eq!( context.focused_pane_status, Some(crate::api::schema::AgentStatus::Working) ); assert_eq!(context.invocation_source.as_deref(), Some("api")); assert_eq!(context.correlation_id.as_deref(), Some("invoke-context")); let worktree = context.worktree.as_ref().unwrap(); assert_eq!(worktree.repo_key, "repo-key"); assert_eq!(worktree.repo_name, "herdr"); assert_eq!(worktree.repo_root, "/repo/herdr"); assert_eq!(worktree.checkout_path, "/repo/herdr-issue"); assert!(worktree.is_linked_worktree); let _ = std::fs::remove_dir_all(root); } #[test] fn manifest_action_invoke_returns_plugin_disabled_when_disabled() { let mut app = test_app(); let root = unique_temp_path("plugin-disabled"); write_manifest(&root); // link as disabled let link_result = app.handle_api_request(Request { id: "link-disabled".into(), method: Method::PluginLink(PluginLinkParams { path: root.display().to_string(), enabled: false, source: None, }), }); assert!( link_result.contains("plugin_linked"), "expected plugin_linked: {link_result}" ); let invoke = app.handle_api_request(Request { id: "invoke-disabled".into(), method: Method::PluginActionInvoke(PluginActionInvokeParams { plugin_id: Some("example.worktree-bootstrap".into()), action_id: "bootstrap".into(), context: None, }), }); let value: serde_json::Value = serde_json::from_str(&invoke).unwrap(); assert_eq!(value["error"]["code"], "plugin_disabled"); let _ = std::fs::remove_dir_all(root); } fn write_manifest_with_bad_event(root: &std::path::Path) -> std::path::PathBuf { std::fs::create_dir_all(root).unwrap(); let manifest = root.join("herdr-plugin.toml"); std::fs::write( &manifest, r#" id = "example.bad-event" name = "Bad Event Plugin" version = "0.1.0" min_herdr_version = "0.6.10" [[events]] on = "worktree.craeted" command = ["sh", "-c", "echo hi"] [[events]] on = "pane.output_changed" command = ["sh", "-c", "echo too noisy"] [[events]] on = "worktree.created" command = ["sh", "-c", "echo ok"] "#, ) .unwrap(); manifest } #[test] fn link_with_unknown_event_name_succeeds_with_warning() { let mut app = test_app(); let root = unique_temp_path("plugin-bad-event"); write_manifest_with_bad_event(&root); let link = app.handle_api_request(Request { id: "link-bad-event".into(), method: Method::PluginLink(PluginLinkParams { path: root.display().to_string(), enabled: true, source: None, }), }); let ResponseResult::PluginLinked { plugin } = response_result(&link) else { panic!("expected plugin_linked: {link}"); }; assert_eq!(plugin.plugin_id, "example.bad-event"); assert!( plugin .warnings .iter() .any(|w| w.contains("worktree.craeted")), "expected warning for misspelled event, got: {:?}", plugin.warnings ); // The correctly named event produces no extra warning assert_eq!( plugin .warnings .iter() .filter(|w| w.contains("worktree.created")) .count(), 0 ); assert!( plugin .warnings .iter() .any(|w| w.contains("pane.output_changed")), "expected warning for unemitted output-change hook, got: {:?}", plugin.warnings ); let _ = std::fs::remove_dir_all(root); } #[test] fn output_changed_event_hooks_do_not_run_even_if_event_is_emitted() { let mut app = test_app(); let root = unique_temp_path("plugin-output-hook"); write_manifest_with_bad_event(&root); link_manifest(&mut app, &root); app.run_plugin_event_hooks(&crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::PaneOutputChanged, data: crate::api::schema::EventData::PaneOutputChanged { pane_id: "w1-1".into(), workspace_id: "w1".into(), revision: 1, }, }); assert!( app.state.plugin_command_logs.is_empty(), "pane.output_changed hooks should be warning-only until hook semantics exist" ); let _ = std::fs::remove_dir_all(root); } #[test] fn unlink_removes_plugin_pane_records_for_that_plugin() { let mut app = test_app(); let root = unique_temp_path("plugin-unlink-panes"); write_manifest(&root); link_manifest(&mut app, &root); // Manually insert plugin_pane records as if pane.open was called. let pane_a = crate::layout::PaneId::from_raw(1001u32); let pane_b = crate::layout::PaneId::from_raw(1002u32); let pane_other = crate::layout::PaneId::from_raw(1003u32); app.state.plugin_panes.insert( pane_a, crate::app::state::PluginPaneRecord { plugin_id: "example.worktree-bootstrap".into(), entrypoint: "main".into(), }, ); app.state.plugin_panes.insert( pane_b, crate::app::state::PluginPaneRecord { plugin_id: "example.worktree-bootstrap".into(), entrypoint: "side".into(), }, ); app.state.plugin_panes.insert( pane_other, crate::app::state::PluginPaneRecord { plugin_id: "other.plugin".into(), entrypoint: "other".into(), }, ); let unlink = app.handle_api_request(Request { id: "unlink-panes".into(), method: Method::PluginUnlink(PluginUnlinkParams { plugin_id: "example.worktree-bootstrap".into(), }), }); assert!(matches!( response_result(&unlink), ResponseResult::PluginUnlinked { removed: true, .. } )); // plugin_panes records for the unlinked plugin are gone assert!(!app.state.plugin_panes.contains_key(&pane_a)); assert!(!app.state.plugin_panes.contains_key(&pane_b)); // other plugin's pane record survives assert!(app.state.plugin_panes.contains_key(&pane_other)); let _ = std::fs::remove_dir_all(root); } #[test] fn plugin_pane_record_survives_pane_move() { let mut app = test_app(); app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-move")]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; let pane_id = app.state.workspaces[0].tabs[0].root_pane; let public_pane_id = app.public_pane_id(0, pane_id).unwrap(); app.state.plugin_panes.insert( pane_id, crate::app::state::PluginPaneRecord { plugin_id: "example.pane".into(), entrypoint: "board".into(), }, ); let response = app.handle_api_request(Request { id: "move".into(), method: Method::PaneMove(crate::api::schema::PaneMoveParams { pane_id: public_pane_id, destination: crate::api::schema::PaneMoveDestination::NewTab { workspace_id: None, label: Some("moved".into()), }, focus: true, }), }); let ResponseResult::PaneMove { move_result } = response_result(&response) else { panic!("expected pane move: {response}"); }; assert!(app.state.plugin_panes.contains_key(&pane_id)); let focus = app.handle_api_request(Request { id: "focus".into(), method: Method::PluginPaneFocus(PluginPaneFocusParams { pane_id: move_result.pane.pane_id.clone(), }), }); let ResponseResult::PluginPaneFocused { plugin_pane } = response_result(&focus) else { panic!("expected plugin pane focus: {focus}"); }; assert_eq!(plugin_pane.plugin_id, "example.pane"); assert_eq!(plugin_pane.entrypoint, "board"); assert_eq!(plugin_pane.pane.pane_id, move_result.pane.pane_id); } #[test] fn pane_exit_removes_plugin_pane_record() { let mut app = test_app(); app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-exit")]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; let pane_id = app.state.workspaces[0].tabs[0].root_pane; app.state.plugin_panes.insert( pane_id, crate::app::state::PluginPaneRecord { plugin_id: "example.pane".into(), entrypoint: "board".into(), }, ); app.handle_internal_event(crate::events::AppEvent::PaneDied { pane_id, exit_reason: crate::platform::ChildExitReason::Exited, }); assert!(!app.state.plugin_panes.contains_key(&pane_id)); } #[test] fn registry_round_trip_via_explicit_path() { let root = unique_temp_path("plugin-registry-rt"); write_manifest(&root); let registry_dir = unique_temp_path("registry-dir"); std::fs::create_dir_all(®istry_dir).unwrap(); let registry_path = registry_dir.join("plugins.json"); // link via load_plugin_manifest + save_to_path (simulating what the App does) let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap(); let plugins = vec![plugin.clone()]; crate::persist::plugin_registry::save_to_path(®istry_path, &plugins).unwrap(); assert!(registry_path.exists()); // load back and verify let loaded = crate::persist::plugin_registry::load_from_path(®istry_path); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].plugin_id, "example.worktree-bootstrap"); assert_eq!(loaded[0].name, "Worktree Bootstrap"); // reload_manifests with real manifest still on disk → fresh parse succeeds let reloaded = crate::persist::plugin_registry::reload_manifests(loaded, |path, enabled| { load_plugin_manifest(path, enabled).map_err(|(_, msg)| msg) }); assert_eq!(reloaded.len(), 1); assert!(reloaded[0].warnings.is_empty()); assert_eq!(reloaded[0].version, "0.1.0"); let _ = std::fs::remove_dir_all(root); let _ = std::fs::remove_dir_all(registry_dir); } #[test] fn reload_manifests_keeps_entry_with_warning_when_manifest_gone() { let root = unique_temp_path("plugin-missing-manifest"); write_manifest(&root); // load_plugin_manifest resolves the absolute path via canonicalize() let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap(); let stored_manifest_path = plugin.manifest_path.clone(); // Now delete the manifest let _ = std::fs::remove_dir_all(&root); // Simulate registry load + reload let entries = vec![plugin]; let reloaded = crate::persist::plugin_registry::reload_manifests(entries, |path, enabled| { load_plugin_manifest(path, enabled).map_err(|(_, msg)| msg) }); assert_eq!(reloaded.len(), 1); assert_eq!(reloaded[0].plugin_id, "example.worktree-bootstrap"); assert!(!reloaded[0].warnings.is_empty(), "expected load warning"); // The stored manifest_path is preserved so the entry is still identifiable assert_eq!(reloaded[0].manifest_path, stored_manifest_path); } // ── Platform compatibility tests ───────────────────────────────────────── #[test] fn manifest_with_platforms_parses_correctly() { let root = unique_temp_path("plugin-platforms"); std::fs::create_dir_all(&root).unwrap(); std::fs::write( root.join("herdr-plugin.toml"), r#" id = "example.platforms" name = "Platforms" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos"] [[actions]] id = "run" title = "Run" command = ["./run.sh"] [[actions]] id = "run-win" title = "Run Windows" platforms = ["windows"] command = ["run.bat"] "#, ) .unwrap(); let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap(); use crate::api::schema::PluginPlatform; assert_eq!( plugin.platforms, Some(vec![PluginPlatform::Linux, PluginPlatform::Macos]) ); // Action without own platforms inherits from plugin level let run = plugin.actions.iter().find(|a| a.id == "run").unwrap(); assert!(run.platforms.is_none()); // Action with own platforms has them set let run_win = plugin.actions.iter().find(|a| a.id == "run-win").unwrap(); assert_eq!(run_win.platforms, Some(vec![PluginPlatform::Windows])); // No missing-platforms warning because platforms is declared assert!( plugin.warnings.is_empty(), "expected no warnings: {:?}", plugin.warnings ); let _ = std::fs::remove_dir_all(root); } #[test] fn effective_platform_resolution_inherits_from_plugin() { use crate::api::schema::PluginPlatform; let plugin_platforms = Some(vec![PluginPlatform::Linux, PluginPlatform::Macos]); let no_override: Option> = None; let action_override = Some(vec![PluginPlatform::Windows]); // No action-level platforms → inherit from plugin assert_eq!( effective_platforms(&no_override, &plugin_platforms), &plugin_platforms ); // Action-level platforms → use action's own list assert_eq!( effective_platforms(&action_override, &plugin_platforms), &action_override ); // Both None → None (undeclared) assert_eq!(effective_platforms(&no_override, &no_override), &None); } #[test] fn invoke_on_unsupported_platform_returns_error() { let mut app = test_app(); let root = unique_temp_path("plugin-platform-reject"); std::fs::create_dir_all(&root).unwrap(); // Declare only platforms that are NOT the current build target so the // invoke is guaranteed to be rejected regardless of which OS this runs on. let excluded_platforms = if cfg!(target_os = "linux") { r#"platforms = ["macos", "windows"]"# } else if cfg!(target_os = "macos") { r#"platforms = ["linux", "windows"]"# } else { r#"platforms = ["linux", "macos"]"# }; std::fs::write( root.join("herdr-plugin.toml"), format!( r#" id = "example.reject" name = "Reject" version = "0.1.0" min_herdr_version = "0.6.10" {excluded_platforms} [[actions]] id = "act" title = "Act" command = ["act"] "# ), ) .unwrap(); let link = app.handle_api_request(Request { id: "link".into(), method: Method::PluginLink(PluginLinkParams { path: root.display().to_string(), enabled: true, source: None, }), }); assert!(link.contains("plugin_linked"), "link failed: {link}"); let invoke = app.handle_api_request(Request { id: "invoke".into(), method: Method::PluginActionInvoke(PluginActionInvokeParams { plugin_id: Some("example.reject".into()), action_id: "act".into(), context: None, }), }); let value: serde_json::Value = serde_json::from_str(&invoke).unwrap(); assert_eq!( value["error"]["code"], "platform_unsupported", "expected platform_unsupported error: {invoke}" ); assert!( invoke.contains("example.reject.act"), "error message should name the action: {invoke}" ); let _ = std::fs::remove_dir_all(root); } #[test] fn invoke_with_action_platform_override_uses_action_platforms() { let mut app = test_app(); let root = unique_temp_path("plugin-platform-action-override"); std::fs::create_dir_all(&root).unwrap(); // Plugin declares all platforms; action declares only the non-current platforms. let excluded_platforms = if cfg!(target_os = "linux") { r#"platforms = ["macos", "windows"]"# } else if cfg!(target_os = "macos") { r#"platforms = ["linux", "windows"]"# } else { r#"platforms = ["linux", "macos"]"# }; std::fs::write( root.join("herdr-plugin.toml"), format!( r#" id = "example.override" name = "Override" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos", "windows"] [[actions]] id = "act" title = "Act" {excluded_platforms} command = ["act"] "# ), ) .unwrap(); let link = app.handle_api_request(Request { id: "link".into(), method: Method::PluginLink(PluginLinkParams { path: root.display().to_string(), enabled: true, source: None, }), }); assert!(link.contains("plugin_linked"), "link failed: {link}"); let invoke = app.handle_api_request(Request { id: "invoke".into(), method: Method::PluginActionInvoke(PluginActionInvokeParams { plugin_id: Some("example.override".into()), action_id: "act".into(), context: None, }), }); let value: serde_json::Value = serde_json::from_str(&invoke).unwrap(); assert_eq!( value["error"]["code"], "platform_unsupported", "expected platform_unsupported for action override: {invoke}" ); let _ = std::fs::remove_dir_all(root); } #[test] fn invoke_with_undeclared_platforms_succeeds() { let mut app = test_app(); let root = unique_temp_path("plugin-platform-undeclared"); std::fs::create_dir_all(&root).unwrap(); std::fs::write( root.join("herdr-plugin.toml"), r#" id = "example.nodecl" name = "No Decl" version = "0.1.0" min_herdr_version = "0.6.10" [[actions]] id = "act" title = "Act" command = ["act"] "#, ) .unwrap(); let link = app.handle_api_request(Request { id: "link".into(), method: Method::PluginLink(PluginLinkParams { path: root.display().to_string(), enabled: true, source: None, }), }); let ResponseResult::PluginLinked { plugin } = response_result(&link) else { panic!("expected plugin_linked: {link}"); }; // Should get the missing-platforms warning assert!( plugin .warnings .iter() .any(|w| w.contains("does not declare platforms")), "expected missing-platforms warning: {:?}", plugin.warnings ); // Invoke should succeed regardless (local dev allowance) let invoke = app.handle_api_request(Request { id: "invoke".into(), method: Method::PluginActionInvoke(PluginActionInvokeParams { plugin_id: Some("example.nodecl".into()), action_id: "act".into(), context: None, }), }); assert!( invoke.contains("plugin_action_invoked"), "expected success for undeclared platforms: {invoke}" ); let _ = std::fs::remove_dir_all(root); } #[test] fn link_with_invalid_platform_string_fails() { let root = unique_temp_path("plugin-bad-platform"); std::fs::create_dir_all(&root).unwrap(); std::fs::write( root.join("herdr-plugin.toml"), r#" id = "example.badplatform" name = "Bad Platform" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "beos"] [[actions]] id = "act" title = "Act" command = ["act"] "#, ) .unwrap(); let result = load_plugin_manifest(&root.display().to_string(), true); assert!(result.is_err(), "expected parse error for unknown platform"); let (_, msg) = result.unwrap_err(); assert!( msg.contains("beos") || msg.contains("platform"), "error message should mention the bad platform: {msg}" ); let _ = std::fs::remove_dir_all(root); } #[test] fn registry_round_trip_preserves_platforms() { let root = unique_temp_path("plugin-platform-rt"); std::fs::create_dir_all(&root).unwrap(); std::fs::write( root.join("herdr-plugin.toml"), r#" id = "example.platform-rt" name = "Platform RT" version = "0.1.0" min_herdr_version = "0.6.10" platforms = ["linux", "macos"] [[actions]] id = "act" title = "Act" platforms = ["windows"] command = ["act.exe"] "#, ) .unwrap(); let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap(); use crate::api::schema::PluginPlatform; assert_eq!( plugin.platforms, Some(vec![PluginPlatform::Linux, PluginPlatform::Macos]) ); assert_eq!( plugin.actions[0].platforms, Some(vec![PluginPlatform::Windows]) ); let registry_dir = unique_temp_path("platform-rt-registry"); std::fs::create_dir_all(®istry_dir).unwrap(); let registry_path = registry_dir.join("plugins.json"); crate::persist::plugin_registry::save_to_path(®istry_path, &[plugin]).unwrap(); let loaded = crate::persist::plugin_registry::load_from_path(®istry_path); assert_eq!( loaded[0].platforms, Some(vec![PluginPlatform::Linux, PluginPlatform::Macos]) ); assert_eq!( loaded[0].actions[0].platforms, Some(vec![PluginPlatform::Windows]) ); let _ = std::fs::remove_dir_all(root); let _ = std::fs::remove_dir_all(registry_dir); } }