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::{
29 PLUGIN_ACTION_INVOKED_KIND, ScriptEvent, ScriptEventData, THEME_CHANGED_KIND,
30};
31use super::restart::ScriptRestartState;
32
33use par_term_config::themes::Theme;
34
35mod overlay;
36mod supervision;
37
38const PLUGIN_RESTART_DELAY_MS: u64 = 250;
42
43const SETTINGS_ARG: &str = "--par-term-settings";
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct EnabledPlugin {
49 pub id: String,
51 pub settings_json: String,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct PluginActionRow {
59 pub wire_id: String,
61 pub label: String,
64}
65
66fn widget_entry_args(plugin: &DiscoveredPlugin) -> &[String] {
68 plugin
69 .manifest
70 .entry_points
71 .get(ENTRY_POINT_STATUS_BAR_WIDGET)
72 .map(|entry| entry.args.as_slice())
73 .unwrap_or_default()
74}
75
76fn action_entry_args(plugin: &DiscoveredPlugin) -> &[String] {
78 plugin
79 .manifest
80 .entry_points
81 .get(ENTRY_POINT_ACTION_CONTRIBUTOR)
82 .map(|entry| entry.args.as_slice())
83 .unwrap_or_default()
84}
85
86fn panel_entry_args(plugin: &DiscoveredPlugin) -> &[String] {
88 plugin
89 .manifest
90 .entry_points
91 .get(ENTRY_POINT_PANEL)
92 .map(|entry| entry.args.as_slice())
93 .unwrap_or_default()
94}
95
96fn overlay_entry_args(plugin: &DiscoveredPlugin) -> &[String] {
98 plugin
99 .manifest
100 .entry_points
101 .get(ENTRY_POINT_OVERLAY)
102 .map(|entry| entry.args.as_slice())
103 .unwrap_or_default()
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110enum KindSlot {
111 Widget,
113 Action,
115 Panel,
117 Overlay,
119}
120
121impl KindSlot {
122 fn as_str(self) -> &'static str {
124 match self {
125 KindSlot::Widget => "widget",
126 KindSlot::Action => "action",
127 KindSlot::Panel => "panel",
128 KindSlot::Overlay => "overlay",
129 }
130 }
131}
132
133#[derive(Debug, Default)]
142pub struct WarnOnce {
143 warned: HashSet<String>,
144}
145
146impl WarnOnce {
147 pub fn should_warn(&mut self, key: &str) -> bool {
150 self.warned.insert(key.to_string())
151 }
152
153 pub fn clear(&mut self, key: &str) {
155 self.warned.remove(key);
156 }
157
158 pub fn clear_except(&mut self, keep: &HashSet<String>) {
161 self.warned.retain(|key| keep.contains(key));
162 }
163}
164
165#[derive(Default)]
168pub struct PluginHost {
169 discovered: Vec<DiscoveredPlugin>,
171 running: HashMap<String, ScriptId>,
175 action_running: HashMap<String, ScriptId>,
179 panel_running: HashMap<String, ScriptId>,
181 overlay_running: HashMap<String, ScriptId>,
183 manager: ScriptManager,
185 widget_texts: HashMap<String, String>,
191 panel_contents: HashMap<String, (String, String)>,
195 overlays: HashMap<String, crate::protocol::PluginOverlay>,
199 focused_overlay: Option<String>,
205 overlay_update_times: HashMap<String, Vec<std::time::Instant>>,
209 actions_dispatched: u64,
213 overlay_events_dispatched: u64,
216 restart: HashMap<String, ScriptRestartState>,
218 action_restart: HashMap<String, ScriptRestartState>,
221 panel_restart: HashMap<String, ScriptRestartState>,
224 overlay_restart: HashMap<String, ScriptRestartState>,
227 settings_json: HashMap<String, String>,
231 enabled_ids: HashSet<String>,
235 ignored_lines: Vec<String>,
237 subscription_forwarders: HashMap<String, Arc<ScriptEventForwarder>>,
245 warned_event_delivery: WarnOnce,
249 warned_not_discovered: WarnOnce,
252 warned_spawn_failed: WarnOnce,
253 warned_action_not_running: WarnOnce,
257 crash_caps: Vec<PluginCrashCap>,
261 theme_names: HashMap<String, String>,
266 theme_greet_due: HashSet<String>,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq)]
279pub struct PluginCrashCap {
280 pub plugin_id: String,
282 pub kind: &'static str,
284 pub entry: String,
286 pub restart_mode: String,
288 pub stderr_tail: Vec<String>,
290}
291
292impl PluginHost {
293 pub fn new() -> Self {
297 Self::default()
298 }
299
300 pub fn refresh_discovery(&mut self, root: &Path) -> &[DiscoveredPlugin] {
303 let (plugins, warnings) = discover_plugins(root);
304 for warning in &warnings {
305 log::warn!(
306 "plugin discovery skipped '{}': {}",
307 warning.dir,
308 warning.reason
309 );
310 }
311 self.discovered = plugins;
312 &self.discovered
313 }
314
315 pub fn discovered(&self, id: &str) -> Option<&DiscoveredPlugin> {
317 self.discovered.iter().find(|d| d.manifest.id == id)
318 }
319
320 pub fn discovered_count(&self) -> usize {
322 self.discovered.len()
323 }
324
325 pub fn apply_enabled(&mut self, enabled: &[EnabledPlugin]) {
333 let now = Instant::now();
334
335 let stale: Vec<String> = self
336 .running
337 .keys()
338 .chain(self.action_running.keys())
339 .chain(self.panel_running.keys())
340 .chain(self.overlay_running.keys())
341 .filter(|id| !enabled.iter().any(|e| e.id == **id))
342 .cloned()
343 .collect();
344 for id in &stale {
345 self.teardown(id);
346 }
347
348 let enabled_ids: HashSet<String> = enabled.iter().map(|e| e.id.clone()).collect();
351 self.enabled_ids = enabled_ids.clone();
352 self.warned_not_discovered.clear_except(&enabled_ids);
353 self.warned_spawn_failed.clear_except(&enabled_ids);
354 self.warned_action_not_running.clear_except(&enabled_ids);
355 self.warned_event_delivery.clear_except(&enabled_ids);
356
357 for plugin in enabled {
358 let Some(found) = self.discovered.iter().find(|d| d.manifest.id == plugin.id) else {
359 if self.warned_not_discovered.should_warn(&plugin.id) {
360 log::warn!(
361 "plugin '{}' is enabled but not discovered; not spawned",
362 plugin.id
363 );
364 }
365 continue;
366 };
367 let has_widget = found
368 .manifest
369 .kinds
370 .iter()
371 .any(|k| k == KIND_STATUS_BAR_WIDGET);
372 let has_action = found
373 .manifest
374 .kinds
375 .iter()
376 .any(|k| k == KIND_ACTION_CONTRIBUTOR);
377 let has_panel = found.manifest.kinds.iter().any(|k| k == KIND_PANEL);
378 let has_overlay = found.manifest.kinds.iter().any(|k| k == KIND_OVERLAY);
379
380 let widget_entry =
386 has_widget.then(|| (found.entry_path.clone(), widget_entry_args(found).to_vec()));
387 let action_entry = found
388 .action_entry_path
389 .clone()
390 .filter(|_| has_action)
391 .map(|path| (path, action_entry_args(found).to_vec()));
392 let panel_entry = found
393 .panel_entry_path
394 .clone()
395 .filter(|_| has_panel)
396 .map(|path| (path, panel_entry_args(found).to_vec()));
397 let overlay_entry = found
398 .overlay_entry_path
399 .clone()
400 .filter(|_| has_overlay)
401 .map(|path| (path, overlay_entry_args(found).to_vec()));
402 let subscriptions = found.manifest.subscriptions.clone();
405 let restart_policy = found.manifest.restart;
409
410 self.spawn_kind_leg(
411 &plugin.id,
412 KindSlot::Widget,
413 widget_entry,
414 &plugin.settings_json,
415 &subscriptions,
416 restart_policy,
417 now,
418 );
419 self.spawn_kind_leg(
420 &plugin.id,
421 KindSlot::Action,
422 action_entry,
423 &plugin.settings_json,
424 &subscriptions,
425 restart_policy,
426 now,
427 );
428 self.spawn_kind_leg(
429 &plugin.id,
430 KindSlot::Panel,
431 panel_entry,
432 &plugin.settings_json,
433 &subscriptions,
434 restart_policy,
435 now,
436 );
437 self.spawn_kind_leg(
438 &plugin.id,
439 KindSlot::Overlay,
440 overlay_entry,
441 &plugin.settings_json,
442 &subscriptions,
443 restart_policy,
444 now,
445 );
446 }
447 }
448
449 pub fn poll(&mut self) {
457 let now = Instant::now();
458 let widget_ids: Vec<String> = self.running.keys().cloned().collect();
459 for id in widget_ids {
460 self.poll_one(&id, KindSlot::Widget, now);
461 }
462 let action_ids: Vec<String> = self.action_running.keys().cloned().collect();
463 for id in action_ids {
464 self.poll_one(&id, KindSlot::Action, now);
465 }
466 let panel_ids: Vec<String> = self.panel_running.keys().cloned().collect();
467 for id in panel_ids {
468 self.poll_one(&id, KindSlot::Panel, now);
469 }
470 let overlay_ids: Vec<String> = self.overlay_running.keys().cloned().collect();
471 for id in overlay_ids {
472 self.poll_one(&id, KindSlot::Overlay, now);
473 }
474 }
475
476 pub fn widget_text(&self, plugin_id: &str) -> Option<&str> {
478 self.widget_texts.get(plugin_id).map(String::as_str)
479 }
480
481 pub fn panel_content(&self, plugin_id: &str) -> Option<&(String, String)> {
483 self.panel_contents.get(plugin_id)
484 }
485
486 pub fn subscription_forwarders(&self) -> &HashMap<String, Arc<ScriptEventForwarder>> {
490 &self.subscription_forwarders
491 }
492
493 pub fn deliver_events(&mut self, plugin_id: &str, events: &[ScriptEvent]) {
502 let targets: Vec<ScriptId> = self
503 .running
504 .get(plugin_id)
505 .into_iter()
506 .chain(self.action_running.get(plugin_id))
507 .chain(self.panel_running.get(plugin_id))
508 .copied()
509 .collect();
510 if targets.is_empty() {
511 return;
512 }
513 let mut write_failed = false;
514 for event in events {
515 for &sid in &targets {
516 if let Err(error) = self.manager.send_event(sid, event) {
517 write_failed = true;
518 if self.warned_event_delivery.should_warn(plugin_id) {
519 log::warn!(
520 "failed to deliver a {} event to plugin '{}': {}",
521 event.kind,
522 plugin_id,
523 error
524 );
525 }
526 }
527 }
528 }
529 if !write_failed {
530 self.warned_event_delivery.clear(plugin_id);
531 }
532 }
533
534 pub fn sync_theme(&mut self, theme: &Theme) {
546 let name = theme.name.clone();
547 let greet_due = std::mem::take(&mut self.theme_greet_due);
548 let mut ids: HashSet<String> = self
549 .running
550 .keys()
551 .chain(self.action_running.keys())
552 .chain(self.panel_running.keys())
553 .chain(self.overlay_running.keys())
554 .cloned()
555 .collect();
556 ids.extend(greet_due.iter().cloned());
557 if ids.is_empty() {
558 return;
559 }
560 let tokens = theme.hex_tokens();
561 for id in &ids {
562 let subscribed = self.discovered.iter().any(|d| {
563 d.manifest.id == *id
564 && d.manifest
565 .subscriptions
566 .iter()
567 .any(|kind| kind == THEME_CHANGED_KIND)
568 });
569 if !subscribed {
570 continue;
571 }
572 if !greet_due.contains(id) && self.theme_names.get(id) == Some(&name) {
573 continue;
574 }
575 let event = ScriptEvent {
576 kind: THEME_CHANGED_KIND.to_string(),
577 data: ScriptEventData::ThemeChanged {
578 theme: name.clone(),
579 tokens: tokens.clone(),
580 },
581 };
582 self.deliver_events(id, std::slice::from_ref(&event));
583 self.theme_names.insert(id.clone(), name.clone());
584 }
585 }
586
587 pub fn widget_texts(&self) -> &HashMap<String, String> {
589 &self.widget_texts
590 }
591
592 pub fn panel_contents(&self) -> &HashMap<String, (String, String)> {
595 &self.panel_contents
596 }
597
598 pub fn actions_dispatched_count(&self) -> u64 {
602 self.actions_dispatched
603 }
604
605 pub fn running_plugin_ids(&self) -> Vec<String> {
608 self.running
609 .keys()
610 .chain(self.action_running.keys())
611 .chain(self.panel_running.keys())
612 .chain(self.overlay_running.keys())
613 .cloned()
614 .collect()
615 }
616
617 pub fn invoke_action(&mut self, plugin_id: &str, action_id: &str) -> bool {
628 let Some(found) = self.discovered.iter().find(|d| d.manifest.id == plugin_id) else {
629 if self.warned_not_discovered.should_warn(plugin_id) {
630 log::warn!(
631 "plugin '{}' is not discovered; cannot invoke action '{}'",
632 plugin_id,
633 action_id
634 );
635 }
636 return false;
637 };
638 if !found
639 .manifest
640 .kinds
641 .iter()
642 .any(|k| k == KIND_ACTION_CONTRIBUTOR)
643 {
644 if self.warned_action_not_running.should_warn(plugin_id) {
645 log::warn!(
646 "plugin '{}' does not declare kind '{}'; cannot invoke action '{}'",
647 plugin_id,
648 KIND_ACTION_CONTRIBUTOR,
649 action_id
650 );
651 }
652 return false;
653 }
654 if !found.manifest.actions.iter().any(|a| a.id == action_id) {
655 if self.warned_action_not_running.should_warn(plugin_id) {
656 log::warn!(
657 "plugin '{}' has no action '{}'; cannot invoke it",
658 plugin_id,
659 action_id
660 );
661 }
662 return false;
663 }
664 let Some(&sid) = self.action_running.get(plugin_id) else {
665 if self.warned_action_not_running.should_warn(plugin_id) {
666 log::warn!(
667 "plugin '{}' has no running action process; action '{}' not invoked",
668 plugin_id,
669 action_id
670 );
671 }
672 return false;
673 };
674 let event = ScriptEvent {
675 kind: PLUGIN_ACTION_INVOKED_KIND.to_string(),
676 data: ScriptEventData::PluginActionInvoked {
677 action: action_id.to_string(),
678 },
679 };
680 match self.manager.send_event(sid, &event) {
681 Ok(()) => {
682 self.warned_action_not_running.clear(plugin_id);
684 self.actions_dispatched += 1;
685 true
686 }
687 Err(error) => {
688 if self.warned_action_not_running.should_warn(plugin_id) {
689 log::warn!(
690 "failed to invoke action '{}' on plugin '{}': {}",
691 action_id,
692 plugin_id,
693 error
694 );
695 }
696 false
697 }
698 }
699 }
700
701 pub fn palette_actions(&self) -> Vec<PluginActionRow> {
704 let mut rows = Vec::new();
705 for found in &self.discovered {
706 if !self.enabled_ids.contains(&found.manifest.id) {
707 continue;
708 }
709 if !found
710 .manifest
711 .kinds
712 .iter()
713 .any(|k| k == KIND_ACTION_CONTRIBUTOR)
714 {
715 continue;
716 }
717 for action in &found.manifest.actions {
718 rows.push(PluginActionRow {
719 wire_id: format!("plugin-action:{}:{}", found.manifest.id, action.id),
720 label: format!("{} · {}", action.label, found.manifest.name),
721 });
722 }
723 }
724 rows
725 }
726
727 pub fn drain_ignored(&mut self) -> Vec<String> {
730 std::mem::take(&mut self.ignored_lines)
731 }
732
733 pub fn drain_crash_caps(&mut self) -> Vec<PluginCrashCap> {
736 std::mem::take(&mut self.crash_caps)
737 }
738
739 pub fn stop_all(&mut self) {
741 self.manager.stop_all();
742 self.running.clear();
743 self.action_running.clear();
744 self.panel_running.clear();
745 self.overlay_running.clear();
746 self.restart.clear();
747 self.action_restart.clear();
748 self.panel_restart.clear();
749 self.overlay_restart.clear();
750 self.widget_texts.clear();
751 self.panel_contents.clear();
752 self.overlays.clear();
753 self.settings_json.clear();
754 self.enabled_ids.clear();
755 self.subscription_forwarders.clear();
756 self.warned_not_discovered = WarnOnce::default();
757 self.warned_spawn_failed = WarnOnce::default();
758 self.warned_action_not_running = WarnOnce::default();
759 self.warned_event_delivery = WarnOnce::default();
760 }
761}
762
763#[cfg(test)]
764mod tests {
765 use super::*;
766 use std::fs;
767 use std::thread;
768 use std::time::Duration;
769
770 const WIDGET_SCRIPT: &str = r#"
772import json, time
773def emit(text):
774 print(json.dumps({"type": "SetWidget", "text": text}), flush=True)
775emit("first")
776emit("second")
777while True:
778 time.sleep(0.2)
779"#;
780
781 const LOG_SCRIPT: &str = r#"
783import json, time
784print(json.dumps({"type": "Log", "level": "info", "message": "hello"}), flush=True)
785while True:
786 time.sleep(0.2)
787"#;
788
789 const CRASH_SCRIPT: &str = "import sys\nsys.exit(3)\n";
791
792 fn manifest_json(id: &str) -> String {
793 format!(
794 "{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"T\",\"version\":\"0.1.0\",\
795\"kinds\":[\"status-bar-widget\"],\"activation\":\"manual\",\
796\"entryPoints\":{{\"statusBarWidget\":{{\"command\":\"widget.py\",\"args\":[]}}}},\
797\"statusBarWidget\":{{\"displayName\":\"T\",\"section\":\"right\",\"defaults\":{{}},\
798\"schema\":[{{\"key\":\"on\",\"type\":\"boolean\",\"label\":\"On\",\"defaultValue\":true}}]}}}}"
799 )
800 }
801
802 fn write_plugin(root: &Path, id: &str, script: &str) {
805 let dir = root.join(id);
806 fs::create_dir_all(&dir).unwrap();
807 fs::write(dir.join("manifest.json"), manifest_json(id)).unwrap();
808 let entry = dir.join("widget.py");
809 fs::write(&entry, script).unwrap();
810 #[cfg(unix)]
811 {
812 use std::os::unix::fs::PermissionsExt;
813 fs::set_permissions(&entry, fs::Permissions::from_mode(0o755)).unwrap();
814 }
815 }
816
817 fn enabled(id: &str) -> EnabledPlugin {
818 EnabledPlugin {
819 id: id.to_string(),
820 settings_json: "{}".to_string(),
821 }
822 }
823
824 fn plugin_running(host: &PluginHost, id: &str) -> bool {
825 host.running_plugin_ids().iter().any(|i| i == id)
826 }
827
828 fn wait_until(host: &mut PluginHost, mut check: impl FnMut(&mut PluginHost) -> bool) -> bool {
831 let deadline = Instant::now() + Duration::from_secs(10);
832 while Instant::now() < deadline {
833 host.poll();
834 if check(host) {
835 return true;
836 }
837 thread::sleep(Duration::from_millis(50));
838 }
839 false
840 }
841
842 fn skip_without_interpreter() -> bool {
843 if crate::manager::python_interpreter().is_some() {
844 return false;
845 }
846 eprintln!("skipping: no Python interpreter on PATH");
847 true
848 }
849
850 #[test]
851 fn discovery_never_spawns_even_for_an_absent_root() {
852 let mut host = PluginHost::new();
853 let root = std::env::temp_dir().join("par-term-plugin-test-absent-root");
854 assert!(host.refresh_discovery(&root).is_empty());
855 host.apply_enabled(&[enabled("com.test.widget")]);
856 assert!(host.running_plugin_ids().is_empty());
857 host.poll(); }
859
860 #[test]
861 fn apply_enabled_spawns_and_last_set_widget_wins() {
862 if skip_without_interpreter() {
863 return;
864 }
865 let root = tempfile::tempdir().unwrap();
866 write_plugin(root.path(), "com.test.widget", WIDGET_SCRIPT);
867
868 let mut host = PluginHost::new();
869 assert_eq!(host.refresh_discovery(root.path()).len(), 1);
870 host.apply_enabled(&[enabled("com.test.widget")]);
871 assert_eq!(host.widget_text("com.test.widget"), None);
873
874 let settled = wait_until(&mut host, |h| h.widget_text("com.test.widget").is_some());
875 assert!(settled, "fixture's SetWidget never arrived");
876 assert_eq!(host.widget_text("com.test.widget"), Some("second"));
879 host.stop_all();
880 }
881
882 #[test]
883 fn disabling_stops_the_plugin_and_its_widget_text() {
884 if skip_without_interpreter() {
885 return;
886 }
887 let root = tempfile::tempdir().unwrap();
888 write_plugin(root.path(), "com.test.widget", WIDGET_SCRIPT);
889
890 let mut host = PluginHost::new();
891 host.refresh_discovery(root.path());
892 host.apply_enabled(&[enabled("com.test.widget")]);
893 assert!(wait_until(&mut host, |h| h
894 .widget_text("com.test.widget")
895 .is_some()));
896
897 host.apply_enabled(&[]);
898 assert!(!plugin_running(&host, "com.test.widget"));
899 assert_eq!(host.widget_text("com.test.widget"), None);
900 for _ in 0..10 {
902 host.poll();
903 thread::sleep(Duration::from_millis(50));
904 }
905 assert!(host.running_plugin_ids().is_empty());
906 }
907
908 #[test]
909 fn crash_loop_is_capped_without_panicking() {
910 if skip_without_interpreter() {
911 return;
912 }
913 let root = tempfile::tempdir().unwrap();
914 write_plugin(root.path(), "com.test.crash", CRASH_SCRIPT);
915
916 let mut host = PluginHost::new();
917 host.refresh_discovery(root.path());
918 host.apply_enabled(&[enabled("com.test.crash")]);
919
920 let gave_up = wait_until(&mut host, |h| {
923 !plugin_running(h, "com.test.crash") && !h.crash_caps.is_empty()
924 });
925 assert!(
926 gave_up,
927 "supervisor never gave up; still running: {:?}",
928 host.running_plugin_ids()
929 );
930 let caps = host.drain_crash_caps();
933 assert_eq!(caps.len(), 1, "exactly one crash-cap event");
934 assert_eq!(caps[0].plugin_id, "com.test.crash");
935 assert_eq!(caps[0].kind, "widget");
936 assert!(
937 caps[0].entry.contains("com.test.crash"),
938 "entry names the plugin dir: {}",
939 caps[0].entry
940 );
941 host.poll();
943 assert!(host.running_plugin_ids().is_empty());
944 assert!(host.drain_crash_caps().is_empty());
945 }
946
947 #[cfg(unix)]
954 #[test]
955 fn respawn_spawn_failures_are_capped_not_retried_forever() {
956 let root = tempfile::tempdir().unwrap();
957 let manifest = manifest_json("com.test.respawn").replacen(
958 "\"command\":\"widget.py\"",
959 "\"command\":\"widget.sh\"",
960 1,
961 );
962 write_plugin_files(
965 root.path(),
966 "com.test.respawn",
967 &manifest,
968 &[("widget.sh", "#!/bin/sh\nsleep 6\nexit 3\n")],
969 );
970
971 let mut host = PluginHost::new();
972 host.refresh_discovery(root.path());
973 host.apply_enabled(&[enabled("com.test.respawn")]);
974 assert!(
975 wait_until(&mut host, |h| plugin_running(h, "com.test.respawn")),
976 "fixture never started"
977 );
978
979 let entry = root.path().join("com.test.respawn").join("widget.sh");
982 {
983 use std::os::unix::fs::PermissionsExt;
984 fs::set_permissions(&entry, fs::Permissions::from_mode(0o644)).unwrap();
985 }
986
987 let deadline = Instant::now() + Duration::from_secs(20);
990 let mut gave_up = false;
991 while Instant::now() < deadline {
992 host.poll();
993 if !plugin_running(&host, "com.test.respawn") && !host.crash_caps.is_empty() {
994 gave_up = true;
995 break;
996 }
997 thread::sleep(Duration::from_millis(50));
998 }
999 assert!(
1000 gave_up,
1001 "supervisor never gave up on failing respawns; still running: {:?}",
1002 host.running_plugin_ids()
1003 );
1004 let caps = host.drain_crash_caps();
1005 assert_eq!(caps.len(), 1, "exactly one crash-cap event");
1006 assert_eq!(caps[0].plugin_id, "com.test.respawn");
1007 assert_eq!(caps[0].kind, "widget");
1008 assert!(host.running_plugin_ids().is_empty());
1009 host.stop_all();
1010 }
1011
1012 const ONE_SHOT_SCRIPT: &str = r#"
1014import json
1015print(json.dumps({"type": "SetWidget", "text": "once"}), flush=True)
1016"#;
1017
1018 fn restart_manifest_json(id: &str, mode: &str) -> String {
1020 manifest_json(id).replacen(
1021 "\"activation\":\"manual\",",
1022 &format!("\"activation\":\"manual\",\"restart\":\"{mode}\","),
1023 1,
1024 )
1025 }
1026
1027 #[test]
1028 fn never_policy_plugin_is_not_restarted_after_a_crash() {
1029 if skip_without_interpreter() {
1030 return;
1031 }
1032 let root = tempfile::tempdir().unwrap();
1033 write_plugin_files(
1034 root.path(),
1035 "com.test.never",
1036 &restart_manifest_json("com.test.never", "never"),
1037 &[("widget.py", CRASH_SCRIPT)],
1038 );
1039
1040 let mut host = PluginHost::new();
1041 host.refresh_discovery(root.path());
1042 host.apply_enabled(&[enabled("com.test.never")]);
1043
1044 let stopped = wait_until(&mut host, |h| !plugin_running(h, "com.test.never"));
1047 assert!(stopped, "plugin never stopped");
1048 assert!(
1049 !host.restart.contains_key("com.test.never"),
1050 "stop_kind must drop the supervision state"
1051 );
1052 assert!(
1054 host.drain_crash_caps().is_empty(),
1055 "never-policy stop must not surface a crash-cap"
1056 );
1057
1058 thread::sleep(Duration::from_millis(700));
1060 host.poll();
1061 assert!(
1062 !plugin_running(&host, "com.test.never"),
1063 "`never` must not respawn a crashed plugin"
1064 );
1065 }
1066
1067 #[test]
1068 fn always_policy_plugin_restarts_after_a_clean_exit() {
1069 if skip_without_interpreter() {
1070 return;
1071 }
1072 let root = tempfile::tempdir().unwrap();
1073 write_plugin_files(
1074 root.path(),
1075 "com.test.always",
1076 &restart_manifest_json("com.test.always", "always"),
1077 &[("widget.py", ONE_SHOT_SCRIPT)],
1078 );
1079
1080 let mut host = PluginHost::new();
1081 host.refresh_discovery(root.path());
1082 host.apply_enabled(&[enabled("com.test.always")]);
1083
1084 let restarted = wait_until(&mut host, |h| {
1090 h.restart
1091 .get("com.test.always")
1092 .is_some_and(|s| s.consecutive_failures() >= 2)
1093 });
1094 assert!(
1095 restarted,
1096 "clean exit was never restarted; failures: {:?}",
1097 host.restart
1098 .get("com.test.always")
1099 .map(|s| s.consecutive_failures())
1100 );
1101 host.stop_all();
1102 }
1103
1104 #[test]
1105 fn non_widget_commands_are_refused_with_an_error_line() {
1106 if skip_without_interpreter() {
1107 return;
1108 }
1109 let root = tempfile::tempdir().unwrap();
1110 write_plugin(root.path(), "com.test.log", LOG_SCRIPT);
1111
1112 let mut host = PluginHost::new();
1113 host.refresh_discovery(root.path());
1114 host.apply_enabled(&[enabled("com.test.log")]);
1115
1116 let mut refused: Vec<String> = Vec::new();
1117 let seen = wait_until(&mut host, |h| {
1118 refused.extend(h.drain_ignored());
1119 refused.iter().any(|line| line.contains("Log"))
1120 });
1121 assert!(
1122 seen,
1123 "Log command was never refused; lines seen: {:?}, widget text: {:?}",
1124 refused,
1125 host.widget_text("com.test.log")
1126 );
1127 assert!(refused.iter().all(|line| line.starts_with("[error]")));
1128 host.stop_all();
1129 }
1130
1131 #[test]
1132 fn failing_spawn_retries_at_the_restart_delay_not_per_frame() {
1133 let root = tempfile::tempdir().unwrap();
1134 let dir = root.path().join("com.test.broken");
1140 fs::create_dir_all(&dir).unwrap();
1141 fs::write(
1142 dir.join("manifest.json"),
1143 manifest_json("com.test.broken").replace("widget.py", "widget.sh"),
1144 )
1145 .unwrap();
1146 fs::write(
1147 dir.join("widget.sh"),
1148 "#!/nonexistent/par-term-test-interpreter\n",
1149 )
1150 .unwrap();
1151 #[cfg(unix)]
1152 {
1153 use std::os::unix::fs::PermissionsExt;
1154 fs::set_permissions(dir.join("widget.sh"), fs::Permissions::from_mode(0o755)).unwrap();
1155 }
1156
1157 let mut host = PluginHost::new();
1158 host.refresh_discovery(root.path());
1159 assert_eq!(host.discovered_count(), 1, "fixture must be discovered");
1160
1161 host.apply_enabled(&[enabled("com.test.broken")]);
1164 assert!(!plugin_running(&host, "com.test.broken"));
1165 let after_first = host
1166 .restart
1167 .get("com.test.broken")
1168 .expect("supervisor state armed by a failed spawn")
1169 .consecutive_failures();
1170 assert_eq!(after_first, 1);
1171
1172 host.apply_enabled(&[enabled("com.test.broken")]);
1175 assert_eq!(
1176 host.restart
1177 .get("com.test.broken")
1178 .unwrap()
1179 .consecutive_failures(),
1180 after_first,
1181 "spawn must not be retried inside the restart delay"
1182 );
1183
1184 thread::sleep(Duration::from_millis(300));
1186 host.apply_enabled(&[enabled("com.test.broken")]);
1187 assert_eq!(
1188 host.restart
1189 .get("com.test.broken")
1190 .unwrap()
1191 .consecutive_failures(),
1192 after_first + 1,
1193 "exactly one retry after the delay elapses"
1194 );
1195 }
1196
1197 #[test]
1198 fn stop_all_drains_every_plugin() {
1199 if skip_without_interpreter() {
1200 return;
1201 }
1202 let root = tempfile::tempdir().unwrap();
1203 write_plugin(root.path(), "com.test.a", WIDGET_SCRIPT);
1204 write_plugin(root.path(), "com.test.b", WIDGET_SCRIPT);
1205
1206 let mut host = PluginHost::new();
1207 host.refresh_discovery(root.path());
1208 host.apply_enabled(&[enabled("com.test.a"), enabled("com.test.b")]);
1209 assert!(wait_until(&mut host, |h| h.running_plugin_ids().len() == 2));
1210
1211 host.stop_all();
1212 assert!(host.running_plugin_ids().is_empty());
1213 assert!(host.widget_texts().is_empty());
1214 host.poll(); }
1216
1217 static WARNED: std::sync::LazyLock<std::sync::Mutex<Vec<String>>> =
1222 std::sync::LazyLock::new(|| std::sync::Mutex::new(Vec::new()));
1223
1224 struct CountingLogger;
1225
1226 impl log::Log for CountingLogger {
1227 fn enabled(&self, _metadata: &log::Metadata) -> bool {
1228 true
1229 }
1230
1231 fn log(&self, record: &log::Record) {
1232 if record.level() == log::Level::Warn {
1233 WARNED.lock().unwrap().push(record.args().to_string());
1234 }
1235 }
1236
1237 fn flush(&self) {}
1238 }
1239
1240 fn install_counting_logger() {
1241 static INSTALL: std::sync::Once = std::sync::Once::new();
1242 static COUNTING_LOGGER: CountingLogger = CountingLogger;
1243 INSTALL.call_once(|| {
1244 let _ = log::set_logger(&COUNTING_LOGGER);
1247 log::set_max_level(log::LevelFilter::Warn);
1248 });
1249 }
1250
1251 fn warns_containing(fragment: &str) -> usize {
1252 WARNED
1253 .lock()
1254 .unwrap()
1255 .iter()
1256 .filter(|msg| msg.contains(fragment))
1257 .count()
1258 }
1259
1260 #[test]
1263 fn fault_warns_fire_once_per_transition_not_per_frame() {
1264 install_counting_logger();
1265 let fragment = "'com.test.missing' is enabled but not discovered";
1266 let mut host = PluginHost::new();
1267 let root = tempfile::tempdir().unwrap();
1269 host.refresh_discovery(root.path());
1270
1271 for _ in 0..5 {
1272 host.apply_enabled(&[enabled("com.test.missing")]);
1273 }
1274 let warns = warns_containing(fragment);
1275 assert_eq!(
1276 warns, 1,
1277 "5 frames of one steady fault must produce exactly 1 warn, got {warns}"
1278 );
1279
1280 host.apply_enabled(&[]);
1283 for _ in 0..2 {
1284 host.apply_enabled(&[enabled("com.test.missing")]);
1285 }
1286 let warns = warns_containing(fragment);
1287 assert_eq!(
1288 warns, 2,
1289 "warn must re-arm after a disable/enable, got {warns}"
1290 );
1291 }
1292
1293 #[test]
1294 fn fault_warn_rearms_after_recovery() {
1295 if skip_without_interpreter() {
1296 return;
1297 }
1298 install_counting_logger();
1299 let empty = tempfile::tempdir().unwrap();
1300 let populated = tempfile::tempdir().unwrap();
1301 write_plugin(populated.path(), "com.test.flapping", WIDGET_SCRIPT);
1302
1303 let mut host = PluginHost::new();
1304 let fragment = "'com.test.flapping' is enabled but not discovered";
1306 host.refresh_discovery(empty.path());
1307 host.apply_enabled(&[enabled("com.test.flapping")]);
1308 assert_eq!(warns_containing(fragment), 1);
1309
1310 host.refresh_discovery(populated.path());
1312 host.apply_enabled(&[enabled("com.test.flapping")]);
1313 assert!(wait_until(&mut host, |h| h
1314 .widget_text("com.test.flapping")
1315 .is_some()));
1316
1317 host.apply_enabled(&[]);
1320 host.refresh_discovery(empty.path());
1321 host.apply_enabled(&[enabled("com.test.flapping")]);
1322 assert_eq!(
1323 warns_containing(fragment),
1324 2,
1325 "a recovered-then-failed-again plugin must warn again"
1326 );
1327 }
1328
1329 const ACTION_SCRIPT: &str = r#"
1333import json, sys
1334def emit(text):
1335 print(json.dumps({"type": "SetWidget", "text": text}), flush=True)
1336for line in iter(sys.stdin.readline, ""):
1337 line = line.strip()
1338 if not line:
1339 continue
1340 try:
1341 event = json.loads(line)
1342 except ValueError:
1343 continue
1344 emit("invoked:" + str(event.get("data", {}).get("action", "?")))
1345"#;
1346
1347 fn action_manifest_json(id: &str) -> String {
1348 format!(
1349 "{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Test Actions\",\"version\":\"0.1.0\",\
1350\"kinds\":[\"action-contributor\"],\"activation\":\"manual\",\
1351\"entryPoints\":{{\"actionContributor\":{{\"command\":\"actions.py\",\"args\":[]}}}},\
1352\"actions\":[{{\"id\":\"say-hello\",\"label\":\"Say hello\"}}]}}"
1353 )
1354 }
1355
1356 fn both_kinds_manifest_json(id: &str) -> String {
1357 format!(
1358 "{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Test Both\",\"version\":\"0.1.0\",\
1359\"kinds\":[\"status-bar-widget\",\"action-contributor\"],\"activation\":\"manual\",\
1360\"entryPoints\":{{\"statusBarWidget\":{{\"command\":\"widget.py\",\"args\":[]}},\
1361\"actionContributor\":{{\"command\":\"actions.py\",\"args\":[]}}}},\
1362\"actions\":[{{\"id\":\"ping\",\"label\":\"Ping\"}}],\
1363\"statusBarWidget\":{{\"displayName\":\"T\",\"section\":\"right\",\"defaults\":{{}},\
1364\"schema\":[{{\"key\":\"on\",\"type\":\"boolean\",\"label\":\"On\",\"defaultValue\":true}}]}}}}"
1365 )
1366 }
1367
1368 fn panel_manifest_json(id: &str) -> String {
1369 format!(
1370 "{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Test Panel\",\"version\":\"0.1.0\",\
1371\"kinds\":[\"panel\"],\"activation\":\"manual\",\
1372\"entryPoints\":{{\"panel\":{{\"command\":\"panel.py\",\"args\":[]}}}}}}"
1373 )
1374 }
1375
1376 const PANEL_SCRIPT: &str = r##"
1379import json, time
1380def emit(obj):
1381 print(json.dumps(obj), flush=True)
1382emit({"type": "SetWidget", "text": "nope"})
1383emit({"type": "SetPanel", "title": "Notes", "content": "# hello\nworld"})
1384while True:
1385 time.sleep(0.2)
1386"##;
1387
1388 const PANEL_CLEAR_SCRIPT: &str = r#"
1390import json, time
1391def emit(obj):
1392 print(json.dumps(obj), flush=True)
1393emit({"type": "SetPanel", "title": "T", "content": "c"})
1394time.sleep(0.3)
1395emit({"type": "ClearPanel"})
1396while True:
1397 time.sleep(0.2)
1398"#;
1399
1400 fn write_plugin_files(root: &Path, id: &str, manifest: &str, files: &[(&str, &str)]) {
1403 let dir = root.join(id);
1404 fs::create_dir_all(&dir).unwrap();
1405 fs::write(dir.join("manifest.json"), manifest).unwrap();
1406 for (name, script) in files {
1407 let entry = dir.join(name);
1408 fs::write(&entry, script).unwrap();
1409 #[cfg(unix)]
1410 {
1411 use std::os::unix::fs::PermissionsExt;
1412 fs::set_permissions(&entry, fs::Permissions::from_mode(0o755)).unwrap();
1413 }
1414 }
1415 }
1416
1417 fn write_action_plugin(root: &Path, id: &str, script: &str) {
1418 write_plugin_files(
1419 root,
1420 id,
1421 &action_manifest_json(id),
1422 &[("actions.py", script)],
1423 );
1424 }
1425
1426 fn write_panel_plugin(root: &Path, id: &str, script: &str) {
1427 write_plugin_files(root, id, &panel_manifest_json(id), &[("panel.py", script)]);
1428 }
1429
1430 #[test]
1431 fn panel_plugin_pushes_content_and_refuses_set_widget() {
1432 if skip_without_interpreter() {
1433 return;
1434 }
1435 let root = tempfile::tempdir().unwrap();
1436 write_panel_plugin(root.path(), "com.test.panel", PANEL_SCRIPT);
1437
1438 let mut host = PluginHost::new();
1439 assert_eq!(host.refresh_discovery(root.path()).len(), 1);
1440 host.apply_enabled(&[enabled("com.test.panel")]);
1441 let settled = wait_until(&mut host, |h| h.panel_content("com.test.panel").is_some());
1442 assert!(settled, "fixture's SetPanel never arrived");
1443 assert_eq!(
1444 host.panel_content("com.test.panel"),
1445 Some(&("Notes".to_string(), "# hello\nworld".to_string()))
1446 );
1447 let ignored = host.drain_ignored().join("\n");
1450 assert!(ignored.contains("SetWidget"), "got: {ignored}");
1451 assert!(ignored.contains("SetPanel/ClearPanel"), "got: {ignored}");
1452 host.stop_all();
1453 }
1454
1455 #[test]
1456 fn clear_panel_from_a_panel_process_removes_the_panel() {
1457 if skip_without_interpreter() {
1458 return;
1459 }
1460 let root = tempfile::tempdir().unwrap();
1461 write_panel_plugin(root.path(), "com.test.panel", PANEL_CLEAR_SCRIPT);
1462
1463 let mut host = PluginHost::new();
1464 host.refresh_discovery(root.path());
1465 host.apply_enabled(&[enabled("com.test.panel")]);
1466 let pushed = wait_until(&mut host, |h| h.panel_content("com.test.panel").is_some());
1467 assert!(pushed, "fixture's SetPanel never arrived");
1468 let cleared = wait_until(&mut host, |h| h.panel_content("com.test.panel").is_none());
1469 assert!(cleared, "fixture's ClearPanel never landed");
1470 host.stop_all();
1471 }
1472
1473 #[test]
1474 fn disabling_a_panel_plugin_stops_the_process_and_clears_the_panel() {
1475 if skip_without_interpreter() {
1476 return;
1477 }
1478 let root = tempfile::tempdir().unwrap();
1479 write_panel_plugin(root.path(), "com.test.panel", PANEL_SCRIPT);
1480
1481 let mut host = PluginHost::new();
1482 host.refresh_discovery(root.path());
1483 host.apply_enabled(&[enabled("com.test.panel")]);
1484 let settled = wait_until(&mut host, |h| h.panel_content("com.test.panel").is_some());
1485 assert!(settled, "fixture's SetPanel never arrived");
1486 assert!(plugin_running(&host, "com.test.panel"));
1487
1488 host.apply_enabled(&[]);
1491 assert!(!plugin_running(&host, "com.test.panel"));
1492 assert_eq!(host.panel_content("com.test.panel"), None);
1493 host.stop_all();
1494 }
1495
1496 fn overlay_manifest_json(id: &str) -> String {
1497 format!(
1498 "{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Test Overlay\",\"version\":\"0.1.0\",\
1499\"kinds\":[\"overlay\"],\"activation\":\"manual\",\
1500\"entryPoints\":{{\"overlay\":{{\"command\":\"overlay.py\",\"args\":[]}}}}}}"
1501 )
1502 }
1503
1504 const OVERLAY_SCRIPT: &str = r##"
1507import json, time
1508def emit(obj):
1509 print(json.dumps(obj), flush=True)
1510emit({"type": "SetWidget", "text": "nope"})
1511emit({"type": "SetOverlay", "id": "hud", "position": "top-right",
1512 "size": {"w": 0.2, "h": 0.1}, "opacity": 0.8,
1513 "content": {"type": "markdown", "text": "# hello"}})
1514while True:
1515 time.sleep(0.2)
1516"##;
1517
1518 const OVERLAY_CLEAR_SCRIPT: &str = r#"
1520import json, time
1521def emit(obj):
1522 print(json.dumps(obj), flush=True)
1523emit({"type": "SetOverlay", "id": "hud", "position": "center",
1524 "size": {"w": 0.2, "h": 0.1},
1525 "content": {"type": "text", "text": "hi"}})
1526time.sleep(0.3)
1527emit({"type": "ClearOverlay", "id": "hud"})
1528while True:
1529 time.sleep(0.2)
1530"#;
1531
1532 fn write_overlay_plugin(root: &Path, id: &str, script: &str) {
1533 write_plugin_files(
1534 root,
1535 id,
1536 &overlay_manifest_json(id),
1537 &[("overlay.py", script)],
1538 );
1539 }
1540
1541 #[test]
1542 fn overlay_plugin_pushes_scene_and_refuses_set_widget() {
1543 if skip_without_interpreter() {
1544 return;
1545 }
1546 let root = tempfile::tempdir().unwrap();
1547 write_overlay_plugin(root.path(), "com.test.overlay", OVERLAY_SCRIPT);
1548
1549 let mut host = PluginHost::new();
1550 assert_eq!(host.refresh_discovery(root.path()).len(), 1);
1551 host.apply_enabled(&[enabled("com.test.overlay")]);
1552 let settled = wait_until(&mut host, |h| {
1553 h.overlays().contains_key("com.test.overlay")
1557 && h.drain_ignored().iter().any(|l| l.contains("SetWidget"))
1558 });
1559 assert!(settled, "fixture's SetOverlay never arrived");
1560
1561 let overlay = &host.overlays()["com.test.overlay"];
1562 assert_eq!(overlay.id, "hud");
1563 assert_eq!(overlay.opacity, 0.8);
1564 assert_eq!(
1565 overlay.position,
1566 crate::protocol::OverlayPosition::Anchor(crate::protocol::OverlayAnchor::TopRight)
1567 );
1568 assert_eq!(
1569 overlay.content,
1570 crate::protocol::OverlayScene::Markdown {
1571 text: "# hello".to_string()
1572 }
1573 );
1574
1575 let ignored = host.drain_ignored().join("\n");
1579 assert!(ignored.contains("SetOverlay/ClearOverlay") || ignored.is_empty());
1580 host.stop_all();
1581 }
1582
1583 #[test]
1584 fn clear_overlay_from_an_overlay_process_removes_it() {
1585 if skip_without_interpreter() {
1586 return;
1587 }
1588 let root = tempfile::tempdir().unwrap();
1589 write_overlay_plugin(root.path(), "com.test.overlay", OVERLAY_CLEAR_SCRIPT);
1590
1591 let mut host = PluginHost::new();
1592 host.refresh_discovery(root.path());
1593 host.apply_enabled(&[enabled("com.test.overlay")]);
1594 let pushed = wait_until(&mut host, |h| h.overlays().contains_key("com.test.overlay"));
1595 assert!(pushed, "fixture's SetOverlay never arrived");
1596 let cleared = wait_until(&mut host, |h| h.overlays().is_empty());
1597 assert!(cleared, "fixture's ClearOverlay never landed");
1598 host.stop_all();
1599 }
1600
1601 #[test]
1602 fn disabling_an_overlay_plugin_stops_the_process_and_clears_the_overlay() {
1603 if skip_without_interpreter() {
1604 return;
1605 }
1606 let root = tempfile::tempdir().unwrap();
1607 write_overlay_plugin(root.path(), "com.test.overlay", OVERLAY_SCRIPT);
1608
1609 let mut host = PluginHost::new();
1610 host.refresh_discovery(root.path());
1611 host.apply_enabled(&[enabled("com.test.overlay")]);
1612 let settled = wait_until(&mut host, |h| h.overlays().contains_key("com.test.overlay"));
1613 assert!(settled, "fixture's SetOverlay never arrived");
1614 assert!(plugin_running(&host, "com.test.overlay"));
1615
1616 host.apply_enabled(&[]);
1619 assert!(!plugin_running(&host, "com.test.overlay"));
1620 assert!(host.overlays().is_empty());
1621 host.stop_all();
1622 }
1623
1624 #[test]
1625 fn a_widget_plugin_cannot_send_overlay_commands() {
1626 if skip_without_interpreter() {
1627 return;
1628 }
1629 let root = tempfile::tempdir().unwrap();
1630 write_plugin(root.path(), "com.test.widget", OVERLAY_SCRIPT);
1631
1632 let mut host = PluginHost::new();
1633 host.refresh_discovery(root.path());
1634 host.apply_enabled(&[enabled("com.test.widget")]);
1635 let mut saw_refusal = false;
1638 let refused = wait_until(&mut host, |h| {
1639 saw_refusal = h.drain_ignored().iter().any(|l| l.contains("SetOverlay"));
1640 saw_refusal
1641 });
1642 assert!(refused, "fixture's SetOverlay refusal never landed");
1643 assert!(host.overlays().is_empty(), "no overlay may exist");
1644 host.stop_all();
1645 }
1646
1647 fn overlay_interactive_manifest_json(id: &str) -> String {
1648 format!(
1649 "{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Test Overlay\",\"version\":\"0.1.0\",\
1650\"kinds\":[\"overlay\"],\"activation\":\"manual\",\
1651\"overlay\":{{\"interactive\":true}},\
1652\"entryPoints\":{{\"overlay\":{{\"command\":\"overlay.py\",\"args\":[]}}}}}}"
1653 )
1654 }
1655
1656 fn write_overlay_plugin_with_manifest(root: &Path, id: &str, manifest: &str, script: &str) {
1657 write_plugin_files(root, id, manifest, &[("overlay.py", script)]);
1658 }
1659
1660 const OVERLAY_INTERACTIVE_SCRIPT: &str = r##"
1663import json, time
1664def emit(obj):
1665 print(json.dumps(obj), flush=True)
1666emit({"type": "SetOverlay", "id": "hud", "position": "top-right",
1667 "size": {"w": 0.3, "h": 0.2}, "opacity": 0.9, "interactive": True,
1668 "content": {"type": "button", "id": "deploy", "label": "Deploy"}})
1669while True:
1670 time.sleep(0.2)
1671"##;
1672
1673 const OVERLAY_ECHO_SCRIPT: &str = r#"
1678import json, sys, time
1679def emit(obj):
1680 print(json.dumps(obj), flush=True)
1681emit({"type": "SetOverlay", "id": "hud", "position": "top-right",
1682 "size": {"w": 0.3, "h": 0.2}, "interactive": True,
1683 "content": {"type": "button", "id": "deploy", "label": "Deploy"}})
1684for line in iter(sys.stdin.readline, ""):
1685 line = line.strip()
1686 if not line:
1687 continue
1688 try:
1689 event = json.loads(line)
1690 except ValueError:
1691 continue
1692 emit({"type": "SetOverlay", "id": "hud", "position": "top-right",
1693 "size": {"w": 0.3, "h": 0.2}, "interactive": True,
1694 "content": {"type": "text", "text": "event:" + str(event.get("kind", "?")) + ":" + str(event.get("data", {}).get("widget", "?"))}})
1695"#;
1696
1697 #[test]
1698 fn interactive_requires_the_manifest_capability() {
1699 if skip_without_interpreter() {
1700 return;
1701 }
1702 let root = tempfile::tempdir().unwrap();
1703 write_overlay_plugin(root.path(), "com.test.no-cap", OVERLAY_INTERACTIVE_SCRIPT);
1705
1706 let mut host = PluginHost::new();
1707 assert_eq!(host.refresh_discovery(root.path()).len(), 1);
1708 host.apply_enabled(&[enabled("com.test.no-cap")]);
1709 let settled = wait_until(&mut host, |h| h.overlays().contains_key("com.test.no-cap"));
1710 assert!(settled, "fixture's SetOverlay never arrived");
1711 let overlay = &host.overlays()["com.test.no-cap"];
1712 assert!(!overlay.interactive, "no capability → forced display-only");
1713
1714 host.focus_overlay("com.test.no-cap");
1716 assert_eq!(host.focused_overlay(), None);
1717 host.stop_all();
1718 }
1719
1720 #[test]
1721 fn interactive_capability_allows_focus_and_clearing_drops_it() {
1722 if skip_without_interpreter() {
1723 return;
1724 }
1725 let root = tempfile::tempdir().unwrap();
1726 write_overlay_plugin_with_manifest(
1727 root.path(),
1728 "com.test.cap",
1729 &overlay_interactive_manifest_json("com.test.cap"),
1730 OVERLAY_INTERACTIVE_SCRIPT,
1731 );
1732
1733 let mut host = PluginHost::new();
1734 assert_eq!(host.refresh_discovery(root.path()).len(), 1);
1735 host.apply_enabled(&[enabled("com.test.cap")]);
1736 let settled = wait_until(&mut host, |h| {
1737 h.overlays()
1738 .get("com.test.cap")
1739 .is_some_and(|o| o.interactive)
1740 });
1741 assert!(settled, "fixture's interactive SetOverlay never arrived");
1742
1743 host.focus_overlay("com.test.cap");
1746 assert_eq!(host.focused_overlay(), Some("com.test.cap"));
1747
1748 host.unfocus_overlay();
1750 assert_eq!(host.focused_overlay(), None);
1751
1752 host.focus_overlay("com.test.cap");
1754 host.apply_enabled(&[]);
1755 assert!(host.overlays().is_empty());
1756 assert_eq!(host.focused_overlay(), None);
1757 host.stop_all();
1758 }
1759
1760 #[test]
1761 fn overlay_events_reach_the_running_process() {
1762 if skip_without_interpreter() {
1763 return;
1764 }
1765 let root = tempfile::tempdir().unwrap();
1766 write_overlay_plugin_with_manifest(
1767 root.path(),
1768 "com.test.echo",
1769 &overlay_interactive_manifest_json("com.test.echo"),
1770 OVERLAY_ECHO_SCRIPT,
1771 );
1772
1773 let mut host = PluginHost::new();
1774 host.refresh_discovery(root.path());
1775 host.apply_enabled(&[enabled("com.test.echo")]);
1776 let up = wait_until(&mut host, |h| {
1779 h.overlays()
1780 .get("com.test.echo")
1781 .is_some_and(|o| o.interactive)
1782 });
1783 assert!(up, "fixture never pushed its overlay");
1784
1785 let delivered = host.send_overlay_event(
1786 "com.test.echo",
1787 "deploy",
1788 crate::protocol::OverlayWidgetEvent::Click {},
1789 );
1790 assert!(delivered, "send_overlay_event reported failure");
1791 assert_eq!(host.overlay_events_dispatched_count(), 1);
1792
1793 let echoed = wait_until(&mut host, |h| {
1796 h.overlays().get("com.test.echo").is_some_and(|o| {
1797 matches!(
1798 &o.content,
1799 crate::protocol::OverlayScene::Text { text }
1800 if text.contains("overlay_event") && text.contains("deploy")
1801 )
1802 })
1803 });
1804 assert!(echoed, "the overlay process never echoed the event");
1805 host.stop_all();
1806 }
1807
1808 #[test]
1809 fn overlay_update_rate_clamp_drops_excess_upserts() {
1810 let mut host = PluginHost::new();
1813 let t0 = Instant::now();
1814 for _ in 0..30 {
1816 assert!(host.overlay_update_permitted("p", t0));
1817 }
1818 assert!(
1820 !host.overlay_update_permitted("p", t0),
1821 "clamp must refuse past the budget"
1822 );
1823 let after = t0 + Duration::from_secs(2);
1826 assert!(host.overlay_update_permitted("p", after));
1827 }
1828
1829 #[test]
1830 fn invoke_action_miss_paths_return_false_without_a_process() {
1831 let root = tempfile::tempdir().unwrap();
1832 write_plugin(root.path(), "com.test.only-widget", WIDGET_SCRIPT);
1833 write_action_plugin(root.path(), "com.test.only-action", ACTION_SCRIPT);
1834
1835 let mut host = PluginHost::new();
1836 host.refresh_discovery(root.path());
1837 host.apply_enabled(&[]); assert!(!host.invoke_action("com.test.nope", "greet"));
1841 assert!(!host.invoke_action("com.test.only-action", "say-hello"));
1843 assert!(!host.invoke_action("com.test.only-action", "bogus"));
1845 assert!(!host.invoke_action("com.test.only-widget", "say-hello"));
1847
1848 assert!(host.running_plugin_ids().is_empty());
1849 assert!(host.action_running.is_empty());
1850 }
1851
1852 #[test]
1853 fn invoke_action_not_running_warns_once_per_episode() {
1854 install_counting_logger();
1855 let root = tempfile::tempdir().unwrap();
1856 write_action_plugin(root.path(), "com.test.invoke-miss", ACTION_SCRIPT);
1857
1858 let mut host = PluginHost::new();
1859 host.refresh_discovery(root.path());
1860 host.apply_enabled(&[]);
1861
1862 let fragment = "'com.test.invoke-miss' has no running action process";
1865 for _ in 0..5 {
1866 assert!(!host.invoke_action("com.test.invoke-miss", "say-hello"));
1867 }
1868 assert_eq!(
1869 warns_containing(fragment),
1870 1,
1871 "5 invokes of one steady miss must produce exactly 1 warn"
1872 );
1873 }
1874
1875 #[test]
1876 fn disable_enable_cycle_rearms_the_action_warn_gate() {
1877 let mut host = PluginHost::new();
1883 assert!(host.warned_action_not_running.should_warn("com.test.gate"));
1884 assert!(
1885 !host.warned_action_not_running.should_warn("com.test.gate"),
1886 "second call in the same episode must be silent"
1887 );
1888
1889 host.apply_enabled(&[]);
1890 assert!(
1891 host.warned_action_not_running.should_warn("com.test.gate"),
1892 "a disable/enable cycle must re-arm the action warn gate"
1893 );
1894 }
1895
1896 #[test]
1897 fn palette_actions_list_enabled_action_plugins_only() {
1898 let root = tempfile::tempdir().unwrap();
1899 write_action_plugin(root.path(), "com.test.pal-on", ACTION_SCRIPT);
1900 write_action_plugin(root.path(), "com.test.pal-off", ACTION_SCRIPT);
1901
1902 let mut host = PluginHost::new();
1903 host.refresh_discovery(root.path());
1904
1905 host.apply_enabled(&[]);
1906 assert!(
1907 host.palette_actions().is_empty(),
1908 "nothing enabled -> no rows"
1909 );
1910
1911 host.apply_enabled(&[enabled("com.test.pal-on")]);
1912 let rows = host.palette_actions();
1913 assert_eq!(rows.len(), 1, "one action on the enabled plugin: {rows:?}");
1914 assert_eq!(rows[0].wire_id, "plugin-action:com.test.pal-on:say-hello");
1915 assert_eq!(rows[0].label, "Say hello · Test Actions");
1916 host.stop_all();
1917 }
1918
1919 #[test]
1920 fn both_kinds_manifest_runs_one_process_per_kind() {
1921 if skip_without_interpreter() {
1922 return;
1923 }
1924 let root = tempfile::tempdir().unwrap();
1925 write_plugin_files(
1926 root.path(),
1927 "com.test.both",
1928 &both_kinds_manifest_json("com.test.both"),
1929 &[("widget.py", WIDGET_SCRIPT), ("actions.py", ACTION_SCRIPT)],
1930 );
1931
1932 let mut host = PluginHost::new();
1933 host.refresh_discovery(root.path());
1934 host.apply_enabled(&[enabled("com.test.both")]);
1935
1936 assert!(
1937 wait_until(&mut host, |h| h.running.len() == 1
1938 && h.action_running.len() == 1),
1939 "a both-kinds manifest must run both entry points after one pass"
1940 );
1941 assert!(wait_until(&mut host, |h| h
1943 .widget_text("com.test.both")
1944 .is_some()));
1945 assert!(host.invoke_action("com.test.both", "ping"));
1947 host.stop_all();
1948 }
1949
1950 #[test]
1951 fn invoke_action_writes_the_event_to_the_running_action_process() {
1952 if skip_without_interpreter() {
1953 return;
1954 }
1955 let root = tempfile::tempdir().unwrap();
1956 write_action_plugin(root.path(), "com.test.invoke", ACTION_SCRIPT);
1957
1958 let mut host = PluginHost::new();
1959 host.refresh_discovery(root.path());
1960 host.apply_enabled(&[enabled("com.test.invoke")]);
1961
1962 assert!(
1963 host.invoke_action("com.test.invoke", "say-hello"),
1964 "writing to a running action process must succeed"
1965 );
1966 let landed = wait_until(&mut host, |h| {
1970 h.widget_text("com.test.invoke") == Some("invoked:say-hello")
1971 });
1972 assert!(
1973 landed,
1974 "PluginActionInvoked never reached the plugin's stdin; widget text: {:?}",
1975 host.widget_text("com.test.invoke")
1976 );
1977 host.stop_all();
1978 }
1979
1980 #[test]
1981 fn actions_dispatched_count_counts_only_successful_writes() {
1982 if skip_without_interpreter() {
1983 return;
1984 }
1985 let root = tempfile::tempdir().unwrap();
1986 write_action_plugin(root.path(), "com.test.count", ACTION_SCRIPT);
1987
1988 let mut host = PluginHost::new();
1989 host.refresh_discovery(root.path());
1990
1991 host.apply_enabled(&[]);
1993 assert!(!host.invoke_action("com.test.count", "say-hello"));
1994 assert_eq!(
1995 host.actions_dispatched_count(),
1996 0,
1997 "a missed invoke is not a successful write"
1998 );
1999
2000 host.apply_enabled(&[enabled("com.test.count")]);
2002 assert!(wait_until(&mut host, |h| !h.action_running.is_empty()));
2003 for expected in 1..=2u64 {
2004 assert!(host.invoke_action("com.test.count", "say-hello"));
2005 assert_eq!(host.actions_dispatched_count(), expected);
2006 }
2007 host.stop_all();
2008 }
2009
2010 fn echo_script(tag: &str) -> String {
2016 format!(
2017 r#"
2018import json, sys
2019def emit(text):
2020 print(json.dumps({{"type": "SetWidget", "text": text}}), flush=True)
2021for line in iter(sys.stdin.readline, ""):
2022 line = line.strip()
2023 if not line:
2024 continue
2025 try:
2026 event = json.loads(line)
2027 except ValueError:
2028 continue
2029 emit("{tag}:" + str(event.get("kind", "?")))
2030"#
2031 )
2032 }
2033
2034 fn subscribed_manifest_json(id: &str, subscriptions_json: &str) -> String {
2037 manifest_json(id).replacen(
2038 r#""kinds":["status-bar-widget"],"#,
2039 &format!(r#""kinds":["status-bar-widget"],"subscriptions":[{subscriptions_json}],"#),
2040 1,
2041 )
2042 }
2043
2044 fn subscribed_both_kinds_manifest_json(id: &str, subscriptions_json: &str) -> String {
2046 both_kinds_manifest_json(id).replacen(
2047 r#""kinds":["status-bar-widget","action-contributor"],"#,
2048 &format!(
2049 r#""kinds":["status-bar-widget","action-contributor"],"subscriptions":[{subscriptions_json}],"#
2050 ),
2051 1,
2052 )
2053 }
2054
2055 #[test]
2056 fn subscribed_plugin_gets_a_kind_filtered_forwarder_on_spawn() {
2057 if skip_without_interpreter() {
2058 return;
2059 }
2060 let root = tempfile::tempdir().unwrap();
2061 write_plugin_files(
2062 root.path(),
2063 "com.test.sub",
2064 &subscribed_manifest_json("com.test.sub", r#""bell_rang""#),
2065 &[("widget.py", WIDGET_SCRIPT)],
2066 );
2067
2068 let mut host = PluginHost::new();
2069 host.refresh_discovery(root.path());
2070 host.apply_enabled(&[enabled("com.test.sub")]);
2071
2072 let forwarder = host
2073 .subscription_forwarders()
2074 .get("com.test.sub")
2075 .cloned()
2076 .expect("a subscribed plugin gets a forwarder on spawn");
2077
2078 use par_term_emu_core_rust::observer::TerminalObserver;
2081 use par_term_emu_core_rust::terminal::{BellEvent, TerminalEvent};
2082 forwarder.on_event(&TerminalEvent::BellRang(BellEvent::VisualBell));
2083 forwarder.on_event(&TerminalEvent::TitleChanged("nope".to_string()));
2084 let drained = forwarder.drain_events();
2085 assert_eq!(drained.len(), 1, "unsubscribed kinds must not buffer");
2086 assert_eq!(drained[0].kind, "bell_rang");
2087 host.stop_all();
2088 }
2089
2090 #[test]
2091 fn unsubscribed_plugin_gets_no_forwarder() {
2092 if skip_without_interpreter() {
2093 return;
2094 }
2095 let root = tempfile::tempdir().unwrap();
2098 write_plugin(root.path(), "com.test.plain", WIDGET_SCRIPT);
2099
2100 let mut host = PluginHost::new();
2101 host.refresh_discovery(root.path());
2102 host.apply_enabled(&[enabled("com.test.plain")]);
2103 assert!(wait_until(&mut host, |h| !h.running.is_empty()));
2104 assert!(
2105 host.subscription_forwarders().is_empty(),
2106 "no subscriptions declared — no forwarder may exist"
2107 );
2108 host.stop_all();
2109 }
2110
2111 #[test]
2112 fn teardown_drops_the_forwarder_when_the_plugin_leaves_the_enabled_set() {
2113 if skip_without_interpreter() {
2114 return;
2115 }
2116 let root = tempfile::tempdir().unwrap();
2117 write_plugin_files(
2118 root.path(),
2119 "com.test.leave",
2120 &subscribed_manifest_json("com.test.leave", r#""bell_rang""#),
2121 &[("widget.py", WIDGET_SCRIPT)],
2122 );
2123
2124 let mut host = PluginHost::new();
2125 host.refresh_discovery(root.path());
2126 host.apply_enabled(&[enabled("com.test.leave")]);
2127 assert!(
2128 host.subscription_forwarders()
2129 .contains_key("com.test.leave")
2130 );
2131
2132 host.apply_enabled(&[]);
2133 assert!(
2134 host.subscription_forwarders().is_empty(),
2135 "teardown must drop the forwarder so its observer is unregistered"
2136 );
2137 }
2138
2139 #[test]
2140 fn delivered_events_reach_the_running_process() {
2141 if skip_without_interpreter() {
2142 return;
2143 }
2144 let root = tempfile::tempdir().unwrap();
2145 write_plugin_files(
2146 root.path(),
2147 "com.test.echo",
2148 &subscribed_manifest_json("com.test.echo", r#""bell_rang""#),
2149 &[("widget.py", &echo_script("event"))],
2150 );
2151
2152 let mut host = PluginHost::new();
2153 host.refresh_discovery(root.path());
2154 host.apply_enabled(&[enabled("com.test.echo")]);
2155
2156 let forwarder = host
2159 .subscription_forwarders()
2160 .get("com.test.echo")
2161 .cloned()
2162 .expect("forwarder exists for a subscribed plugin");
2163 use par_term_emu_core_rust::observer::TerminalObserver;
2164 use par_term_emu_core_rust::terminal::{BellEvent, TerminalEvent};
2165 forwarder.on_event(&TerminalEvent::BellRang(BellEvent::VisualBell));
2166 let events = forwarder.drain_events();
2167 assert_eq!(events.len(), 1);
2168 host.deliver_events("com.test.echo", &events);
2169
2170 let landed = wait_until(&mut host, |h| {
2174 h.widget_text("com.test.echo") == Some("event:bell_rang")
2175 });
2176 assert!(
2177 landed,
2178 "the bell event never reached the plugin's stdin; widget text: {:?}",
2179 host.widget_text("com.test.echo")
2180 );
2181 host.stop_all();
2182 }
2183
2184 #[test]
2185 fn sync_theme_greets_a_theme_subscribed_plugin_with_tokens() {
2186 if skip_without_interpreter() {
2187 return;
2188 }
2189 let root = tempfile::tempdir().unwrap();
2190 write_plugin_files(
2191 root.path(),
2192 "com.test.theme",
2193 &subscribed_manifest_json("com.test.theme", r#""theme_changed""#),
2194 &[("widget.py", THEME_ECHO_SCRIPT)],
2195 );
2196
2197 let mut host = PluginHost::new();
2198 host.refresh_discovery(root.path());
2199 host.apply_enabled(&[enabled("com.test.theme")]);
2200
2201 host.sync_theme(&Theme::dracula());
2202 let landed = wait_until(&mut host, |h| {
2203 h.widget_text("com.test.theme")
2204 .is_some_and(|t| t.starts_with("t:Dracula|#282a36|1|"))
2205 });
2206 assert!(
2207 landed,
2208 "the theme_changed greet never reached the plugin; widget text: {:?}",
2209 host.widget_text("com.test.theme")
2210 );
2211 host.stop_all();
2212 }
2213
2214 #[test]
2215 fn sync_theme_dedups_by_name_and_redelivers_on_change() {
2216 if skip_without_interpreter() {
2217 return;
2218 }
2219 let root = tempfile::tempdir().unwrap();
2220 write_plugin_files(
2221 root.path(),
2222 "com.test.dedup",
2223 &subscribed_manifest_json("com.test.dedup", r#""theme_changed""#),
2224 &[("widget.py", THEME_ECHO_SCRIPT)],
2225 );
2226
2227 let mut host = PluginHost::new();
2228 host.refresh_discovery(root.path());
2229 host.apply_enabled(&[enabled("com.test.dedup")]);
2230
2231 host.sync_theme(&Theme::dracula());
2232 assert!(wait_until(&mut host, |h| h
2233 .widget_text("com.test.dedup")
2234 .is_some_and(|t| t.starts_with("t:Dracula|#282a36|1|"))));
2235
2236 host.sync_theme(&Theme::dracula());
2241 host.sync_theme(&Theme::solarized_dark());
2242 let landed = wait_until(&mut host, |h| {
2243 h.widget_text("com.test.dedup")
2244 .is_some_and(|t| t.starts_with("t:Solarized Dark|#002b36|2|"))
2245 });
2246 assert!(
2247 landed,
2248 "the theme change never reached the plugin (or the same-name sync double-delivered); widget text: {:?}",
2249 host.widget_text("com.test.dedup")
2250 );
2251 host.stop_all();
2252 }
2253
2254 #[test]
2255 fn sync_theme_ignores_a_plugin_subscribed_to_other_kinds() {
2256 if skip_without_interpreter() {
2257 return;
2258 }
2259 let root = tempfile::tempdir().unwrap();
2260 write_plugin_files(
2261 root.path(),
2262 "com.test.other",
2263 &subscribed_manifest_json("com.test.other", r#""bell_rang""#),
2264 &[("widget.py", THEME_ECHO_SCRIPT)],
2265 );
2266
2267 let mut host = PluginHost::new();
2268 host.refresh_discovery(root.path());
2269 host.apply_enabled(&[enabled("com.test.other")]);
2270
2271 host.sync_theme(&Theme::dracula());
2274 settle(&mut host, 800);
2275 assert!(
2276 host.widget_text("com.test.other").is_none(),
2277 "theme_changed delivered to a bell_rang-only plugin; widget text: {:?}",
2278 host.widget_text("com.test.other")
2279 );
2280 host.stop_all();
2281 }
2282
2283 #[test]
2284 fn sync_theme_regreets_a_supervised_respawn_with_the_same_theme() {
2285 if skip_without_interpreter() {
2286 return;
2287 }
2288 let root = tempfile::tempdir().unwrap();
2289 write_plugin_files(
2290 root.path(),
2291 "com.test.regreet",
2292 &subscribed_manifest_json("com.test.regreet", r#""theme_changed""#),
2293 &[("widget.py", THEME_CRASH_AFTER_GREET_SCRIPT)],
2294 );
2295
2296 let mut host = PluginHost::new();
2297 host.refresh_discovery(root.path());
2298 host.apply_enabled(&[enabled("com.test.regreet")]);
2299
2300 host.sync_theme(&Theme::dracula());
2301 let first = {
2302 let landed = wait_until(&mut host, |h| {
2303 h.widget_text("com.test.regreet")
2304 .is_some_and(|t| t.starts_with("t:Dracula|#282a36|1|"))
2305 });
2306 assert!(
2307 landed,
2308 "the initial greet never landed; widget text: {:?}",
2309 host.widget_text("com.test.regreet")
2310 );
2311 host.widget_text("com.test.regreet").unwrap().to_string()
2312 };
2313
2314 let mut regreeted = false;
2322 for _ in 0..40 {
2323 host.poll();
2324 host.sync_theme(&Theme::dracula());
2325 if host
2326 .widget_text("com.test.regreet")
2327 .is_some_and(|t| t != first)
2328 {
2329 regreeted = true;
2330 break;
2331 }
2332 thread::sleep(Duration::from_millis(100));
2333 }
2334 assert!(
2335 regreeted,
2336 "the respawned process never received its re-greet; widget text: {:?}",
2337 host.widget_text("com.test.regreet")
2338 );
2339 host.stop_all();
2340 }
2341
2342 const WIDGET_ECHO_DELAYED_SCRIPT: &str = r#"
2347import json, sys, time
2348def emit(text):
2349 print(json.dumps({"type": "SetWidget", "text": text}), flush=True)
2350for line in iter(sys.stdin.readline, ""):
2351 line = line.strip()
2352 if not line:
2353 continue
2354 try:
2355 event = json.loads(line)
2356 except ValueError:
2357 continue
2358 time.sleep(0.3)
2359 emit("w:" + str(event.get("kind", "?")))
2360"#;
2361
2362 const THEME_ECHO_SCRIPT: &str = r#"
2368import json, sys, os
2369def emit(text):
2370 print(json.dumps({"type": "SetWidget", "text": text}), flush=True)
2371n = 0
2372for line in iter(sys.stdin.readline, ""):
2373 line = line.strip()
2374 if not line:
2375 continue
2376 try:
2377 event = json.loads(line)
2378 except ValueError:
2379 continue
2380 if event.get("kind") != "theme_changed":
2381 continue
2382 n += 1
2383 data = event.get("data", {})
2384 tokens = data.get("tokens", {})
2385 emit("t:%s|%s|%d|%d" % (data.get("theme", "?"), tokens.get("background", "?"), n, os.getpid()))
2386"#;
2387
2388 const THEME_CRASH_AFTER_GREET_SCRIPT: &str = r#"
2391import json, sys, os
2392def emit(text):
2393 print(json.dumps({"type": "SetWidget", "text": text}), flush=True)
2394n = 0
2395for line in iter(sys.stdin.readline, ""):
2396 line = line.strip()
2397 if not line:
2398 continue
2399 try:
2400 event = json.loads(line)
2401 except ValueError:
2402 continue
2403 if event.get("kind") != "theme_changed":
2404 continue
2405 n += 1
2406 data = event.get("data", {})
2407 tokens = data.get("tokens", {})
2408 emit("t:%s|%s|%d|%d" % (data.get("theme", "?"), tokens.get("background", "?"), n, os.getpid()))
2409 sys.exit(1)
2410"#;
2411
2412 fn settle(host: &mut PluginHost, ms: u64) {
2415 let deadline = Instant::now() + Duration::from_millis(ms);
2416 while Instant::now() < deadline {
2417 host.poll();
2418 thread::sleep(Duration::from_millis(25));
2419 }
2420 }
2421
2422 #[test]
2423 fn both_kinds_processes_receive_subscribed_events() {
2424 if skip_without_interpreter() {
2425 return;
2426 }
2427 let root = tempfile::tempdir().unwrap();
2428 write_plugin_files(
2429 root.path(),
2430 "com.test.both-echo",
2431 &subscribed_both_kinds_manifest_json("com.test.both-echo", r#""bell_rang""#),
2432 &[
2433 ("widget.py", WIDGET_ECHO_DELAYED_SCRIPT),
2434 ("actions.py", &echo_script("a")),
2435 ],
2436 );
2437
2438 let mut host = PluginHost::new();
2439 host.refresh_discovery(root.path());
2440 host.apply_enabled(&[enabled("com.test.both-echo")]);
2441 assert!(wait_until(&mut host, |h| h.running.len() == 1
2442 && h.action_running.len() == 1));
2443
2444 let forwarder = host
2445 .subscription_forwarders()
2446 .get("com.test.both-echo")
2447 .cloned()
2448 .expect("forwarder exists for a subscribed plugin");
2449 use par_term_emu_core_rust::observer::TerminalObserver;
2450 use par_term_emu_core_rust::terminal::{BellEvent, TerminalEvent};
2451 forwarder.on_event(&TerminalEvent::BellRang(BellEvent::VisualBell));
2452 let events = forwarder.drain_events();
2453 host.deliver_events("com.test.both-echo", &events);
2454
2455 let mut seen_widget = false;
2459 let mut seen_action = false;
2460 let both_seen = wait_until(&mut host, |h| {
2461 if h.widget_text("com.test.both-echo") == Some("w:bell_rang") {
2462 seen_widget = true;
2463 }
2464 if h.widget_text("com.test.both-echo") == Some("a:bell_rang") {
2465 seen_action = true;
2466 }
2467 seen_widget && seen_action
2468 });
2469 assert!(
2470 both_seen,
2471 "both kind processes must receive the event (w seen: {seen_widget}, a seen: {seen_action})"
2472 );
2473 host.stop_all();
2474 }
2475
2476 #[test]
2477 fn deliver_events_is_silent_with_no_running_process() {
2478 install_counting_logger();
2481 let mut host = PluginHost::new();
2482 let event = ScriptEvent {
2483 kind: "bell_rang".to_string(),
2484 data: ScriptEventData::Empty {},
2485 };
2486 host.deliver_events("com.test.nobody", std::slice::from_ref(&event));
2487 assert_eq!(warns_containing("com.test.nobody"), 0);
2488 }
2489}