Skip to main content

par_term_scripting/plugin_manager/
mod.rs

1//! Window-scoped plugin host over the script runtime.
2//!
3//! [`PluginHost`] owns its own [`ScriptManager`] instance — the same registry
4//! type the per-tab script system uses, kept entirely separate so a plugin
5//! can never be confused with a tab script. Discovery, per-kind process
6//! lifecycle (one supervised process per declared kind, design D5), restart
7//! supervision (manifest-declared mode — `on_failure` default, `never`,
8//! `always` — under the host-owned delay and crash-loop cap), action
9//! invocation, and the `SetWidget` text map all live here.
10//!
11//! Land-disabled is structural (design D3): [`PluginHost::apply_enabled`]
12//! receives only the enabled set, and discovery never spawns anything by
13//! itself — a newly dropped-in plugin cannot run until the Settings layer
14//! says so.
15
16use 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
38/// Restart delay for a plugin process, host-owned alongside the crash-loop
39/// cap (the manifest declares the restart *mode* only — parsight decision
40/// 95: a manifest must not be able to tune its way out of the cap).
41const PLUGIN_RESTART_DELAY_MS: u64 = 250;
42
43/// Settings argv marker passed to every plugin entry point (design D2).
44const SETTINGS_ARG: &str = "--par-term-settings";
45
46/// One plugin the host should be running (Task 4 maps config state to this).
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct EnabledPlugin {
49    /// Manifest id; must match a discovered plugin.
50    pub id: String,
51    /// Settings values serialized as one JSON object string, validated
52    /// against the manifest schema by the caller before landing here.
53    pub settings_json: String,
54}
55
56/// One command-palette entry contributed by an enabled action plugin.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct PluginActionRow {
59    /// Palette action id, `plugin-action:<plugin-id>:<action-id>` (design D1).
60    pub wire_id: String,
61    /// Palette label: the manifest action label, then `" · "`, then the
62    /// manifest plugin name (design D2).
63    pub label: String,
64}
65
66/// Extra argv the widget kind's entry point declares before the settings.
67fn 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
76/// Extra argv the action kind's entry point declares before the settings.
77fn 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
86/// Extra argv the panel kind's entry point declares before the settings.
87fn 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
96/// Extra argv the overlay kind's entry point declares before the settings.
97fn 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/// One declared plugin kind's supervision slot. Every per-kind map pair is
107/// selected through this so the three kinds cannot drift apart — the same
108/// role the `action: bool` toggle played for two kinds.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110enum KindSlot {
111    /// `status-bar-widget` — owns the shared `SetWidget` text map.
112    Widget,
113    /// `action-contributor` — palette actions; also writes `SetWidget`.
114    Action,
115    /// `panel` — owns the `SetPanel`/`ClearPanel` content map.
116    Panel,
117    /// `overlay` — owns the `SetOverlay`/`ClearOverlay` map.
118    Overlay,
119}
120
121impl KindSlot {
122    /// Kind name used in log lines.
123    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/// Deduplicates a repeating fault warning to once per condition episode.
134///
135/// [`PluginHost::apply_enabled`] runs every render frame, so an ungated
136/// steady-state fault (an enabled plugin that is not discovered) emits the
137/// same warn ~60×/s — measured at ~480 identical warns in one 8 s run.
138/// `should_warn` returns `true` only on the first call per key; `clear`
139/// re-arms the key when the condition resolves (successful spawn, teardown)
140/// so a recurrence warns again.
141#[derive(Debug, Default)]
142pub struct WarnOnce {
143    warned: HashSet<String>,
144}
145
146impl WarnOnce {
147    /// Whether the caller should emit the warn: `true` the first time `key`
148    /// is seen, `false` while the same key persists.
149    pub fn should_warn(&mut self, key: &str) -> bool {
150        self.warned.insert(key.to_string())
151    }
152
153    /// Re-arm `key` so a later recurrence warns again.
154    pub fn clear(&mut self, key: &str) {
155        self.warned.remove(key);
156    }
157
158    /// Re-arm every key except those in `keep` — call when the monitored
159    /// set shrinks, so an entry that left and later returns warns again.
160    pub fn clear_except(&mut self, keep: &HashSet<String>) {
161        self.warned.retain(|key| keep.contains(key));
162    }
163}
164
165/// Window-scoped plugin host: discovery cache, running processes, restart
166/// supervision, and the last `SetWidget` text per plugin.
167#[derive(Default)]
168pub struct PluginHost {
169    /// Last discovery scan's valid plugins.
170    discovered: Vec<DiscoveredPlugin>,
171    /// Running (or supervised) widget-kind plugin id → process id. An exited
172    /// process stays mapped while its restart is pending so the supervisor
173    /// keeps being polled; teardown is the only removal path.
174    running: HashMap<String, ScriptId>,
175    /// Running (or supervised) action-kind plugin id → process id — one
176    /// supervised process per (plugin, kind), so a both-kinds manifest runs
177    /// both entry points independently (design D5).
178    action_running: HashMap<String, ScriptId>,
179    /// Running (or supervised) panel-kind plugin id → process id.
180    panel_running: HashMap<String, ScriptId>,
181    /// Running (or supervised) overlay-kind plugin id → process id.
182    overlay_running: HashMap<String, ScriptId>,
183    /// The host's own script registry; never shared with tab scripts.
184    manager: ScriptManager,
185    /// Last `SetWidget` text per plugin id (last write wins). Either kind's
186    /// process writes through the same shared key; stopping the action kind
187    /// leaves its last text latched until the widget kind stops or teardown
188    /// clears it (documented asymmetry, consistent with the shared-key
189    /// contract).
190    widget_texts: HashMap<String, String>,
191    /// Last `SetPanel` (title, content) per plugin id (last write wins).
192    /// Panel-kind processes only; a `ClearPanel` (or teardown — no orphaned
193    /// surface) removes the key.
194    panel_contents: HashMap<String, (String, String)>,
195    /// Live overlays per plugin id (overlay kind, one per plugin by design):
196    /// the last upsert scene per overlay id. `ClearOverlay` (or teardown)
197    /// removes the key — a stopped plugin leaves no orphaned surface.
198    overlays: HashMap<String, crate::protocol::PluginOverlay>,
199    /// The one focused overlay's plugin id, if any (design: at most one
200    /// overlay is focused at a time). Focus never lands automatically —
201    /// only a click on the overlay's widgets or an explicit host API sets
202    /// it (O4) — and it drops when the overlay is cleared, replaced by a
203    /// display-only upsert, or its plugin stops.
204    focused_overlay: Option<String>,
205    /// SetOverlay upsert timestamps per plugin id, for the update-rate
206    /// clamp (design: ~30/sec with a warning; a runaway plugin must not
207    /// spin the renderer).
208    overlay_update_times: HashMap<String, Vec<std::time::Instant>>,
209    /// Successful [`Self::invoke_action`] stdin writes this session — the
210    /// `plugin_action_dispatched` ui-test operand's source. Monotonic for
211    /// the session; never reset.
212    actions_dispatched: u64,
213    /// Successful [`Self::send_overlay_event`] stdin writes this session —
214    /// the `plugin_overlay_event` ui-test operand's source. Monotonic.
215    overlay_events_dispatched: u64,
216    /// Per-plugin widget-kind restart supervisor (pin P3).
217    restart: HashMap<String, ScriptRestartState>,
218    /// Per-plugin action-kind restart supervisor, driven identically to
219    /// [`Self::restart`] but independently per kind.
220    action_restart: HashMap<String, ScriptRestartState>,
221    /// Per-plugin panel-kind restart supervisor, driven identically to the
222    /// other kinds.
223    panel_restart: HashMap<String, ScriptRestartState>,
224    /// Per-plugin overlay-kind restart supervisor, driven identically to the
225    /// other kinds.
226    overlay_restart: HashMap<String, ScriptRestartState>,
227    /// Settings argv each running plugin was spawned with, kept so a
228    /// supervisor restart re-spawns with the same settings. Shared by both
229    /// kinds of one plugin (same settings argv, design D2).
230    settings_json: HashMap<String, String>,
231    /// Enabled plugin ids retained from the last [`Self::apply_enabled`]
232    /// call, so the palette can enumerate enabled plugins' actions without
233    /// a config read.
234    enabled_ids: HashSet<String>,
235    /// Commands refused because v1 plugins may only send `SetWidget`.
236    ignored_lines: Vec<String>,
237    /// Per-plugin event forwarders for plugins whose manifest declares
238    /// subscriptions. Created on the plugin's first successful spawn, dropped
239    /// on teardown — the window layer registers each forwarder as an observer
240    /// on the window's tab terminals, drains it per sweep, and delivers via
241    /// [`Self::deliver_events`] (the same forwarder machinery per-tab scripts
242    /// use; per-plugin filtering at the forwarder level is what keeps a
243    /// subscription reliable under event floods).
244    subscription_forwarders: HashMap<String, Arc<ScriptEventForwarder>>,
245    /// Gate for event-delivery write failures (a process exiting
246    /// mid-delivery) — the sweep retries delivery every wake, so a dead
247    /// process must not warn per frame.
248    warned_event_delivery: WarnOnce,
249    /// Per-fault warn gates: steady faults warn once per episode, not per
250    /// frame (see [`WarnOnce`]).
251    warned_not_discovered: WarnOnce,
252    warned_spawn_failed: WarnOnce,
253    /// Gate for invoke misses on the action kind (not running, kind absent,
254    /// unknown action, failed write) — a stuck keybinding can retry a
255    /// dispatch at frame rate.
256    warned_action_not_running: WarnOnce,
257    /// Supervisor give-up events (crash-loop past the restart cap) awaiting
258    /// the frontend's per-frame drain, so the window layer can surface them
259    /// as crash-triage offers.
260    crash_caps: Vec<PluginCrashCap>,
261    /// Theme name last delivered per plugin subscribed to
262    /// [`THEME_CHANGED_KIND`] — the same-name dedup for
263    /// [`Self::sync_theme`]. Cleared when the plugin fully stops, so a
264    /// disable/enable cycle re-greets.
265    theme_names: HashMap<String, String>,
266    /// Plugins (re)spawned since the last [`Self::sync_theme`] — armed by
267    /// every successful spawn and respawn (supervision), consumed by the
268    /// next sync. A fresh process has never seen a theme, so it is greeted
269    /// with the current one even when the theme *name* is unchanged; the
270    /// remembered name alone would skip delivery.
271    theme_greet_due: HashSet<String>,
272}
273
274/// A plugin whose supervisor gave up: the entry crash-looped past
275/// [`crate::restart::MAX_RESTART_ATTEMPTS`] inside the grace window (or the
276/// initial-spawn attempts did). Surfaced by the frontend as a crash-triage
277/// offer — same consent surface as a crashed pane.
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub struct PluginCrashCap {
280    /// The manifest id of the plugin that crash-looped.
281    pub plugin_id: String,
282    /// Which kind slot crash-looped (widget / action / panel / overlay).
283    pub kind: &'static str,
284    /// The supervised entry executable, display-formatted.
285    pub entry: String,
286    /// The manifest's restart policy for that entry.
287    pub restart_mode: String,
288    /// Recent stderr lines, when the process produced any before dying.
289    pub stderr_tail: Vec<String>,
290}
291
292impl PluginHost {
293    /// Create an empty host. Nothing runs until
294    /// [`refresh_discovery`](Self::refresh_discovery) +
295    /// [`apply_enabled`](Self::apply_enabled) say otherwise.
296    pub fn new() -> Self {
297        Self::default()
298    }
299
300    /// Re-scan a plugins root, replacing the discovery cache. Running
301    /// plugins are preserved — a re-scan never stops or spawns anything.
302    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    /// The discovery-cache entry for a plugin id, if the last scan found it.
316    pub fn discovered(&self, id: &str) -> Option<&DiscoveredPlugin> {
317        self.discovered.iter().find(|d| d.manifest.id == id)
318    }
319
320    /// Number of plugins the last discovery scan accepted.
321    pub fn discovered_count(&self) -> usize {
322        self.discovered.len()
323    }
324
325    /// Reconcile running plugins with the enabled set (diff → spawn/stop).
326    ///
327    /// Land-disabled is structural: only ids present in `enabled` AND in the
328    /// discovery cache are spawned, so dropping a plugin directory into the
329    /// root does nothing until the Settings layer enables it. A multi-kind
330    /// manifest spawns every declared kind's entry in this one pass, with
331    /// the same settings argv and one supervision slot per kind (design D5).
332    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        // A plugin leaving the enabled set ends its fault episodes, so a
349        // disable/enable cycle of a still-missing plugin warns again.
350        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            // Extract every declared kind's spawn descriptor up front so the
381            // discovery borrow ends before the supervisor/spawn calls below.
382            // The action and panel kinds route through their own
383            // confinement-checked entries — never `entry_path`, which belongs
384            // to the widget kind.
385            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            // Plugin-level, kind-independent: whichever kind spawns first
403            // creates the shared event forwarder.
404            let subscriptions = found.manifest.subscriptions.clone();
405            // Restart mode is plugin-level too (every kind slot supervises
406            // under the same manifest policy; the delay and cap stay
407            // host-owned).
408            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    /// Advance supervision one frame: drain each running plugin process's
450    /// commands (every kind), observe exits, and drive each kind's restart
451    /// supervisor (manifest-declared mode, capped) — one frame advance
452    /// covers the widget, action, and panel maps identically.
453    ///
454    /// Commands are drained before exit handling so a plugin's final
455    /// output before a crash still lands.
456    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    /// Last `SetWidget` text recorded for a plugin, if any.
477    pub fn widget_text(&self, plugin_id: &str) -> Option<&str> {
478        self.widget_texts.get(plugin_id).map(String::as_str)
479    }
480
481    /// Last `SetPanel` (title, content) recorded for a panel plugin, if any.
482    pub fn panel_content(&self, plugin_id: &str) -> Option<&(String, String)> {
483        self.panel_contents.get(plugin_id)
484    }
485
486    /// Live per-plugin event forwarders (plugin id → forwarder), for the
487    /// window layer's observer registration and per-sweep drain — the pump
488    /// in `StatusBarUI::pump_plugin_events`.
489    pub fn subscription_forwarders(&self) -> &HashMap<String, Arc<ScriptEventForwarder>> {
490        &self.subscription_forwarders
491    }
492
493    /// Deliver drained events to one plugin's running processes (every kind
494    /// alike — the manifest's subscriptions are plugin-level, so every
495    /// running process of the plugin hears them).
496    ///
497    /// A plugin with no running process this sweep silently drops the events:
498    /// they are moments in time, not state, and its supervisor restarts it
499    /// for the next ones. Write failures (a process exiting mid-delivery)
500    /// warn once per episode.
501    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    /// Deliver the active theme to every running plugin subscribed to
535    /// [`THEME_CHANGED_KIND`]: the greet after each (re)spawn and every
536    /// change thereafter, deduped by theme name.
537    ///
538    /// App-sourced — nothing here reads a terminal. The status bar calls
539    /// this per frame with the config-resolved theme, which catches every
540    /// theme-application site (system dark/light switch, settings apply,
541    /// config reload) without editing any of them. A same-name sync
542    /// delivers nothing unless the plugin was (re)spawned since (the
543    /// greet-due flag), so a respawned process that has never seen a theme
544    /// still gets one.
545    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    /// All recorded widget texts (plugin id → text), for the status bar.
588    pub fn widget_texts(&self) -> &HashMap<String, String> {
589        &self.widget_texts
590    }
591
592    /// All recorded panel contents (plugin id → (title, content)), for the
593    /// settings plugins section.
594    pub fn panel_contents(&self) -> &HashMap<String, (String, String)> {
595        &self.panel_contents
596    }
597
598    /// Successful [`Self::invoke_action`] stdin writes this session.
599    /// Monotonic for the session and never reset — delivered-means-true
600    /// dispatch has no undo, so the count only grows.
601    pub fn actions_dispatched_count(&self) -> u64 {
602        self.actions_dispatched
603    }
604
605    /// Ids of plugins with any running or supervised process, of any
606    /// kind. A multi-kind plugin appears once.
607    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    /// Invoke one of a plugin's contributed palette actions: write a single
618    /// [`ScriptEvent`] line carrying [`ScriptEventData::PluginActionInvoked`]
619    /// to the plugin's running action process.
620    ///
621    /// Returns `true` only when the plugin is discovered, declares the
622    /// action-contributor kind, the action id is in its manifest, a process
623    /// is running, and the stdin write succeeded. Every miss returns `false`
624    /// behind [`WarnOnce`] gates (`warned_not_discovered`, and
625    /// `warned_action_not_running` for every post-discovery miss) — a stuck
626    /// keybinding can retry this at frame rate.
627    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                // A landed invoke starts a fresh miss episode.
683                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    /// Palette rows for every action of every discovered, enabled,
702    /// action-contributor plugin (design D2), in discovery order.
703    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    /// Take the lines accumulated from refused plugin commands (the
728    /// read-errors-style surface for commands v1 ignores).
729    pub fn drain_ignored(&mut self) -> Vec<String> {
730        std::mem::take(&mut self.ignored_lines)
731    }
732
733    /// Drain supervisor give-up events (see [`PluginCrashCap`]). Called once
734    /// per frame by the window layer.
735    pub fn drain_crash_caps(&mut self) -> Vec<PluginCrashCap> {
736        std::mem::take(&mut self.crash_caps)
737    }
738
739    /// Stop every plugin and drop all supervision state.
740    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    /// A plugin that emits two `SetWidget` lines and then stays alive.
771    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    /// A plugin that emits a `Log` command (refused in v1) and stays alive.
782    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    /// A plugin that exits immediately with a failure code.
790    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    /// Write one valid plugin directory under `root`; executable on unix so
803    /// discovery's permissions check accepts the entry.
804    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    /// Drive `poll` until `check` holds or ~10 s elapse (the fixture is a
829    /// real subprocess; its first output line can take a moment to arrive).
830    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(); // must not panic on an empty host
858    }
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        // Nothing is drained until poll runs, so no widget text yet.
872        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        // The fixture emits "first" then "second"; both lines are typically
877        // drained by one poll, so the surviving text proves last-write-wins.
878        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        // A disabled plugin must not be resurrected by the supervisor.
901        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        // 5 supervised restarts at a 250 ms delay, plus spawn overhead —
921        // generous wall-clock budget, hard deadline so a bug fails fast.
922        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        // The give-up surfaces as a crash-cap event for the frontend's
931        // triage surface, naming the plugin, kind, and entry.
932        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        // Polling a given-up slot stays a no-op — and re-drains nothing.
942        host.poll();
943        assert!(host.running_plugin_ids().is_empty());
944        assert!(host.drain_crash_caps().is_empty());
945    }
946
947    /// A plugin that runs past the restart grace window, then whose entry is
948    /// made unspawnable (exec bit removed) before its crash: the respawn
949    /// attempts must count toward the cap and give up. Pre-fix, the Restart
950    /// branch discarded reschedule()'s Stop and the grace-window reset kept
951    /// zeroing the counter, so the supervisor retried forever and never
952    /// surfaced a crash-cap.
953    #[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        // Sleeps past RESTART_GRACE (5 s) so the exit cannot reset the
963        // failure counter, then dies like a crash.
964        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        // Break the entry while the process still runs: every respawn from
980        // here fails to spawn (EACCES) — the path whose Stop was dropped.
981        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        // 6 s grace sleep + 5 rounds × 250 ms delay + spawn overhead. Hard
988        // deadline so a regression fails fast instead of hanging the suite.
989        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    /// A one-shot plugin: emits one widget line and exits 0.
1013    const ONE_SHOT_SCRIPT: &str = r#"
1014import json
1015print(json.dumps({"type": "SetWidget", "text": "once"}), flush=True)
1016"#;
1017
1018    /// A widget manifest declaring a restart `mode` (never / always).
1019    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        // The crash exits once and `never` stops it there — no capped round
1045        // of retries, no supervision state left behind.
1046        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        // A policy stop is not a crash — no triage offer for it.
1053        assert!(
1054            host.drain_crash_caps().is_empty(),
1055            "never-policy stop must not surface a crash-cap"
1056        );
1057
1058        // Well past the 250 ms restart delay: no respawn may appear.
1059        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        // Under `always` even a clean (exit 0) one-shot is restarted: the
1085        // first exit schedules attempt 1, the respawn's exit attempt 2, so
1086        // two consecutive failures prove at least one clean-exit restart
1087        // fired (on_failure, today's behaviour, would have stopped at the
1088        // first exit).
1089        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        // An executable non-.py entry with a broken interpreter line:
1135        // discovery accepts it, direct exec fails — a persistently
1136        // failing spawn. (.py entries bypass the shebang via the
1137        // interpreter route in `spawn_command`, so the entry must not be
1138        // python.)
1139        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        // First reconcile: one spawn attempt, it fails, the supervisor's
1162        // backoff arms.
1163        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        // Reconcile again immediately (well inside the 250 ms delay): no
1173        // re-attempt — a second exec would have pushed the failure count.
1174        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        // Once the delay has elapsed: exactly one retry.
1185        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(); // no panic after a full drain
1215    }
1216
1217    /// Records every `log::warn!` this test binary emits so tests can assert
1218    /// on warn *frequency*, not just occurrence. Installed once per process;
1219    /// this crate's tests never install a file logger, so capturing here
1220    /// changes nothing for the other tests.
1221    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            // An error here would mean another logger is already installed;
1245            // capture works regardless.
1246            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    // Both dedup tests run in parallel against the shared warn capture, so
1261    // each filters on its own plugin id, not the shared phrase.
1262    #[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        // An empty root: the enabled plugin is never discovered.
1268        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        // Disabling re-arms the warn: re-enabling the same fault warns again
1281        // rather than staying silent forever.
1282        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        // Fault: enabled but not discovered.
1305        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        // Recovery: the plugin appears and spawns, which re-arms the warn.
1311        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        // The plugin is disabled, disappears, and is enabled again: the same
1318        // fault must warn a second time.
1319        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    /// An action entry that echoes every stdin event back as a `SetWidget`
1330    /// naming the invoked action — the observable seam for invoke writes
1331    /// (same pattern as `WIDGET_SCRIPT` for spawned plugin stdout).
1332    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    /// A panel plugin that emits a `SetWidget` line (refused for the panel
1377    /// kind) and then a `SetPanel`, then stays alive.
1378    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    /// A panel plugin that pushes a panel and then clears it.
1389    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    /// Write one plugin directory with a manifest plus named entry files,
1401    /// executable on unix so discovery's permissions check accepts them.
1402    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        // The panel kind's allowlist refused the fixture's SetWidget line
1448        // with the panel-specific message.
1449        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        // Leaving the enabled set tears the plugin down: process stopped AND
1489        // the pushed panel dropped — no orphaned surface.
1490        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    /// An overlay plugin that emits a `SetWidget` line (refused for the
1505    /// overlay kind) and then a `SetOverlay`, then stays alive.
1506    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    /// An overlay plugin that pushes an overlay and then clears it.
1519    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            // Both lines are typically drained by one poll; waiting on the
1554            // overlay alone could assert the refusal before it lands, so
1555            // wait until the SetWidget refusal is recorded too.
1556            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        // The overlay kind's allowlist refused the fixture's SetWidget line
1576        // with the overlay-specific message (drained inside the wait; the
1577        // remaining lines here are just belt-and-braces).
1578        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        // Leaving the enabled set tears the plugin down: process stopped AND
1617        // the pushed overlay dropped — no orphaned surface.
1618        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        // Kind purity: the fixture's SetOverlay is refused with an
1636        // error-style line, and no overlay ever exists.
1637        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    /// An overlay plugin that requests interactivity and then idles. The
1661    /// capability comes from the manifest the test pairs it with.
1662    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    /// An overlay plugin that pushes an interactive overlay and then echoes
1674    /// every stdin event back by re-pushing the overlay with the event named
1675    /// in its text — the observable seam for overlay-event writes (the
1676    /// overlay kind cannot send SetWidget, so the scene text is the echo).
1677    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        // No capability block: the request is forced off at ingest.
1704        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        // Focus on a display-only overlay never lands.
1715        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        // Click-to-focus lands; a second focus call re-points (still the
1744        // only focused overlay — at most one by construction, but assert).
1745        host.focus_overlay("com.test.cap");
1746        assert_eq!(host.focused_overlay(), Some("com.test.cap"));
1747
1748        // Escape path.
1749        host.unfocus_overlay();
1750        assert_eq!(host.focused_overlay(), None);
1751
1752        // Disable clears overlay AND focus — no orphaned focus.
1753        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        // Wait for the overlay to be live (the renderer's precondition for
1777        // sending events) and the process up.
1778        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        // The echo fixture re-pushes its overlay with the event named in
1794        // the scene text — proof the process parsed the event.
1795        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        // Pure host-side test: no interpreter needed — drive the clamp
1811        // bookkeeping directly through overlay_update_permitted.
1812        let mut host = PluginHost::new();
1813        let t0 = Instant::now();
1814        // 30 land inside the first sliding second…
1815        for _ in 0..30 {
1816            assert!(host.overlay_update_permitted("p", t0));
1817        }
1818        // …and the 31st is refused while the window is saturated.
1819        assert!(
1820            !host.overlay_update_permitted("p", t0),
1821            "clamp must refuse past the budget"
1822        );
1823        // After the window slides fully past every recorded timestamp,
1824        // upserts are permitted again.
1825        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(&[]); // nothing enabled, so nothing spawns
1838
1839        // Unknown plugin: not discovered.
1840        assert!(!host.invoke_action("com.test.nope", "greet"));
1841        // Discovered but disabled: the action process is not running.
1842        assert!(!host.invoke_action("com.test.only-action", "say-hello"));
1843        // Discovered, but the action id is not in the manifest.
1844        assert!(!host.invoke_action("com.test.only-action", "bogus"));
1845        // Discovered widget-only plugin: the action kind is absent.
1846        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        // A stuck keybinding can retry dispatch at frame rate; the warn must
1863        // fire once per episode, not per call.
1864        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        // A discovered+enabled plugin whose action entry never spawned holds a
1878        // set action warn gate; disabling must prune it (apply_enabled's
1879        // clear_except) so a re-enable warns again instead of staying
1880        // stale-silent across the cycle. Arms the gate directly — the same
1881        // seam every invoke miss-path uses.
1882        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        // Widget behavior is untouched: the widget entry's output still lands.
1942        assert!(wait_until(&mut host, |h| h
1943            .widget_text("com.test.both")
1944            .is_some()));
1945        // The action entry answers invokes.
1946        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        // The fixture echoes the invoked action id back as its widget text,
1967        // so the round-trip through stdin is observable at the spawned-
1968        // process seam the crate's SetWidget tests already use.
1969        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        // A miss (not running) must not bump the counter.
1992        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        // Each delivered invoke bumps it exactly once.
2001        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    /// A plugin that echoes every stdin event's kind back as a `SetWidget`
2011    /// line prefixed with `tag` — the observable seam for event delivery
2012    /// (same pattern as `ACTION_SCRIPT` for invokes). `tag` distinguishes
2013    /// the two kind processes of a both-kinds plugin, which share the
2014    /// widget-text key.
2015    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    /// A widget-kind manifest declaring `subscriptions_json` (raw JSON array
2035    /// body, e.g. `"bell_rang"`).
2036    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    /// A both-kinds manifest declaring `subscriptions_json`.
2045    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        // The forwarder carries the plugin's kind filter: a bell buffers, a
2079        // title change (not subscribed) never enters the buffer.
2080        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        // The self-scheduled contract: a plugin with no subscriptions block
2096        // must not gain event delivery (or its buffering machinery).
2097        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        // Simulate the window pump: the forwarder observes a terminal event,
2157        // is drained, and the drained events are delivered to the plugin.
2158        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        // The fixture echoes the event kind back as its widget text, so the
2171        // round-trip through plugin stdin is observable at the same seam the
2172        // crate's SetWidget tests use.
2173        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        // A second sync with the same resolved theme name must deliver
2237        // nothing (the counter would show it), and a real change must
2238        // redeliver — the final line proves both: new tokens AND counter 2,
2239        // where a duplicate greet would have left it at 3.
2240        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        // The fixture writes only on theme_changed; a delivery to a plugin
2272        // that did not subscribe to the kind is the bug under test.
2273        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        // The fixture exited non-zero right after its reply; the on-failure
2315        // supervisor respawns it, and the respawned process — which has
2316        // never seen a theme — must be re-greeted even though the theme NAME
2317        // is unchanged (the remembered name alone would skip delivery).
2318        // The reply carries the new process's pid, so a changed line is the
2319        // proof; without the respawn greet the fresh process stays silent
2320        // forever.
2321        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    /// Widget-kind echo that delays its reply. Both kind processes of a
2343    /// plugin share one last-write-wins widget-text key, so when one poll
2344    /// drains both echoes the second masks the first before any observer can
2345    /// sample it — the delay spaces the two writes so each is seen.
2346    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    /// A widget plugin that reacts only to `theme_changed` events: each one
2363    /// replies with `t:<theme>|<background token>|<per-process counter>|<pid>`.
2364    /// The counter proves same-name dedup (a duplicate delivery bumps it) and
2365    /// the pid proves a fresh process received its own greet after a
2366    /// supervised respawn.
2367    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    /// The theme echo that exits non-zero after its first reply, so the
2389    /// on-failure supervisor respawns it — the fixture for re-greet coverage.
2390    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    /// Poll for `ms` without a success condition — a bounded wait for "the
2413    /// buggy delivery would have landed by now" negative assertions.
2414    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        // Both kind processes echo with their own tag into the shared
2456        // widget-text key (last write wins), so the proof is having SEEN
2457        // each tag at some point during polling.
2458        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        // A plugin between restarts must not warn or panic when the sweep
2479        // delivers events nobody is running to receive.
2480        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}