Skip to main content

xianyun_testutil/
lib.rs

1//! Shared test-support mocks for `xianyun-*` crates.
2//!
3//! This crate exists because `xianyun_executor::exec::CommandRunner` is a
4//! `pub` trait implemented by clients across several crates
5//! (`xianyun-backends`, `xianyun-cluster`, `xianyun-deploy`, ...), but
6//! `xianyun-executor`'s own hand-rolled mock (`exec::mock::MockCommandRunner`)
7//! is `pub(crate)` and cannot be reused outside that crate. Rather than
8//! leave every downstream crate to hand-roll (and slowly diverge) its own
9//! copy -- as `xianyun-backends`'s `testutil.rs` already had to -- this
10//! crate provides one shared, more capable implementation as a
11//! workspace-relative dev-dependency.
12//!
13//! Compared to the hand-rolled mocks it replaces, [`MockCommandRunner`]
14//! adds two capabilities several `PORT_NOTES.md` files flagged as
15//! missing:
16//!
17//! - **Predicate matching** (`expect_*_matching`), the equivalent of
18//!   testify's `mock.MatchedBy`, for expectations that need to inspect
19//!   arguments rather than match them exactly (e.g. a `--timeout=<dur>`
20//!   value that shrinks across calls).
21//! - **Repeating expectations** (`expect_*_repeating`), the equivalent of
22//!   testify's `.Maybe()`, for expectations that may match any number of
23//!   times (including zero) rather than exactly once.
24//!
25//! Every expectation is queued per `CommandRunner` method and matched
26//! FIFO: when a call arrives, the first non-exhausted queued expectation
27//! for that method whose matcher accepts the call's `(name, args)` wins.
28//! Exact-match, one-shot expectations behave exactly like the mocks this
29//! crate replaces (queue in the order you expect calls, first in first
30//! served). Calling [`MockCommandRunner::verify`] -- or simply letting
31//! the mock drop -- panics if any one-shot expectation was never fully
32//! consumed; an unexpected call (nothing queued matches) always panics
33//! immediately with a diagnostic listing what *was* queued.
34
35mod expectation;
36
37use std::path::PathBuf;
38use std::sync::atomic::{AtomicBool, Ordering};
39use std::sync::{Arc, Mutex};
40
41use xianyun_executor::exec::{CommandRunner, ExecError, ExecResult, ExecStreamedOpts, Options};
42
43use expectation::{Budget, Expectation, Matcher, collect_unmet, resolve};
44
45/// A reusable, hand-rolled mock for `xianyun_executor::exec::CommandRunner`.
46///
47/// See the module-level documentation for the matching/exhaustion model.
48/// Construct with [`MockCommandRunner::new`], queue expectations with the
49/// `expect_*` methods, exercise it through the `CommandRunner` trait, and
50/// either call [`MockCommandRunner::verify`] explicitly or let it drop at
51/// the end of the test.
52pub struct MockCommandRunner {
53    run: Mutex<Vec<Expectation<ExecResult>>>,
54    run_calls: Mutex<Vec<RecordedRunCall>>,
55    read_stdout: Mutex<Vec<Expectation<Result<String, ExecError>>>>,
56    run_sudo: Mutex<Vec<Expectation<Result<(), ExecError>>>>,
57    check: Mutex<Vec<Expectation<Result<bool, ExecError>>>>,
58    exec_streamed_command: Mutex<Vec<Expectation<Result<(), ExecError>>>>,
59    look_path: Mutex<Vec<Expectation<Result<PathBuf, ExecError>>>>,
60    run_in_repository: Mutex<Vec<Expectation<Result<(), ExecError>>>>,
61    run_in_repository_calls: Mutex<Vec<RecordedRunInRepositoryCall>>,
62    exec_streamed_command_calls: Mutex<Vec<RecordedExecStreamedCommandCall>>,
63    verified: AtomicBool,
64}
65
66impl Default for MockCommandRunner {
67    fn default() -> Self {
68        Self {
69            run: Mutex::new(Vec::new()),
70            run_calls: Mutex::new(Vec::new()),
71            read_stdout: Mutex::new(Vec::new()),
72            run_sudo: Mutex::new(Vec::new()),
73            check: Mutex::new(Vec::new()),
74            exec_streamed_command: Mutex::new(Vec::new()),
75            look_path: Mutex::new(Vec::new()),
76            run_in_repository: Mutex::new(Vec::new()),
77            run_in_repository_calls: Mutex::new(Vec::new()),
78            exec_streamed_command_calls: Mutex::new(Vec::new()),
79            verified: AtomicBool::new(false),
80        }
81    }
82}
83
84fn owned_args(args: &[&str]) -> Vec<String> {
85    args.iter().map(|s| (*s).to_string()).collect()
86}
87
88/// Wraps a plain value as a one-shot responder: the value is moved out on
89/// first (and only, since the expectation's budget is `Finite { remaining:
90/// 1 }`) call.
91fn once_responder<T: Send + 'static>(value: T) -> Box<dyn Fn() -> T + Send + Sync> {
92    let slot = Mutex::new(Some(value));
93    Box::new(move || {
94        slot.lock()
95            .unwrap()
96            .take()
97            .expect("MockCommandRunner: one-shot expectation invoked more than once")
98    })
99}
100
101macro_rules! mock_method {
102    (
103        $queue:ident,
104        $method_label:literal,
105        $expect_exact:ident,
106        $expect_matching:ident,
107        $expect_repeating:ident,
108        $result:ty
109    ) => {
110        /// Queues an exact-match, one-shot (called exactly once)
111        /// expectation.
112        pub fn $expect_exact(&self, name: &str, args: &[&str], result: $result) {
113            self.$queue.lock().unwrap().push(Expectation::new(
114                name.to_string(),
115                Matcher::Exact(owned_args(args)),
116                once_responder(result),
117                Budget::Finite { remaining: 1 },
118            ));
119        }
120
121        /// Queues a predicate-matched, one-shot expectation -- the
122        /// equivalent of testify's `mock.MatchedBy`. `predicate` is
123        /// evaluated against the call's arguments.
124        pub fn $expect_matching(
125            &self,
126            name: &str,
127            predicate: impl Fn(&[String]) -> bool + Send + Sync + 'static,
128            result: $result,
129        ) {
130            self.$queue.lock().unwrap().push(Expectation::new(
131                name.to_string(),
132                Matcher::Predicate(Arc::new(predicate)),
133                once_responder(result),
134                Budget::Finite { remaining: 1 },
135            ));
136        }
137
138        /// Queues an exact-match expectation that may match any number
139        /// of times (including zero) -- the equivalent of testify's
140        /// `.Maybe()`. `responder` is invoked fresh on every matching
141        /// call, so it can return a fallible value type with no `Clone`
142        /// bound.
143        pub fn $expect_repeating(
144            &self,
145            name: &str,
146            args: &[&str],
147            responder: impl Fn() -> $result + Send + Sync + 'static,
148        ) {
149            self.$queue.lock().unwrap().push(Expectation::new(
150                name.to_string(),
151                Matcher::Exact(owned_args(args)),
152                Box::new(responder),
153                Budget::Unlimited,
154            ));
155        }
156    };
157}
158
159impl MockCommandRunner {
160    #[must_use]
161    pub fn new() -> Self {
162        Self::default()
163    }
164
165    mock_method!(
166        run,
167        "Run",
168        expect_run,
169        expect_run_matching,
170        expect_run_repeating,
171        ExecResult
172    );
173    mock_method!(
174        read_stdout,
175        "ReadStdout",
176        expect_read_stdout,
177        expect_read_stdout_matching,
178        expect_read_stdout_repeating,
179        Result<String, ExecError>
180    );
181    mock_method!(
182        run_sudo,
183        "RunSudo",
184        expect_run_sudo,
185        expect_run_sudo_matching,
186        expect_run_sudo_repeating,
187        Result<(), ExecError>
188    );
189    mock_method!(
190        check,
191        "Check",
192        expect_check,
193        expect_check_matching,
194        expect_check_repeating,
195        Result<bool, ExecError>
196    );
197    mock_method!(
198        exec_streamed_command,
199        "ExecStreamedCommand",
200        expect_exec_streamed_command,
201        expect_exec_streamed_command_matching,
202        expect_exec_streamed_command_repeating,
203        Result<(), ExecError>
204    );
205    mock_method!(
206        run_in_repository,
207        "RunInRepository",
208        expect_run_in_repository,
209        expect_run_in_repository_matching,
210        expect_run_in_repository_repeating,
211        Result<(), ExecError>
212    );
213
214    /// Queues an exact-match, one-shot expectation for `look_path(file)`.
215    /// `look_path` has no `args` slice (it takes a single `file`), so it
216    /// is not covered by the `mock_method` macro above.
217    ///
218    /// # Panics
219    ///
220    /// Panics if another thread poisoned this mock's internal lock by
221    /// panicking while holding it.
222    pub fn expect_look_path(&self, file: &str, result: Result<PathBuf, ExecError>) {
223        self.look_path.lock().unwrap().push(Expectation::new(
224            file.to_string(),
225            Matcher::Exact(Vec::new()),
226            once_responder(result),
227            Budget::Finite { remaining: 1 },
228        ));
229    }
230
231    /// Queues a `look_path(file)` expectation that may match any number
232    /// of times.
233    ///
234    /// # Panics
235    ///
236    /// Panics if another thread poisoned this mock's internal lock by
237    /// panicking while holding it.
238    pub fn expect_look_path_repeating(
239        &self,
240        file: &str,
241        responder: impl Fn() -> Result<PathBuf, ExecError> + Send + Sync + 'static,
242    ) {
243        self.look_path.lock().unwrap().push(Expectation::new(
244            file.to_string(),
245            Matcher::Exact(Vec::new()),
246            Box::new(responder),
247            Budget::Unlimited,
248        ));
249    }
250
251    /// Returns every recorded call to `run`, in call order, regardless of
252    /// whether an expectation matched it. Use this to assert on `opts`
253    /// (particularly `opts.env`) -- the one thing `expect_run_matching`'s
254    /// predicate can't see. This mirrors `run_in_repository`/
255    /// `exec_streamed_command`'s own recorded-calls accessors; `run` only
256    /// grew one once a real bug (`xianyun-backends::kubectl::Client`
257    /// never wiring `KUBECONFIG` into `Options::env`) went undetected
258    /// because nothing could assert on `run`'s `opts` -- see
259    /// `crates/xianyun-backends/PORT_NOTES.md`.
260    ///
261    /// # Panics
262    ///
263    /// Panics if another thread poisoned this mock's internal lock by
264    /// panicking while holding it.
265    #[must_use]
266    pub fn recorded_run_calls(&self) -> Vec<RecordedRunCall> {
267        self.run_calls.lock().unwrap().clone()
268    }
269
270    /// Returns every recorded call to `run_in_repository`, in call order,
271    /// regardless of whether an expectation matched it. Use this to
272    /// assert on `opts` (particularly `opts.env`) -- the one thing
273    /// `expect_run_in_repository_matching`'s predicate can't see.
274    ///
275    /// # Panics
276    ///
277    /// Panics if another thread poisoned this mock's internal lock by
278    /// panicking while holding it.
279    #[must_use]
280    pub fn recorded_run_in_repository_calls(&self) -> Vec<RecordedRunInRepositoryCall> {
281        self.run_in_repository_calls.lock().unwrap().clone()
282    }
283
284    /// Returns every recorded call to `exec_streamed_command`, in call
285    /// order, regardless of whether an expectation matched it. Use this
286    /// to assert on `opts` (particularly `opts.env`) -- the one thing
287    /// `expect_exec_streamed_command_matching`'s predicate can't see.
288    ///
289    /// # Panics
290    ///
291    /// Panics if another thread poisoned this mock's internal lock by
292    /// panicking while holding it.
293    #[must_use]
294    pub fn recorded_exec_streamed_command_calls(&self) -> Vec<RecordedExecStreamedCommandCall> {
295        self.exec_streamed_command_calls.lock().unwrap().clone()
296    }
297
298    /// Checks every queued expectation across every mocked method and
299    /// panics, listing them, if any one-shot (non-repeating) expectation
300    /// was never fully consumed. Safe to call multiple times: only the
301    /// first call can panic, since matched expectations are removed from
302    /// their queues as they're consumed. Also run implicitly on
303    /// [`Drop`] if not called explicitly (skipped if the test is already
304    /// panicking, to avoid a double panic during unwind).
305    ///
306    /// # Panics
307    ///
308    /// Panics if any one-shot expectation across any mocked method was
309    /// never fully consumed, or if another thread poisoned this mock's
310    /// internal lock by panicking while holding it.
311    pub fn verify(&self) {
312        self.verified.store(true, Ordering::SeqCst);
313
314        let mut unmet = Vec::new();
315        collect_unmet(&self.run.lock().unwrap(), "Run", &mut unmet);
316        collect_unmet(&self.read_stdout.lock().unwrap(), "ReadStdout", &mut unmet);
317        collect_unmet(&self.run_sudo.lock().unwrap(), "RunSudo", &mut unmet);
318        collect_unmet(&self.check.lock().unwrap(), "Check", &mut unmet);
319        collect_unmet(
320            &self.exec_streamed_command.lock().unwrap(),
321            "ExecStreamedCommand",
322            &mut unmet,
323        );
324        collect_unmet(&self.look_path.lock().unwrap(), "LookPath", &mut unmet);
325        collect_unmet(
326            &self.run_in_repository.lock().unwrap(),
327            "RunInRepository",
328            &mut unmet,
329        );
330
331        assert!(
332            unmet.is_empty(),
333            "MockCommandRunner::verify: {} expectation(s) never matched:\n{}",
334            unmet.len(),
335            unmet.join("\n")
336        );
337    }
338}
339
340impl Drop for MockCommandRunner {
341    fn drop(&mut self) {
342        if self.verified.load(Ordering::SeqCst) || std::thread::panicking() {
343            return;
344        }
345        self.verify();
346    }
347}
348
349impl CommandRunner for MockCommandRunner {
350    fn run(&self, name: &str, args: &[String], opts: Option<Options>) -> ExecResult {
351        // `Options::stdin` is `Box<dyn Read + Send>`, which isn't `Clone`,
352        // so the recorded call carries the fields a test would plausibly
353        // want to assert on (particularly `env`) rather than the whole
354        // `Options` -- `opts` is moved here, not borrowed, since `resolve`
355        // below never reads it (matching is on `name`/`args` only).
356        self.run_calls.lock().unwrap().push(RecordedRunCall {
357            name: name.to_string(),
358            args: args.to_vec(),
359            env: opts.as_ref().and_then(|o| o.env.clone()),
360            timeout: opts.as_ref().and_then(|o| o.timeout),
361        });
362        resolve(&mut self.run.lock().unwrap(), "Run", name, args)
363    }
364
365    fn read_stdout(&self, name: &str, args: &[String]) -> Result<String, ExecError> {
366        resolve(
367            &mut self.read_stdout.lock().unwrap(),
368            "ReadStdout",
369            name,
370            args,
371        )
372    }
373
374    fn run_sudo(&self, name: &str, args: &[String]) -> Result<(), ExecError> {
375        resolve(&mut self.run_sudo.lock().unwrap(), "RunSudo", name, args)
376    }
377
378    fn check(&self, name: &str, args: &[String]) -> Result<bool, ExecError> {
379        resolve(&mut self.check.lock().unwrap(), "Check", name, args)
380    }
381
382    fn exec_streamed_command(
383        &self,
384        opts: Option<ExecStreamedOpts>,
385        name: &str,
386        args: &[String],
387    ) -> Result<(), ExecError> {
388        self.exec_streamed_command_calls
389            .lock()
390            .unwrap()
391            .push(RecordedExecStreamedCommandCall {
392                name: name.to_string(),
393                args: args.to_vec(),
394                opts: opts.clone(),
395            });
396        resolve(
397            &mut self.exec_streamed_command.lock().unwrap(),
398            "ExecStreamedCommand",
399            name,
400            args,
401        )
402    }
403
404    fn look_path(&self, file: &str) -> Result<PathBuf, ExecError> {
405        resolve(&mut self.look_path.lock().unwrap(), "LookPath", file, &[])
406    }
407
408    fn run_in_repository(
409        &self,
410        repo_dir: &str,
411        name: &str,
412        args: &[String],
413        opts: Option<ExecStreamedOpts>,
414    ) -> Result<(), ExecError> {
415        self.run_in_repository_calls
416            .lock()
417            .unwrap()
418            .push(RecordedRunInRepositoryCall {
419                repo_dir: repo_dir.to_string(),
420                name: name.to_string(),
421                args: args.to_vec(),
422                opts: opts.clone(),
423            });
424        // `run_in_repository` is keyed on `name` (the command), matching
425        // every other method; `repo_dir` is folded into the matched
426        // arguments so expectations can still distinguish repositories
427        // without a dedicated queue shape.
428        let mut full_args = vec![repo_dir.to_string()];
429        full_args.extend_from_slice(args);
430        resolve(
431            &mut self.run_in_repository.lock().unwrap(),
432            "RunInRepository",
433            name,
434            &full_args,
435        )
436    }
437}
438
439/// One recorded call to [`MockCommandRunner::run`], captured regardless of
440/// whether an expectation matched it. Exists because `Matcher::Predicate`
441/// only sees `(name, args)`, not `opts`. Carries `env`/`timeout` rather
442/// than the full `Options`, since `Options::stdin` (`Box<dyn Read + Send>`)
443/// isn't `Clone` and doesn't need recording for this purpose anyway --
444/// see [`MockCommandRunner::recorded_run_calls`].
445#[derive(Debug, Clone)]
446pub struct RecordedRunCall {
447    pub name: String,
448    pub args: Vec<String>,
449    pub env: Option<Vec<String>>,
450    pub timeout: Option<std::time::Duration>,
451}
452
453/// One recorded call to [`MockCommandRunner::run_in_repository`], captured
454/// regardless of whether an expectation matched it. Exists because
455/// `Matcher::Predicate` only sees `(name, args)`, not `opts` -- `Options`
456/// and `ExecStreamedOpts` carry fields (`env`, in particular) several
457/// `PORT_NOTES.md` files flagged as needing inspection that the
458/// expectation-matching system can't offer directly. See
459/// [`MockCommandRunner::recorded_run_in_repository_calls`].
460#[derive(Debug, Clone)]
461pub struct RecordedRunInRepositoryCall {
462    pub repo_dir: String,
463    pub name: String,
464    pub args: Vec<String>,
465    pub opts: Option<ExecStreamedOpts>,
466}
467
468/// One recorded call to [`MockCommandRunner::exec_streamed_command`],
469/// captured regardless of whether an expectation matched it. See
470/// [`RecordedRunInRepositoryCall`] for why this exists.
471#[derive(Debug, Clone)]
472pub struct RecordedExecStreamedCommandCall {
473    pub name: String,
474    pub args: Vec<String>,
475    pub opts: Option<ExecStreamedOpts>,
476}
477
478#[cfg(test)]
479mod tests;