ironflow_engine/testing/mocks.rs
1//! Canned step results and the closure-backed doubles that serve them.
2//!
3//! [`MockInterceptor`] answers shell and HTTP steps from closures; agent steps
4//! are answered one seam lower, by [`MockAgentProvider`] or
5//! [`MissingAgentProvider`]. Everything here mirrors the shape the real
6//! executors persist, so a handler cannot tell the difference.
7//!
8//! The value types are defined locally rather than reusing
9//! [`ShellOutput`](ironflow_core::operations::shell::ShellOutput) and
10//! [`HttpOutput`](ironflow_core::operations::http::HttpOutput): those have
11//! private fields and no public constructor.
12
13use std::collections::BTreeMap;
14use std::fmt;
15use std::sync::Arc;
16
17use rust_decimal::Decimal;
18use serde_json::{Value, json, to_string};
19
20use ironflow_core::error::{AgentError, OperationError};
21use ironflow_core::provider::{AgentConfig, AgentOutput, AgentProvider, InvokeFuture};
22
23use crate::config::{ApprovalConfig, HttpConfig, ShellConfig, StepConfig};
24use crate::error::EngineError;
25use crate::executor::{ApprovalOutcome, StepInterceptor, StepOutput};
26
27/// Message carried by [`MissingAgentProvider`] failures.
28const MISSING_AGENT_PROVIDER: &str = "TestEngine has no agent provider: call with_mock_agent(...), with_recorded_agent(...) or \
29 with_agent_provider(...)";
30
31/// Canned result of a mocked shell step.
32///
33/// # Examples
34///
35/// ```
36/// use ironflow_engine::testing::MockShellOutput;
37///
38/// let ok = MockShellOutput::ok("built 3 crates");
39/// assert_eq!(ok.exit_code, 0);
40///
41/// let ko = MockShellOutput::failed(2, "linker not found");
42/// assert_eq!(ko.stderr, "linker not found");
43/// ```
44#[derive(Debug, Clone, PartialEq, Eq, Default)]
45pub struct MockShellOutput {
46 /// Standard output the step reports.
47 pub stdout: String,
48 /// Standard error the step reports.
49 pub stderr: String,
50 /// Process exit code. Anything but `0` fails the step.
51 pub exit_code: i32,
52}
53
54impl MockShellOutput {
55 /// A successful command that printed `stdout`.
56 ///
57 /// # Examples
58 ///
59 /// ```
60 /// use ironflow_engine::testing::MockShellOutput;
61 ///
62 /// let output = MockShellOutput::ok("ok\n");
63 /// assert_eq!(output.stdout, "ok\n");
64 /// assert!(output.stderr.is_empty());
65 /// ```
66 pub fn ok(stdout: &str) -> Self {
67 Self {
68 stdout: stdout.to_string(),
69 ..Self::default()
70 }
71 }
72
73 /// A command that exited with `exit_code` after printing `stderr`.
74 ///
75 /// # Examples
76 ///
77 /// ```
78 /// use ironflow_engine::testing::MockShellOutput;
79 ///
80 /// let output = MockShellOutput::failed(127, "command not found");
81 /// assert_eq!(output.exit_code, 127);
82 /// ```
83 pub fn failed(exit_code: i32, stderr: &str) -> Self {
84 Self {
85 stdout: String::new(),
86 stderr: stderr.to_string(),
87 exit_code,
88 }
89 }
90
91 /// Convert to what the step lifecycle expects.
92 ///
93 /// Mirrors [`ShellExecutor`](crate::executor::ShellExecutor): a non-zero
94 /// exit code is an error, not an output. `allow_failure`, step retry
95 /// policies and run failure all key off that error.
96 pub(crate) fn into_step_result(self) -> Result<StepOutput, EngineError> {
97 if self.exit_code != 0 {
98 return Err(EngineError::Operation(OperationError::Shell {
99 exit_code: self.exit_code,
100 stderr: self.stderr,
101 }));
102 }
103
104 Ok(StepOutput {
105 output: json!({
106 "stdout": self.stdout,
107 "stderr": self.stderr,
108 "exit_code": self.exit_code,
109 }),
110 duration_ms: 0,
111 cost_usd: Decimal::ZERO,
112 input_tokens: None,
113 output_tokens: None,
114 model: None,
115 debug_messages: None,
116 })
117 }
118}
119
120/// Canned response of a mocked HTTP step.
121///
122/// A non-2xx status is *not* an error, exactly like the real
123/// [`HttpExecutor`](crate::executor::HttpExecutor): the status lands in the
124/// step output. A transport failure is expressed by returning
125/// `Err(OperationError::Http { status: None, .. })` from the mock closure.
126///
127/// # Examples
128///
129/// ```
130/// use ironflow_engine::testing::MockHttpResponse;
131/// use serde_json::json;
132///
133/// let created = MockHttpResponse::json(201, &json!({"id": 7}))
134/// .header("location", "/things/7");
135/// assert_eq!(created.status, 201);
136/// assert_eq!(created.headers, vec![("location".to_string(), "/things/7".to_string())]);
137/// ```
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct MockHttpResponse {
140 /// HTTP status code the step reports.
141 pub status: u16,
142 /// Response headers, in insertion order.
143 pub headers: Vec<(String, String)>,
144 /// Raw response body.
145 pub body: String,
146}
147
148impl Default for MockHttpResponse {
149 fn default() -> Self {
150 Self {
151 status: 200,
152 headers: Vec::new(),
153 body: String::new(),
154 }
155 }
156}
157
158impl MockHttpResponse {
159 /// A `200 OK` carrying `body` serialized as JSON.
160 ///
161 /// # Examples
162 ///
163 /// ```
164 /// use ironflow_engine::testing::MockHttpResponse;
165 /// use serde_json::json;
166 ///
167 /// let response = MockHttpResponse::ok(&json!({"ok": true}));
168 /// assert_eq!(response.status, 200);
169 /// assert_eq!(response.body, r#"{"ok":true}"#);
170 /// ```
171 pub fn ok(body: &Value) -> Self {
172 Self::json(200, body)
173 }
174
175 /// A response with the given status carrying `body` serialized as JSON.
176 ///
177 /// # Examples
178 ///
179 /// ```
180 /// use ironflow_engine::testing::MockHttpResponse;
181 /// use serde_json::json;
182 ///
183 /// let response = MockHttpResponse::json(404, &json!({"error": "not found"}));
184 /// assert_eq!(response.status, 404);
185 /// ```
186 pub fn json(status: u16, body: &Value) -> Self {
187 Self {
188 status,
189 headers: Vec::new(),
190 // `Value` always serializes; the fallback keeps the mock infallible.
191 body: to_string(body).unwrap_or_else(|_| body.to_string()),
192 }
193 }
194
195 /// A response with the given status carrying a raw text body.
196 ///
197 /// # Examples
198 ///
199 /// ```
200 /// use ironflow_engine::testing::MockHttpResponse;
201 ///
202 /// let response = MockHttpResponse::text(503, "upstream is down");
203 /// assert_eq!(response.body, "upstream is down");
204 /// ```
205 pub fn text(status: u16, body: &str) -> Self {
206 Self {
207 status,
208 headers: Vec::new(),
209 body: body.to_string(),
210 }
211 }
212
213 /// Add a response header.
214 ///
215 /// # Examples
216 ///
217 /// ```
218 /// use ironflow_engine::testing::MockHttpResponse;
219 ///
220 /// let response = MockHttpResponse::text(200, "pong").header("x-trace", "abc");
221 /// assert_eq!(response.headers.len(), 1);
222 /// ```
223 pub fn header(mut self, name: &str, value: &str) -> Self {
224 self.headers.push((name.to_string(), value.to_string()));
225 self
226 }
227
228 /// Convert to the exact shape [`HttpExecutor`](crate::executor::HttpExecutor)
229 /// persists.
230 pub(crate) fn into_step_output(self) -> StepOutput {
231 let headers: BTreeMap<String, String> = self.headers.into_iter().collect();
232 StepOutput {
233 output: json!({
234 "status": self.status,
235 "headers": headers,
236 "body": self.body,
237 }),
238 duration_ms: 0,
239 cost_usd: Decimal::ZERO,
240 input_tokens: None,
241 output_tokens: None,
242 model: None,
243 debug_messages: None,
244 }
245 }
246}
247
248/// Closure answering a shell step from its config.
249pub type ShellMock =
250 Arc<dyn Fn(&ShellConfig) -> Result<MockShellOutput, OperationError> + Send + Sync>;
251
252/// Closure answering an HTTP step from its config.
253pub type HttpMock =
254 Arc<dyn Fn(&HttpConfig) -> Result<MockHttpResponse, OperationError> + Send + Sync>;
255
256/// Closure answering an agent invocation from its config.
257pub type AgentMock = Arc<dyn Fn(&AgentConfig) -> Result<AgentOutput, AgentError> + Send + Sync>;
258
259/// A [`StepInterceptor`] built from closures.
260///
261/// Built by [`TestEngine`](crate::testing::TestEngine); a step kind with no
262/// mock attached falls through to the real executor.
263///
264/// # Examples
265///
266/// ```
267/// use ironflow_engine::config::{ShellConfig, StepConfig};
268/// use ironflow_engine::executor::StepInterceptor;
269/// use ironflow_engine::testing::{MockInterceptor, MockShellOutput};
270///
271/// let interceptor = MockInterceptor::new().shell(|_cfg| Ok(MockShellOutput::ok("mocked")));
272/// let config = StepConfig::Shell(ShellConfig::new("./deploy.sh"));
273///
274/// let output = interceptor
275/// .intercept(&config)
276/// .expect("shell steps are mocked")
277/// .expect("the mock succeeded");
278/// assert_eq!(output.stdout(), "mocked");
279/// ```
280#[derive(Clone, Default)]
281pub struct MockInterceptor {
282 shell: Option<ShellMock>,
283 http: Option<HttpMock>,
284 approval: Option<ApprovalOutcome>,
285}
286
287impl MockInterceptor {
288 /// An interceptor that mocks nothing.
289 ///
290 /// # Examples
291 ///
292 /// ```
293 /// use ironflow_engine::config::{ShellConfig, StepConfig};
294 /// use ironflow_engine::executor::StepInterceptor;
295 /// use ironflow_engine::testing::MockInterceptor;
296 ///
297 /// let interceptor = MockInterceptor::new();
298 /// let config = StepConfig::Shell(ShellConfig::new("echo hi"));
299 /// assert!(interceptor.intercept(&config).is_none());
300 /// ```
301 pub fn new() -> Self {
302 Self::default()
303 }
304
305 /// Answer every shell step with `f`.
306 ///
307 /// # Examples
308 ///
309 /// ```
310 /// use ironflow_engine::testing::{MockInterceptor, MockShellOutput};
311 ///
312 /// let interceptor = MockInterceptor::new()
313 /// .shell(|cfg| Ok(MockShellOutput::ok(&format!("ran {}", cfg.command))));
314 /// # let _ = interceptor;
315 /// ```
316 pub fn shell(
317 mut self,
318 f: impl Fn(&ShellConfig) -> Result<MockShellOutput, OperationError> + Send + Sync + 'static,
319 ) -> Self {
320 self.shell = Some(Arc::new(f));
321 self
322 }
323
324 /// Answer every HTTP step with `f`.
325 ///
326 /// # Examples
327 ///
328 /// ```
329 /// use ironflow_engine::testing::{MockHttpResponse, MockInterceptor};
330 /// use serde_json::json;
331 ///
332 /// let interceptor = MockInterceptor::new()
333 /// .http(|_cfg| Ok(MockHttpResponse::ok(&json!({"ok": true}))));
334 /// # let _ = interceptor;
335 /// ```
336 pub fn http(
337 mut self,
338 f: impl Fn(&HttpConfig) -> Result<MockHttpResponse, OperationError> + Send + Sync + 'static,
339 ) -> Self {
340 self.http = Some(Arc::new(f));
341 self
342 }
343
344 /// Resolve every approval gate with `outcome`.
345 ///
346 /// # Examples
347 ///
348 /// ```
349 /// use ironflow_engine::testing::{ApprovalOutcome, MockInterceptor};
350 ///
351 /// let interceptor = MockInterceptor::new().approval(ApprovalOutcome::Approved);
352 /// # let _ = interceptor;
353 /// ```
354 pub fn approval(mut self, outcome: ApprovalOutcome) -> Self {
355 self.approval = Some(outcome);
356 self
357 }
358}
359
360impl fmt::Debug for MockInterceptor {
361 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
362 // Closures are not `Debug`: report which seams are mocked instead.
363 f.debug_struct("MockInterceptor")
364 .field("shell", &self.shell.is_some())
365 .field("http", &self.http.is_some())
366 .field("approval", &self.approval)
367 .finish()
368 }
369}
370
371impl StepInterceptor for MockInterceptor {
372 fn intercept(&self, config: &StepConfig) -> Option<Result<StepOutput, EngineError>> {
373 match config {
374 StepConfig::Shell(cfg) => {
375 let mock = self.shell.as_ref()?;
376 Some(match mock(cfg) {
377 Ok(out) => out.into_step_result(),
378 Err(err) => Err(EngineError::Operation(err)),
379 })
380 }
381 StepConfig::Http(cfg) => {
382 let mock = self.http.as_ref()?;
383 Some(match mock(cfg) {
384 Ok(res) => Ok(res.into_step_output()),
385 Err(err) => Err(EngineError::Operation(err)),
386 })
387 }
388 // Agent steps are mocked at the provider seam instead.
389 _ => None,
390 }
391 }
392
393 fn intercept_approval(&self, _name: &str, _config: &ApprovalConfig) -> Option<ApprovalOutcome> {
394 self.approval.clone()
395 }
396}
397
398/// An [`AgentProvider`] backed by a closure.
399///
400/// # Examples
401///
402/// ```
403/// use ironflow_core::provider::AgentOutput;
404/// use ironflow_engine::testing::MockAgentProvider;
405/// use serde_json::json;
406///
407/// let provider = MockAgentProvider::new(|cfg| {
408/// assert!(cfg.prompt.contains("review"));
409/// Ok(AgentOutput::new(json!({"score": 9})))
410/// });
411/// # let _ = provider;
412/// ```
413pub struct MockAgentProvider {
414 f: AgentMock,
415}
416
417impl MockAgentProvider {
418 /// Answer every invocation with `f`.
419 ///
420 /// # Examples
421 ///
422 /// ```
423 /// use ironflow_core::provider::AgentOutput;
424 /// use ironflow_engine::testing::MockAgentProvider;
425 /// use serde_json::json;
426 ///
427 /// let provider = MockAgentProvider::new(|_cfg| Ok(AgentOutput::new(json!("done"))));
428 /// # let _ = provider;
429 /// ```
430 pub fn new(
431 f: impl Fn(&AgentConfig) -> Result<AgentOutput, AgentError> + Send + Sync + 'static,
432 ) -> Self {
433 Self { f: Arc::new(f) }
434 }
435}
436
437impl fmt::Debug for MockAgentProvider {
438 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
439 f.debug_struct("MockAgentProvider").finish_non_exhaustive()
440 }
441}
442
443impl AgentProvider for MockAgentProvider {
444 fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a> {
445 let result = (self.f)(config);
446 Box::pin(async move { result })
447 }
448}
449
450/// The provider a [`TestEngine`](crate::testing::TestEngine) uses when no agent
451/// backend was configured.
452///
453/// Every invocation fails with an explanation instead of reaching the Claude
454/// CLI, so a forgotten `with_mock_agent` is a loud test failure, not a silent
455/// network call.
456///
457/// # Examples
458///
459/// ```
460/// use ironflow_engine::testing::MissingAgentProvider;
461///
462/// // Usable anywhere an `AgentProvider` is expected, including as the inner
463/// // provider of a `RecordReplayProvider` in replay mode.
464/// let provider = MissingAgentProvider;
465/// assert_eq!(format!("{provider:?}"), "MissingAgentProvider");
466/// ```
467#[derive(Debug, Default, Clone, Copy)]
468pub struct MissingAgentProvider;
469
470impl AgentProvider for MissingAgentProvider {
471 fn invoke<'a>(&'a self, _config: &'a AgentConfig) -> InvokeFuture<'a> {
472 Box::pin(async move {
473 Err(AgentError::ProcessFailed {
474 exit_code: -1,
475 stderr: MISSING_AGENT_PROVIDER.to_string(),
476 })
477 })
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 use crate::config::AgentStepConfig;
486
487 #[test]
488 fn shell_ok_maps_to_the_real_executor_output_shape() {
489 let output = MockShellOutput::ok("hello\n")
490 .into_step_result()
491 .expect("exit code 0 succeeds");
492
493 assert_eq!(output.output["stdout"], "hello\n");
494 assert_eq!(output.output["stderr"], "");
495 assert_eq!(output.output["exit_code"], 0);
496 assert_eq!(output.cost_usd, Decimal::ZERO);
497 }
498
499 #[test]
500 fn shell_default_is_an_empty_success() {
501 let default = MockShellOutput::default();
502 assert_eq!(default.exit_code, 0);
503 assert!(default.stdout.is_empty());
504 assert!(default.stderr.is_empty());
505 }
506
507 #[test]
508 fn shell_non_zero_exit_is_an_operation_error() {
509 let err = MockShellOutput::failed(2, "x")
510 .into_step_result()
511 .expect_err("a non-zero exit code fails the step");
512
513 match err {
514 EngineError::Operation(OperationError::Shell { exit_code, stderr }) => {
515 assert_eq!(exit_code, 2);
516 assert_eq!(stderr, "x");
517 }
518 other => panic!("expected a shell operation error, got {other}"),
519 }
520 }
521
522 #[test]
523 fn http_json_carries_status_body_and_headers() {
524 let output = MockHttpResponse::json(201, &json!({"id": 7}))
525 .header("location", "/things/7")
526 .into_step_output();
527
528 assert_eq!(output.output["status"], 201);
529 assert_eq!(output.output["body"], r#"{"id":7}"#);
530 assert_eq!(output.output["headers"]["location"], "/things/7");
531 }
532
533 #[test]
534 fn http_default_is_an_empty_200() {
535 let default = MockHttpResponse::default();
536 assert_eq!(default.status, 200);
537 assert!(default.body.is_empty());
538 assert!(default.headers.is_empty());
539 }
540
541 #[test]
542 fn http_non_2xx_is_still_an_output() {
543 let output = MockHttpResponse::text(500, "boom").into_step_output();
544 assert_eq!(output.status(), Some(500));
545 assert_eq!(output.body(), "boom");
546 }
547
548 #[test]
549 fn intercept_declines_agent_steps() {
550 let interceptor = MockInterceptor::new().shell(|_| Ok(MockShellOutput::ok("x")));
551 let config = StepConfig::Agent(AgentStepConfig::new("review this"));
552
553 assert!(interceptor.intercept(&config).is_none());
554 }
555
556 #[test]
557 fn intercept_declines_shell_steps_without_a_shell_mock() {
558 let interceptor = MockInterceptor::new();
559 let config = StepConfig::Shell(ShellConfig::new("echo hi"));
560
561 assert!(interceptor.intercept(&config).is_none());
562 }
563
564 #[test]
565 fn intercept_approval_returns_the_configured_outcome() {
566 let interceptor = MockInterceptor::new().approval(ApprovalOutcome::reject("nope"));
567 let config = ApprovalConfig::new("Approve?");
568
569 assert_eq!(
570 interceptor.intercept_approval("gate", &config),
571 Some(ApprovalOutcome::reject("nope"))
572 );
573 assert_eq!(
574 MockInterceptor::new().intercept_approval("gate", &config),
575 None
576 );
577 }
578
579 #[test]
580 fn debug_reports_which_seams_are_mocked() {
581 let interceptor = MockInterceptor::new().http(|_| Ok(MockHttpResponse::default()));
582 let rendered = format!("{interceptor:?}");
583
584 assert!(rendered.contains("shell: false"));
585 assert!(rendered.contains("http: true"));
586 }
587
588 #[tokio::test]
589 async fn missing_agent_provider_names_the_three_constructors() {
590 let config = AgentConfig::new("anything");
591 let err = MissingAgentProvider
592 .invoke(&config)
593 .await
594 .expect_err("no agent backend is configured");
595
596 let message = err.to_string();
597 assert!(message.contains("with_mock_agent"));
598 assert!(message.contains("with_recorded_agent"));
599 assert!(message.contains("with_agent_provider"));
600 }
601
602 #[tokio::test]
603 async fn mock_agent_provider_runs_the_closure() {
604 let provider = MockAgentProvider::new(|cfg| {
605 let echoed = json!({"echoed": cfg.prompt.clone()});
606 Ok(AgentOutput::new(echoed))
607 });
608 let config = AgentConfig::new("say hi");
609
610 let output = provider.invoke(&config).await.expect("the mock succeeded");
611
612 assert_eq!(output.value["echoed"], "say hi");
613 }
614}