1use std::collections::{HashMap, HashSet};
17use std::path::Path;
18use std::sync::Arc;
19use std::time::Instant;
20
21use super::manager::{ScriptId, ScriptManager};
22use super::manifest::{
23 DiscoveredPlugin, ENTRY_POINT_ACTION_CONTRIBUTOR, ENTRY_POINT_OVERLAY, ENTRY_POINT_PANEL,
24 ENTRY_POINT_STATUS_BAR_WIDGET, KIND_ACTION_CONTRIBUTOR, KIND_OVERLAY, KIND_PANEL,
25 KIND_STATUS_BAR_WIDGET, discover_plugins,
26};
27use super::observer::ScriptEventForwarder;
28use super::protocol::{PLUGIN_ACTION_INVOKED_KIND, ScriptEvent, ScriptEventData};
29use super::restart::ScriptRestartState;
30
31mod supervision;
32
33const PLUGIN_RESTART_DELAY_MS: u64 = 250;
37
38const SETTINGS_ARG: &str = "--par-term-settings";
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct EnabledPlugin {
44 pub id: String,
46 pub settings_json: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct PluginActionRow {
54 pub wire_id: String,
56 pub label: String,
59}
60
61fn widget_entry_args(plugin: &DiscoveredPlugin) -> &[String] {
63 plugin
64 .manifest
65 .entry_points
66 .get(ENTRY_POINT_STATUS_BAR_WIDGET)
67 .map(|entry| entry.args.as_slice())
68 .unwrap_or_default()
69}
70
71fn action_entry_args(plugin: &DiscoveredPlugin) -> &[String] {
73 plugin
74 .manifest
75 .entry_points
76 .get(ENTRY_POINT_ACTION_CONTRIBUTOR)
77 .map(|entry| entry.args.as_slice())
78 .unwrap_or_default()
79}
80
81fn panel_entry_args(plugin: &DiscoveredPlugin) -> &[String] {
83 plugin
84 .manifest
85 .entry_points
86 .get(ENTRY_POINT_PANEL)
87 .map(|entry| entry.args.as_slice())
88 .unwrap_or_default()
89}
90
91fn overlay_entry_args(plugin: &DiscoveredPlugin) -> &[String] {
93 plugin
94 .manifest
95 .entry_points
96 .get(ENTRY_POINT_OVERLAY)
97 .map(|entry| entry.args.as_slice())
98 .unwrap_or_default()
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105enum KindSlot {
106 Widget,
108 Action,
110 Panel,
112 Overlay,
114}
115
116impl KindSlot {
117 fn as_str(self) -> &'static str {
119 match self {
120 KindSlot::Widget => "widget",
121 KindSlot::Action => "action",
122 KindSlot::Panel => "panel",
123 KindSlot::Overlay => "overlay",
124 }
125 }
126}
127
128#[derive(Debug, Default)]
137pub struct WarnOnce {
138 warned: HashSet<String>,
139}
140
141impl WarnOnce {
142 pub fn should_warn(&mut self, key: &str) -> bool {
145 self.warned.insert(key.to_string())
146 }
147
148 pub fn clear(&mut self, key: &str) {
150 self.warned.remove(key);
151 }
152
153 pub fn clear_except(&mut self, keep: &HashSet<String>) {
156 self.warned.retain(|key| keep.contains(key));
157 }
158}
159
160#[derive(Default)]
163pub struct PluginHost {
164 discovered: Vec<DiscoveredPlugin>,
166 running: HashMap<String, ScriptId>,
170 action_running: HashMap<String, ScriptId>,
174 panel_running: HashMap<String, ScriptId>,
176 overlay_running: HashMap<String, ScriptId>,
178 manager: ScriptManager,
180 widget_texts: HashMap<String, String>,
186 panel_contents: HashMap<String, (String, String)>,
190 overlays: HashMap<String, crate::protocol::PluginOverlay>,
194 focused_overlay: Option<String>,
200 overlay_update_times: HashMap<String, Vec<std::time::Instant>>,
204 actions_dispatched: u64,
208 overlay_events_dispatched: u64,
211 restart: HashMap<String, ScriptRestartState>,
213 action_restart: HashMap<String, ScriptRestartState>,
216 panel_restart: HashMap<String, ScriptRestartState>,
219 overlay_restart: HashMap<String, ScriptRestartState>,
222 settings_json: HashMap<String, String>,
226 enabled_ids: HashSet<String>,
230 ignored_lines: Vec<String>,
232 subscription_forwarders: HashMap<String, Arc<ScriptEventForwarder>>,
240 warned_event_delivery: WarnOnce,
244 warned_not_discovered: WarnOnce,
247 warned_spawn_failed: WarnOnce,
248 warned_action_not_running: WarnOnce,
252}
253
254impl PluginHost {
255 pub fn new() -> Self {
259 Self::default()
260 }
261
262 pub fn refresh_discovery(&mut self, root: &Path) -> &[DiscoveredPlugin] {
265 let (plugins, warnings) = discover_plugins(root);
266 for warning in &warnings {
267 log::warn!(
268 "plugin discovery skipped '{}': {}",
269 warning.dir,
270 warning.reason
271 );
272 }
273 self.discovered = plugins;
274 &self.discovered
275 }
276
277 pub fn discovered(&self, id: &str) -> Option<&DiscoveredPlugin> {
279 self.discovered.iter().find(|d| d.manifest.id == id)
280 }
281
282 pub fn discovered_count(&self) -> usize {
284 self.discovered.len()
285 }
286
287 pub fn apply_enabled(&mut self, enabled: &[EnabledPlugin]) {
295 let now = Instant::now();
296
297 let stale: Vec<String> = self
298 .running
299 .keys()
300 .chain(self.action_running.keys())
301 .chain(self.panel_running.keys())
302 .chain(self.overlay_running.keys())
303 .filter(|id| !enabled.iter().any(|e| e.id == **id))
304 .cloned()
305 .collect();
306 for id in &stale {
307 self.teardown(id);
308 }
309
310 let enabled_ids: HashSet<String> = enabled.iter().map(|e| e.id.clone()).collect();
313 self.enabled_ids = enabled_ids.clone();
314 self.warned_not_discovered.clear_except(&enabled_ids);
315 self.warned_spawn_failed.clear_except(&enabled_ids);
316 self.warned_action_not_running.clear_except(&enabled_ids);
317 self.warned_event_delivery.clear_except(&enabled_ids);
318
319 for plugin in enabled {
320 let Some(found) = self.discovered.iter().find(|d| d.manifest.id == plugin.id) else {
321 if self.warned_not_discovered.should_warn(&plugin.id) {
322 log::warn!(
323 "plugin '{}' is enabled but not discovered; not spawned",
324 plugin.id
325 );
326 }
327 continue;
328 };
329 let has_widget = found
330 .manifest
331 .kinds
332 .iter()
333 .any(|k| k == KIND_STATUS_BAR_WIDGET);
334 let has_action = found
335 .manifest
336 .kinds
337 .iter()
338 .any(|k| k == KIND_ACTION_CONTRIBUTOR);
339 let has_panel = found.manifest.kinds.iter().any(|k| k == KIND_PANEL);
340 let has_overlay = found.manifest.kinds.iter().any(|k| k == KIND_OVERLAY);
341
342 let widget_entry =
348 has_widget.then(|| (found.entry_path.clone(), widget_entry_args(found).to_vec()));
349 let action_entry = found
350 .action_entry_path
351 .clone()
352 .filter(|_| has_action)
353 .map(|path| (path, action_entry_args(found).to_vec()));
354 let panel_entry = found
355 .panel_entry_path
356 .clone()
357 .filter(|_| has_panel)
358 .map(|path| (path, panel_entry_args(found).to_vec()));
359 let overlay_entry = found
360 .overlay_entry_path
361 .clone()
362 .filter(|_| has_overlay)
363 .map(|path| (path, overlay_entry_args(found).to_vec()));
364 let subscriptions = found.manifest.subscriptions.clone();
367 let restart_policy = found.manifest.restart;
371
372 self.spawn_kind_leg(
373 &plugin.id,
374 KindSlot::Widget,
375 widget_entry,
376 &plugin.settings_json,
377 &subscriptions,
378 restart_policy,
379 now,
380 );
381 self.spawn_kind_leg(
382 &plugin.id,
383 KindSlot::Action,
384 action_entry,
385 &plugin.settings_json,
386 &subscriptions,
387 restart_policy,
388 now,
389 );
390 self.spawn_kind_leg(
391 &plugin.id,
392 KindSlot::Panel,
393 panel_entry,
394 &plugin.settings_json,
395 &subscriptions,
396 restart_policy,
397 now,
398 );
399 self.spawn_kind_leg(
400 &plugin.id,
401 KindSlot::Overlay,
402 overlay_entry,
403 &plugin.settings_json,
404 &subscriptions,
405 restart_policy,
406 now,
407 );
408 }
409 }
410
411 pub fn poll(&mut self) {
419 let now = Instant::now();
420 let widget_ids: Vec<String> = self.running.keys().cloned().collect();
421 for id in widget_ids {
422 self.poll_one(&id, KindSlot::Widget, now);
423 }
424 let action_ids: Vec<String> = self.action_running.keys().cloned().collect();
425 for id in action_ids {
426 self.poll_one(&id, KindSlot::Action, now);
427 }
428 let panel_ids: Vec<String> = self.panel_running.keys().cloned().collect();
429 for id in panel_ids {
430 self.poll_one(&id, KindSlot::Panel, now);
431 }
432 let overlay_ids: Vec<String> = self.overlay_running.keys().cloned().collect();
433 for id in overlay_ids {
434 self.poll_one(&id, KindSlot::Overlay, now);
435 }
436 }
437
438 pub fn widget_text(&self, plugin_id: &str) -> Option<&str> {
440 self.widget_texts.get(plugin_id).map(String::as_str)
441 }
442
443 pub fn panel_content(&self, plugin_id: &str) -> Option<&(String, String)> {
445 self.panel_contents.get(plugin_id)
446 }
447
448 pub fn subscription_forwarders(&self) -> &HashMap<String, Arc<ScriptEventForwarder>> {
452 &self.subscription_forwarders
453 }
454
455 pub fn deliver_events(&mut self, plugin_id: &str, events: &[ScriptEvent]) {
464 let targets: Vec<ScriptId> = self
465 .running
466 .get(plugin_id)
467 .into_iter()
468 .chain(self.action_running.get(plugin_id))
469 .chain(self.panel_running.get(plugin_id))
470 .copied()
471 .collect();
472 if targets.is_empty() {
473 return;
474 }
475 let mut write_failed = false;
476 for event in events {
477 for &sid in &targets {
478 if let Err(error) = self.manager.send_event(sid, event) {
479 write_failed = true;
480 if self.warned_event_delivery.should_warn(plugin_id) {
481 log::warn!(
482 "failed to deliver a {} event to plugin '{}': {}",
483 event.kind,
484 plugin_id,
485 error
486 );
487 }
488 }
489 }
490 }
491 if !write_failed {
492 self.warned_event_delivery.clear(plugin_id);
493 }
494 }
495
496 pub fn widget_texts(&self) -> &HashMap<String, String> {
498 &self.widget_texts
499 }
500
501 pub fn panel_contents(&self) -> &HashMap<String, (String, String)> {
504 &self.panel_contents
505 }
506
507 pub fn overlays(&self) -> &HashMap<String, crate::protocol::PluginOverlay> {
509 &self.overlays
510 }
511
512 pub fn focused_overlay(&self) -> Option<&str> {
515 self.focused_overlay.as_deref()
516 }
517
518 pub fn focus_overlay(&mut self, plugin_id: &str) {
522 self.focused_overlay = self
523 .overlays
524 .get(plugin_id)
525 .filter(|o| o.interactive)
526 .map(|_| plugin_id.to_string());
527 }
528
529 pub fn unfocus_overlay(&mut self) {
532 self.focused_overlay = None;
533 }
534
535 pub(super) fn clear_focus_if(&mut self, plugin_id: &str) {
538 if self.focused_overlay.as_deref() == Some(plugin_id) {
539 self.focused_overlay = None;
540 }
541 }
542
543 pub fn send_overlay_event(
549 &mut self,
550 plugin_id: &str,
551 widget_id: &str,
552 event: crate::protocol::OverlayWidgetEvent,
553 ) -> bool {
554 let Some(&sid) = self.overlay_running.get(plugin_id) else {
555 return false;
556 };
557 let Some(overlay) = self.overlays.get(plugin_id) else {
558 return false;
559 };
560 let script_event = ScriptEvent {
561 kind: crate::protocol::OVERLAY_EVENT_KIND.to_string(),
562 data: ScriptEventData::OverlayEvent {
563 overlay: overlay.id.clone(),
564 widget: widget_id.to_string(),
565 event,
566 },
567 };
568 match self.manager.send_event(sid, &script_event) {
569 Ok(()) => {
570 self.overlay_events_dispatched += 1;
571 true
572 }
573 Err(error) => {
574 if self.warned_event_delivery.should_warn(plugin_id) {
575 log::warn!(
576 "failed to deliver overlay event to plugin '{}': {}",
577 plugin_id,
578 error
579 );
580 }
581 false
582 }
583 }
584 }
585
586 pub fn overlay_events_dispatched_count(&self) -> u64 {
589 self.overlay_events_dispatched
590 }
591
592 const OVERLAY_UPDATE_WINDOW_SECS: u64 = 1;
595 const OVERLAY_UPDATE_MAX_PER_WINDOW: usize = 30;
596
597 pub(super) fn overlay_update_permitted(&mut self, id: &str, now: Instant) -> bool {
603 let times = self.overlay_update_times.entry(id.to_string()).or_default();
604 times.retain(|t| now.duration_since(*t).as_secs() < Self::OVERLAY_UPDATE_WINDOW_SECS);
605 if times.len() >= Self::OVERLAY_UPDATE_MAX_PER_WINDOW {
606 if self
607 .warned_event_delivery
608 .should_warn(&format!("{id}/overlay-rate"))
609 {
610 log::warn!(
611 "plugin '{id}' is pushing SetOverlay faster than {} per second; \
612 dropping the excess (update-rate clamp)",
613 Self::OVERLAY_UPDATE_MAX_PER_WINDOW
614 );
615 }
616 return false;
617 }
618 times.push(now);
619 true
620 }
621
622 pub fn actions_dispatched_count(&self) -> u64 {
626 self.actions_dispatched
627 }
628
629 pub fn running_plugin_ids(&self) -> Vec<String> {
632 self.running
633 .keys()
634 .chain(self.action_running.keys())
635 .chain(self.panel_running.keys())
636 .chain(self.overlay_running.keys())
637 .cloned()
638 .collect()
639 }
640
641 pub fn invoke_action(&mut self, plugin_id: &str, action_id: &str) -> bool {
652 let Some(found) = self.discovered.iter().find(|d| d.manifest.id == plugin_id) else {
653 if self.warned_not_discovered.should_warn(plugin_id) {
654 log::warn!(
655 "plugin '{}' is not discovered; cannot invoke action '{}'",
656 plugin_id,
657 action_id
658 );
659 }
660 return false;
661 };
662 if !found
663 .manifest
664 .kinds
665 .iter()
666 .any(|k| k == KIND_ACTION_CONTRIBUTOR)
667 {
668 if self.warned_action_not_running.should_warn(plugin_id) {
669 log::warn!(
670 "plugin '{}' does not declare kind '{}'; cannot invoke action '{}'",
671 plugin_id,
672 KIND_ACTION_CONTRIBUTOR,
673 action_id
674 );
675 }
676 return false;
677 }
678 if !found.manifest.actions.iter().any(|a| a.id == action_id) {
679 if self.warned_action_not_running.should_warn(plugin_id) {
680 log::warn!(
681 "plugin '{}' has no action '{}'; cannot invoke it",
682 plugin_id,
683 action_id
684 );
685 }
686 return false;
687 }
688 let Some(&sid) = self.action_running.get(plugin_id) else {
689 if self.warned_action_not_running.should_warn(plugin_id) {
690 log::warn!(
691 "plugin '{}' has no running action process; action '{}' not invoked",
692 plugin_id,
693 action_id
694 );
695 }
696 return false;
697 };
698 let event = ScriptEvent {
699 kind: PLUGIN_ACTION_INVOKED_KIND.to_string(),
700 data: ScriptEventData::PluginActionInvoked {
701 action: action_id.to_string(),
702 },
703 };
704 match self.manager.send_event(sid, &event) {
705 Ok(()) => {
706 self.warned_action_not_running.clear(plugin_id);
708 self.actions_dispatched += 1;
709 true
710 }
711 Err(error) => {
712 if self.warned_action_not_running.should_warn(plugin_id) {
713 log::warn!(
714 "failed to invoke action '{}' on plugin '{}': {}",
715 action_id,
716 plugin_id,
717 error
718 );
719 }
720 false
721 }
722 }
723 }
724
725 pub fn palette_actions(&self) -> Vec<PluginActionRow> {
728 let mut rows = Vec::new();
729 for found in &self.discovered {
730 if !self.enabled_ids.contains(&found.manifest.id) {
731 continue;
732 }
733 if !found
734 .manifest
735 .kinds
736 .iter()
737 .any(|k| k == KIND_ACTION_CONTRIBUTOR)
738 {
739 continue;
740 }
741 for action in &found.manifest.actions {
742 rows.push(PluginActionRow {
743 wire_id: format!("plugin-action:{}:{}", found.manifest.id, action.id),
744 label: format!("{} · {}", action.label, found.manifest.name),
745 });
746 }
747 }
748 rows
749 }
750
751 pub fn drain_ignored(&mut self) -> Vec<String> {
754 std::mem::take(&mut self.ignored_lines)
755 }
756
757 pub fn stop_all(&mut self) {
759 self.manager.stop_all();
760 self.running.clear();
761 self.action_running.clear();
762 self.panel_running.clear();
763 self.overlay_running.clear();
764 self.restart.clear();
765 self.action_restart.clear();
766 self.panel_restart.clear();
767 self.overlay_restart.clear();
768 self.widget_texts.clear();
769 self.panel_contents.clear();
770 self.overlays.clear();
771 self.settings_json.clear();
772 self.enabled_ids.clear();
773 self.subscription_forwarders.clear();
774 self.warned_not_discovered = WarnOnce::default();
775 self.warned_spawn_failed = WarnOnce::default();
776 self.warned_action_not_running = WarnOnce::default();
777 self.warned_event_delivery = WarnOnce::default();
778 }
779}
780
781#[cfg(test)]
782mod tests {
783 use super::*;
784 use std::fs;
785 use std::thread;
786 use std::time::Duration;
787
788 const WIDGET_SCRIPT: &str = r#"
790import json, time
791def emit(text):
792 print(json.dumps({"type": "SetWidget", "text": text}), flush=True)
793emit("first")
794emit("second")
795while True:
796 time.sleep(0.2)
797"#;
798
799 const LOG_SCRIPT: &str = r#"
801import json, time
802print(json.dumps({"type": "Log", "level": "info", "message": "hello"}), flush=True)
803while True:
804 time.sleep(0.2)
805"#;
806
807 const CRASH_SCRIPT: &str = "import sys\nsys.exit(3)\n";
809
810 fn manifest_json(id: &str) -> String {
811 format!(
812 "{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"T\",\"version\":\"0.1.0\",\
813\"kinds\":[\"status-bar-widget\"],\"activation\":\"manual\",\
814\"entryPoints\":{{\"statusBarWidget\":{{\"command\":\"widget.py\",\"args\":[]}}}},\
815\"statusBarWidget\":{{\"displayName\":\"T\",\"section\":\"right\",\"defaults\":{{}},\
816\"schema\":[{{\"key\":\"on\",\"type\":\"boolean\",\"label\":\"On\",\"defaultValue\":true}}]}}}}"
817 )
818 }
819
820 fn write_plugin(root: &Path, id: &str, script: &str) {
823 let dir = root.join(id);
824 fs::create_dir_all(&dir).unwrap();
825 fs::write(dir.join("manifest.json"), manifest_json(id)).unwrap();
826 let entry = dir.join("widget.py");
827 fs::write(&entry, script).unwrap();
828 #[cfg(unix)]
829 {
830 use std::os::unix::fs::PermissionsExt;
831 fs::set_permissions(&entry, fs::Permissions::from_mode(0o755)).unwrap();
832 }
833 }
834
835 fn enabled(id: &str) -> EnabledPlugin {
836 EnabledPlugin {
837 id: id.to_string(),
838 settings_json: "{}".to_string(),
839 }
840 }
841
842 fn plugin_running(host: &PluginHost, id: &str) -> bool {
843 host.running_plugin_ids().iter().any(|i| i == id)
844 }
845
846 fn wait_until(host: &mut PluginHost, mut check: impl FnMut(&mut PluginHost) -> bool) -> bool {
849 let deadline = Instant::now() + Duration::from_secs(10);
850 while Instant::now() < deadline {
851 host.poll();
852 if check(host) {
853 return true;
854 }
855 thread::sleep(Duration::from_millis(50));
856 }
857 false
858 }
859
860 fn skip_without_interpreter() -> bool {
861 if crate::manager::python_interpreter().is_some() {
862 return false;
863 }
864 eprintln!("skipping: no Python interpreter on PATH");
865 true
866 }
867
868 #[test]
869 fn discovery_never_spawns_even_for_an_absent_root() {
870 let mut host = PluginHost::new();
871 let root = std::env::temp_dir().join("par-term-plugin-test-absent-root");
872 assert!(host.refresh_discovery(&root).is_empty());
873 host.apply_enabled(&[enabled("com.test.widget")]);
874 assert!(host.running_plugin_ids().is_empty());
875 host.poll(); }
877
878 #[test]
879 fn apply_enabled_spawns_and_last_set_widget_wins() {
880 if skip_without_interpreter() {
881 return;
882 }
883 let root = tempfile::tempdir().unwrap();
884 write_plugin(root.path(), "com.test.widget", WIDGET_SCRIPT);
885
886 let mut host = PluginHost::new();
887 assert_eq!(host.refresh_discovery(root.path()).len(), 1);
888 host.apply_enabled(&[enabled("com.test.widget")]);
889 assert_eq!(host.widget_text("com.test.widget"), None);
891
892 let settled = wait_until(&mut host, |h| h.widget_text("com.test.widget").is_some());
893 assert!(settled, "fixture's SetWidget never arrived");
894 assert_eq!(host.widget_text("com.test.widget"), Some("second"));
897 host.stop_all();
898 }
899
900 #[test]
901 fn disabling_stops_the_plugin_and_its_widget_text() {
902 if skip_without_interpreter() {
903 return;
904 }
905 let root = tempfile::tempdir().unwrap();
906 write_plugin(root.path(), "com.test.widget", WIDGET_SCRIPT);
907
908 let mut host = PluginHost::new();
909 host.refresh_discovery(root.path());
910 host.apply_enabled(&[enabled("com.test.widget")]);
911 assert!(wait_until(&mut host, |h| h
912 .widget_text("com.test.widget")
913 .is_some()));
914
915 host.apply_enabled(&[]);
916 assert!(!plugin_running(&host, "com.test.widget"));
917 assert_eq!(host.widget_text("com.test.widget"), None);
918 for _ in 0..10 {
920 host.poll();
921 thread::sleep(Duration::from_millis(50));
922 }
923 assert!(host.running_plugin_ids().is_empty());
924 }
925
926 #[test]
927 fn crash_loop_is_capped_without_panicking() {
928 if skip_without_interpreter() {
929 return;
930 }
931 let root = tempfile::tempdir().unwrap();
932 write_plugin(root.path(), "com.test.crash", CRASH_SCRIPT);
933
934 let mut host = PluginHost::new();
935 host.refresh_discovery(root.path());
936 host.apply_enabled(&[enabled("com.test.crash")]);
937
938 let gave_up = wait_until(&mut host, |h| !plugin_running(h, "com.test.crash"));
941 assert!(
942 gave_up,
943 "supervisor never gave up; still running: {:?}",
944 host.running_plugin_ids()
945 );
946 host.poll();
948 assert!(host.running_plugin_ids().is_empty());
949 }
950
951 const ONE_SHOT_SCRIPT: &str = r#"
953import json
954print(json.dumps({"type": "SetWidget", "text": "once"}), flush=True)
955"#;
956
957 fn restart_manifest_json(id: &str, mode: &str) -> String {
959 manifest_json(id).replacen(
960 "\"activation\":\"manual\",",
961 &format!("\"activation\":\"manual\",\"restart\":\"{mode}\","),
962 1,
963 )
964 }
965
966 #[test]
967 fn never_policy_plugin_is_not_restarted_after_a_crash() {
968 if skip_without_interpreter() {
969 return;
970 }
971 let root = tempfile::tempdir().unwrap();
972 write_plugin_files(
973 root.path(),
974 "com.test.never",
975 &restart_manifest_json("com.test.never", "never"),
976 &[("widget.py", CRASH_SCRIPT)],
977 );
978
979 let mut host = PluginHost::new();
980 host.refresh_discovery(root.path());
981 host.apply_enabled(&[enabled("com.test.never")]);
982
983 let stopped = wait_until(&mut host, |h| !plugin_running(h, "com.test.never"));
986 assert!(stopped, "plugin never stopped");
987 assert!(
988 !host.restart.contains_key("com.test.never"),
989 "stop_kind must drop the supervision state"
990 );
991
992 thread::sleep(Duration::from_millis(700));
994 host.poll();
995 assert!(
996 !plugin_running(&host, "com.test.never"),
997 "`never` must not respawn a crashed plugin"
998 );
999 }
1000
1001 #[test]
1002 fn always_policy_plugin_restarts_after_a_clean_exit() {
1003 if skip_without_interpreter() {
1004 return;
1005 }
1006 let root = tempfile::tempdir().unwrap();
1007 write_plugin_files(
1008 root.path(),
1009 "com.test.always",
1010 &restart_manifest_json("com.test.always", "always"),
1011 &[("widget.py", ONE_SHOT_SCRIPT)],
1012 );
1013
1014 let mut host = PluginHost::new();
1015 host.refresh_discovery(root.path());
1016 host.apply_enabled(&[enabled("com.test.always")]);
1017
1018 let restarted = wait_until(&mut host, |h| {
1024 h.restart
1025 .get("com.test.always")
1026 .is_some_and(|s| s.consecutive_failures() >= 2)
1027 });
1028 assert!(
1029 restarted,
1030 "clean exit was never restarted; failures: {:?}",
1031 host.restart
1032 .get("com.test.always")
1033 .map(|s| s.consecutive_failures())
1034 );
1035 host.stop_all();
1036 }
1037
1038 #[test]
1039 fn non_widget_commands_are_refused_with_an_error_line() {
1040 if skip_without_interpreter() {
1041 return;
1042 }
1043 let root = tempfile::tempdir().unwrap();
1044 write_plugin(root.path(), "com.test.log", LOG_SCRIPT);
1045
1046 let mut host = PluginHost::new();
1047 host.refresh_discovery(root.path());
1048 host.apply_enabled(&[enabled("com.test.log")]);
1049
1050 let mut refused: Vec<String> = Vec::new();
1051 let seen = wait_until(&mut host, |h| {
1052 refused.extend(h.drain_ignored());
1053 refused.iter().any(|line| line.contains("Log"))
1054 });
1055 assert!(
1056 seen,
1057 "Log command was never refused; lines seen: {:?}, widget text: {:?}",
1058 refused,
1059 host.widget_text("com.test.log")
1060 );
1061 assert!(refused.iter().all(|line| line.starts_with("[error]")));
1062 host.stop_all();
1063 }
1064
1065 #[test]
1066 fn failing_spawn_retries_at_the_restart_delay_not_per_frame() {
1067 let root = tempfile::tempdir().unwrap();
1068 let dir = root.path().join("com.test.broken");
1074 fs::create_dir_all(&dir).unwrap();
1075 fs::write(
1076 dir.join("manifest.json"),
1077 manifest_json("com.test.broken").replace("widget.py", "widget.sh"),
1078 )
1079 .unwrap();
1080 fs::write(
1081 dir.join("widget.sh"),
1082 "#!/nonexistent/par-term-test-interpreter\n",
1083 )
1084 .unwrap();
1085 #[cfg(unix)]
1086 {
1087 use std::os::unix::fs::PermissionsExt;
1088 fs::set_permissions(dir.join("widget.sh"), fs::Permissions::from_mode(0o755)).unwrap();
1089 }
1090
1091 let mut host = PluginHost::new();
1092 host.refresh_discovery(root.path());
1093 assert_eq!(host.discovered_count(), 1, "fixture must be discovered");
1094
1095 host.apply_enabled(&[enabled("com.test.broken")]);
1098 assert!(!plugin_running(&host, "com.test.broken"));
1099 let after_first = host
1100 .restart
1101 .get("com.test.broken")
1102 .expect("supervisor state armed by a failed spawn")
1103 .consecutive_failures();
1104 assert_eq!(after_first, 1);
1105
1106 host.apply_enabled(&[enabled("com.test.broken")]);
1109 assert_eq!(
1110 host.restart
1111 .get("com.test.broken")
1112 .unwrap()
1113 .consecutive_failures(),
1114 after_first,
1115 "spawn must not be retried inside the restart delay"
1116 );
1117
1118 thread::sleep(Duration::from_millis(300));
1120 host.apply_enabled(&[enabled("com.test.broken")]);
1121 assert_eq!(
1122 host.restart
1123 .get("com.test.broken")
1124 .unwrap()
1125 .consecutive_failures(),
1126 after_first + 1,
1127 "exactly one retry after the delay elapses"
1128 );
1129 }
1130
1131 #[test]
1132 fn stop_all_drains_every_plugin() {
1133 if skip_without_interpreter() {
1134 return;
1135 }
1136 let root = tempfile::tempdir().unwrap();
1137 write_plugin(root.path(), "com.test.a", WIDGET_SCRIPT);
1138 write_plugin(root.path(), "com.test.b", WIDGET_SCRIPT);
1139
1140 let mut host = PluginHost::new();
1141 host.refresh_discovery(root.path());
1142 host.apply_enabled(&[enabled("com.test.a"), enabled("com.test.b")]);
1143 assert!(wait_until(&mut host, |h| h.running_plugin_ids().len() == 2));
1144
1145 host.stop_all();
1146 assert!(host.running_plugin_ids().is_empty());
1147 assert!(host.widget_texts().is_empty());
1148 host.poll(); }
1150
1151 static WARNED: std::sync::LazyLock<std::sync::Mutex<Vec<String>>> =
1156 std::sync::LazyLock::new(|| std::sync::Mutex::new(Vec::new()));
1157
1158 struct CountingLogger;
1159
1160 impl log::Log for CountingLogger {
1161 fn enabled(&self, _metadata: &log::Metadata) -> bool {
1162 true
1163 }
1164
1165 fn log(&self, record: &log::Record) {
1166 if record.level() == log::Level::Warn {
1167 WARNED.lock().unwrap().push(record.args().to_string());
1168 }
1169 }
1170
1171 fn flush(&self) {}
1172 }
1173
1174 fn install_counting_logger() {
1175 static INSTALL: std::sync::Once = std::sync::Once::new();
1176 static COUNTING_LOGGER: CountingLogger = CountingLogger;
1177 INSTALL.call_once(|| {
1178 let _ = log::set_logger(&COUNTING_LOGGER);
1181 log::set_max_level(log::LevelFilter::Warn);
1182 });
1183 }
1184
1185 fn warns_containing(fragment: &str) -> usize {
1186 WARNED
1187 .lock()
1188 .unwrap()
1189 .iter()
1190 .filter(|msg| msg.contains(fragment))
1191 .count()
1192 }
1193
1194 #[test]
1197 fn fault_warns_fire_once_per_transition_not_per_frame() {
1198 install_counting_logger();
1199 let fragment = "'com.test.missing' is enabled but not discovered";
1200 let mut host = PluginHost::new();
1201 let root = tempfile::tempdir().unwrap();
1203 host.refresh_discovery(root.path());
1204
1205 for _ in 0..5 {
1206 host.apply_enabled(&[enabled("com.test.missing")]);
1207 }
1208 let warns = warns_containing(fragment);
1209 assert_eq!(
1210 warns, 1,
1211 "5 frames of one steady fault must produce exactly 1 warn, got {warns}"
1212 );
1213
1214 host.apply_enabled(&[]);
1217 for _ in 0..2 {
1218 host.apply_enabled(&[enabled("com.test.missing")]);
1219 }
1220 let warns = warns_containing(fragment);
1221 assert_eq!(
1222 warns, 2,
1223 "warn must re-arm after a disable/enable, got {warns}"
1224 );
1225 }
1226
1227 #[test]
1228 fn fault_warn_rearms_after_recovery() {
1229 if skip_without_interpreter() {
1230 return;
1231 }
1232 install_counting_logger();
1233 let empty = tempfile::tempdir().unwrap();
1234 let populated = tempfile::tempdir().unwrap();
1235 write_plugin(populated.path(), "com.test.flapping", WIDGET_SCRIPT);
1236
1237 let mut host = PluginHost::new();
1238 let fragment = "'com.test.flapping' is enabled but not discovered";
1240 host.refresh_discovery(empty.path());
1241 host.apply_enabled(&[enabled("com.test.flapping")]);
1242 assert_eq!(warns_containing(fragment), 1);
1243
1244 host.refresh_discovery(populated.path());
1246 host.apply_enabled(&[enabled("com.test.flapping")]);
1247 assert!(wait_until(&mut host, |h| h
1248 .widget_text("com.test.flapping")
1249 .is_some()));
1250
1251 host.apply_enabled(&[]);
1254 host.refresh_discovery(empty.path());
1255 host.apply_enabled(&[enabled("com.test.flapping")]);
1256 assert_eq!(
1257 warns_containing(fragment),
1258 2,
1259 "a recovered-then-failed-again plugin must warn again"
1260 );
1261 }
1262
1263 const ACTION_SCRIPT: &str = r#"
1267import json, sys
1268def emit(text):
1269 print(json.dumps({"type": "SetWidget", "text": text}), flush=True)
1270for line in iter(sys.stdin.readline, ""):
1271 line = line.strip()
1272 if not line:
1273 continue
1274 try:
1275 event = json.loads(line)
1276 except ValueError:
1277 continue
1278 emit("invoked:" + str(event.get("data", {}).get("action", "?")))
1279"#;
1280
1281 fn action_manifest_json(id: &str) -> String {
1282 format!(
1283 "{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Test Actions\",\"version\":\"0.1.0\",\
1284\"kinds\":[\"action-contributor\"],\"activation\":\"manual\",\
1285\"entryPoints\":{{\"actionContributor\":{{\"command\":\"actions.py\",\"args\":[]}}}},\
1286\"actions\":[{{\"id\":\"say-hello\",\"label\":\"Say hello\"}}]}}"
1287 )
1288 }
1289
1290 fn both_kinds_manifest_json(id: &str) -> String {
1291 format!(
1292 "{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Test Both\",\"version\":\"0.1.0\",\
1293\"kinds\":[\"status-bar-widget\",\"action-contributor\"],\"activation\":\"manual\",\
1294\"entryPoints\":{{\"statusBarWidget\":{{\"command\":\"widget.py\",\"args\":[]}},\
1295\"actionContributor\":{{\"command\":\"actions.py\",\"args\":[]}}}},\
1296\"actions\":[{{\"id\":\"ping\",\"label\":\"Ping\"}}],\
1297\"statusBarWidget\":{{\"displayName\":\"T\",\"section\":\"right\",\"defaults\":{{}},\
1298\"schema\":[{{\"key\":\"on\",\"type\":\"boolean\",\"label\":\"On\",\"defaultValue\":true}}]}}}}"
1299 )
1300 }
1301
1302 fn panel_manifest_json(id: &str) -> String {
1303 format!(
1304 "{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Test Panel\",\"version\":\"0.1.0\",\
1305\"kinds\":[\"panel\"],\"activation\":\"manual\",\
1306\"entryPoints\":{{\"panel\":{{\"command\":\"panel.py\",\"args\":[]}}}}}}"
1307 )
1308 }
1309
1310 const PANEL_SCRIPT: &str = r##"
1313import json, time
1314def emit(obj):
1315 print(json.dumps(obj), flush=True)
1316emit({"type": "SetWidget", "text": "nope"})
1317emit({"type": "SetPanel", "title": "Notes", "content": "# hello\nworld"})
1318while True:
1319 time.sleep(0.2)
1320"##;
1321
1322 const PANEL_CLEAR_SCRIPT: &str = r#"
1324import json, time
1325def emit(obj):
1326 print(json.dumps(obj), flush=True)
1327emit({"type": "SetPanel", "title": "T", "content": "c"})
1328time.sleep(0.3)
1329emit({"type": "ClearPanel"})
1330while True:
1331 time.sleep(0.2)
1332"#;
1333
1334 fn write_plugin_files(root: &Path, id: &str, manifest: &str, files: &[(&str, &str)]) {
1337 let dir = root.join(id);
1338 fs::create_dir_all(&dir).unwrap();
1339 fs::write(dir.join("manifest.json"), manifest).unwrap();
1340 for (name, script) in files {
1341 let entry = dir.join(name);
1342 fs::write(&entry, script).unwrap();
1343 #[cfg(unix)]
1344 {
1345 use std::os::unix::fs::PermissionsExt;
1346 fs::set_permissions(&entry, fs::Permissions::from_mode(0o755)).unwrap();
1347 }
1348 }
1349 }
1350
1351 fn write_action_plugin(root: &Path, id: &str, script: &str) {
1352 write_plugin_files(
1353 root,
1354 id,
1355 &action_manifest_json(id),
1356 &[("actions.py", script)],
1357 );
1358 }
1359
1360 fn write_panel_plugin(root: &Path, id: &str, script: &str) {
1361 write_plugin_files(root, id, &panel_manifest_json(id), &[("panel.py", script)]);
1362 }
1363
1364 #[test]
1365 fn panel_plugin_pushes_content_and_refuses_set_widget() {
1366 if skip_without_interpreter() {
1367 return;
1368 }
1369 let root = tempfile::tempdir().unwrap();
1370 write_panel_plugin(root.path(), "com.test.panel", PANEL_SCRIPT);
1371
1372 let mut host = PluginHost::new();
1373 assert_eq!(host.refresh_discovery(root.path()).len(), 1);
1374 host.apply_enabled(&[enabled("com.test.panel")]);
1375 let settled = wait_until(&mut host, |h| h.panel_content("com.test.panel").is_some());
1376 assert!(settled, "fixture's SetPanel never arrived");
1377 assert_eq!(
1378 host.panel_content("com.test.panel"),
1379 Some(&("Notes".to_string(), "# hello\nworld".to_string()))
1380 );
1381 let ignored = host.drain_ignored().join("\n");
1384 assert!(ignored.contains("SetWidget"), "got: {ignored}");
1385 assert!(ignored.contains("SetPanel/ClearPanel"), "got: {ignored}");
1386 host.stop_all();
1387 }
1388
1389 #[test]
1390 fn clear_panel_from_a_panel_process_removes_the_panel() {
1391 if skip_without_interpreter() {
1392 return;
1393 }
1394 let root = tempfile::tempdir().unwrap();
1395 write_panel_plugin(root.path(), "com.test.panel", PANEL_CLEAR_SCRIPT);
1396
1397 let mut host = PluginHost::new();
1398 host.refresh_discovery(root.path());
1399 host.apply_enabled(&[enabled("com.test.panel")]);
1400 let pushed = wait_until(&mut host, |h| h.panel_content("com.test.panel").is_some());
1401 assert!(pushed, "fixture's SetPanel never arrived");
1402 let cleared = wait_until(&mut host, |h| h.panel_content("com.test.panel").is_none());
1403 assert!(cleared, "fixture's ClearPanel never landed");
1404 host.stop_all();
1405 }
1406
1407 #[test]
1408 fn disabling_a_panel_plugin_stops_the_process_and_clears_the_panel() {
1409 if skip_without_interpreter() {
1410 return;
1411 }
1412 let root = tempfile::tempdir().unwrap();
1413 write_panel_plugin(root.path(), "com.test.panel", PANEL_SCRIPT);
1414
1415 let mut host = PluginHost::new();
1416 host.refresh_discovery(root.path());
1417 host.apply_enabled(&[enabled("com.test.panel")]);
1418 let settled = wait_until(&mut host, |h| h.panel_content("com.test.panel").is_some());
1419 assert!(settled, "fixture's SetPanel never arrived");
1420 assert!(plugin_running(&host, "com.test.panel"));
1421
1422 host.apply_enabled(&[]);
1425 assert!(!plugin_running(&host, "com.test.panel"));
1426 assert_eq!(host.panel_content("com.test.panel"), None);
1427 host.stop_all();
1428 }
1429
1430 fn overlay_manifest_json(id: &str) -> String {
1431 format!(
1432 "{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Test Overlay\",\"version\":\"0.1.0\",\
1433\"kinds\":[\"overlay\"],\"activation\":\"manual\",\
1434\"entryPoints\":{{\"overlay\":{{\"command\":\"overlay.py\",\"args\":[]}}}}}}"
1435 )
1436 }
1437
1438 const OVERLAY_SCRIPT: &str = r##"
1441import json, time
1442def emit(obj):
1443 print(json.dumps(obj), flush=True)
1444emit({"type": "SetWidget", "text": "nope"})
1445emit({"type": "SetOverlay", "id": "hud", "position": "top-right",
1446 "size": {"w": 0.2, "h": 0.1}, "opacity": 0.8,
1447 "content": {"type": "markdown", "text": "# hello"}})
1448while True:
1449 time.sleep(0.2)
1450"##;
1451
1452 const OVERLAY_CLEAR_SCRIPT: &str = r#"
1454import json, time
1455def emit(obj):
1456 print(json.dumps(obj), flush=True)
1457emit({"type": "SetOverlay", "id": "hud", "position": "center",
1458 "size": {"w": 0.2, "h": 0.1},
1459 "content": {"type": "text", "text": "hi"}})
1460time.sleep(0.3)
1461emit({"type": "ClearOverlay", "id": "hud"})
1462while True:
1463 time.sleep(0.2)
1464"#;
1465
1466 fn write_overlay_plugin(root: &Path, id: &str, script: &str) {
1467 write_plugin_files(
1468 root,
1469 id,
1470 &overlay_manifest_json(id),
1471 &[("overlay.py", script)],
1472 );
1473 }
1474
1475 #[test]
1476 fn overlay_plugin_pushes_scene_and_refuses_set_widget() {
1477 if skip_without_interpreter() {
1478 return;
1479 }
1480 let root = tempfile::tempdir().unwrap();
1481 write_overlay_plugin(root.path(), "com.test.overlay", OVERLAY_SCRIPT);
1482
1483 let mut host = PluginHost::new();
1484 assert_eq!(host.refresh_discovery(root.path()).len(), 1);
1485 host.apply_enabled(&[enabled("com.test.overlay")]);
1486 let settled = wait_until(&mut host, |h| {
1487 h.overlays().contains_key("com.test.overlay")
1491 && h.drain_ignored().iter().any(|l| l.contains("SetWidget"))
1492 });
1493 assert!(settled, "fixture's SetOverlay never arrived");
1494
1495 let overlay = &host.overlays()["com.test.overlay"];
1496 assert_eq!(overlay.id, "hud");
1497 assert_eq!(overlay.opacity, 0.8);
1498 assert_eq!(
1499 overlay.position,
1500 crate::protocol::OverlayPosition::Anchor(crate::protocol::OverlayAnchor::TopRight)
1501 );
1502 assert_eq!(
1503 overlay.content,
1504 crate::protocol::OverlayScene::Markdown {
1505 text: "# hello".to_string()
1506 }
1507 );
1508
1509 let ignored = host.drain_ignored().join("\n");
1513 assert!(ignored.contains("SetOverlay/ClearOverlay") || ignored.is_empty());
1514 host.stop_all();
1515 }
1516
1517 #[test]
1518 fn clear_overlay_from_an_overlay_process_removes_it() {
1519 if skip_without_interpreter() {
1520 return;
1521 }
1522 let root = tempfile::tempdir().unwrap();
1523 write_overlay_plugin(root.path(), "com.test.overlay", OVERLAY_CLEAR_SCRIPT);
1524
1525 let mut host = PluginHost::new();
1526 host.refresh_discovery(root.path());
1527 host.apply_enabled(&[enabled("com.test.overlay")]);
1528 let pushed = wait_until(&mut host, |h| h.overlays().contains_key("com.test.overlay"));
1529 assert!(pushed, "fixture's SetOverlay never arrived");
1530 let cleared = wait_until(&mut host, |h| h.overlays().is_empty());
1531 assert!(cleared, "fixture's ClearOverlay never landed");
1532 host.stop_all();
1533 }
1534
1535 #[test]
1536 fn disabling_an_overlay_plugin_stops_the_process_and_clears_the_overlay() {
1537 if skip_without_interpreter() {
1538 return;
1539 }
1540 let root = tempfile::tempdir().unwrap();
1541 write_overlay_plugin(root.path(), "com.test.overlay", OVERLAY_SCRIPT);
1542
1543 let mut host = PluginHost::new();
1544 host.refresh_discovery(root.path());
1545 host.apply_enabled(&[enabled("com.test.overlay")]);
1546 let settled = wait_until(&mut host, |h| h.overlays().contains_key("com.test.overlay"));
1547 assert!(settled, "fixture's SetOverlay never arrived");
1548 assert!(plugin_running(&host, "com.test.overlay"));
1549
1550 host.apply_enabled(&[]);
1553 assert!(!plugin_running(&host, "com.test.overlay"));
1554 assert!(host.overlays().is_empty());
1555 host.stop_all();
1556 }
1557
1558 #[test]
1559 fn a_widget_plugin_cannot_send_overlay_commands() {
1560 if skip_without_interpreter() {
1561 return;
1562 }
1563 let root = tempfile::tempdir().unwrap();
1564 write_plugin(root.path(), "com.test.widget", OVERLAY_SCRIPT);
1565
1566 let mut host = PluginHost::new();
1567 host.refresh_discovery(root.path());
1568 host.apply_enabled(&[enabled("com.test.widget")]);
1569 let mut saw_refusal = false;
1572 let refused = wait_until(&mut host, |h| {
1573 saw_refusal = h.drain_ignored().iter().any(|l| l.contains("SetOverlay"));
1574 saw_refusal
1575 });
1576 assert!(refused, "fixture's SetOverlay refusal never landed");
1577 assert!(host.overlays().is_empty(), "no overlay may exist");
1578 host.stop_all();
1579 }
1580
1581 fn overlay_interactive_manifest_json(id: &str) -> String {
1582 format!(
1583 "{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Test Overlay\",\"version\":\"0.1.0\",\
1584\"kinds\":[\"overlay\"],\"activation\":\"manual\",\
1585\"overlay\":{{\"interactive\":true}},\
1586\"entryPoints\":{{\"overlay\":{{\"command\":\"overlay.py\",\"args\":[]}}}}}}"
1587 )
1588 }
1589
1590 fn write_overlay_plugin_with_manifest(root: &Path, id: &str, manifest: &str, script: &str) {
1591 write_plugin_files(root, id, manifest, &[("overlay.py", script)]);
1592 }
1593
1594 const OVERLAY_INTERACTIVE_SCRIPT: &str = r##"
1597import json, time
1598def emit(obj):
1599 print(json.dumps(obj), flush=True)
1600emit({"type": "SetOverlay", "id": "hud", "position": "top-right",
1601 "size": {"w": 0.3, "h": 0.2}, "opacity": 0.9, "interactive": True,
1602 "content": {"type": "button", "id": "deploy", "label": "Deploy"}})
1603while True:
1604 time.sleep(0.2)
1605"##;
1606
1607 const OVERLAY_ECHO_SCRIPT: &str = r#"
1612import json, sys, time
1613def emit(obj):
1614 print(json.dumps(obj), flush=True)
1615emit({"type": "SetOverlay", "id": "hud", "position": "top-right",
1616 "size": {"w": 0.3, "h": 0.2}, "interactive": True,
1617 "content": {"type": "button", "id": "deploy", "label": "Deploy"}})
1618for line in iter(sys.stdin.readline, ""):
1619 line = line.strip()
1620 if not line:
1621 continue
1622 try:
1623 event = json.loads(line)
1624 except ValueError:
1625 continue
1626 emit({"type": "SetOverlay", "id": "hud", "position": "top-right",
1627 "size": {"w": 0.3, "h": 0.2}, "interactive": True,
1628 "content": {"type": "text", "text": "event:" + str(event.get("kind", "?")) + ":" + str(event.get("data", {}).get("widget", "?"))}})
1629"#;
1630
1631 #[test]
1632 fn interactive_requires_the_manifest_capability() {
1633 if skip_without_interpreter() {
1634 return;
1635 }
1636 let root = tempfile::tempdir().unwrap();
1637 write_overlay_plugin(root.path(), "com.test.no-cap", OVERLAY_INTERACTIVE_SCRIPT);
1639
1640 let mut host = PluginHost::new();
1641 assert_eq!(host.refresh_discovery(root.path()).len(), 1);
1642 host.apply_enabled(&[enabled("com.test.no-cap")]);
1643 let settled = wait_until(&mut host, |h| h.overlays().contains_key("com.test.no-cap"));
1644 assert!(settled, "fixture's SetOverlay never arrived");
1645 let overlay = &host.overlays()["com.test.no-cap"];
1646 assert!(!overlay.interactive, "no capability → forced display-only");
1647
1648 host.focus_overlay("com.test.no-cap");
1650 assert_eq!(host.focused_overlay(), None);
1651 host.stop_all();
1652 }
1653
1654 #[test]
1655 fn interactive_capability_allows_focus_and_clearing_drops_it() {
1656 if skip_without_interpreter() {
1657 return;
1658 }
1659 let root = tempfile::tempdir().unwrap();
1660 write_overlay_plugin_with_manifest(
1661 root.path(),
1662 "com.test.cap",
1663 &overlay_interactive_manifest_json("com.test.cap"),
1664 OVERLAY_INTERACTIVE_SCRIPT,
1665 );
1666
1667 let mut host = PluginHost::new();
1668 assert_eq!(host.refresh_discovery(root.path()).len(), 1);
1669 host.apply_enabled(&[enabled("com.test.cap")]);
1670 let settled = wait_until(&mut host, |h| {
1671 h.overlays()
1672 .get("com.test.cap")
1673 .is_some_and(|o| o.interactive)
1674 });
1675 assert!(settled, "fixture's interactive SetOverlay never arrived");
1676
1677 host.focus_overlay("com.test.cap");
1680 assert_eq!(host.focused_overlay(), Some("com.test.cap"));
1681
1682 host.unfocus_overlay();
1684 assert_eq!(host.focused_overlay(), None);
1685
1686 host.focus_overlay("com.test.cap");
1688 host.apply_enabled(&[]);
1689 assert!(host.overlays().is_empty());
1690 assert_eq!(host.focused_overlay(), None);
1691 host.stop_all();
1692 }
1693
1694 #[test]
1695 fn overlay_events_reach_the_running_process() {
1696 if skip_without_interpreter() {
1697 return;
1698 }
1699 let root = tempfile::tempdir().unwrap();
1700 write_overlay_plugin_with_manifest(
1701 root.path(),
1702 "com.test.echo",
1703 &overlay_interactive_manifest_json("com.test.echo"),
1704 OVERLAY_ECHO_SCRIPT,
1705 );
1706
1707 let mut host = PluginHost::new();
1708 host.refresh_discovery(root.path());
1709 host.apply_enabled(&[enabled("com.test.echo")]);
1710 let up = wait_until(&mut host, |h| {
1713 h.overlays()
1714 .get("com.test.echo")
1715 .is_some_and(|o| o.interactive)
1716 });
1717 assert!(up, "fixture never pushed its overlay");
1718
1719 let delivered = host.send_overlay_event(
1720 "com.test.echo",
1721 "deploy",
1722 crate::protocol::OverlayWidgetEvent::Click {},
1723 );
1724 assert!(delivered, "send_overlay_event reported failure");
1725 assert_eq!(host.overlay_events_dispatched_count(), 1);
1726
1727 let echoed = wait_until(&mut host, |h| {
1730 h.overlays().get("com.test.echo").is_some_and(|o| {
1731 matches!(
1732 &o.content,
1733 crate::protocol::OverlayScene::Text { text }
1734 if text.contains("overlay_event") && text.contains("deploy")
1735 )
1736 })
1737 });
1738 assert!(echoed, "the overlay process never echoed the event");
1739 host.stop_all();
1740 }
1741
1742 #[test]
1743 fn overlay_update_rate_clamp_drops_excess_upserts() {
1744 let mut host = PluginHost::new();
1747 let t0 = Instant::now();
1748 for _ in 0..30 {
1750 assert!(host.overlay_update_permitted("p", t0));
1751 }
1752 assert!(
1754 !host.overlay_update_permitted("p", t0),
1755 "clamp must refuse past the budget"
1756 );
1757 let after = t0 + Duration::from_secs(2);
1760 assert!(host.overlay_update_permitted("p", after));
1761 }
1762
1763 #[test]
1764 fn invoke_action_miss_paths_return_false_without_a_process() {
1765 let root = tempfile::tempdir().unwrap();
1766 write_plugin(root.path(), "com.test.only-widget", WIDGET_SCRIPT);
1767 write_action_plugin(root.path(), "com.test.only-action", ACTION_SCRIPT);
1768
1769 let mut host = PluginHost::new();
1770 host.refresh_discovery(root.path());
1771 host.apply_enabled(&[]); assert!(!host.invoke_action("com.test.nope", "greet"));
1775 assert!(!host.invoke_action("com.test.only-action", "say-hello"));
1777 assert!(!host.invoke_action("com.test.only-action", "bogus"));
1779 assert!(!host.invoke_action("com.test.only-widget", "say-hello"));
1781
1782 assert!(host.running_plugin_ids().is_empty());
1783 assert!(host.action_running.is_empty());
1784 }
1785
1786 #[test]
1787 fn invoke_action_not_running_warns_once_per_episode() {
1788 install_counting_logger();
1789 let root = tempfile::tempdir().unwrap();
1790 write_action_plugin(root.path(), "com.test.invoke-miss", ACTION_SCRIPT);
1791
1792 let mut host = PluginHost::new();
1793 host.refresh_discovery(root.path());
1794 host.apply_enabled(&[]);
1795
1796 let fragment = "'com.test.invoke-miss' has no running action process";
1799 for _ in 0..5 {
1800 assert!(!host.invoke_action("com.test.invoke-miss", "say-hello"));
1801 }
1802 assert_eq!(
1803 warns_containing(fragment),
1804 1,
1805 "5 invokes of one steady miss must produce exactly 1 warn"
1806 );
1807 }
1808
1809 #[test]
1810 fn disable_enable_cycle_rearms_the_action_warn_gate() {
1811 let mut host = PluginHost::new();
1817 assert!(host.warned_action_not_running.should_warn("com.test.gate"));
1818 assert!(
1819 !host.warned_action_not_running.should_warn("com.test.gate"),
1820 "second call in the same episode must be silent"
1821 );
1822
1823 host.apply_enabled(&[]);
1824 assert!(
1825 host.warned_action_not_running.should_warn("com.test.gate"),
1826 "a disable/enable cycle must re-arm the action warn gate"
1827 );
1828 }
1829
1830 #[test]
1831 fn palette_actions_list_enabled_action_plugins_only() {
1832 let root = tempfile::tempdir().unwrap();
1833 write_action_plugin(root.path(), "com.test.pal-on", ACTION_SCRIPT);
1834 write_action_plugin(root.path(), "com.test.pal-off", ACTION_SCRIPT);
1835
1836 let mut host = PluginHost::new();
1837 host.refresh_discovery(root.path());
1838
1839 host.apply_enabled(&[]);
1840 assert!(
1841 host.palette_actions().is_empty(),
1842 "nothing enabled -> no rows"
1843 );
1844
1845 host.apply_enabled(&[enabled("com.test.pal-on")]);
1846 let rows = host.palette_actions();
1847 assert_eq!(rows.len(), 1, "one action on the enabled plugin: {rows:?}");
1848 assert_eq!(rows[0].wire_id, "plugin-action:com.test.pal-on:say-hello");
1849 assert_eq!(rows[0].label, "Say hello · Test Actions");
1850 host.stop_all();
1851 }
1852
1853 #[test]
1854 fn both_kinds_manifest_runs_one_process_per_kind() {
1855 if skip_without_interpreter() {
1856 return;
1857 }
1858 let root = tempfile::tempdir().unwrap();
1859 write_plugin_files(
1860 root.path(),
1861 "com.test.both",
1862 &both_kinds_manifest_json("com.test.both"),
1863 &[("widget.py", WIDGET_SCRIPT), ("actions.py", ACTION_SCRIPT)],
1864 );
1865
1866 let mut host = PluginHost::new();
1867 host.refresh_discovery(root.path());
1868 host.apply_enabled(&[enabled("com.test.both")]);
1869
1870 assert!(
1871 wait_until(&mut host, |h| h.running.len() == 1
1872 && h.action_running.len() == 1),
1873 "a both-kinds manifest must run both entry points after one pass"
1874 );
1875 assert!(wait_until(&mut host, |h| h
1877 .widget_text("com.test.both")
1878 .is_some()));
1879 assert!(host.invoke_action("com.test.both", "ping"));
1881 host.stop_all();
1882 }
1883
1884 #[test]
1885 fn invoke_action_writes_the_event_to_the_running_action_process() {
1886 if skip_without_interpreter() {
1887 return;
1888 }
1889 let root = tempfile::tempdir().unwrap();
1890 write_action_plugin(root.path(), "com.test.invoke", ACTION_SCRIPT);
1891
1892 let mut host = PluginHost::new();
1893 host.refresh_discovery(root.path());
1894 host.apply_enabled(&[enabled("com.test.invoke")]);
1895
1896 assert!(
1897 host.invoke_action("com.test.invoke", "say-hello"),
1898 "writing to a running action process must succeed"
1899 );
1900 let landed = wait_until(&mut host, |h| {
1904 h.widget_text("com.test.invoke") == Some("invoked:say-hello")
1905 });
1906 assert!(
1907 landed,
1908 "PluginActionInvoked never reached the plugin's stdin; widget text: {:?}",
1909 host.widget_text("com.test.invoke")
1910 );
1911 host.stop_all();
1912 }
1913
1914 #[test]
1915 fn actions_dispatched_count_counts_only_successful_writes() {
1916 if skip_without_interpreter() {
1917 return;
1918 }
1919 let root = tempfile::tempdir().unwrap();
1920 write_action_plugin(root.path(), "com.test.count", ACTION_SCRIPT);
1921
1922 let mut host = PluginHost::new();
1923 host.refresh_discovery(root.path());
1924
1925 host.apply_enabled(&[]);
1927 assert!(!host.invoke_action("com.test.count", "say-hello"));
1928 assert_eq!(
1929 host.actions_dispatched_count(),
1930 0,
1931 "a missed invoke is not a successful write"
1932 );
1933
1934 host.apply_enabled(&[enabled("com.test.count")]);
1936 assert!(wait_until(&mut host, |h| !h.action_running.is_empty()));
1937 for expected in 1..=2u64 {
1938 assert!(host.invoke_action("com.test.count", "say-hello"));
1939 assert_eq!(host.actions_dispatched_count(), expected);
1940 }
1941 host.stop_all();
1942 }
1943
1944 fn echo_script(tag: &str) -> String {
1950 format!(
1951 r#"
1952import json, sys
1953def emit(text):
1954 print(json.dumps({{"type": "SetWidget", "text": text}}), flush=True)
1955for line in iter(sys.stdin.readline, ""):
1956 line = line.strip()
1957 if not line:
1958 continue
1959 try:
1960 event = json.loads(line)
1961 except ValueError:
1962 continue
1963 emit("{tag}:" + str(event.get("kind", "?")))
1964"#
1965 )
1966 }
1967
1968 fn subscribed_manifest_json(id: &str, subscriptions_json: &str) -> String {
1971 manifest_json(id).replacen(
1972 r#""kinds":["status-bar-widget"],"#,
1973 &format!(r#""kinds":["status-bar-widget"],"subscriptions":[{subscriptions_json}],"#),
1974 1,
1975 )
1976 }
1977
1978 fn subscribed_both_kinds_manifest_json(id: &str, subscriptions_json: &str) -> String {
1980 both_kinds_manifest_json(id).replacen(
1981 r#""kinds":["status-bar-widget","action-contributor"],"#,
1982 &format!(
1983 r#""kinds":["status-bar-widget","action-contributor"],"subscriptions":[{subscriptions_json}],"#
1984 ),
1985 1,
1986 )
1987 }
1988
1989 #[test]
1990 fn subscribed_plugin_gets_a_kind_filtered_forwarder_on_spawn() {
1991 if skip_without_interpreter() {
1992 return;
1993 }
1994 let root = tempfile::tempdir().unwrap();
1995 write_plugin_files(
1996 root.path(),
1997 "com.test.sub",
1998 &subscribed_manifest_json("com.test.sub", r#""bell_rang""#),
1999 &[("widget.py", WIDGET_SCRIPT)],
2000 );
2001
2002 let mut host = PluginHost::new();
2003 host.refresh_discovery(root.path());
2004 host.apply_enabled(&[enabled("com.test.sub")]);
2005
2006 let forwarder = host
2007 .subscription_forwarders()
2008 .get("com.test.sub")
2009 .cloned()
2010 .expect("a subscribed plugin gets a forwarder on spawn");
2011
2012 use par_term_emu_core_rust::observer::TerminalObserver;
2015 use par_term_emu_core_rust::terminal::{BellEvent, TerminalEvent};
2016 forwarder.on_event(&TerminalEvent::BellRang(BellEvent::VisualBell));
2017 forwarder.on_event(&TerminalEvent::TitleChanged("nope".to_string()));
2018 let drained = forwarder.drain_events();
2019 assert_eq!(drained.len(), 1, "unsubscribed kinds must not buffer");
2020 assert_eq!(drained[0].kind, "bell_rang");
2021 host.stop_all();
2022 }
2023
2024 #[test]
2025 fn unsubscribed_plugin_gets_no_forwarder() {
2026 if skip_without_interpreter() {
2027 return;
2028 }
2029 let root = tempfile::tempdir().unwrap();
2032 write_plugin(root.path(), "com.test.plain", WIDGET_SCRIPT);
2033
2034 let mut host = PluginHost::new();
2035 host.refresh_discovery(root.path());
2036 host.apply_enabled(&[enabled("com.test.plain")]);
2037 assert!(wait_until(&mut host, |h| !h.running.is_empty()));
2038 assert!(
2039 host.subscription_forwarders().is_empty(),
2040 "no subscriptions declared — no forwarder may exist"
2041 );
2042 host.stop_all();
2043 }
2044
2045 #[test]
2046 fn teardown_drops_the_forwarder_when_the_plugin_leaves_the_enabled_set() {
2047 if skip_without_interpreter() {
2048 return;
2049 }
2050 let root = tempfile::tempdir().unwrap();
2051 write_plugin_files(
2052 root.path(),
2053 "com.test.leave",
2054 &subscribed_manifest_json("com.test.leave", r#""bell_rang""#),
2055 &[("widget.py", WIDGET_SCRIPT)],
2056 );
2057
2058 let mut host = PluginHost::new();
2059 host.refresh_discovery(root.path());
2060 host.apply_enabled(&[enabled("com.test.leave")]);
2061 assert!(
2062 host.subscription_forwarders()
2063 .contains_key("com.test.leave")
2064 );
2065
2066 host.apply_enabled(&[]);
2067 assert!(
2068 host.subscription_forwarders().is_empty(),
2069 "teardown must drop the forwarder so its observer is unregistered"
2070 );
2071 }
2072
2073 #[test]
2074 fn delivered_events_reach_the_running_process() {
2075 if skip_without_interpreter() {
2076 return;
2077 }
2078 let root = tempfile::tempdir().unwrap();
2079 write_plugin_files(
2080 root.path(),
2081 "com.test.echo",
2082 &subscribed_manifest_json("com.test.echo", r#""bell_rang""#),
2083 &[("widget.py", &echo_script("event"))],
2084 );
2085
2086 let mut host = PluginHost::new();
2087 host.refresh_discovery(root.path());
2088 host.apply_enabled(&[enabled("com.test.echo")]);
2089
2090 let forwarder = host
2093 .subscription_forwarders()
2094 .get("com.test.echo")
2095 .cloned()
2096 .expect("forwarder exists for a subscribed plugin");
2097 use par_term_emu_core_rust::observer::TerminalObserver;
2098 use par_term_emu_core_rust::terminal::{BellEvent, TerminalEvent};
2099 forwarder.on_event(&TerminalEvent::BellRang(BellEvent::VisualBell));
2100 let events = forwarder.drain_events();
2101 assert_eq!(events.len(), 1);
2102 host.deliver_events("com.test.echo", &events);
2103
2104 let landed = wait_until(&mut host, |h| {
2108 h.widget_text("com.test.echo") == Some("event:bell_rang")
2109 });
2110 assert!(
2111 landed,
2112 "the bell event never reached the plugin's stdin; widget text: {:?}",
2113 host.widget_text("com.test.echo")
2114 );
2115 host.stop_all();
2116 }
2117
2118 const WIDGET_ECHO_DELAYED_SCRIPT: &str = r#"
2123import json, sys, time
2124def emit(text):
2125 print(json.dumps({"type": "SetWidget", "text": text}), flush=True)
2126for line in iter(sys.stdin.readline, ""):
2127 line = line.strip()
2128 if not line:
2129 continue
2130 try:
2131 event = json.loads(line)
2132 except ValueError:
2133 continue
2134 time.sleep(0.3)
2135 emit("w:" + str(event.get("kind", "?")))
2136"#;
2137
2138 #[test]
2139 fn both_kinds_processes_receive_subscribed_events() {
2140 if skip_without_interpreter() {
2141 return;
2142 }
2143 let root = tempfile::tempdir().unwrap();
2144 write_plugin_files(
2145 root.path(),
2146 "com.test.both-echo",
2147 &subscribed_both_kinds_manifest_json("com.test.both-echo", r#""bell_rang""#),
2148 &[
2149 ("widget.py", WIDGET_ECHO_DELAYED_SCRIPT),
2150 ("actions.py", &echo_script("a")),
2151 ],
2152 );
2153
2154 let mut host = PluginHost::new();
2155 host.refresh_discovery(root.path());
2156 host.apply_enabled(&[enabled("com.test.both-echo")]);
2157 assert!(wait_until(&mut host, |h| h.running.len() == 1
2158 && h.action_running.len() == 1));
2159
2160 let forwarder = host
2161 .subscription_forwarders()
2162 .get("com.test.both-echo")
2163 .cloned()
2164 .expect("forwarder exists for a subscribed plugin");
2165 use par_term_emu_core_rust::observer::TerminalObserver;
2166 use par_term_emu_core_rust::terminal::{BellEvent, TerminalEvent};
2167 forwarder.on_event(&TerminalEvent::BellRang(BellEvent::VisualBell));
2168 let events = forwarder.drain_events();
2169 host.deliver_events("com.test.both-echo", &events);
2170
2171 let mut seen_widget = false;
2175 let mut seen_action = false;
2176 let both_seen = wait_until(&mut host, |h| {
2177 if h.widget_text("com.test.both-echo") == Some("w:bell_rang") {
2178 seen_widget = true;
2179 }
2180 if h.widget_text("com.test.both-echo") == Some("a:bell_rang") {
2181 seen_action = true;
2182 }
2183 seen_widget && seen_action
2184 });
2185 assert!(
2186 both_seen,
2187 "both kind processes must receive the event (w seen: {seen_widget}, a seen: {seen_action})"
2188 );
2189 host.stop_all();
2190 }
2191
2192 #[test]
2193 fn deliver_events_is_silent_with_no_running_process() {
2194 install_counting_logger();
2197 let mut host = PluginHost::new();
2198 let event = ScriptEvent {
2199 kind: "bell_rang".to_string(),
2200 data: ScriptEventData::Empty {},
2201 };
2202 host.deliver_events("com.test.nobody", std::slice::from_ref(&event));
2203 assert_eq!(warns_containing("com.test.nobody"), 0);
2204 }
2205}