Skip to main content

standard_plugin/
testing.rs

1//! Run a plugin under `cargo test`, against an in-process host.
2//!
3//! On targets other than wasm, every SDK call goes to the thread's
4//! [`MockHost`] instead of the WIT imports. The mock checks grants exactly
5//! as the viewer's host does (both use `standard-plugin-manifest`), keeps
6//! values, records what the plugin emitted and committed, and lays out
7//! surfaces. [`Harness`] drives a [`UiPlugin`] the way a viewer does:
8//! activate, resize, frames, events.
9//!
10//! ```rust,ignore
11#![doc = include_str!("../tests/testing_harness.rs")]
12//! ```
13
14mod http;
15mod machine;
16
17pub(crate) use http::send as http_send;
18
19pub use machine::{MockPaneLaunch, MockProcess, MockWatch, MockWebSocket, ProcessScript};
20
21use alloc::collections::{BTreeMap, BTreeSet};
22use alloc::string::{String, ToString};
23use alloc::vec::Vec;
24use core::cell::RefCell;
25
26use standard_plugin_manifest::{GrantDenied, Grants};
27
28use crate::api::Target;
29use crate::api::account::{AccountState, Pane};
30use crate::api::capabilities::Capabilities;
31use crate::api::view::Theme;
32use crate::error::{Error, Result};
33use crate::geometry::{Geometry, Rect};
34use crate::surface::{HEADER_LEN, Model, RegionLayout};
35use crate::ui_runtime::{Event, Power, UiPlugin, UiRuntime};
36
37/// How a plugin made a call.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum CallKind {
40    /// `calls.call`: it waited for the answer.
41    Wait,
42    /// `calls.send`: nothing answers.
43    Send,
44    /// `calls.call-async` with this id; the answer is delivered by
45    /// [`Harness::answer_calls`].
46    Async(u64),
47}
48
49/// One call as the host saw it.
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct MockCall {
52    pub method: String,
53    pub payload: String,
54    pub target: Target,
55    pub kind: CallKind,
56    pub timeout_ms: Option<u32>,
57    /// A `call-async` whose answer was delivered.
58    pub answered: bool,
59}
60
61/// One commit as the host saw it.
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub struct MockCommit {
64    /// The header sequence number the region carried.
65    pub seq: u32,
66    pub slot: u8,
67    /// The rectangles the plugin sent.
68    pub dirty: Vec<Rect>,
69    /// The host treats it as fully dirty (the first after an attach).
70    pub full: bool,
71    pub geometry: Geometry,
72    pub model: Model,
73    /// The whole region (header and both slots) at commit time.
74    pub region: Vec<u8>,
75}
76
77impl MockCommit {
78    /// The committed slot's bytes.
79    pub fn slot_bytes(&self) -> &[u8] {
80        let layout = RegionLayout::new(self.model, self.geometry);
81        let start = (HEADER_LEN + u32::from(self.slot) * layout.slot_len) as usize;
82        &self.region[start..start + layout.slot_len as usize]
83    }
84}
85
86#[derive(Clone, Debug)]
87struct MockSurface {
88    model: Model,
89    geometry: Geometry,
90    /// Address and length of the attached region in this process.
91    region: Option<(usize, usize)>,
92    pending: Option<(usize, usize)>,
93    committed_once: bool,
94    commits: Vec<MockCommit>,
95}
96
97/// The in-process host. Build one, [`install`] it (or hand it to a
98/// [`Harness`]), and inspect it after the plugin ran.
99#[derive(Clone, Debug)]
100pub struct MockHost {
101    plugin_id: String,
102    granted: Vec<String>,
103    grants: Grants,
104    pub config: String,
105    pub values: BTreeMap<String, String>,
106    pub secrets: BTreeMap<String, String>,
107    pub account: AccountState,
108    pub driving: bool,
109    pub theme: Theme,
110    pub capabilities: Capabilities,
111    pub focused_pane: Option<String>,
112    /// Claims another viewer holds: claiming these fails.
113    pub claimed_elsewhere: BTreeSet<String>,
114    /// Claims this instance holds.
115    pub claims: BTreeSet<String>,
116    /// `events.emit` calls: name, payload, target.
117    pub emitted: Vec<(String, String, Target)>,
118    /// `live.publish` calls: key, payload.
119    pub published: Vec<(String, String)>,
120    /// `live.delete` calls: the keys withdrawn, in order.
121    pub live_deleted: Vec<String>,
122    /// Patterns `events.on` registered.
123    pub listening: BTreeSet<String>,
124    /// `calls.call` and `call-async` answers by method (the companion's
125    /// side); a method without one answers `invalid`.
126    pub call_answers: BTreeMap<String, String>,
127    /// Every `calls.call`, `send` and `call-async`, in order.
128    pub calls: Vec<MockCall>,
129    /// Grants the plugin was denied, in order.
130    pub denials: Vec<String>,
131    pub panes: Vec<Pane>,
132    /// A frame the plugin asked for outside `frame()`
133    /// ([`crate::Context::request_frame`] or `wake_at`): the earliest
134    /// viewer instant, `0` for "the next frame".
135    pub frame_request: Option<u64>,
136    /// Surfaces `open` opened, in order.
137    pub opened: Vec<String>,
138    /// URLs `url::open` opened, in order.
139    pub opened_urls: Vec<String>,
140    /// Panes `focus_pane` asked the viewer to focus, in order.
141    pub focused_panes: Vec<String>,
142    /// The viewer clock of the last `Harness::frame`, for the `url.open`
143    /// input window.
144    pub now_ms: u64,
145    /// When the last user input was delivered (`now_ms` then), until a URL
146    /// opened for it.
147    pub url_gesture_ms: Option<u64>,
148    /// Surfaces `close` closed, in order.
149    pub closed: Vec<String>,
150    /// Whether the plugin is handling a user gesture now (the harness sets
151    /// it around key, paste, pointer-press and command events).
152    pub gesture: bool,
153    /// The machine the plugin runs on: a daemon's `Context::machine_id`, a
154    /// viewer's `view::machine_id`.
155    pub machine_id: Option<String>,
156    /// A daemon's `Context::account_id`.
157    pub account_id: Option<String>,
158    /// `daemon-context.lease-epoch`: a singleton's epoch, `None` for fleet
159    /// (`MockHost::singleton`).
160    pub lease_epoch: Option<u64>,
161    /// The build `daemon::Context::release` reports.
162    pub release: Option<crate::daemon::Release>,
163    /// A viewer's `view::instance_id`.
164    pub instance_id: String,
165    /// A daemon plugin's environment (`daemon::env`).
166    pub environment: Vec<(String, String)>,
167    /// `view::wall_ms`: milliseconds since the Unix epoch.
168    pub wall_ms: u64,
169    /// `view::utc_offset_minutes`.
170    pub utc_offset_minutes: i32,
171    /// `view::time_zone`.
172    pub time_zone: Option<String>,
173    /// The plugin's last `health::set`: its state and message.
174    pub health: Option<(crate::health::Health, String)>,
175    /// Identity tints by machine or project id (`view::surface_tint`,
176    /// `view::identity_tint`).
177    pub tints: BTreeMap<String, u32>,
178    /// Sizes the plugin asked for, by surface (`request_size`), bounded as
179    /// the host bounds them.
180    pub requested: BTreeMap<String, (u32, u32)>,
181    /// Labels the plugin set, by surface (`set_label`).
182    pub labels: BTreeMap<String, String>,
183    /// Carets the plugin set, by surface (`set_caret`): column and row.
184    pub carets: BTreeMap<String, (u32, u32)>,
185    /// Declared surfaces instanced per machine (`machine.after`).
186    machine_surfaces: BTreeSet<String>,
187    /// Declared surfaces instanced per project (`project.after`).
188    project_surfaces: BTreeSet<String>,
189    surfaces: BTreeMap<String, MockSurface>,
190    /// Children and watches (the daemon world).
191    machine: machine::Machine,
192    /// Scripted `daemon::http` (`MockHost::http`).
193    http: http::MockHttp,
194}
195
196impl MockHost {
197    /// A host for plugin `plugin_id` with no grants, not driving.
198    pub fn new(plugin_id: &str) -> Self {
199        Self {
200            plugin_id: plugin_id.to_string(),
201            granted: Vec::new(),
202            grants: Grants::new(plugin_id, []),
203            config: String::new(),
204            values: BTreeMap::new(),
205            secrets: BTreeMap::new(),
206            account: AccountState::default(),
207            driving: false,
208            theme: Theme::default(),
209            capabilities: Capabilities {
210                graphics: false,
211                kitty: false,
212                frame_rate: 60,
213                cell_px: (0, 0),
214                pixel_scale: 1,
215            },
216            focused_pane: None,
217            claimed_elsewhere: BTreeSet::new(),
218            claims: BTreeSet::new(),
219            emitted: Vec::new(),
220            published: Vec::new(),
221            live_deleted: Vec::new(),
222            listening: BTreeSet::new(),
223            call_answers: BTreeMap::new(),
224            calls: Vec::new(),
225            denials: Vec::new(),
226            panes: Vec::new(),
227            frame_request: None,
228            opened: Vec::new(),
229            focused_panes: Vec::new(),
230            opened_urls: Vec::new(),
231            now_ms: 0,
232            url_gesture_ms: None,
233            closed: Vec::new(),
234            gesture: false,
235            machine_id: None,
236            account_id: None,
237            lease_epoch: None,
238            release: None,
239            instance_id: "test-viewer".to_string(),
240            environment: Vec::new(),
241            wall_ms: 0,
242            utc_offset_minutes: 0,
243            time_zone: None,
244            health: None,
245            tints: BTreeMap::new(),
246            requested: BTreeMap::new(),
247            labels: BTreeMap::new(),
248            carets: BTreeMap::new(),
249            machine_surfaces: BTreeSet::new(),
250            project_surfaces: BTreeSet::new(),
251            surfaces: BTreeMap::new(),
252            machine: machine::Machine::default(),
253            http: http::MockHttp::default(),
254        }
255    }
256
257    /// The viewer's wall clock and time zone.
258    pub fn clock(mut self, wall_ms: u64, utc_offset_minutes: i32, time_zone: Option<&str>) -> Self {
259        self.wall_ms = wall_ms;
260        self.utc_offset_minutes = utc_offset_minutes;
261        self.time_zone = time_zone.map(str::to_string);
262        self
263    }
264
265    /// Runs a daemon plugin as a singleton under lease `epoch`.
266    pub fn singleton(mut self, epoch: u64) -> Self {
267        self.lease_epoch = Some(epoch);
268        self
269    }
270
271    /// The machine the plugin runs on.
272    pub fn machine(mut self, machine_id: &str) -> Self {
273        self.machine_id = Some(machine_id.to_string());
274        self
275    }
276
277    /// Sets one variable of a daemon plugin's environment.
278    pub fn env(mut self, name: &str, value: &str) -> Self {
279        self.environment.retain(|(key, _)| key != name);
280        self.environment.push((name.to_string(), value.to_string()));
281        self.environment.sort();
282        self
283    }
284
285    /// Adds grants (the manifest's grant keys).
286    pub fn grant(mut self, grants: &[&str]) -> Self {
287        self.granted
288            .extend(grants.iter().map(|grant| grant.to_string()));
289        self.grants = Grants::new(&self.plugin_id, self.granted.iter().cloned());
290        self
291    }
292
293    /// Declares a surface the way a manifest does, with no size yet.
294    pub fn surface(mut self, id: &str, model: Model) -> Self {
295        self.surfaces.insert(
296            id.to_string(),
297            MockSurface {
298                model,
299                geometry: Geometry::default(),
300                region: None,
301                pending: None,
302                committed_once: false,
303                commits: Vec::new(),
304            },
305        );
306        self
307    }
308
309    /// Declares a `machine.after` surface: its instances are
310    /// `<surface>@<machine>`.
311    pub fn machine_surface(mut self, id: &str, model: Model) -> Self {
312        self.machine_surfaces.insert(id.to_string());
313        self.surface(id, model)
314    }
315
316    /// Declares a `project.after` surface: its instances are
317    /// `<surface>@<project>`.
318    pub fn project_surface(mut self, id: &str, model: Model) -> Self {
319        self.project_surfaces.insert(id.to_string());
320        self.surface(id, model)
321    }
322
323    pub fn driving(mut self, driving: bool) -> Self {
324        self.driving = driving;
325        self
326    }
327
328    /// Sets a surface's size, as the viewer's layout does. Returns the
329    /// resize event to deliver (the [`Harness`] delivers it).
330    pub fn set_geometry(&mut self, id: &str, geometry: Geometry) -> Option<Event> {
331        if !self.surfaces.contains_key(id)
332            && let Some((base, _pane)) = id.split_once('@')
333            && let Some(template) = self.surfaces.get(base)
334        {
335            // A pane instance of a declared footer or header surface.
336            let mut instance = template.clone();
337            instance.geometry = Geometry::default();
338            instance.region = None;
339            instance.pending = None;
340            instance.committed_once = false;
341            instance.commits.clear();
342            self.surfaces.insert(id.to_string(), instance);
343        }
344        let surface = self.surfaces.get_mut(id)?;
345        if surface.geometry == geometry {
346            return None;
347        }
348        surface.geometry = geometry;
349        Some(Event::Resize {
350            surface: id.to_string(),
351            geometry,
352        })
353    }
354
355    /// Switches the model the viewer uses for a surface that lists
356    /// several: the plugin sees it as a resize.
357    pub fn set_model(&mut self, id: &str, model: Model) -> Option<Event> {
358        let surface = self.surfaces.get_mut(id)?;
359        if surface.model == model {
360            return None;
361        }
362        surface.model = model;
363        surface.region = None;
364        surface.pending = None;
365        surface.committed_once = false;
366        Some(Event::Resize {
367            surface: id.to_string(),
368            geometry: surface.geometry,
369        })
370    }
371
372    /// Every commit of a surface, oldest first.
373    pub fn commits(&self, id: &str) -> Vec<MockCommit> {
374        self.surfaces
375            .get(id)
376            .map(|surface| surface.commits.clone())
377            .unwrap_or_default()
378    }
379
380    /// The companion's answer to `method`, as a call gets it.
381    fn answer(&self, method: &str) -> Result<String> {
382        self.call_answers
383            .get(method)
384            .cloned()
385            .ok_or_else(|| Error::Invalid(alloc::format!("unknown method {method:?}")))
386    }
387
388    /// The `call-async`s not answered yet, marked answered, each with the
389    /// event that answers it.
390    fn take_async_answers(&mut self) -> Vec<Event> {
391        let mut events = Vec::new();
392        for index in 0..self.calls.len() {
393            let CallKind::Async(id) = self.calls[index].kind else {
394                continue;
395            };
396            if self.calls[index].answered {
397                continue;
398            }
399            self.calls[index].answered = true;
400            let result = self.answer(&self.calls[index].method).map(crate::Json);
401            events.push(Event::CallResult {
402                id: crate::api::calls::CallId(id),
403                result,
404            });
405        }
406        events
407    }
408
409    fn deny(&mut self, denial: GrantDenied) -> Error {
410        self.denials.push(denial.grant.clone());
411        Error::GrantDenied {
412            grant: denial.grant,
413        }
414    }
415
416    fn check(&mut self, grant: &str) -> Result<()> {
417        self.grants.check(grant).map_err(|denial| self.deny(denial))
418    }
419
420    fn check_namespaced(&mut self, kind: &str, subject: &str) -> Result<()> {
421        self.grants
422            .check_namespaced(kind, subject)
423            .map_err(|denial| self.deny(denial))
424    }
425}
426
427std::thread_local! {
428    static MOCK: RefCell<Option<MockHost>> = const { RefCell::new(None) };
429}
430
431/// Makes `host` this thread's host. A thread without one gets
432/// `MockHost::new("test-plugin")` on its first call.
433pub fn install(host: MockHost) {
434    MOCK.with(|mock| *mock.borrow_mut() = Some(host));
435}
436
437/// Runs `f` on this thread's host.
438pub fn with_host<R>(f: impl FnOnce(&mut MockHost) -> R) -> R {
439    MOCK.with(|mock| {
440        let mut mock = mock.borrow_mut();
441        f(mock.get_or_insert_with(|| MockHost::new("test-plugin")))
442    })
443}
444
445/// Drives a [`UiPlugin`] as a viewer does, on this thread's [`MockHost`].
446pub struct Harness<P: UiPlugin> {
447    runtime: UiRuntime<P>,
448}
449
450impl<P: UiPlugin> Harness<P> {
451    /// Installs `host` and activates the plugin with `config`.
452    pub fn activate(host: MockHost, config: &str) -> Self {
453        let mut host = host;
454        host.config = config.to_string();
455        install(host);
456        let runtime = UiRuntime::new();
457        runtime.activate(config.to_string());
458        Self { runtime }
459    }
460
461    /// Lays a surface out at `geometry` and delivers the resize event.
462    pub fn resize(&mut self, surface: &str, geometry: Geometry) {
463        if let Some(event) = with_host(|host| host.set_geometry(surface, geometry)) {
464            self.runtime.event(event);
465        }
466    }
467
468    /// Calls `frame` with the paced interval and power state a viewer
469    /// passes; returns when the plugin wants the next one.
470    pub fn frame(&mut self, now_ms: u64, interval_ms: u32, power: Power) -> Option<u64> {
471        with_host(|host| {
472            host.frame_request = None;
473            host.now_ms = now_ms;
474        });
475        self.runtime.frame(now_ms, interval_ms, power)
476    }
477
478    pub fn event(&mut self, event: Event) {
479        let gesture = matches!(
480            &event,
481            Event::Key(_)
482                | Event::Paste { .. }
483                | Event::Command(_)
484                | Event::Pointer(crate::Pointer {
485                    kind: crate::PointerKind::Down,
486                    ..
487                })
488        );
489        with_host(|host| {
490            host.gesture = gesture;
491            if gesture {
492                host.url_gesture_ms = Some(host.now_ms);
493            }
494        });
495        self.runtime.event(event);
496        with_host(|host| host.gesture = false);
497    }
498
499    /// Shows or hides a surface, as the viewer does when it places it.
500    pub fn show(&mut self, surface: &str, visible: bool) {
501        self.event(Event::Visibility {
502            surface: surface.to_string(),
503            visible,
504        });
505    }
506
507    /// Changes what the viewer can show and tells the plugin.
508    pub fn set_capabilities(&mut self, capabilities: Capabilities) {
509        with_host(|host| host.capabilities = capabilities);
510        self.event(Event::CapabilitiesChanged);
511    }
512
513    /// Changes the theme and tells the plugin.
514    pub fn set_theme(&mut self, theme: Theme) {
515        with_host(|host| host.theme = theme);
516        self.event(Event::ThemeChanged);
517    }
518
519    /// Switches a multi-model surface to `model`, as the viewer does when
520    /// its graphics support changes.
521    pub fn set_model(&mut self, surface: &str, model: Model) {
522        if let Some(event) = with_host(|host| host.set_model(surface, model)) {
523            self.runtime.event(event);
524        }
525    }
526
527    /// A key press on `surface`.
528    pub fn key(&mut self, surface: &str, code: crate::KeyCode) {
529        self.event(Event::Key(crate::Key::press(surface, code)));
530    }
531
532    /// Text pasted into `surface`.
533    pub fn paste(&mut self, surface: &str, text: &str) {
534        self.event(Event::Paste {
535            surface: surface.to_string(),
536            text: text.to_string(),
537        });
538    }
539
540    /// A pointer event at `(x, y)` in the surface's units.
541    pub fn pointer(&mut self, surface: &str, kind: crate::PointerKind, x: u32, y: u32) {
542        let cell = with_host(|host| {
543            host.surfaces
544                .get(surface)
545                .map_or((1, 1), |surface| match surface.model {
546                    Model::Cells => (1, 1),
547                    Model::Pixels => (
548                        surface.geometry.cell_px_w.max(1),
549                        surface.geometry.cell_px_h.max(1),
550                    ),
551                })
552        });
553        self.event(Event::Pointer(crate::Pointer {
554            surface: surface.to_string(),
555            x,
556            y,
557            col: x / cell.0,
558            row: y / cell.1,
559            button: if matches!(kind, crate::PointerKind::Move) {
560                crate::Button::None
561            } else {
562                crate::Button::Left
563            },
564            kind,
565            modifiers: crate::Modifiers::NONE,
566        }));
567    }
568
569    /// Runs one of the manifest's commands.
570    pub fn command(&mut self, id: &str) {
571        self.event(Event::Command(id.to_string()));
572    }
573
574    /// Answers every `call-async` the plugin made so far from
575    /// [`MockHost::call_answers`], delivering one
576    /// [`Event::CallResult`] each, in order. Returns how many.
577    pub fn answer_calls(&mut self) -> usize {
578        let events = with_host(MockHost::take_async_answers);
579        let count = events.len();
580        for event in events {
581            self.event(event);
582        }
583        count
584    }
585
586    /// The frame the plugin asked for from an event, and clears it.
587    pub fn take_frame_request(&mut self) -> Option<u64> {
588        with_host(|host| host.frame_request.take())
589    }
590
591    /// The plugin, to assert on its state.
592    pub fn plugin<R>(&self, f: impl FnOnce(&mut P) -> R) -> R {
593        self.runtime
594            .with_plugin(f)
595            .unwrap_or_else(|| panic!("the plugin is not active"))
596    }
597
598    pub fn commits(&self, surface: &str) -> Vec<MockCommit> {
599        with_host(|host| host.commits(surface))
600    }
601
602    pub fn host<R>(&self, f: impl FnOnce(&mut MockHost) -> R) -> R {
603        with_host(f)
604    }
605
606    pub fn deactivate(self) {
607        self.runtime.deactivate();
608    }
609}
610
611/// Drives a [`DaemonPlugin`](crate::daemon::DaemonPlugin) as `standardd`
612/// would: activate, `drive` at the instants it asks for, events and calls.
613pub struct DaemonHarness<P: crate::daemon::DaemonPlugin> {
614    runtime: crate::daemon::runtime::DaemonRuntime<P>,
615}
616
617impl<P: crate::daemon::DaemonPlugin> DaemonHarness<P> {
618    /// Installs `host` and activates the plugin with `config`.
619    pub fn activate(host: MockHost, config: &str) -> Self {
620        let mut host = host;
621        host.config = config.to_string();
622        install(host);
623        let runtime = crate::daemon::runtime::DaemonRuntime::new();
624        runtime.activate(config.to_string());
625        Self { runtime }
626    }
627
628    /// Delivers what happened on the machine since the last drive (a
629    /// scripted child's output and exit, file changes), then runs the
630    /// plugin's tasks at `now_ms`; when it next wants to run.
631    pub fn drive(&mut self, now_ms: u64) -> Option<u64> {
632        loop {
633            let pending = with_host(|host| host.machine.pending.pop_front());
634            let Some(event) = pending else {
635                break;
636            };
637            self.runtime.event(event);
638        }
639        self.runtime.drive(now_ms)
640    }
641
642    /// Output of a running child, as its piped stream carries it; delivered
643    /// on the next [`DaemonHarness::drive`].
644    pub fn output(&mut self, pid: u32, stderr: bool, bytes: &[u8]) {
645        with_host(|host| host.output(pid, stderr, bytes));
646    }
647
648    /// A running child exits with `status`; delivered on the next drive.
649    pub fn exit(&mut self, pid: u32, status: i32) {
650        with_host(|host| host.exited(pid, status));
651    }
652
653    /// Something changed at `path`: every watch that covers it, at its
654    /// depth and outside its exclude globs, hears it on the next drive.
655    /// How many watches hear it.
656    pub fn file_changed(&mut self, path: &str) -> usize {
657        with_host(|host| host.changed(path))
658    }
659
660    /// The plugin, to assert on its state.
661    pub fn plugin<R>(&self, f: impl FnOnce(&P) -> R) -> R {
662        self.runtime
663            .with_plugin(f)
664            .unwrap_or_else(|| panic!("the plugin is not active"))
665    }
666
667    /// Delivers an event (drive afterwards to let the plugin see it).
668    pub fn event(&mut self, event: Event) {
669        self.runtime.event(event);
670    }
671
672    /// The account's viewers now show exactly `surfaces` of the plugin, as
673    /// the host reports it (the [`INTEREST_EVENT`](crate::daemon::INTEREST_EVENT)
674    /// plugin event); the plugin hears
675    /// [`Event::Interest`](crate::Event::Interest) on the next drive when it
676    /// changed. `&[]`: nobody looks.
677    pub fn interest(&mut self, surfaces: &[&str]) {
678        let payload = serde_json::json!({ "surfaces": surfaces }).to_string();
679        self.runtime.event(Event::Plugin {
680            name: crate::daemon::INTEREST_EVENT.to_string(),
681            payload: crate::Json(payload),
682        });
683    }
684
685    /// Calls the plugin's `call` handler as its UI half would.
686    pub fn call(&mut self, method: &str, request: &str) -> Result<String> {
687        self.call_from(method, request, crate::daemon::Caller::default())
688    }
689
690    /// Calls the plugin's `call` handler as `caller`.
691    pub fn call_from(
692        &mut self,
693        method: &str,
694        request: &str,
695        caller: crate::daemon::Caller,
696    ) -> Result<String> {
697        self.runtime
698            .handle_call(method, request.to_string(), caller)
699    }
700
701    pub fn host<R>(&self, f: impl FnOnce(&mut MockHost) -> R) -> R {
702        with_host(f)
703    }
704
705    pub fn deactivate(self) {
706        self.runtime.deactivate();
707    }
708}
709
710/// The host calls, as `crate::host` names them.
711pub(crate) mod host_calls {
712    use super::*;
713
714    /// The account service's rule for a value or live key's name (after
715    /// `<namespace>.`): it starts with a letter, digit or `_`, then only
716    /// those, `.`, `:`, `/` and `-`. The mock refuses what the account
717    /// would, so a plugin's tests catch a key it could never write.
718    fn check_key_name(key: &str) -> Result<()> {
719        let name = key.split_once('.').map_or("", |(_, name)| name);
720        let allowed =
721            |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | ':' | '/' | '-');
722        let first = name.chars().next();
723        if first.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_') && name.chars().all(allowed)
724        {
725            Ok(())
726        } else {
727            Err(Error::Invalid(alloc::format!(
728                "{key:?}: a key is <namespace>.<name>, the name of letters, digits and _ . : / -"
729            )))
730        }
731    }
732
733    pub(crate) fn values_get(key: &str) -> Result<Option<String>> {
734        with_host(|host| {
735            host.check_namespaced("values.read", key)?;
736            Ok(host.values.get(key).cloned())
737        })
738    }
739
740    pub(crate) fn values_set(key: &str, value: &str) -> Result<()> {
741        with_host(|host| {
742            host.check_namespaced("values.write", key)?;
743            check_key_name(key)?;
744            host.values.insert(key.to_string(), value.to_string());
745            Ok(())
746        })
747    }
748
749    pub(crate) fn values_delete(key: &str) -> Result<()> {
750        with_host(|host| {
751            host.check_namespaced("values.write", key)?;
752            host.values.remove(key);
753            Ok(())
754        })
755    }
756
757    pub(crate) fn values_keys(prefix: &str) -> Result<Vec<String>> {
758        with_host(|host| {
759            host.check_namespaced("values.read", prefix)?;
760            Ok(host
761                .values
762                .keys()
763                .filter(|key| key.starts_with(prefix))
764                .cloned()
765                .collect())
766        })
767    }
768
769    pub(crate) fn values_watch(prefix: &str) -> Result<()> {
770        with_host(|host| host.check_namespaced("values.read", prefix))
771    }
772
773    pub(crate) fn live_publish(key: &str, payload: &str) -> Result<()> {
774        with_host(|host| {
775            host.check_namespaced("live.publish", key)?;
776            check_key_name(key)?;
777            host.published.push((key.to_string(), payload.to_string()));
778            Ok(())
779        })
780    }
781
782    pub(crate) fn live_delete(key: &str) -> Result<()> {
783        with_host(|host| {
784            host.check_namespaced("live.publish", key)?;
785            host.live_deleted.push(key.to_string());
786            Ok(())
787        })
788    }
789
790    pub(crate) fn live_subscribe(prefix: &str) -> Result<()> {
791        with_host(|host| host.check_namespaced("live.subscribe", prefix))
792    }
793
794    pub(crate) fn live_unsubscribe(prefix: &str) -> Result<()> {
795        with_host(|host| host.check_namespaced("live.subscribe", prefix))
796    }
797
798    pub(crate) fn events_emit(name: &str, payload: &str, to: &Target) -> Result<()> {
799        with_host(|host| {
800            if name.starts_with("system.") {
801                return Err(host.deny(GrantDenied {
802                    grant: alloc::format!("events.emit:{name}"),
803                }));
804            }
805            host.check_namespaced("events.emit", name)?;
806            host.emitted
807                .push((name.to_string(), payload.to_string(), to.clone()));
808            Ok(())
809        })
810    }
811
812    pub(crate) fn events_on(pattern: &str) -> Result<()> {
813        with_host(|host| {
814            host.check_namespaced("events.on", pattern)?;
815            host.listening.insert(pattern.to_string());
816            Ok(())
817        })
818    }
819
820    pub(crate) fn events_off(pattern: &str) -> Result<()> {
821        with_host(|host| {
822            host.check_namespaced("events.on", pattern)?;
823            host.listening.remove(pattern);
824            Ok(())
825        })
826    }
827
828    fn record_call(
829        host: &mut MockHost,
830        method: &str,
831        payload: &str,
832        to: &Target,
833        kind: CallKind,
834        timeout_ms: Option<u32>,
835    ) -> Result<()> {
836        let own = alloc::format!("call:{}", host.grants.plugin_id());
837        host.check(&own)?;
838        host.calls.push(MockCall {
839            method: method.to_string(),
840            payload: payload.to_string(),
841            target: to.clone(),
842            kind,
843            timeout_ms,
844            answered: false,
845        });
846        Ok(())
847    }
848
849    pub(crate) fn call(
850        method: &str,
851        payload: &str,
852        to: &Target,
853        timeout_ms: Option<u32>,
854    ) -> Result<String> {
855        with_host(|host| {
856            record_call(host, method, payload, to, CallKind::Wait, timeout_ms)?;
857            host.answer(method)
858        })
859    }
860
861    pub(crate) fn call_send(method: &str, payload: &str, to: &Target) -> Result<()> {
862        with_host(|host| record_call(host, method, payload, to, CallKind::Send, None))
863    }
864
865    pub(crate) fn call_async(
866        method: &str,
867        payload: &str,
868        to: &Target,
869        timeout_ms: Option<u32>,
870    ) -> Result<u64> {
871        with_host(|host| {
872            let id = host
873                .calls
874                .iter()
875                .filter(|call| matches!(call.kind, CallKind::Async(_)))
876                .count() as u64
877                + 1;
878            record_call(host, method, payload, to, CallKind::Async(id), timeout_ms)?;
879            Ok(id)
880        })
881    }
882
883    pub(crate) fn config() -> String {
884        with_host(|host| host.config.clone())
885    }
886
887    pub(crate) fn health_set(health: crate::health::Health, message: &str) {
888        with_host(|host| host.health = Some((health, message.to_string())));
889    }
890
891    pub(crate) fn secret(name: &str) -> Result<Option<String>> {
892        with_host(|host| {
893            host.check(&alloc::format!("secret:{name}"))?;
894            Ok(host.secrets.get(name).cloned())
895        })
896    }
897
898    pub(crate) fn account_state() -> Result<AccountState> {
899        with_host(|host| {
900            host.check("account.read")?;
901            Ok(host.account.clone())
902        })
903    }
904
905    pub(crate) fn account_watch() -> Result<()> {
906        with_host(|host| host.check("account.read"))
907    }
908
909    pub(crate) fn claim(key: &str, _ttl_ms: u32) -> Result<bool> {
910        with_host(|host| {
911            if host.claimed_elsewhere.contains(key) {
912                return Ok(false);
913            }
914            host.claims.insert(key.to_string());
915            Ok(true)
916        })
917    }
918
919    pub(crate) fn release(key: &str) -> Result<()> {
920        with_host(|host| {
921            host.claims.remove(key);
922            Ok(())
923        })
924    }
925
926    fn unknown(id: &str) -> Error {
927        Error::Invalid(alloc::format!("no surface {id:?} in the manifest"))
928    }
929
930    pub(crate) fn surface_layout(id: &str) -> Result<RegionLayout> {
931        with_host(|host| {
932            let surface = host.surfaces.get(id).ok_or_else(|| unknown(id))?;
933            Ok(RegionLayout::new(surface.model, surface.geometry))
934        })
935    }
936
937    pub(crate) fn surface_attach(id: &str, region: &[u8]) -> Result<()> {
938        with_host(|host| {
939            let surface = host.surfaces.get_mut(id).ok_or_else(|| unknown(id))?;
940            let layout = RegionLayout::new(surface.model, surface.geometry);
941            if surface.geometry.is_empty() {
942                return Err(Error::Invalid(alloc::format!(
943                    "surface {id:?} has no size yet"
944                )));
945            }
946            if region.len() < layout.len as usize {
947                return Err(Error::Invalid("region too small".into()));
948            }
949            let word = |index: usize| {
950                u32::from_le_bytes([
951                    region[index * 4],
952                    region[index * 4 + 1],
953                    region[index * 4 + 2],
954                    region[index * 4 + 3],
955                ])
956            };
957            let g = surface.geometry;
958            let expected = [
959                match surface.model {
960                    Model::Cells => 0,
961                    Model::Pixels => 1,
962                },
963                g.cols,
964                g.rows,
965                g.px_w,
966                g.px_h,
967                g.cell_px_w,
968                g.cell_px_h,
969            ];
970            if (1..8).any(|index| word(index) != expected[index - 1]) {
971                return Err(Error::Invalid(
972                    "region header does not describe the current layout".into(),
973                ));
974            }
975            let bound = (region.as_ptr() as usize, region.len());
976            if surface.region.is_none() {
977                surface.region = Some(bound);
978            } else {
979                surface.pending = Some(bound);
980            }
981            Ok(())
982        })
983    }
984
985    pub(crate) fn surface_commit(id: &str, slot: u8, dirty: &[Rect]) -> Result<()> {
986        with_host(|host| {
987            let surface = host.surfaces.get_mut(id).ok_or_else(|| unknown(id))?;
988            if surface.region.is_none() && surface.pending.is_none() {
989                return Err(Error::Invalid(alloc::format!(
990                    "surface {id:?} has no region attached"
991                )));
992            }
993            if slot > 1 {
994                return Err(Error::Invalid("slot must be 0 or 1".into()));
995            }
996            let full = surface.pending.is_some() || !surface.committed_once;
997            if let Some(pending) = surface.pending.take() {
998                surface.region = Some(pending);
999            }
1000            let Some((address, len)) = surface.region else {
1001                return Err(Error::Invalid("no region".into()));
1002            };
1003            // SAFETY: the SDK keeps an attached region alive until the
1004            // first commit after its replacement, the same guarantee the
1005            // viewer's host relies on to read guest memory; this is that
1006            // commit's region, read while the plugin is inside the call.
1007            let bytes = unsafe { core::slice::from_raw_parts(address as *const u8, len) };
1008            let seq = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
1009            surface.committed_once = true;
1010            surface.commits.push(MockCommit {
1011                seq,
1012                slot,
1013                dirty: dirty.to_vec(),
1014                full,
1015                geometry: surface.geometry,
1016                model: surface.model,
1017                region: bytes.to_vec(),
1018            });
1019            Ok(())
1020        })
1021    }
1022
1023    pub(crate) fn surface_detach(id: &str) -> Result<()> {
1024        with_host(|host| {
1025            let surface = host.surfaces.get_mut(id).ok_or_else(|| unknown(id))?;
1026            surface.region = None;
1027            surface.pending = None;
1028            Ok(())
1029        })
1030    }
1031
1032    pub(crate) fn is_driving() -> bool {
1033        with_host(|host| host.driving)
1034    }
1035
1036    pub(crate) fn theme() -> Theme {
1037        with_host(|host| host.theme)
1038    }
1039
1040    pub(crate) fn capabilities() -> Capabilities {
1041        with_host(|host| host.capabilities)
1042    }
1043
1044    pub(crate) fn focused_pane() -> Option<String> {
1045        with_host(|host| host.focused_pane.clone())
1046    }
1047
1048    /// The template and owner of an instance id (`<surface>@<owner>`).
1049    fn instance(surface: &str) -> Option<(&str, &str)> {
1050        surface
1051            .split_once('@')
1052            .filter(|(_, owner)| !owner.is_empty())
1053    }
1054
1055    pub(crate) fn surface_machine(surface: &str) -> Option<String> {
1056        let (template, machine) = instance(surface)?;
1057        with_host(|host| host.machine_surfaces.contains(template)).then(|| machine.to_string())
1058    }
1059
1060    pub(crate) fn surface_project(surface: &str) -> Option<String> {
1061        let (template, project) = instance(surface)?;
1062        with_host(|host| host.project_surfaces.contains(template)).then(|| project.to_string())
1063    }
1064
1065    pub(crate) fn surface_tint(surface: &str) -> Option<u32> {
1066        if let Some(owner) = surface_machine(surface).or_else(|| surface_project(surface)) {
1067            return with_host(|host| host.tints.get(&owner).copied());
1068        }
1069        let pane = surface_pane(surface)?;
1070        with_host(|host| {
1071            let pane = host
1072                .account
1073                .panes
1074                .iter()
1075                .find(|known| known.id == pane.id)?;
1076            pane.project
1077                .as_ref()
1078                .and_then(|project| host.tints.get(project))
1079                .or_else(|| host.tints.get(&pane.machine))
1080                .copied()
1081        })
1082    }
1083
1084    pub(crate) fn identity_tint(id: &str) -> Option<u32> {
1085        with_host(|host| host.tints.get(id).copied())
1086    }
1087
1088    pub(crate) fn surface_request_size(id: &str, cols: u32, rows: u32) -> Result<()> {
1089        with_host(|host| {
1090            let known = host.surfaces.contains_key(id)
1091                || instance(id).is_some_and(|(template, _)| host.surfaces.contains_key(template));
1092            if !known {
1093                return Err(unknown(id));
1094            }
1095            host.requested
1096                .insert(id.to_string(), (cols.min(1024), rows.min(512)));
1097            Ok(())
1098        })
1099    }
1100
1101    pub(crate) fn surface_set_label(id: &str, label: Option<&str>) -> Result<()> {
1102        with_host(|host| {
1103            let known = host.surfaces.contains_key(id)
1104                || instance(id).is_some_and(|(template, _)| host.surfaces.contains_key(template));
1105            if !known {
1106                return Err(unknown(id));
1107            }
1108            match label {
1109                Some(label)
1110                    if label.chars().count() > 48 || label.chars().any(char::is_control) =>
1111                {
1112                    return Err(crate::Error::Invalid(
1113                        "a label is at most 48 characters with no control characters".into(),
1114                    ));
1115                }
1116                Some(label) => {
1117                    host.labels.insert(id.to_string(), label.to_string());
1118                }
1119                None => {
1120                    host.labels.remove(id);
1121                }
1122            }
1123            Ok(())
1124        })
1125    }
1126
1127    pub(crate) fn surface_set_caret(id: &str, caret: Option<(u32, u32)>) -> Result<()> {
1128        with_host(|host| {
1129            let known = host.surfaces.contains_key(id)
1130                || instance(id).is_some_and(|(template, _)| host.surfaces.contains_key(template));
1131            if !known {
1132                return Err(unknown(id));
1133            }
1134            match caret {
1135                Some(caret) => {
1136                    host.carets.insert(id.to_string(), caret);
1137                }
1138                None => {
1139                    host.carets.remove(id);
1140                }
1141            }
1142            Ok(())
1143        })
1144    }
1145
1146    pub(crate) fn surface_pane(surface: &str) -> Option<crate::api::view::PaneRef> {
1147        let (template, pane) = instance(surface)?;
1148        if with_host(|host| {
1149            host.machine_surfaces.contains(template) || host.project_surfaces.contains(template)
1150        }) {
1151            return None;
1152        }
1153        let id = pane.to_string();
1154        let generation = with_host(|host| {
1155            host.account
1156                .panes
1157                .iter()
1158                .find(|pane| pane.id == id)
1159                .map_or(0, |pane| pane.generation)
1160        });
1161        Some(crate::api::view::PaneRef { id, generation })
1162    }
1163
1164    pub(crate) fn machine_id() -> Option<String> {
1165        with_host(|host| host.machine_id.clone())
1166    }
1167
1168    pub(crate) fn instance_id() -> String {
1169        with_host(|host| host.instance_id.clone())
1170    }
1171
1172    pub(crate) fn wall_ms() -> u64 {
1173        with_host(|host| host.wall_ms)
1174    }
1175
1176    pub(crate) fn utc_offset_minutes() -> i32 {
1177        with_host(|host| host.utc_offset_minutes)
1178    }
1179
1180    pub(crate) fn time_zone() -> Option<String> {
1181        with_host(|host| host.time_zone.clone())
1182    }
1183
1184    pub(crate) fn daemon_machine_id() -> String {
1185        with_host(|host| host.machine_id.clone().unwrap_or_default())
1186    }
1187
1188    pub(crate) fn daemon_account_id() -> Option<String> {
1189        with_host(|host| host.account_id.clone())
1190    }
1191
1192    pub(crate) fn daemon_lease_epoch() -> Option<u64> {
1193        with_host(|host| host.lease_epoch)
1194    }
1195
1196    pub(crate) fn daemon_release() -> Option<crate::daemon::Release> {
1197        with_host(|host| host.release.clone())
1198    }
1199
1200    pub(crate) fn daemon_environment() -> Vec<(String, String)> {
1201        with_host(|host| host.environment.clone())
1202    }
1203
1204    pub(crate) fn request_frame() {
1205        wake_at(0);
1206    }
1207
1208    pub(crate) fn wake_at(at_ms: u64) {
1209        with_host(|host| {
1210            host.frame_request = Some(host.frame_request.map_or(at_ms, |at| at.min(at_ms)));
1211        });
1212    }
1213
1214    pub(crate) fn url_open(url: &str) -> Result<()> {
1215        use standard_plugin_manifest::url::{URL_OPEN_WINDOW_MS, https_host};
1216        with_host(|host| {
1217            let domain = https_host(url).map_err(Error::Invalid)?;
1218            if !host.grants.allows_url_open(&domain) {
1219                return Err(host.deny(GrantDenied {
1220                    grant: alloc::format!("url.open:{domain}"),
1221                }));
1222            }
1223            let now = host.now_ms;
1224            if !host
1225                .url_gesture_ms
1226                .is_some_and(|at| host.gesture || now.saturating_sub(at) <= URL_OPEN_WINDOW_MS)
1227            {
1228                return Err(Error::Invalid(
1229                    "url.open needs user input within the last second".into(),
1230                ));
1231            }
1232            host.url_gesture_ms = None;
1233            host.opened_urls.push(url.to_string());
1234            Ok(())
1235        })
1236    }
1237
1238    pub(crate) fn surface_open(id: &str) -> Result<()> {
1239        with_host(|host| {
1240            if !host.surfaces.contains_key(id) {
1241                return Err(unknown(id));
1242            }
1243            if !host.gesture {
1244                return Err(Error::Invalid(
1245                    "surface.open needs a user gesture or a command".into(),
1246                ));
1247            }
1248            host.opened.push(id.to_string());
1249            Ok(())
1250        })
1251    }
1252
1253    pub(crate) fn focus_pane(pane: &str) -> Result<()> {
1254        with_host(|host| {
1255            if !host.gesture {
1256                return Err(Error::Invalid(
1257                    "view.focus-pane needs a user gesture or a command".into(),
1258                ));
1259            }
1260            host.focused_panes.push(pane.to_string());
1261            Ok(())
1262        })
1263    }
1264
1265    pub(crate) fn surface_close(id: &str) -> Result<()> {
1266        with_host(|host| {
1267            host.closed.push(id.to_string());
1268            Ok(())
1269        })
1270    }
1271
1272    pub(crate) fn process_spawn(program: &str, args: &[String], cwd: Option<&str>) -> Result<u32> {
1273        let mut command = crate::daemon::process::Command::new(program);
1274        command.args = args.to_vec();
1275        command.cwd = cwd.map(ToString::to_string);
1276        with_host(|host| host.spawn(&command))
1277    }
1278
1279    pub(crate) fn process_run(command: &crate::daemon::process::Command) -> Result<u32> {
1280        with_host(|host| host.spawn(command))
1281    }
1282
1283    pub(crate) fn process_write(pid: u32, bytes: &[u8]) -> Result<()> {
1284        with_host(|host| host.write(pid, bytes))
1285    }
1286
1287    pub(crate) fn process_close_stdin(pid: u32) -> Result<()> {
1288        with_host(|host| host.close_stdin(pid))
1289    }
1290
1291    pub(crate) fn process_try_wait(pid: u32) -> Result<Option<i32>> {
1292        with_host(|host| host.try_wait(pid))
1293    }
1294
1295    pub(crate) fn process_wait(pid: u32) -> Result<i32> {
1296        with_host(|host| host.wait(pid))
1297    }
1298
1299    pub(crate) fn process_kill(pid: u32) -> Result<()> {
1300        with_host(|host| host.kill(pid))
1301    }
1302
1303    pub(crate) fn watch(path: &str, options: &crate::daemon::watch::Options) -> Result<u32> {
1304        with_host(|host| host.watch(path, options.recursive, &options.exclude))
1305    }
1306
1307    pub(crate) fn unwatch(handle: u32) -> Result<()> {
1308        with_host(|host| host.unwatch(handle))
1309    }
1310
1311    pub(crate) fn websocket_open(url: &str, headers: &[(String, String)]) -> Result<u32> {
1312        with_host(|host| host.websocket_open(url, headers))
1313    }
1314
1315    pub(crate) fn websocket_send(socket: u32, text: &str) -> Result<()> {
1316        with_host(|host| host.websocket_send(socket, text))
1317    }
1318
1319    pub(crate) fn websocket_close(socket: u32) {
1320        with_host(|host| host.websocket_close(socket));
1321    }
1322
1323    pub(crate) fn panes() -> Result<Vec<Pane>> {
1324        with_host(|host| {
1325            host.check("panes.read")?;
1326            Ok(host.panes.clone())
1327        })
1328    }
1329
1330    pub(crate) fn pane_create(
1331        cwd: &str,
1332        command: Option<&str>,
1333        env: &[(String, String)],
1334    ) -> Result<(String, u64)> {
1335        with_host(|host| {
1336            host.check("panes.write")?;
1337            host.create_pane(cwd, command, env, None)
1338        })
1339    }
1340
1341    pub(crate) fn pane_create_with(
1342        cwd: &str,
1343        command: Option<&str>,
1344        env: &[(String, String)],
1345        title: Option<&str>,
1346    ) -> Result<(String, u64)> {
1347        with_host(|host| {
1348            host.check("panes.write")?;
1349            host.create_pane(cwd, command, env, title)
1350        })
1351    }
1352
1353    pub(crate) fn pane_input(_pane: &str, _bytes: &[u8]) -> Result<()> {
1354        with_host(|host| host.check("panes.write"))
1355    }
1356
1357    pub(crate) fn pane_close(_pane: &str) -> Result<()> {
1358        with_host(|host| host.check("panes.write"))
1359    }
1360
1361    pub(crate) fn panes_subscribe(_on: bool) -> Result<()> {
1362        with_host(|host| host.check("panes.read"))
1363    }
1364
1365    pub(crate) fn pane_wait(_pane: &str, _timeout_ms: u32) -> Result<bool> {
1366        with_host(|host| {
1367            host.check("panes.read")?;
1368            Ok(true)
1369        })
1370    }
1371}