use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::Arc;
use std::time::Instant;
use super::manager::{ScriptId, ScriptManager};
use super::manifest::{
DiscoveredPlugin, ENTRY_POINT_ACTION_CONTRIBUTOR, ENTRY_POINT_OVERLAY, ENTRY_POINT_PANEL,
ENTRY_POINT_STATUS_BAR_WIDGET, KIND_ACTION_CONTRIBUTOR, KIND_OVERLAY, KIND_PANEL,
KIND_STATUS_BAR_WIDGET, discover_plugins,
};
use super::observer::ScriptEventForwarder;
use super::protocol::{PLUGIN_ACTION_INVOKED_KIND, ScriptEvent, ScriptEventData};
use super::restart::ScriptRestartState;
mod supervision;
const PLUGIN_RESTART_DELAY_MS: u64 = 250;
const SETTINGS_ARG: &str = "--par-term-settings";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnabledPlugin {
pub id: String,
pub settings_json: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginActionRow {
pub wire_id: String,
pub label: String,
}
fn widget_entry_args(plugin: &DiscoveredPlugin) -> &[String] {
plugin
.manifest
.entry_points
.get(ENTRY_POINT_STATUS_BAR_WIDGET)
.map(|entry| entry.args.as_slice())
.unwrap_or_default()
}
fn action_entry_args(plugin: &DiscoveredPlugin) -> &[String] {
plugin
.manifest
.entry_points
.get(ENTRY_POINT_ACTION_CONTRIBUTOR)
.map(|entry| entry.args.as_slice())
.unwrap_or_default()
}
fn panel_entry_args(plugin: &DiscoveredPlugin) -> &[String] {
plugin
.manifest
.entry_points
.get(ENTRY_POINT_PANEL)
.map(|entry| entry.args.as_slice())
.unwrap_or_default()
}
fn overlay_entry_args(plugin: &DiscoveredPlugin) -> &[String] {
plugin
.manifest
.entry_points
.get(ENTRY_POINT_OVERLAY)
.map(|entry| entry.args.as_slice())
.unwrap_or_default()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum KindSlot {
Widget,
Action,
Panel,
Overlay,
}
impl KindSlot {
fn as_str(self) -> &'static str {
match self {
KindSlot::Widget => "widget",
KindSlot::Action => "action",
KindSlot::Panel => "panel",
KindSlot::Overlay => "overlay",
}
}
}
#[derive(Debug, Default)]
pub struct WarnOnce {
warned: HashSet<String>,
}
impl WarnOnce {
pub fn should_warn(&mut self, key: &str) -> bool {
self.warned.insert(key.to_string())
}
pub fn clear(&mut self, key: &str) {
self.warned.remove(key);
}
pub fn clear_except(&mut self, keep: &HashSet<String>) {
self.warned.retain(|key| keep.contains(key));
}
}
#[derive(Default)]
pub struct PluginHost {
discovered: Vec<DiscoveredPlugin>,
running: HashMap<String, ScriptId>,
action_running: HashMap<String, ScriptId>,
panel_running: HashMap<String, ScriptId>,
overlay_running: HashMap<String, ScriptId>,
manager: ScriptManager,
widget_texts: HashMap<String, String>,
panel_contents: HashMap<String, (String, String)>,
overlays: HashMap<String, crate::protocol::PluginOverlay>,
focused_overlay: Option<String>,
overlay_update_times: HashMap<String, Vec<std::time::Instant>>,
actions_dispatched: u64,
overlay_events_dispatched: u64,
restart: HashMap<String, ScriptRestartState>,
action_restart: HashMap<String, ScriptRestartState>,
panel_restart: HashMap<String, ScriptRestartState>,
overlay_restart: HashMap<String, ScriptRestartState>,
settings_json: HashMap<String, String>,
enabled_ids: HashSet<String>,
ignored_lines: Vec<String>,
subscription_forwarders: HashMap<String, Arc<ScriptEventForwarder>>,
warned_event_delivery: WarnOnce,
warned_not_discovered: WarnOnce,
warned_spawn_failed: WarnOnce,
warned_action_not_running: WarnOnce,
}
impl PluginHost {
pub fn new() -> Self {
Self::default()
}
pub fn refresh_discovery(&mut self, root: &Path) -> &[DiscoveredPlugin] {
let (plugins, warnings) = discover_plugins(root);
for warning in &warnings {
log::warn!(
"plugin discovery skipped '{}': {}",
warning.dir,
warning.reason
);
}
self.discovered = plugins;
&self.discovered
}
pub fn discovered(&self, id: &str) -> Option<&DiscoveredPlugin> {
self.discovered.iter().find(|d| d.manifest.id == id)
}
pub fn discovered_count(&self) -> usize {
self.discovered.len()
}
pub fn apply_enabled(&mut self, enabled: &[EnabledPlugin]) {
let now = Instant::now();
let stale: Vec<String> = self
.running
.keys()
.chain(self.action_running.keys())
.chain(self.panel_running.keys())
.chain(self.overlay_running.keys())
.filter(|id| !enabled.iter().any(|e| e.id == **id))
.cloned()
.collect();
for id in &stale {
self.teardown(id);
}
let enabled_ids: HashSet<String> = enabled.iter().map(|e| e.id.clone()).collect();
self.enabled_ids = enabled_ids.clone();
self.warned_not_discovered.clear_except(&enabled_ids);
self.warned_spawn_failed.clear_except(&enabled_ids);
self.warned_action_not_running.clear_except(&enabled_ids);
self.warned_event_delivery.clear_except(&enabled_ids);
for plugin in enabled {
let Some(found) = self.discovered.iter().find(|d| d.manifest.id == plugin.id) else {
if self.warned_not_discovered.should_warn(&plugin.id) {
log::warn!(
"plugin '{}' is enabled but not discovered; not spawned",
plugin.id
);
}
continue;
};
let has_widget = found
.manifest
.kinds
.iter()
.any(|k| k == KIND_STATUS_BAR_WIDGET);
let has_action = found
.manifest
.kinds
.iter()
.any(|k| k == KIND_ACTION_CONTRIBUTOR);
let has_panel = found.manifest.kinds.iter().any(|k| k == KIND_PANEL);
let has_overlay = found.manifest.kinds.iter().any(|k| k == KIND_OVERLAY);
let widget_entry =
has_widget.then(|| (found.entry_path.clone(), widget_entry_args(found).to_vec()));
let action_entry = found
.action_entry_path
.clone()
.filter(|_| has_action)
.map(|path| (path, action_entry_args(found).to_vec()));
let panel_entry = found
.panel_entry_path
.clone()
.filter(|_| has_panel)
.map(|path| (path, panel_entry_args(found).to_vec()));
let overlay_entry = found
.overlay_entry_path
.clone()
.filter(|_| has_overlay)
.map(|path| (path, overlay_entry_args(found).to_vec()));
let subscriptions = found.manifest.subscriptions.clone();
let restart_policy = found.manifest.restart;
self.spawn_kind_leg(
&plugin.id,
KindSlot::Widget,
widget_entry,
&plugin.settings_json,
&subscriptions,
restart_policy,
now,
);
self.spawn_kind_leg(
&plugin.id,
KindSlot::Action,
action_entry,
&plugin.settings_json,
&subscriptions,
restart_policy,
now,
);
self.spawn_kind_leg(
&plugin.id,
KindSlot::Panel,
panel_entry,
&plugin.settings_json,
&subscriptions,
restart_policy,
now,
);
self.spawn_kind_leg(
&plugin.id,
KindSlot::Overlay,
overlay_entry,
&plugin.settings_json,
&subscriptions,
restart_policy,
now,
);
}
}
pub fn poll(&mut self) {
let now = Instant::now();
let widget_ids: Vec<String> = self.running.keys().cloned().collect();
for id in widget_ids {
self.poll_one(&id, KindSlot::Widget, now);
}
let action_ids: Vec<String> = self.action_running.keys().cloned().collect();
for id in action_ids {
self.poll_one(&id, KindSlot::Action, now);
}
let panel_ids: Vec<String> = self.panel_running.keys().cloned().collect();
for id in panel_ids {
self.poll_one(&id, KindSlot::Panel, now);
}
let overlay_ids: Vec<String> = self.overlay_running.keys().cloned().collect();
for id in overlay_ids {
self.poll_one(&id, KindSlot::Overlay, now);
}
}
pub fn widget_text(&self, plugin_id: &str) -> Option<&str> {
self.widget_texts.get(plugin_id).map(String::as_str)
}
pub fn panel_content(&self, plugin_id: &str) -> Option<&(String, String)> {
self.panel_contents.get(plugin_id)
}
pub fn subscription_forwarders(&self) -> &HashMap<String, Arc<ScriptEventForwarder>> {
&self.subscription_forwarders
}
pub fn deliver_events(&mut self, plugin_id: &str, events: &[ScriptEvent]) {
let targets: Vec<ScriptId> = self
.running
.get(plugin_id)
.into_iter()
.chain(self.action_running.get(plugin_id))
.chain(self.panel_running.get(plugin_id))
.copied()
.collect();
if targets.is_empty() {
return;
}
let mut write_failed = false;
for event in events {
for &sid in &targets {
if let Err(error) = self.manager.send_event(sid, event) {
write_failed = true;
if self.warned_event_delivery.should_warn(plugin_id) {
log::warn!(
"failed to deliver a {} event to plugin '{}': {}",
event.kind,
plugin_id,
error
);
}
}
}
}
if !write_failed {
self.warned_event_delivery.clear(plugin_id);
}
}
pub fn widget_texts(&self) -> &HashMap<String, String> {
&self.widget_texts
}
pub fn panel_contents(&self) -> &HashMap<String, (String, String)> {
&self.panel_contents
}
pub fn overlays(&self) -> &HashMap<String, crate::protocol::PluginOverlay> {
&self.overlays
}
pub fn focused_overlay(&self) -> Option<&str> {
self.focused_overlay.as_deref()
}
pub fn focus_overlay(&mut self, plugin_id: &str) {
self.focused_overlay = self
.overlays
.get(plugin_id)
.filter(|o| o.interactive)
.map(|_| plugin_id.to_string());
}
pub fn unfocus_overlay(&mut self) {
self.focused_overlay = None;
}
pub(super) fn clear_focus_if(&mut self, plugin_id: &str) {
if self.focused_overlay.as_deref() == Some(plugin_id) {
self.focused_overlay = None;
}
}
pub fn send_overlay_event(
&mut self,
plugin_id: &str,
widget_id: &str,
event: crate::protocol::OverlayWidgetEvent,
) -> bool {
let Some(&sid) = self.overlay_running.get(plugin_id) else {
return false;
};
let Some(overlay) = self.overlays.get(plugin_id) else {
return false;
};
let script_event = ScriptEvent {
kind: crate::protocol::OVERLAY_EVENT_KIND.to_string(),
data: ScriptEventData::OverlayEvent {
overlay: overlay.id.clone(),
widget: widget_id.to_string(),
event,
},
};
match self.manager.send_event(sid, &script_event) {
Ok(()) => {
self.overlay_events_dispatched += 1;
true
}
Err(error) => {
if self.warned_event_delivery.should_warn(plugin_id) {
log::warn!(
"failed to deliver overlay event to plugin '{}': {}",
plugin_id,
error
);
}
false
}
}
}
pub fn overlay_events_dispatched_count(&self) -> u64 {
self.overlay_events_dispatched
}
const OVERLAY_UPDATE_WINDOW_SECS: u64 = 1;
const OVERLAY_UPDATE_MAX_PER_WINDOW: usize = 30;
pub(super) fn overlay_update_permitted(&mut self, id: &str, now: Instant) -> bool {
let times = self.overlay_update_times.entry(id.to_string()).or_default();
times.retain(|t| now.duration_since(*t).as_secs() < Self::OVERLAY_UPDATE_WINDOW_SECS);
if times.len() >= Self::OVERLAY_UPDATE_MAX_PER_WINDOW {
if self
.warned_event_delivery
.should_warn(&format!("{id}/overlay-rate"))
{
log::warn!(
"plugin '{id}' is pushing SetOverlay faster than {} per second; \
dropping the excess (update-rate clamp)",
Self::OVERLAY_UPDATE_MAX_PER_WINDOW
);
}
return false;
}
times.push(now);
true
}
pub fn actions_dispatched_count(&self) -> u64 {
self.actions_dispatched
}
pub fn running_plugin_ids(&self) -> Vec<String> {
self.running
.keys()
.chain(self.action_running.keys())
.chain(self.panel_running.keys())
.chain(self.overlay_running.keys())
.cloned()
.collect()
}
pub fn invoke_action(&mut self, plugin_id: &str, action_id: &str) -> bool {
let Some(found) = self.discovered.iter().find(|d| d.manifest.id == plugin_id) else {
if self.warned_not_discovered.should_warn(plugin_id) {
log::warn!(
"plugin '{}' is not discovered; cannot invoke action '{}'",
plugin_id,
action_id
);
}
return false;
};
if !found
.manifest
.kinds
.iter()
.any(|k| k == KIND_ACTION_CONTRIBUTOR)
{
if self.warned_action_not_running.should_warn(plugin_id) {
log::warn!(
"plugin '{}' does not declare kind '{}'; cannot invoke action '{}'",
plugin_id,
KIND_ACTION_CONTRIBUTOR,
action_id
);
}
return false;
}
if !found.manifest.actions.iter().any(|a| a.id == action_id) {
if self.warned_action_not_running.should_warn(plugin_id) {
log::warn!(
"plugin '{}' has no action '{}'; cannot invoke it",
plugin_id,
action_id
);
}
return false;
}
let Some(&sid) = self.action_running.get(plugin_id) else {
if self.warned_action_not_running.should_warn(plugin_id) {
log::warn!(
"plugin '{}' has no running action process; action '{}' not invoked",
plugin_id,
action_id
);
}
return false;
};
let event = ScriptEvent {
kind: PLUGIN_ACTION_INVOKED_KIND.to_string(),
data: ScriptEventData::PluginActionInvoked {
action: action_id.to_string(),
},
};
match self.manager.send_event(sid, &event) {
Ok(()) => {
self.warned_action_not_running.clear(plugin_id);
self.actions_dispatched += 1;
true
}
Err(error) => {
if self.warned_action_not_running.should_warn(plugin_id) {
log::warn!(
"failed to invoke action '{}' on plugin '{}': {}",
action_id,
plugin_id,
error
);
}
false
}
}
}
pub fn palette_actions(&self) -> Vec<PluginActionRow> {
let mut rows = Vec::new();
for found in &self.discovered {
if !self.enabled_ids.contains(&found.manifest.id) {
continue;
}
if !found
.manifest
.kinds
.iter()
.any(|k| k == KIND_ACTION_CONTRIBUTOR)
{
continue;
}
for action in &found.manifest.actions {
rows.push(PluginActionRow {
wire_id: format!("plugin-action:{}:{}", found.manifest.id, action.id),
label: format!("{} · {}", action.label, found.manifest.name),
});
}
}
rows
}
pub fn drain_ignored(&mut self) -> Vec<String> {
std::mem::take(&mut self.ignored_lines)
}
pub fn stop_all(&mut self) {
self.manager.stop_all();
self.running.clear();
self.action_running.clear();
self.panel_running.clear();
self.overlay_running.clear();
self.restart.clear();
self.action_restart.clear();
self.panel_restart.clear();
self.overlay_restart.clear();
self.widget_texts.clear();
self.panel_contents.clear();
self.overlays.clear();
self.settings_json.clear();
self.enabled_ids.clear();
self.subscription_forwarders.clear();
self.warned_not_discovered = WarnOnce::default();
self.warned_spawn_failed = WarnOnce::default();
self.warned_action_not_running = WarnOnce::default();
self.warned_event_delivery = WarnOnce::default();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::thread;
use std::time::Duration;
const WIDGET_SCRIPT: &str = r#"
import json, time
def emit(text):
print(json.dumps({"type": "SetWidget", "text": text}), flush=True)
emit("first")
emit("second")
while True:
time.sleep(0.2)
"#;
const LOG_SCRIPT: &str = r#"
import json, time
print(json.dumps({"type": "Log", "level": "info", "message": "hello"}), flush=True)
while True:
time.sleep(0.2)
"#;
const CRASH_SCRIPT: &str = "import sys\nsys.exit(3)\n";
fn manifest_json(id: &str) -> String {
format!(
"{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"T\",\"version\":\"0.1.0\",\
\"kinds\":[\"status-bar-widget\"],\"activation\":\"manual\",\
\"entryPoints\":{{\"statusBarWidget\":{{\"command\":\"widget.py\",\"args\":[]}}}},\
\"statusBarWidget\":{{\"displayName\":\"T\",\"section\":\"right\",\"defaults\":{{}},\
\"schema\":[{{\"key\":\"on\",\"type\":\"boolean\",\"label\":\"On\",\"defaultValue\":true}}]}}}}"
)
}
fn write_plugin(root: &Path, id: &str, script: &str) {
let dir = root.join(id);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("manifest.json"), manifest_json(id)).unwrap();
let entry = dir.join("widget.py");
fs::write(&entry, script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&entry, fs::Permissions::from_mode(0o755)).unwrap();
}
}
fn enabled(id: &str) -> EnabledPlugin {
EnabledPlugin {
id: id.to_string(),
settings_json: "{}".to_string(),
}
}
fn plugin_running(host: &PluginHost, id: &str) -> bool {
host.running_plugin_ids().iter().any(|i| i == id)
}
fn wait_until(host: &mut PluginHost, mut check: impl FnMut(&mut PluginHost) -> bool) -> bool {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
host.poll();
if check(host) {
return true;
}
thread::sleep(Duration::from_millis(50));
}
false
}
fn skip_without_interpreter() -> bool {
if crate::manager::python_interpreter().is_some() {
return false;
}
eprintln!("skipping: no Python interpreter on PATH");
true
}
#[test]
fn discovery_never_spawns_even_for_an_absent_root() {
let mut host = PluginHost::new();
let root = std::env::temp_dir().join("par-term-plugin-test-absent-root");
assert!(host.refresh_discovery(&root).is_empty());
host.apply_enabled(&[enabled("com.test.widget")]);
assert!(host.running_plugin_ids().is_empty());
host.poll(); }
#[test]
fn apply_enabled_spawns_and_last_set_widget_wins() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_plugin(root.path(), "com.test.widget", WIDGET_SCRIPT);
let mut host = PluginHost::new();
assert_eq!(host.refresh_discovery(root.path()).len(), 1);
host.apply_enabled(&[enabled("com.test.widget")]);
assert_eq!(host.widget_text("com.test.widget"), None);
let settled = wait_until(&mut host, |h| h.widget_text("com.test.widget").is_some());
assert!(settled, "fixture's SetWidget never arrived");
assert_eq!(host.widget_text("com.test.widget"), Some("second"));
host.stop_all();
}
#[test]
fn disabling_stops_the_plugin_and_its_widget_text() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_plugin(root.path(), "com.test.widget", WIDGET_SCRIPT);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[enabled("com.test.widget")]);
assert!(wait_until(&mut host, |h| h
.widget_text("com.test.widget")
.is_some()));
host.apply_enabled(&[]);
assert!(!plugin_running(&host, "com.test.widget"));
assert_eq!(host.widget_text("com.test.widget"), None);
for _ in 0..10 {
host.poll();
thread::sleep(Duration::from_millis(50));
}
assert!(host.running_plugin_ids().is_empty());
}
#[test]
fn crash_loop_is_capped_without_panicking() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_plugin(root.path(), "com.test.crash", CRASH_SCRIPT);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[enabled("com.test.crash")]);
let gave_up = wait_until(&mut host, |h| !plugin_running(h, "com.test.crash"));
assert!(
gave_up,
"supervisor never gave up; still running: {:?}",
host.running_plugin_ids()
);
host.poll();
assert!(host.running_plugin_ids().is_empty());
}
const ONE_SHOT_SCRIPT: &str = r#"
import json
print(json.dumps({"type": "SetWidget", "text": "once"}), flush=True)
"#;
fn restart_manifest_json(id: &str, mode: &str) -> String {
manifest_json(id).replacen(
"\"activation\":\"manual\",",
&format!("\"activation\":\"manual\",\"restart\":\"{mode}\","),
1,
)
}
#[test]
fn never_policy_plugin_is_not_restarted_after_a_crash() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_plugin_files(
root.path(),
"com.test.never",
&restart_manifest_json("com.test.never", "never"),
&[("widget.py", CRASH_SCRIPT)],
);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[enabled("com.test.never")]);
let stopped = wait_until(&mut host, |h| !plugin_running(h, "com.test.never"));
assert!(stopped, "plugin never stopped");
assert!(
!host.restart.contains_key("com.test.never"),
"stop_kind must drop the supervision state"
);
thread::sleep(Duration::from_millis(700));
host.poll();
assert!(
!plugin_running(&host, "com.test.never"),
"`never` must not respawn a crashed plugin"
);
}
#[test]
fn always_policy_plugin_restarts_after_a_clean_exit() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_plugin_files(
root.path(),
"com.test.always",
&restart_manifest_json("com.test.always", "always"),
&[("widget.py", ONE_SHOT_SCRIPT)],
);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[enabled("com.test.always")]);
let restarted = wait_until(&mut host, |h| {
h.restart
.get("com.test.always")
.is_some_and(|s| s.consecutive_failures() >= 2)
});
assert!(
restarted,
"clean exit was never restarted; failures: {:?}",
host.restart
.get("com.test.always")
.map(|s| s.consecutive_failures())
);
host.stop_all();
}
#[test]
fn non_widget_commands_are_refused_with_an_error_line() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_plugin(root.path(), "com.test.log", LOG_SCRIPT);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[enabled("com.test.log")]);
let mut refused: Vec<String> = Vec::new();
let seen = wait_until(&mut host, |h| {
refused.extend(h.drain_ignored());
refused.iter().any(|line| line.contains("Log"))
});
assert!(
seen,
"Log command was never refused; lines seen: {:?}, widget text: {:?}",
refused,
host.widget_text("com.test.log")
);
assert!(refused.iter().all(|line| line.starts_with("[error]")));
host.stop_all();
}
#[test]
fn failing_spawn_retries_at_the_restart_delay_not_per_frame() {
let root = tempfile::tempdir().unwrap();
let dir = root.path().join("com.test.broken");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("manifest.json"),
manifest_json("com.test.broken").replace("widget.py", "widget.sh"),
)
.unwrap();
fs::write(
dir.join("widget.sh"),
"#!/nonexistent/par-term-test-interpreter\n",
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dir.join("widget.sh"), fs::Permissions::from_mode(0o755)).unwrap();
}
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
assert_eq!(host.discovered_count(), 1, "fixture must be discovered");
host.apply_enabled(&[enabled("com.test.broken")]);
assert!(!plugin_running(&host, "com.test.broken"));
let after_first = host
.restart
.get("com.test.broken")
.expect("supervisor state armed by a failed spawn")
.consecutive_failures();
assert_eq!(after_first, 1);
host.apply_enabled(&[enabled("com.test.broken")]);
assert_eq!(
host.restart
.get("com.test.broken")
.unwrap()
.consecutive_failures(),
after_first,
"spawn must not be retried inside the restart delay"
);
thread::sleep(Duration::from_millis(300));
host.apply_enabled(&[enabled("com.test.broken")]);
assert_eq!(
host.restart
.get("com.test.broken")
.unwrap()
.consecutive_failures(),
after_first + 1,
"exactly one retry after the delay elapses"
);
}
#[test]
fn stop_all_drains_every_plugin() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_plugin(root.path(), "com.test.a", WIDGET_SCRIPT);
write_plugin(root.path(), "com.test.b", WIDGET_SCRIPT);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[enabled("com.test.a"), enabled("com.test.b")]);
assert!(wait_until(&mut host, |h| h.running_plugin_ids().len() == 2));
host.stop_all();
assert!(host.running_plugin_ids().is_empty());
assert!(host.widget_texts().is_empty());
host.poll(); }
static WARNED: std::sync::LazyLock<std::sync::Mutex<Vec<String>>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(Vec::new()));
struct CountingLogger;
impl log::Log for CountingLogger {
fn enabled(&self, _metadata: &log::Metadata) -> bool {
true
}
fn log(&self, record: &log::Record) {
if record.level() == log::Level::Warn {
WARNED.lock().unwrap().push(record.args().to_string());
}
}
fn flush(&self) {}
}
fn install_counting_logger() {
static INSTALL: std::sync::Once = std::sync::Once::new();
static COUNTING_LOGGER: CountingLogger = CountingLogger;
INSTALL.call_once(|| {
let _ = log::set_logger(&COUNTING_LOGGER);
log::set_max_level(log::LevelFilter::Warn);
});
}
fn warns_containing(fragment: &str) -> usize {
WARNED
.lock()
.unwrap()
.iter()
.filter(|msg| msg.contains(fragment))
.count()
}
#[test]
fn fault_warns_fire_once_per_transition_not_per_frame() {
install_counting_logger();
let fragment = "'com.test.missing' is enabled but not discovered";
let mut host = PluginHost::new();
let root = tempfile::tempdir().unwrap();
host.refresh_discovery(root.path());
for _ in 0..5 {
host.apply_enabled(&[enabled("com.test.missing")]);
}
let warns = warns_containing(fragment);
assert_eq!(
warns, 1,
"5 frames of one steady fault must produce exactly 1 warn, got {warns}"
);
host.apply_enabled(&[]);
for _ in 0..2 {
host.apply_enabled(&[enabled("com.test.missing")]);
}
let warns = warns_containing(fragment);
assert_eq!(
warns, 2,
"warn must re-arm after a disable/enable, got {warns}"
);
}
#[test]
fn fault_warn_rearms_after_recovery() {
if skip_without_interpreter() {
return;
}
install_counting_logger();
let empty = tempfile::tempdir().unwrap();
let populated = tempfile::tempdir().unwrap();
write_plugin(populated.path(), "com.test.flapping", WIDGET_SCRIPT);
let mut host = PluginHost::new();
let fragment = "'com.test.flapping' is enabled but not discovered";
host.refresh_discovery(empty.path());
host.apply_enabled(&[enabled("com.test.flapping")]);
assert_eq!(warns_containing(fragment), 1);
host.refresh_discovery(populated.path());
host.apply_enabled(&[enabled("com.test.flapping")]);
assert!(wait_until(&mut host, |h| h
.widget_text("com.test.flapping")
.is_some()));
host.apply_enabled(&[]);
host.refresh_discovery(empty.path());
host.apply_enabled(&[enabled("com.test.flapping")]);
assert_eq!(
warns_containing(fragment),
2,
"a recovered-then-failed-again plugin must warn again"
);
}
const ACTION_SCRIPT: &str = r#"
import json, sys
def emit(text):
print(json.dumps({"type": "SetWidget", "text": text}), flush=True)
for line in iter(sys.stdin.readline, ""):
line = line.strip()
if not line:
continue
try:
event = json.loads(line)
except ValueError:
continue
emit("invoked:" + str(event.get("data", {}).get("action", "?")))
"#;
fn action_manifest_json(id: &str) -> String {
format!(
"{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Test Actions\",\"version\":\"0.1.0\",\
\"kinds\":[\"action-contributor\"],\"activation\":\"manual\",\
\"entryPoints\":{{\"actionContributor\":{{\"command\":\"actions.py\",\"args\":[]}}}},\
\"actions\":[{{\"id\":\"say-hello\",\"label\":\"Say hello\"}}]}}"
)
}
fn both_kinds_manifest_json(id: &str) -> String {
format!(
"{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Test Both\",\"version\":\"0.1.0\",\
\"kinds\":[\"status-bar-widget\",\"action-contributor\"],\"activation\":\"manual\",\
\"entryPoints\":{{\"statusBarWidget\":{{\"command\":\"widget.py\",\"args\":[]}},\
\"actionContributor\":{{\"command\":\"actions.py\",\"args\":[]}}}},\
\"actions\":[{{\"id\":\"ping\",\"label\":\"Ping\"}}],\
\"statusBarWidget\":{{\"displayName\":\"T\",\"section\":\"right\",\"defaults\":{{}},\
\"schema\":[{{\"key\":\"on\",\"type\":\"boolean\",\"label\":\"On\",\"defaultValue\":true}}]}}}}"
)
}
fn panel_manifest_json(id: &str) -> String {
format!(
"{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Test Panel\",\"version\":\"0.1.0\",\
\"kinds\":[\"panel\"],\"activation\":\"manual\",\
\"entryPoints\":{{\"panel\":{{\"command\":\"panel.py\",\"args\":[]}}}}}}"
)
}
const PANEL_SCRIPT: &str = r##"
import json, time
def emit(obj):
print(json.dumps(obj), flush=True)
emit({"type": "SetWidget", "text": "nope"})
emit({"type": "SetPanel", "title": "Notes", "content": "# hello\nworld"})
while True:
time.sleep(0.2)
"##;
const PANEL_CLEAR_SCRIPT: &str = r#"
import json, time
def emit(obj):
print(json.dumps(obj), flush=True)
emit({"type": "SetPanel", "title": "T", "content": "c"})
time.sleep(0.3)
emit({"type": "ClearPanel"})
while True:
time.sleep(0.2)
"#;
fn write_plugin_files(root: &Path, id: &str, manifest: &str, files: &[(&str, &str)]) {
let dir = root.join(id);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("manifest.json"), manifest).unwrap();
for (name, script) in files {
let entry = dir.join(name);
fs::write(&entry, script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&entry, fs::Permissions::from_mode(0o755)).unwrap();
}
}
}
fn write_action_plugin(root: &Path, id: &str, script: &str) {
write_plugin_files(
root,
id,
&action_manifest_json(id),
&[("actions.py", script)],
);
}
fn write_panel_plugin(root: &Path, id: &str, script: &str) {
write_plugin_files(root, id, &panel_manifest_json(id), &[("panel.py", script)]);
}
#[test]
fn panel_plugin_pushes_content_and_refuses_set_widget() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_panel_plugin(root.path(), "com.test.panel", PANEL_SCRIPT);
let mut host = PluginHost::new();
assert_eq!(host.refresh_discovery(root.path()).len(), 1);
host.apply_enabled(&[enabled("com.test.panel")]);
let settled = wait_until(&mut host, |h| h.panel_content("com.test.panel").is_some());
assert!(settled, "fixture's SetPanel never arrived");
assert_eq!(
host.panel_content("com.test.panel"),
Some(&("Notes".to_string(), "# hello\nworld".to_string()))
);
let ignored = host.drain_ignored().join("\n");
assert!(ignored.contains("SetWidget"), "got: {ignored}");
assert!(ignored.contains("SetPanel/ClearPanel"), "got: {ignored}");
host.stop_all();
}
#[test]
fn clear_panel_from_a_panel_process_removes_the_panel() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_panel_plugin(root.path(), "com.test.panel", PANEL_CLEAR_SCRIPT);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[enabled("com.test.panel")]);
let pushed = wait_until(&mut host, |h| h.panel_content("com.test.panel").is_some());
assert!(pushed, "fixture's SetPanel never arrived");
let cleared = wait_until(&mut host, |h| h.panel_content("com.test.panel").is_none());
assert!(cleared, "fixture's ClearPanel never landed");
host.stop_all();
}
#[test]
fn disabling_a_panel_plugin_stops_the_process_and_clears_the_panel() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_panel_plugin(root.path(), "com.test.panel", PANEL_SCRIPT);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[enabled("com.test.panel")]);
let settled = wait_until(&mut host, |h| h.panel_content("com.test.panel").is_some());
assert!(settled, "fixture's SetPanel never arrived");
assert!(plugin_running(&host, "com.test.panel"));
host.apply_enabled(&[]);
assert!(!plugin_running(&host, "com.test.panel"));
assert_eq!(host.panel_content("com.test.panel"), None);
host.stop_all();
}
fn overlay_manifest_json(id: &str) -> String {
format!(
"{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Test Overlay\",\"version\":\"0.1.0\",\
\"kinds\":[\"overlay\"],\"activation\":\"manual\",\
\"entryPoints\":{{\"overlay\":{{\"command\":\"overlay.py\",\"args\":[]}}}}}}"
)
}
const OVERLAY_SCRIPT: &str = r##"
import json, time
def emit(obj):
print(json.dumps(obj), flush=True)
emit({"type": "SetWidget", "text": "nope"})
emit({"type": "SetOverlay", "id": "hud", "position": "top-right",
"size": {"w": 0.2, "h": 0.1}, "opacity": 0.8,
"content": {"type": "markdown", "text": "# hello"}})
while True:
time.sleep(0.2)
"##;
const OVERLAY_CLEAR_SCRIPT: &str = r#"
import json, time
def emit(obj):
print(json.dumps(obj), flush=True)
emit({"type": "SetOverlay", "id": "hud", "position": "center",
"size": {"w": 0.2, "h": 0.1},
"content": {"type": "text", "text": "hi"}})
time.sleep(0.3)
emit({"type": "ClearOverlay", "id": "hud"})
while True:
time.sleep(0.2)
"#;
fn write_overlay_plugin(root: &Path, id: &str, script: &str) {
write_plugin_files(
root,
id,
&overlay_manifest_json(id),
&[("overlay.py", script)],
);
}
#[test]
fn overlay_plugin_pushes_scene_and_refuses_set_widget() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_overlay_plugin(root.path(), "com.test.overlay", OVERLAY_SCRIPT);
let mut host = PluginHost::new();
assert_eq!(host.refresh_discovery(root.path()).len(), 1);
host.apply_enabled(&[enabled("com.test.overlay")]);
let settled = wait_until(&mut host, |h| {
h.overlays().contains_key("com.test.overlay")
&& h.drain_ignored().iter().any(|l| l.contains("SetWidget"))
});
assert!(settled, "fixture's SetOverlay never arrived");
let overlay = &host.overlays()["com.test.overlay"];
assert_eq!(overlay.id, "hud");
assert_eq!(overlay.opacity, 0.8);
assert_eq!(
overlay.position,
crate::protocol::OverlayPosition::Anchor(crate::protocol::OverlayAnchor::TopRight)
);
assert_eq!(
overlay.content,
crate::protocol::OverlayScene::Markdown {
text: "# hello".to_string()
}
);
let ignored = host.drain_ignored().join("\n");
assert!(ignored.contains("SetOverlay/ClearOverlay") || ignored.is_empty());
host.stop_all();
}
#[test]
fn clear_overlay_from_an_overlay_process_removes_it() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_overlay_plugin(root.path(), "com.test.overlay", OVERLAY_CLEAR_SCRIPT);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[enabled("com.test.overlay")]);
let pushed = wait_until(&mut host, |h| h.overlays().contains_key("com.test.overlay"));
assert!(pushed, "fixture's SetOverlay never arrived");
let cleared = wait_until(&mut host, |h| h.overlays().is_empty());
assert!(cleared, "fixture's ClearOverlay never landed");
host.stop_all();
}
#[test]
fn disabling_an_overlay_plugin_stops_the_process_and_clears_the_overlay() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_overlay_plugin(root.path(), "com.test.overlay", OVERLAY_SCRIPT);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[enabled("com.test.overlay")]);
let settled = wait_until(&mut host, |h| h.overlays().contains_key("com.test.overlay"));
assert!(settled, "fixture's SetOverlay never arrived");
assert!(plugin_running(&host, "com.test.overlay"));
host.apply_enabled(&[]);
assert!(!plugin_running(&host, "com.test.overlay"));
assert!(host.overlays().is_empty());
host.stop_all();
}
#[test]
fn a_widget_plugin_cannot_send_overlay_commands() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_plugin(root.path(), "com.test.widget", OVERLAY_SCRIPT);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[enabled("com.test.widget")]);
let mut saw_refusal = false;
let refused = wait_until(&mut host, |h| {
saw_refusal = h.drain_ignored().iter().any(|l| l.contains("SetOverlay"));
saw_refusal
});
assert!(refused, "fixture's SetOverlay refusal never landed");
assert!(host.overlays().is_empty(), "no overlay may exist");
host.stop_all();
}
fn overlay_interactive_manifest_json(id: &str) -> String {
format!(
"{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Test Overlay\",\"version\":\"0.1.0\",\
\"kinds\":[\"overlay\"],\"activation\":\"manual\",\
\"overlay\":{{\"interactive\":true}},\
\"entryPoints\":{{\"overlay\":{{\"command\":\"overlay.py\",\"args\":[]}}}}}}"
)
}
fn write_overlay_plugin_with_manifest(root: &Path, id: &str, manifest: &str, script: &str) {
write_plugin_files(root, id, manifest, &[("overlay.py", script)]);
}
const OVERLAY_INTERACTIVE_SCRIPT: &str = r##"
import json, time
def emit(obj):
print(json.dumps(obj), flush=True)
emit({"type": "SetOverlay", "id": "hud", "position": "top-right",
"size": {"w": 0.3, "h": 0.2}, "opacity": 0.9, "interactive": True,
"content": {"type": "button", "id": "deploy", "label": "Deploy"}})
while True:
time.sleep(0.2)
"##;
const OVERLAY_ECHO_SCRIPT: &str = r#"
import json, sys, time
def emit(obj):
print(json.dumps(obj), flush=True)
emit({"type": "SetOverlay", "id": "hud", "position": "top-right",
"size": {"w": 0.3, "h": 0.2}, "interactive": True,
"content": {"type": "button", "id": "deploy", "label": "Deploy"}})
for line in iter(sys.stdin.readline, ""):
line = line.strip()
if not line:
continue
try:
event = json.loads(line)
except ValueError:
continue
emit({"type": "SetOverlay", "id": "hud", "position": "top-right",
"size": {"w": 0.3, "h": 0.2}, "interactive": True,
"content": {"type": "text", "text": "event:" + str(event.get("kind", "?")) + ":" + str(event.get("data", {}).get("widget", "?"))}})
"#;
#[test]
fn interactive_requires_the_manifest_capability() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_overlay_plugin(root.path(), "com.test.no-cap", OVERLAY_INTERACTIVE_SCRIPT);
let mut host = PluginHost::new();
assert_eq!(host.refresh_discovery(root.path()).len(), 1);
host.apply_enabled(&[enabled("com.test.no-cap")]);
let settled = wait_until(&mut host, |h| h.overlays().contains_key("com.test.no-cap"));
assert!(settled, "fixture's SetOverlay never arrived");
let overlay = &host.overlays()["com.test.no-cap"];
assert!(!overlay.interactive, "no capability → forced display-only");
host.focus_overlay("com.test.no-cap");
assert_eq!(host.focused_overlay(), None);
host.stop_all();
}
#[test]
fn interactive_capability_allows_focus_and_clearing_drops_it() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_overlay_plugin_with_manifest(
root.path(),
"com.test.cap",
&overlay_interactive_manifest_json("com.test.cap"),
OVERLAY_INTERACTIVE_SCRIPT,
);
let mut host = PluginHost::new();
assert_eq!(host.refresh_discovery(root.path()).len(), 1);
host.apply_enabled(&[enabled("com.test.cap")]);
let settled = wait_until(&mut host, |h| {
h.overlays()
.get("com.test.cap")
.is_some_and(|o| o.interactive)
});
assert!(settled, "fixture's interactive SetOverlay never arrived");
host.focus_overlay("com.test.cap");
assert_eq!(host.focused_overlay(), Some("com.test.cap"));
host.unfocus_overlay();
assert_eq!(host.focused_overlay(), None);
host.focus_overlay("com.test.cap");
host.apply_enabled(&[]);
assert!(host.overlays().is_empty());
assert_eq!(host.focused_overlay(), None);
host.stop_all();
}
#[test]
fn overlay_events_reach_the_running_process() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_overlay_plugin_with_manifest(
root.path(),
"com.test.echo",
&overlay_interactive_manifest_json("com.test.echo"),
OVERLAY_ECHO_SCRIPT,
);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[enabled("com.test.echo")]);
let up = wait_until(&mut host, |h| {
h.overlays()
.get("com.test.echo")
.is_some_and(|o| o.interactive)
});
assert!(up, "fixture never pushed its overlay");
let delivered = host.send_overlay_event(
"com.test.echo",
"deploy",
crate::protocol::OverlayWidgetEvent::Click {},
);
assert!(delivered, "send_overlay_event reported failure");
assert_eq!(host.overlay_events_dispatched_count(), 1);
let echoed = wait_until(&mut host, |h| {
h.overlays().get("com.test.echo").is_some_and(|o| {
matches!(
&o.content,
crate::protocol::OverlayScene::Text { text }
if text.contains("overlay_event") && text.contains("deploy")
)
})
});
assert!(echoed, "the overlay process never echoed the event");
host.stop_all();
}
#[test]
fn overlay_update_rate_clamp_drops_excess_upserts() {
let mut host = PluginHost::new();
let t0 = Instant::now();
for _ in 0..30 {
assert!(host.overlay_update_permitted("p", t0));
}
assert!(
!host.overlay_update_permitted("p", t0),
"clamp must refuse past the budget"
);
let after = t0 + Duration::from_secs(2);
assert!(host.overlay_update_permitted("p", after));
}
#[test]
fn invoke_action_miss_paths_return_false_without_a_process() {
let root = tempfile::tempdir().unwrap();
write_plugin(root.path(), "com.test.only-widget", WIDGET_SCRIPT);
write_action_plugin(root.path(), "com.test.only-action", ACTION_SCRIPT);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[]);
assert!(!host.invoke_action("com.test.nope", "greet"));
assert!(!host.invoke_action("com.test.only-action", "say-hello"));
assert!(!host.invoke_action("com.test.only-action", "bogus"));
assert!(!host.invoke_action("com.test.only-widget", "say-hello"));
assert!(host.running_plugin_ids().is_empty());
assert!(host.action_running.is_empty());
}
#[test]
fn invoke_action_not_running_warns_once_per_episode() {
install_counting_logger();
let root = tempfile::tempdir().unwrap();
write_action_plugin(root.path(), "com.test.invoke-miss", ACTION_SCRIPT);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[]);
let fragment = "'com.test.invoke-miss' has no running action process";
for _ in 0..5 {
assert!(!host.invoke_action("com.test.invoke-miss", "say-hello"));
}
assert_eq!(
warns_containing(fragment),
1,
"5 invokes of one steady miss must produce exactly 1 warn"
);
}
#[test]
fn disable_enable_cycle_rearms_the_action_warn_gate() {
let mut host = PluginHost::new();
assert!(host.warned_action_not_running.should_warn("com.test.gate"));
assert!(
!host.warned_action_not_running.should_warn("com.test.gate"),
"second call in the same episode must be silent"
);
host.apply_enabled(&[]);
assert!(
host.warned_action_not_running.should_warn("com.test.gate"),
"a disable/enable cycle must re-arm the action warn gate"
);
}
#[test]
fn palette_actions_list_enabled_action_plugins_only() {
let root = tempfile::tempdir().unwrap();
write_action_plugin(root.path(), "com.test.pal-on", ACTION_SCRIPT);
write_action_plugin(root.path(), "com.test.pal-off", ACTION_SCRIPT);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[]);
assert!(
host.palette_actions().is_empty(),
"nothing enabled -> no rows"
);
host.apply_enabled(&[enabled("com.test.pal-on")]);
let rows = host.palette_actions();
assert_eq!(rows.len(), 1, "one action on the enabled plugin: {rows:?}");
assert_eq!(rows[0].wire_id, "plugin-action:com.test.pal-on:say-hello");
assert_eq!(rows[0].label, "Say hello · Test Actions");
host.stop_all();
}
#[test]
fn both_kinds_manifest_runs_one_process_per_kind() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_plugin_files(
root.path(),
"com.test.both",
&both_kinds_manifest_json("com.test.both"),
&[("widget.py", WIDGET_SCRIPT), ("actions.py", ACTION_SCRIPT)],
);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[enabled("com.test.both")]);
assert!(
wait_until(&mut host, |h| h.running.len() == 1
&& h.action_running.len() == 1),
"a both-kinds manifest must run both entry points after one pass"
);
assert!(wait_until(&mut host, |h| h
.widget_text("com.test.both")
.is_some()));
assert!(host.invoke_action("com.test.both", "ping"));
host.stop_all();
}
#[test]
fn invoke_action_writes_the_event_to_the_running_action_process() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_action_plugin(root.path(), "com.test.invoke", ACTION_SCRIPT);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[enabled("com.test.invoke")]);
assert!(
host.invoke_action("com.test.invoke", "say-hello"),
"writing to a running action process must succeed"
);
let landed = wait_until(&mut host, |h| {
h.widget_text("com.test.invoke") == Some("invoked:say-hello")
});
assert!(
landed,
"PluginActionInvoked never reached the plugin's stdin; widget text: {:?}",
host.widget_text("com.test.invoke")
);
host.stop_all();
}
#[test]
fn actions_dispatched_count_counts_only_successful_writes() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_action_plugin(root.path(), "com.test.count", ACTION_SCRIPT);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[]);
assert!(!host.invoke_action("com.test.count", "say-hello"));
assert_eq!(
host.actions_dispatched_count(),
0,
"a missed invoke is not a successful write"
);
host.apply_enabled(&[enabled("com.test.count")]);
assert!(wait_until(&mut host, |h| !h.action_running.is_empty()));
for expected in 1..=2u64 {
assert!(host.invoke_action("com.test.count", "say-hello"));
assert_eq!(host.actions_dispatched_count(), expected);
}
host.stop_all();
}
fn echo_script(tag: &str) -> String {
format!(
r#"
import json, sys
def emit(text):
print(json.dumps({{"type": "SetWidget", "text": text}}), flush=True)
for line in iter(sys.stdin.readline, ""):
line = line.strip()
if not line:
continue
try:
event = json.loads(line)
except ValueError:
continue
emit("{tag}:" + str(event.get("kind", "?")))
"#
)
}
fn subscribed_manifest_json(id: &str, subscriptions_json: &str) -> String {
manifest_json(id).replacen(
r#""kinds":["status-bar-widget"],"#,
&format!(r#""kinds":["status-bar-widget"],"subscriptions":[{subscriptions_json}],"#),
1,
)
}
fn subscribed_both_kinds_manifest_json(id: &str, subscriptions_json: &str) -> String {
both_kinds_manifest_json(id).replacen(
r#""kinds":["status-bar-widget","action-contributor"],"#,
&format!(
r#""kinds":["status-bar-widget","action-contributor"],"subscriptions":[{subscriptions_json}],"#
),
1,
)
}
#[test]
fn subscribed_plugin_gets_a_kind_filtered_forwarder_on_spawn() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_plugin_files(
root.path(),
"com.test.sub",
&subscribed_manifest_json("com.test.sub", r#""bell_rang""#),
&[("widget.py", WIDGET_SCRIPT)],
);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[enabled("com.test.sub")]);
let forwarder = host
.subscription_forwarders()
.get("com.test.sub")
.cloned()
.expect("a subscribed plugin gets a forwarder on spawn");
use par_term_emu_core_rust::observer::TerminalObserver;
use par_term_emu_core_rust::terminal::{BellEvent, TerminalEvent};
forwarder.on_event(&TerminalEvent::BellRang(BellEvent::VisualBell));
forwarder.on_event(&TerminalEvent::TitleChanged("nope".to_string()));
let drained = forwarder.drain_events();
assert_eq!(drained.len(), 1, "unsubscribed kinds must not buffer");
assert_eq!(drained[0].kind, "bell_rang");
host.stop_all();
}
#[test]
fn unsubscribed_plugin_gets_no_forwarder() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_plugin(root.path(), "com.test.plain", WIDGET_SCRIPT);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[enabled("com.test.plain")]);
assert!(wait_until(&mut host, |h| !h.running.is_empty()));
assert!(
host.subscription_forwarders().is_empty(),
"no subscriptions declared — no forwarder may exist"
);
host.stop_all();
}
#[test]
fn teardown_drops_the_forwarder_when_the_plugin_leaves_the_enabled_set() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_plugin_files(
root.path(),
"com.test.leave",
&subscribed_manifest_json("com.test.leave", r#""bell_rang""#),
&[("widget.py", WIDGET_SCRIPT)],
);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[enabled("com.test.leave")]);
assert!(
host.subscription_forwarders()
.contains_key("com.test.leave")
);
host.apply_enabled(&[]);
assert!(
host.subscription_forwarders().is_empty(),
"teardown must drop the forwarder so its observer is unregistered"
);
}
#[test]
fn delivered_events_reach_the_running_process() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_plugin_files(
root.path(),
"com.test.echo",
&subscribed_manifest_json("com.test.echo", r#""bell_rang""#),
&[("widget.py", &echo_script("event"))],
);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[enabled("com.test.echo")]);
let forwarder = host
.subscription_forwarders()
.get("com.test.echo")
.cloned()
.expect("forwarder exists for a subscribed plugin");
use par_term_emu_core_rust::observer::TerminalObserver;
use par_term_emu_core_rust::terminal::{BellEvent, TerminalEvent};
forwarder.on_event(&TerminalEvent::BellRang(BellEvent::VisualBell));
let events = forwarder.drain_events();
assert_eq!(events.len(), 1);
host.deliver_events("com.test.echo", &events);
let landed = wait_until(&mut host, |h| {
h.widget_text("com.test.echo") == Some("event:bell_rang")
});
assert!(
landed,
"the bell event never reached the plugin's stdin; widget text: {:?}",
host.widget_text("com.test.echo")
);
host.stop_all();
}
const WIDGET_ECHO_DELAYED_SCRIPT: &str = r#"
import json, sys, time
def emit(text):
print(json.dumps({"type": "SetWidget", "text": text}), flush=True)
for line in iter(sys.stdin.readline, ""):
line = line.strip()
if not line:
continue
try:
event = json.loads(line)
except ValueError:
continue
time.sleep(0.3)
emit("w:" + str(event.get("kind", "?")))
"#;
#[test]
fn both_kinds_processes_receive_subscribed_events() {
if skip_without_interpreter() {
return;
}
let root = tempfile::tempdir().unwrap();
write_plugin_files(
root.path(),
"com.test.both-echo",
&subscribed_both_kinds_manifest_json("com.test.both-echo", r#""bell_rang""#),
&[
("widget.py", WIDGET_ECHO_DELAYED_SCRIPT),
("actions.py", &echo_script("a")),
],
);
let mut host = PluginHost::new();
host.refresh_discovery(root.path());
host.apply_enabled(&[enabled("com.test.both-echo")]);
assert!(wait_until(&mut host, |h| h.running.len() == 1
&& h.action_running.len() == 1));
let forwarder = host
.subscription_forwarders()
.get("com.test.both-echo")
.cloned()
.expect("forwarder exists for a subscribed plugin");
use par_term_emu_core_rust::observer::TerminalObserver;
use par_term_emu_core_rust::terminal::{BellEvent, TerminalEvent};
forwarder.on_event(&TerminalEvent::BellRang(BellEvent::VisualBell));
let events = forwarder.drain_events();
host.deliver_events("com.test.both-echo", &events);
let mut seen_widget = false;
let mut seen_action = false;
let both_seen = wait_until(&mut host, |h| {
if h.widget_text("com.test.both-echo") == Some("w:bell_rang") {
seen_widget = true;
}
if h.widget_text("com.test.both-echo") == Some("a:bell_rang") {
seen_action = true;
}
seen_widget && seen_action
});
assert!(
both_seen,
"both kind processes must receive the event (w seen: {seen_widget}, a seen: {seen_action})"
);
host.stop_all();
}
#[test]
fn deliver_events_is_silent_with_no_running_process() {
install_counting_logger();
let mut host = PluginHost::new();
let event = ScriptEvent {
kind: "bell_rang".to_string(),
data: ScriptEventData::Empty {},
};
host.deliver_events("com.test.nobody", std::slice::from_ref(&event));
assert_eq!(warns_containing("com.test.nobody"), 0);
}
}