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;