Skip to main content

pmpx_testkit/
host.rs

1#![allow(unsafe_code)] // the fake host is an ABI boundary: it installs a table a plugin calls through
2//! A fake host, so a plugin's own tests can see what it says.
3//!
4//! A plugin cannot print usefully: its lines go to the host, and without one they fall back to its own
5//! stderr -- which a test cannot assert on. So this module installs the smallest possible host: one
6//! [`PmpxLog`] table whose callback records into a list, at the level the test asks for.
7//!
8//! The list is process-wide, exactly like the real hooks, so a test that installs one should keep the
9//! [`Captured`] handle around and assert on its own lines rather than on the absence of others.
10
11use std::sync::Mutex;
12
13use pmpx_plugin::abi::{
14    PmpxLog, PmpxStr, PMPX_LEVEL_DEBUG, PMPX_LEVEL_ERROR, PMPX_LEVEL_INFO, PMPX_LEVEL_WARN,
15};
16
17/// The lines the plugin wrote, oldest first.
18static LINES: Mutex<Vec<(u32, String)>> = Mutex::new(Vec::new());
19
20/// The tables, one per level.
21///
22/// A table carries its `max_level` as a *value*, and the shell reads it once, when the table is installed
23/// -- so a level is a different table, not a field to change. That is why the real host has three of them
24/// too.
25static TRACE: PmpxLog = table(PMPX_LEVEL_DEBUG);
26static INFO: PmpxLog = table(PMPX_LEVEL_INFO);
27static NORMAL: PmpxLog = table(PMPX_LEVEL_WARN);
28static QUIET: PmpxLog = table(PMPX_LEVEL_ERROR);
29
30/// One table at one level.
31const fn table(max_level: u32) -> PmpxLog {
32    PmpxLog {
33        size: std::mem::size_of::<PmpxLog>(),
34        write: record,
35        max_level,
36    }
37}
38
39/// Record one line. The callback a plugin calls through.
40///
41/// # Safety
42/// `message` must be valid for the duration of this call, which is what the contract promises.
43unsafe extern "C" fn record(level: u32, message: PmpxStr) {
44    // SAFETY: the plugin passes a borrowed view of a `String` that outlives this call.
45    let bytes = unsafe { message.as_bytes() }.unwrap_or(&[]);
46    let text = String::from_utf8_lossy(bytes).into_owned();
47
48    if let Ok(mut lines) = LINES.lock() {
49        lines.push((level, text));
50    }
51}
52
53/// A handle on the fake host: install it, run the plugin, assert on the lines.
54pub struct Captured;
55
56impl Captured {
57    /// Everything the plugin wrote since the handle was created, oldest first.
58    ///
59    /// Draining by taking it: a test that asserts twice does not see the first assertion's lines again.
60    pub fn take(&self) -> Vec<String> {
61        let mut lines = LINES.lock().expect("the fake host's list is not poisoned");
62        lines.drain(..).map(|(_, text)| text).collect()
63    }
64
65    /// The same, with the contract's level numbers.
66    pub fn take_levels(&self) -> Vec<(u32, String)> {
67        let mut lines = LINES.lock().expect("the fake host's list is not poisoned");
68        lines.drain(..).collect()
69    }
70
71    /// How loud this host is: installing the table for that level is what tells the plugin.
72    pub fn level(self, level: u32) -> Self {
73        let table = match level {
74            PMPX_LEVEL_DEBUG => &TRACE,
75            PMPX_LEVEL_INFO => &INFO,
76            PMPX_LEVEL_ERROR => &QUIET,
77            _ => &NORMAL,
78        };
79        // SAFETY: this module's own `'static` table, large enough for the shell's check.
80        unsafe { pmpx_plugin::shell::install_log_table(table) };
81        self
82    }
83
84    /// Everything: notes and detail included.
85    pub fn trace(self) -> Self {
86        self.level(PMPX_LEVEL_DEBUG)
87    }
88
89    /// Only failures.
90    pub fn quiet(self) -> Self {
91        self.level(PMPX_LEVEL_ERROR)
92    }
93
94    /// A plain run: warnings and errors.
95    pub fn normal(self) -> Self {
96        self.level(PMPX_LEVEL_WARN)
97    }
98
99    /// Notes and above.
100    pub fn info(self) -> Self {
101        self.level(PMPX_LEVEL_INFO)
102    }
103}
104
105/// Install the fake host and hand back a handle on it.
106///
107/// The level starts at `Warn`, like a plain run; [`Captured::trace`] and friends change it. Whatever was
108/// recorded before is dropped, so a test starts from an empty list.
109pub fn capture() -> Captured {
110    if let Ok(mut lines) = LINES.lock() {
111        lines.clear();
112    }
113
114    Captured.level(PMPX_LEVEL_WARN)
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use pmpx_plugin::{CommandSpec, Context, Family, PackageManager, PluginError, Verb};
121
122    /// The hooks are process-wide, so these three tests take turns: one installs a level, runs the plugin,
123    /// and asserts -- with the lock held, so no other test can install a different level in between. A
124    /// plugin author writing one test does not have to think about this; this module's own tests do.
125    static SERIAL: Mutex<()> = Mutex::new(());
126
127    /// A plugin that says one thing per level, to prove the capture works the way an author would use it.
128    struct Talker;
129
130    impl PackageManager for Talker {
131        fn name(&self) -> &str {
132            "talker"
133        }
134
135        fn family(&self) -> Family {
136            Family::NODE
137        }
138
139        fn command(
140            &self,
141            _ctx: &Context,
142            _verb: Verb,
143            _args: &[std::ffi::OsString],
144        ) -> Result<CommandSpec, PluginError> {
145            pmpx_plugin::debug!("choosing a version");
146            pmpx_plugin::warn!("no lockfile, guessing");
147            pmpx_plugin::error!("this will not work");
148            Ok(CommandSpec::new("tool"))
149        }
150    }
151
152    /// With a trace on, every line is recorded, in order, at the level it was written.
153    #[test]
154    fn a_trace_captures_every_level() {
155        let _serial = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
156        let host = capture().trace();
157
158        let _ = Talker.command(&crate::context().build(), Verb::Install, &[]);
159
160        let lines = host.take_levels();
161        for level in [PMPX_LEVEL_DEBUG, PMPX_LEVEL_WARN, PMPX_LEVEL_ERROR] {
162            assert!(
163                lines.iter().any(|(got, _)| *got == level),
164                "level {level} is missing from {lines:?}"
165            );
166        }
167        assert!(
168            lines.iter().any(|(_, text)| text == "choosing a version"),
169            "{lines:?}"
170        );
171    }
172
173    /// After a normal run, only what a normal run shows is recorded -- the "nothing is formatted when
174    /// nobody asked" promise, from a test's side.
175    #[test]
176    fn a_normal_capture_drops_notes_and_detail() {
177        let _serial = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
178        let host = capture().normal();
179
180        let _ = Talker.command(&crate::context().build(), Verb::Install, &[]);
181
182        let lines = host.take_levels();
183        assert!(
184            lines
185                .iter()
186                .any(|(_, text)| text == "no lockfile, guessing"),
187            "a warning is shown by a normal run: {lines:?}"
188        );
189        assert!(
190            !lines.iter().any(|(level, _)| *level == PMPX_LEVEL_DEBUG),
191            "detail is not even formatted: {lines:?}"
192        );
193        assert!(host.take().is_empty(), "the list is drained by taking it");
194    }
195
196    /// A quiet host keeps only the failures.
197    #[test]
198    fn a_quiet_capture_keeps_only_errors() {
199        let _serial = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
200        let host = capture().quiet();
201
202        let _ = Talker.command(&crate::context().build(), Verb::Install, &[]);
203
204        let lines = host.take_levels();
205        assert!(
206            lines.iter().any(|(_, text)| text == "this will not work"),
207            "{lines:?}"
208        );
209        assert!(
210            lines.iter().all(|(level, _)| *level == PMPX_LEVEL_ERROR),
211            "a quiet host keeps only failures: {lines:?}"
212        );
213    }
214}