Skip to main content

orbit_runtime/
testing.rs

1//! In-process workflow simulation, used by `orbit test` and `orbit simulate`.
2//!
3//! The [`Simulator`] runs the real [`Engine`] over an in-memory store with a
4//! manual clock. It acts as the worker for every task (asking a
5//! [`TaskHandler`] for each result), starts child workflows like the server
6//! does, sends signals as soon as the execution waits for them (or, for
7//! signals an `on event` block handles, as soon as it is idle), and advances
8//! durable time (sleeps, retry backoff, timeouts) straight to the next
9//! wake-up whenever nothing else can happen, so simulations finish instantly
10//! and deterministically.
11
12use crate::engine::{ContractAnnouncement, RegisterWorker, TaskAssignment};
13use crate::model::*;
14use crate::{Engine, EngineConfig, Store};
15use orbit_ir::testing::{value_matches, Expectation, MockOutcome, TestCase};
16use orbit_ir::{eval, WORKFLOW_CONTRACT_PREFIX};
17use orbit_ir::{Action, Scope, SourceLocation, TaskContract, Type, WorkflowIr};
18use serde::Serialize;
19use serde_json::{json, Value};
20use std::collections::{BTreeMap, BTreeSet, HashMap};
21use std::sync::atomic::{AtomicU64, Ordering};
22use std::sync::Arc;
23
24/// Simulated time starts at 2026-01-01T00:00:00Z.
25pub const SIMULATION_EPOCH_MS: u64 = 1_767_225_600_000;
26/// Id of the execution a simulation starts (children get derived ids).
27pub const ROOT_EXECUTION_ID: &str = "sim";
28/// Upper bound on scheduling rounds, so a runaway simulation always ends.
29const MAX_ROUNDS: usize = 100_000;
30/// Runtime announced by the simulated worker that takes steps without `using`.
31const DEFAULT_RUNTIME: &str = "simulator";
32/// Simulated time never runs further than this past the epoch (ten years).
33const MAX_SIMULATED_MS: u64 = 10 * 365 * 24 * 3600 * 1000;
34
35/// What the simulated worker does with one task.
36#[derive(Debug, Clone, PartialEq)]
37pub enum TaskOutcome {
38    Complete(Value),
39    Fail(TaskError),
40    /// Never answer; the attempt's timeout (if any) decides what happens.
41    Hang,
42    /// The simulation cannot continue (e.g. a test did not mock this task).
43    Abort(String),
44}
45
46/// One task handed to the simulated worker.
47pub struct TaskCall<'a> {
48    pub assignment: &'a TaskAssignment,
49    pub contract: Option<&'a TaskContract>,
50    /// 1 for the first time this unit (step, element or compensation) is
51    /// dispatched, 2 for the second, and so on.
52    pub call: u32,
53}
54
55pub trait TaskHandler {
56    fn handle(&mut self, call: &TaskCall) -> TaskOutcome;
57}
58
59/// One execution of a finished simulation.
60#[derive(Debug, Clone, Serialize)]
61pub struct SimulatedExecution {
62    pub state: ExecutionState,
63    pub history: Vec<HistoryEvent>,
64}
65
66#[derive(Debug, Clone, Serialize)]
67pub struct Simulation {
68    /// The execution the simulation started.
69    pub root: SimulatedExecution,
70    /// Child workflow executions, by id.
71    pub children: Vec<SimulatedExecution>,
72    /// Why the simulation stopped before the execution finished, or other
73    /// things that did not go as the caller asked (e.g. unsent signals).
74    pub problems: Vec<String>,
75    /// Signals that were never sent because nothing waited for them.
76    pub unsent_signals: Vec<String>,
77    /// Simulated time from start to the end of the simulation.
78    pub elapsed_ms: u64,
79}
80
81impl Simulation {
82    pub fn finished(&self) -> bool {
83        self.root.state.status.is_terminal()
84    }
85}
86
87pub struct Simulator {
88    engine: Engine,
89    clock: Arc<AtomicU64>,
90    /// (runtime, session id) of the simulated workers.
91    sessions: Vec<(String, String)>,
92    contracts: HashMap<String, TaskContract>,
93}
94
95impl Simulator {
96    /// Register `workflows` (children before parents) with a fresh engine.
97    pub fn new(workflows: &[WorkflowIr]) -> Result<Simulator, String> {
98        let clock = Arc::new(AtomicU64::new(SIMULATION_EPOCH_MS));
99        let c = clock.clone();
100        let config = EngineConfig {
101            // The simulated workers never go missing, however far time jumps.
102            session_timeout_ms: 1 << 50,
103            heartbeat_interval_ms: 1 << 40,
104            // Built-in tasks are handed to the simulator instead of run.
105            http_adapter: true,
106            ..EngineConfig::default()
107        };
108        let store = Store::in_memory().map_err(|e| e.to_string())?;
109        let mut engine = Engine::open(store, config, Box::new(move || c.load(Ordering::SeqCst)))
110            .map_err(|e| e.to_string())?;
111        // Register children first: a parent pins its children's definitions.
112        let mut pending: Vec<&WorkflowIr> = workflows.iter().collect();
113        while !pending.is_empty() {
114            let mut next = vec![];
115            let mut last_error = None;
116            for wf in &pending {
117                match engine.register_definition((*wf).clone()) {
118                    Ok(_) => {}
119                    Err(e) if e.code == "CHILD_NOT_REGISTERED" => {
120                        last_error = Some(e.message.clone());
121                        next.push(*wf);
122                    }
123                    Err(e) => return Err(format!("{}@{}: {}", wf.name, wf.version, e.message)),
124                }
125            }
126            if next.len() == pending.len() {
127                return Err(last_error.unwrap_or_default());
128            }
129            pending = next;
130        }
131        // One simulated worker per language that steps ask for (`using`),
132        // plus one for steps that do not care, all serving every task.
133        let mut contracts = HashMap::new();
134        let mut runtimes = BTreeSet::from([DEFAULT_RUNTIME.to_string()]);
135        for wf in workflows {
136            for c in wf.contracts.values() {
137                if !c.name.starts_with(WORKFLOW_CONTRACT_PREFIX) {
138                    contracts.insert(c.name.clone(), c.clone());
139                }
140            }
141            for s in &wf.steps {
142                for inv in s.invocation().into_iter().chain(s.compensation.as_ref()) {
143                    if let Some(l) = &inv.language {
144                        runtimes.insert(l.to_lowercase());
145                    }
146                }
147            }
148        }
149        let announced: BTreeSet<(String, String)> = contracts
150            .values()
151            .filter(|c| !c.builtin)
152            .map(|c| (c.name.clone(), c.version.clone()))
153            .collect();
154        let mut sessions = vec![];
155        if !announced.is_empty() {
156            for runtime in runtimes {
157                let reg = engine
158                    .register_worker(RegisterWorker {
159                        worker_id: if runtime == DEFAULT_RUNTIME {
160                            "simulator".to_string()
161                        } else {
162                            format!("simulator-{runtime}")
163                        },
164                        runtime: runtime.clone(),
165                        sdk: Some("orbit-simulator".into()),
166                        contracts: announced
167                            .iter()
168                            .map(|(n, v)| ContractAnnouncement {
169                                task_name: n.clone(),
170                                contract_version: v.clone(),
171                            })
172                            .collect(),
173                    })
174                    .map_err(|e| e.message)?;
175                sessions.push((runtime, reg.worker_session_id));
176            }
177        }
178        Ok(Simulator {
179            engine,
180            clock,
181            sessions,
182            contracts,
183        })
184    }
185
186    fn now(&self) -> u64 {
187        self.clock.load(Ordering::SeqCst)
188    }
189
190    /// Run one execution to completion (or until it cannot make progress).
191    pub fn run(
192        mut self,
193        workflow: &str,
194        version: Option<&str>,
195        input: Value,
196        signals: &[(String, Value)],
197        handler: &mut dyn TaskHandler,
198    ) -> Result<Simulation, String> {
199        let root = ROOT_EXECUTION_ID.to_string();
200        self.engine
201            .start(workflow, version, input, Some(root.clone()))
202            .map_err(|e| e.message)?;
203        let mut calls: HashMap<(String, String), u32> = HashMap::new();
204        let mut problems = vec![];
205        let mut next_signal = 0;
206        let mut rounds = 0;
207        let mut seen_progress = 0;
208        'run: loop {
209            rounds += 1;
210            if rounds > MAX_ROUNDS {
211                problems.push(format!(
212                    "the simulation did not finish within {MAX_ROUNDS} rounds"
213                ));
214                break;
215            }
216            let mut work = self.engine.tick().map_err(|e| e.message)?;
217            for (_, session) in self.sessions.clone() {
218                while let Some(a) = self.engine.poll(&session).map_err(|e| e.message)? {
219                    work.push(a);
220                }
221            }
222            for a in work {
223                let n = calls
224                    .entry((a.execution_id.clone(), a.step_id.clone()))
225                    .or_default();
226                *n += 1;
227                let call = TaskCall {
228                    assignment: &a,
229                    contract: self.contracts.get(&a.task_name),
230                    call: *n,
231                };
232                match handler.handle(&call) {
233                    TaskOutcome::Complete(v) => {
234                        self.engine.complete(&a.token, v).map_err(|e| e.message)?;
235                    }
236                    TaskOutcome::Fail(e) => {
237                        self.engine.fail(&a.token, e).map_err(|e| e.message)?;
238                    }
239                    TaskOutcome::Hang => {}
240                    TaskOutcome::Abort(msg) => {
241                        problems.push(msg);
242                        break 'run;
243                    }
244                }
245            }
246            let state = self.engine.execution(&root).map_err(|e| e.message)?;
247            if state.status.is_terminal() {
248                break;
249            }
250            // Anything recorded this round (results, children started or
251            // finished, timers fired) may enable more work right away.
252            let progress = self.progress()?;
253            if progress != seen_progress {
254                seen_progress = progress;
255                continue;
256            }
257            // Send the next signal once the execution waits for it.
258            if let Some((name, payload)) = signals.get(next_signal) {
259                if self.awaiting(&state).contains(name.as_str()) {
260                    self.engine
261                        .signal(&root, name, payload.clone())
262                        .map_err(|e| e.message)?;
263                    next_signal += 1;
264                    continue;
265                }
266            }
267            // Nothing can happen now: jump to the next durable wake-up.
268            match self.next_wake_up()? {
269                Some(at) if at - SIMULATION_EPOCH_MS <= MAX_SIMULATED_MS => {
270                    self.clock.store(at, Ordering::SeqCst);
271                }
272                Some(_) => {
273                    problems.push("the simulation ran past ten years of simulated time".into());
274                    break;
275                }
276                None => {
277                    problems.push(self.stuck_reason(&state, signals.get(next_signal)));
278                    break;
279                }
280            }
281        }
282        let unsent_signals: Vec<String> = signals[next_signal.min(signals.len())..]
283            .iter()
284            .map(|(n, _)| n.clone())
285            .collect();
286        let mut children = vec![];
287        for s in self
288            .engine
289            .list_executions(None, None, 1000)
290            .map_err(|e| e.message)?
291        {
292            if s.execution_id != root {
293                children.push(self.collect(&s.execution_id)?);
294            }
295        }
296        children.sort_by(|a, b| a.state.id.cmp(&b.state.id));
297        let root = self.collect(&root)?;
298        let end = root.state.finished_at_ms.unwrap_or(self.now());
299        Ok(Simulation {
300            elapsed_ms: end.saturating_sub(root.state.created_at_ms),
301            root,
302            children,
303            problems,
304            unsent_signals,
305        })
306    }
307
308    /// Total number of events recorded across all executions.
309    fn progress(&self) -> Result<u64, String> {
310        let mut total = 0;
311        for s in self
312            .engine
313            .list_executions(None, None, 1000)
314            .map_err(|e| e.message)?
315        {
316            total += 1 + self
317                .engine
318                .execution(&s.execution_id)
319                .map_err(|e| e.message)?
320                .last_seq;
321        }
322        Ok(total)
323    }
324
325    fn collect(&self, id: &str) -> Result<SimulatedExecution, String> {
326        Ok(SimulatedExecution {
327            state: self.engine.execution(id).map_err(|e| e.message)?,
328            history: self.engine.history(id).map_err(|e| e.message)?,
329        })
330    }
331
332    fn definition(&self, state: &ExecutionState) -> Option<Arc<WorkflowIr>> {
333        self.engine
334            .definition(&state.workflow, Some(&state.version))
335    }
336
337    /// Signals the execution's waiting `await` steps want.
338    fn awaiting(&self, state: &ExecutionState) -> BTreeSet<String> {
339        let Some(def) = self.definition(state) else {
340            return BTreeSet::new();
341        };
342        // Signals handled by `on event` are accepted whenever the execution is
343        // running and not shutting down.
344        let handled = def.events.keys().filter(|_| state.abort.is_none()).cloned();
345        state
346            .units
347            .iter()
348            .filter(|(_, u)| u.status() == UnitStatus::Waiting)
349            .filter_map(|(key, _)| awaited(&def, key).map(str::to_string))
350            .chain(handled)
351            .collect()
352    }
353
354    /// The earliest future time at which a timer, retry, deadline or timeout
355    /// of any unfinished execution fires.
356    fn next_wake_up(&self) -> Result<Option<u64>, String> {
357        let now = self.now();
358        let mut best: Option<u64> = None;
359        let mut consider = |t: u64| {
360            if t > now && best.is_none_or(|b| t < b) {
361                best = Some(t);
362            }
363        };
364        for s in self
365            .engine
366            .list_executions(None, None, 1000)
367            .map_err(|e| e.message)?
368        {
369            let state = self
370                .engine
371                .execution(&s.execution_id)
372                .map_err(|e| e.message)?;
373            if state.status.is_terminal() {
374                continue;
375            }
376            let def = self.definition(&state);
377            if let (Some(def), None) = (&def, &state.abort) {
378                if let Some(t) = def.timeout_ms {
379                    consider(state.created_at_ms + t);
380                }
381            }
382            for (key, u) in &state.units {
383                match u.status() {
384                    UnitStatus::Waiting | UnitStatus::RetryWait => {
385                        if let Some(t) = u.wake_at_ms {
386                            consider(t);
387                        }
388                    }
389                    UnitStatus::Dispatched => {
390                        if let Some(t) = u.deadline_ms {
391                            consider(t);
392                        }
393                    }
394                    UnitStatus::Scheduled => {
395                        let qt = def
396                            .as_ref()
397                            .and_then(|d| crate::decide::invocation(d, key))
398                            .and_then(|i| i.queue_timeout_ms);
399                        if let (Some(qt), Some(at), false) = (qt, u.scheduled_at_ms, u.redelivery) {
400                            consider(at + qt);
401                        }
402                    }
403                    _ => {}
404                }
405            }
406        }
407        Ok(best)
408    }
409
410    fn stuck_reason(&self, state: &ExecutionState, next: Option<&(String, Value)>) -> String {
411        let def = self.definition(state);
412        let mut waits = vec![];
413        for (key, u) in &state.units {
414            match u.status() {
415                UnitStatus::Waiting => {
416                    if let Some(sig) = def.as_ref().and_then(|d| awaited(d, key)) {
417                        let detail = match next {
418                            Some((n, _)) if n != sig => format!(
419                                "signal `{sig}`, but the next signal to send is `{n}` (signals are sent in order)"
420                            ),
421                            Some(_) => format!("signal `{sig}`"),
422                            None => format!("signal `{sig}`, and no signal is left to send"),
423                        };
424                        waits.push(format!("step `{key}` waits for {detail}"));
425                    }
426                }
427                UnitStatus::Dispatched => {
428                    let task = def
429                        .as_ref()
430                        .and_then(|d| crate::decide::invocation(d, key))
431                        .map(|i| i.task.clone())
432                        .unwrap_or_default();
433                    if !task.starts_with(WORKFLOW_CONTRACT_PREFIX) {
434                        waits.push(format!(
435                            "step `{key}` waits for task `{task}`, which never answers and has no timeout"
436                        ));
437                    }
438                }
439                _ => {}
440            }
441        }
442        if waits.is_empty() {
443            format!(
444                "the execution stopped making progress while {}",
445                state.status.as_str()
446            )
447        } else {
448            format!("the execution cannot make progress: {}", waits.join("; "))
449        }
450    }
451}
452
453/// The signal an `await` unit waits for.
454fn awaited<'a>(def: &'a WorkflowIr, key: &str) -> Option<&'a str> {
455    let k = parse_key(key);
456    if k.compensation {
457        return None;
458    }
459    match &def.step(k.step)?.action {
460        Action::Await { signal, .. } => Some(signal),
461        _ => None,
462    }
463}
464
465/// Run `workflow` once with `handler` acting as every worker.
466pub fn simulate(
467    workflows: &[WorkflowIr],
468    workflow: &str,
469    version: Option<&str>,
470    input: Value,
471    signals: &[(String, Value)],
472    handler: &mut dyn TaskHandler,
473) -> Result<Simulation, String> {
474    Simulator::new(workflows)?.run(workflow, version, input, signals, handler)
475}
476
477// ------------------------------------------------------------------- samples
478
479/// A deterministic, type-valid sample value (used by `orbit simulate` for
480/// tasks without a mock). `name` is the field the value is for.
481///
482/// | Type        | Sample                                   |
483/// |-------------|------------------------------------------|
484/// | `String`    | `"<name>-sample"`                        |
485/// | `Int`       | `1`                                      |
486/// | `Float`     | `1.0`                                    |
487/// | `Bool`      | `true`                                   |
488/// | `Bytes`     | `"c2FtcGxl"` (base64 of `sample`)        |
489/// | `Duration`  | `1000` (one second)                      |
490/// | `Timestamp` | `"2026-01-01T00:00:00Z"`                 |
491/// | `UUID`      | `"00000000-0000-4000-8000-000000000001"` |
492/// | `JSON`      | `{}`                                     |
493/// | `List<T>`   | one sample element                       |
494/// | `Map<String, T>` | `{ "key": <sample T> }`             |
495/// | `T?`        | `null`                                   |
496/// | record      | every field: its default, else a sample  |
497pub fn sample_value(ty: &Type, name: &str) -> Value {
498    match ty {
499        Type::String => json!(format!("{name}-sample")),
500        Type::Int => json!(1),
501        Type::Float => json!(1.0),
502        Type::Bool => json!(true),
503        Type::Bytes => json!("c2FtcGxl"),
504        Type::Duration => json!(1000),
505        Type::Timestamp => json!("2026-01-01T00:00:00Z"),
506        Type::Uuid => json!("00000000-0000-4000-8000-000000000001"),
507        Type::Json => json!({}),
508        Type::Null | Type::Optional { .. } => Value::Null,
509        Type::List { item } => json!([sample_value(item, name)]),
510        Type::Map { value, .. } => json!({ "key": sample_value(value, name) }),
511        Type::Record { fields, .. } => Value::Object(
512            fields
513                .iter()
514                .map(|f| {
515                    let v = f
516                        .default
517                        .clone()
518                        .unwrap_or_else(|| sample_value(&f.ty, &f.name));
519                    (f.name.clone(), v)
520                })
521                .collect(),
522        ),
523    }
524}
525
526/// The worker used by `orbit simulate`: fixed results and failures by task
527/// name, and a generated sample for every other task.
528#[derive(Debug, Clone, Default)]
529pub struct SampleHandler {
530    /// Task name -> output to return.
531    pub results: BTreeMap<String, Value>,
532    /// Task name -> error code to fail with (permanently).
533    pub failures: BTreeMap<String, String>,
534}
535
536impl TaskHandler for SampleHandler {
537    fn handle(&mut self, call: &TaskCall) -> TaskOutcome {
538        let task = &call.assignment.task_name;
539        if let Some(code) = self.failures.get(task) {
540            return TaskOutcome::Fail(TaskError::new(
541                code,
542                format!("simulated failure of `{task}` (--fail)"),
543                false,
544            ));
545        }
546        if let Some(v) = self.results.get(task) {
547            return TaskOutcome::Complete(v.clone());
548        }
549        TaskOutcome::Complete(
550            call.contract
551                .map(|c| sample_value(&c.output, "value"))
552                .unwrap_or_else(|| json!({})),
553        )
554    }
555}
556
557// --------------------------------------------------------------------- tests
558
559/// The worker used by `orbit test`: answers from the test's mocks.
560pub struct MockHandler<'a> {
561    pub test: &'a TestCase,
562}
563
564struct TaskScope<'a> {
565    input: &'a Value,
566    assignment: &'a TaskAssignment,
567}
568
569impl Scope for TaskScope<'_> {
570    fn input(&self) -> &Value {
571        self.input
572    }
573    fn step_output(&self, _: &str) -> Option<&Value> {
574        None
575    }
576    fn execution_field(&self, field: &str) -> Option<Value> {
577        Some(Value::String(match field {
578            "id" => self.assignment.execution_id.clone(),
579            "workflow" => self.assignment.workflow.clone(),
580            "version" => self.assignment.workflow_version.clone(),
581            _ => return None,
582        }))
583    }
584}
585
586impl TaskHandler for MockHandler<'_> {
587    fn handle(&mut self, call: &TaskCall) -> TaskOutcome {
588        let a = call.assignment;
589        let Some(behaviours) = self.test.mocks.get(&a.task_name) else {
590            return TaskOutcome::Abort(format!(
591                "task `{}` (step `{}`) is not mocked; add `mock \"{}\" returns {{ ... }}` to the test",
592                a.task_name, a.step_id, a.task_name
593            ));
594        };
595        let mut covered = 0u32;
596        let mut chosen = behaviours.last();
597        for b in behaviours {
598            match b.times {
599                Some(n) => {
600                    covered = covered.saturating_add(n);
601                    if call.call <= covered {
602                        chosen = Some(b);
603                        break;
604                    }
605                }
606                None => {
607                    chosen = Some(b);
608                    break;
609                }
610            }
611        }
612        match chosen.map(|b| &b.outcome) {
613            Some(MockOutcome::Returns { output }) => {
614                let scope = TaskScope {
615                    input: &a.input,
616                    assignment: a,
617                };
618                match eval(output, &scope) {
619                    Ok(v) => TaskOutcome::Complete(v),
620                    Err(e) => TaskOutcome::Abort(format!(
621                        "the mock of `{}` (step `{}`) failed to evaluate: {e}",
622                        a.task_name, a.step_id
623                    )),
624                }
625            }
626            Some(MockOutcome::Fails {
627                code,
628                message,
629                retryable,
630            }) => TaskOutcome::Fail(TaskError::new(code, message.clone(), *retryable)),
631            Some(MockOutcome::Hangs) => TaskOutcome::Hang,
632            None => TaskOutcome::Abort(format!("the mock of `{}` has no behaviour", a.task_name)),
633        }
634    }
635}
636
637/// The outcome of one test.
638#[derive(Debug, Clone, Serialize)]
639pub struct TestResult {
640    pub name: String,
641    pub workflow: String,
642    pub version: String,
643    pub location: SourceLocation,
644    pub passed: bool,
645    /// One message per failed expectation (or why the test could not run).
646    pub failures: Vec<String>,
647    #[serde(skip)]
648    pub simulation: Option<Simulation>,
649}
650
651/// Run one compiled test against `workflows` (which must include the tested
652/// workflow and its children).
653pub fn run_test(workflows: &[WorkflowIr], test: &TestCase) -> TestResult {
654    let mut result = TestResult {
655        name: test.name.clone(),
656        workflow: test.workflow.clone(),
657        version: test.version.clone(),
658        location: test.location.clone(),
659        passed: false,
660        failures: vec![],
661        simulation: None,
662    };
663    let signals: Vec<(String, Value)> = test
664        .signals
665        .iter()
666        .map(|s| (s.name.clone(), s.payload.clone()))
667        .collect();
668    let mut handler = MockHandler { test };
669    let sim = match simulate(
670        workflows,
671        &test.workflow,
672        Some(&test.version),
673        test.input.clone(),
674        &signals,
675        &mut handler,
676    ) {
677        Ok(s) => s,
678        Err(e) => {
679            result
680                .failures
681                .push(format!("could not start the execution: {e}"));
682            return result;
683        }
684    };
685    result.failures.extend(sim.problems.iter().cloned());
686    if sim.finished() {
687        for e in &test.expectations {
688            if let Some(f) = check(e, &sim.root.state) {
689                result.failures.push(f);
690            }
691        }
692        for s in &sim.unsent_signals {
693            result.failures.push(format!(
694                "signal `{s}` was never sent: the execution finished without waiting for it"
695            ));
696        }
697    }
698    result.passed = result.failures.is_empty();
699    result.simulation = Some(sim);
700    result
701}
702
703fn compact(v: &Value) -> String {
704    serde_json::to_string(v).unwrap_or_default()
705}
706
707fn status_name(s: UnitStatus) -> String {
708    serde_json::to_value(s)
709        .ok()
710        .and_then(|v| v.as_str().map(str::to_string))
711        .unwrap_or_default()
712}
713
714fn describe_end(state: &ExecutionState) -> String {
715    match &state.error {
716        Some(e) => format!("{} ({}: {})", state.status.as_str(), e.code, e.message),
717        None => state.status.as_str().to_string(),
718    }
719}
720
721/// `None` if the expectation holds, else what went wrong. A step written
722/// `on_event:<signal>:<step>` stands for that step in every instance of the
723/// block: the expectation must hold for each, and at least one must exist.
724fn check(e: &Expectation, state: &ExecutionState) -> Option<String> {
725    let event_step = match e {
726        Expectation::StepStatus { step, .. }
727        | Expectation::StepOutput { step, .. }
728        | Expectation::StepAttempts { step, .. } => step
729            .strip_prefix("on_event:")
730            .and_then(|r| r.split_once(':'))
731            .map(|(signal, id)| (step.clone(), signal.to_string(), id.to_string())),
732        _ => None,
733    };
734    if let Some((written, signal, id)) = event_step {
735        let instances: Vec<String> = state
736            .signals
737            .iter()
738            .filter_map(|r| r.delivered_to.clone())
739            .filter(|k| k.starts_with(&format!("on_event:{signal}@")))
740            .collect();
741        if instances.is_empty() {
742            return Some(format!(
743                "expected step `{written}`, but no `{signal}` signal started the `on event {signal}` block"
744            ));
745        }
746        for instance in instances {
747            let key = format!("{instance}:{id}");
748            let mut one = e.clone();
749            match &mut one {
750                Expectation::StepStatus { step, .. }
751                | Expectation::StepOutput { step, .. }
752                | Expectation::StepAttempts { step, .. } => *step = key,
753                _ => {}
754            }
755            if let Some(why) = check_one(&one, state) {
756                return Some(why.replace(&format!("{instance}:{id}"), &written));
757            }
758        }
759        return None;
760    }
761    check_one(e, state)
762}
763
764fn check_one(e: &Expectation, state: &ExecutionState) -> Option<String> {
765    let unit = |step: &str| state.units.get(step).cloned().unwrap_or_default();
766    match e {
767        Expectation::Status { status } => (state.status.as_str() != status)
768            .then(|| format!("expected status {status}, got {}", describe_end(state))),
769        Expectation::Output { expected } => {
770            let actual = state.output.clone().unwrap_or(Value::Null);
771            (!value_matches(expected, &actual)).then(|| {
772                if state.output.is_none() {
773                    format!(
774                        "expected output {}, but the workflow produced none (it ended {})",
775                        compact(expected),
776                        describe_end(state)
777                    )
778                } else {
779                    format!(
780                        "expected output {}, got {}",
781                        compact(expected),
782                        compact(&actual)
783                    )
784                }
785            })
786        }
787        Expectation::Error { code } => match &state.error {
788            Some(err) if err.code == *code => None,
789            Some(err) => Some(format!(
790                "expected error {code:?}, got {:?} ({})",
791                err.code, err.message
792            )),
793            None => Some(format!(
794                "expected error {code:?}, but the workflow ended {} without an error",
795                state.status.as_str()
796            )),
797        },
798        Expectation::StepStatus { step, status } => {
799            let actual = status_name(unit(step).status());
800            (actual != *status).then(|| {
801                let u = unit(step);
802                let why = u
803                    .error
804                    .as_ref()
805                    .map(|e| format!(" ({}: {})", e.code, e.message))
806                    .unwrap_or_default();
807                format!("expected step `{step}` to be {status}, got {actual}{why}")
808            })
809        }
810        Expectation::StepOutput { step, expected } => {
811            let u = unit(step);
812            let actual = u.output.clone().unwrap_or(Value::Null);
813            (!value_matches(expected, &actual)).then(|| {
814                format!(
815                    "expected step `{step}` output {}, got {} (step is {})",
816                    compact(expected),
817                    compact(&actual),
818                    status_name(u.status())
819                )
820            })
821        }
822        Expectation::StepAttempts { step, attempts } => {
823            let actual = unit(step).attempt;
824            (actual != *attempts).then(|| {
825                format!("expected step `{step}` to take {attempts} attempt(s), took {actual}")
826            })
827        }
828        Expectation::Compensated { step } => {
829            let element = format!("{}[", compensation_key(step));
830            let units: Vec<(&String, &UnitState)> = state
831                .units
832                .iter()
833                .filter(|(k, _)| **k == compensation_key(step) || k.starts_with(&element))
834                .collect();
835            if units.is_empty() {
836                return Some(format!(
837                    "expected step `{step}` to be compensated, but its compensation never ran"
838                ));
839            }
840            units
841                .iter()
842                .find(|(_, u)| u.status() != UnitStatus::Succeeded)
843                .map(|(k, u)| {
844                    let why = u
845                        .error
846                        .as_ref()
847                        .map(|e| format!(" ({}: {})", e.code, e.message))
848                        .unwrap_or_default();
849                    format!(
850                        "expected step `{step}` to be compensated, but `{k}` is {}{why}",
851                        status_name(u.status())
852                    )
853                })
854        }
855    }
856}