Skip to main content

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