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}