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        // Across every run of the execution (continue-as-new).
201        let started_at = self.now();
202        self.engine
203            .start(workflow, version, input, Some(root.clone()))
204            .map_err(|e| e.message)?;
205        let mut calls: HashMap<(String, String), u32> = HashMap::new();
206        let mut problems = vec![];
207        let mut next_signal = 0;
208        let mut rounds = 0;
209        let mut seen_progress = 0;
210        'run: loop {
211            rounds += 1;
212            if rounds > MAX_ROUNDS {
213                problems.push(format!(
214                    "the simulation did not finish within {MAX_ROUNDS} rounds"
215                ));
216                break;
217            }
218            let mut work = self.engine.tick().map_err(|e| e.message)?;
219            for (_, session) in self.sessions.clone() {
220                while let Some(a) = self.engine.poll(&session).map_err(|e| e.message)? {
221                    work.push(a);
222                }
223            }
224            for a in work {
225                let n = calls
226                    .entry((a.execution_id.clone(), a.step_id.clone()))
227                    .or_default();
228                *n += 1;
229                let call = TaskCall {
230                    assignment: &a,
231                    contract: self.contracts.get(&a.task_name),
232                    call: *n,
233                };
234                match handler.handle(&call) {
235                    TaskOutcome::Complete(v) => {
236                        self.engine.complete(&a.token, v).map_err(|e| e.message)?;
237                    }
238                    TaskOutcome::Fail(e) => {
239                        self.engine.fail(&a.token, e).map_err(|e| e.message)?;
240                    }
241                    TaskOutcome::Hang => {}
242                    TaskOutcome::Abort(msg) => {
243                        problems.push(msg);
244                        break 'run;
245                    }
246                }
247            }
248            let state = self.engine.execution(&root).map_err(|e| e.message)?;
249            if state.status.is_terminal() {
250                break;
251            }
252            // Anything recorded this round (results, children started or
253            // finished, timers fired) may enable more work right away.
254            let progress = self.progress()?;
255            if progress != seen_progress {
256                seen_progress = progress;
257                continue;
258            }
259            // Send the next signal once the execution waits for it.
260            if let Some((name, payload)) = signals.get(next_signal) {
261                if self.awaiting(&state).contains(name.as_str()) {
262                    self.engine
263                        .signal(&root, name, payload.clone())
264                        .map_err(|e| e.message)?;
265                    next_signal += 1;
266                    continue;
267                }
268            }
269            // Nothing can happen now: jump to the next durable wake-up.
270            match self.next_wake_up()? {
271                Some(at) if at - SIMULATION_EPOCH_MS <= MAX_SIMULATED_MS => {
272                    self.clock.store(at, Ordering::SeqCst);
273                }
274                Some(_) => {
275                    problems.push("the simulation ran past ten years of simulated time".into());
276                    break;
277                }
278                None => {
279                    problems.push(self.stuck_reason(&state, signals.get(next_signal)));
280                    break;
281                }
282            }
283        }
284        let unsent_signals: Vec<String> = signals[next_signal.min(signals.len())..]
285            .iter()
286            .map(|(n, _)| n.clone())
287            .collect();
288        let mut children = vec![];
289        for s in self
290            .engine
291            .list_executions(None, None, 1000)
292            .map_err(|e| e.message)?
293        {
294            if s.execution_id != root {
295                children.push(self.collect(&s.execution_id)?);
296            }
297        }
298        children.sort_by(|a, b| a.state.id.cmp(&b.state.id));
299        let root = self.collect(&root)?;
300        let end = root.state.finished_at_ms.unwrap_or(self.now());
301        Ok(Simulation {
302            elapsed_ms: end.saturating_sub(started_at),
303            root,
304            children,
305            problems,
306            unsent_signals,
307        })
308    }
309
310    /// Total number of events recorded across all executions.
311    fn progress(&self) -> Result<u64, String> {
312        let mut total = 0;
313        for s in self
314            .engine
315            .list_executions(None, None, 1000)
316            .map_err(|e| e.message)?
317        {
318            // Each run's history starts again at 1 (continue-as-new).
319            let state = self
320                .engine
321                .execution(&s.execution_id)
322                .map_err(|e| e.message)?;
323            total += 1 + state.last_seq + (state.run as u64) * (1 << 32);
324        }
325        Ok(total)
326    }
327
328    fn collect(&self, id: &str) -> Result<SimulatedExecution, String> {
329        Ok(SimulatedExecution {
330            state: self.engine.execution(id).map_err(|e| e.message)?,
331            history: self.engine.history(id).map_err(|e| e.message)?,
332        })
333    }
334
335    fn definition(&self, state: &ExecutionState) -> Option<Arc<WorkflowIr>> {
336        self.engine
337            .definition(&state.workflow, Some(&state.version))
338    }
339
340    /// Signals the execution's waiting `await` steps want.
341    fn awaiting(&self, state: &ExecutionState) -> BTreeSet<String> {
342        let Some(def) = self.definition(state) else {
343            return BTreeSet::new();
344        };
345        // Signals handled by `on event` are accepted whenever the execution is
346        // running and not shutting down.
347        let handled = def.events.keys().filter(|_| state.abort.is_none()).cloned();
348        state
349            .units
350            .iter()
351            .filter(|(_, u)| u.status() == UnitStatus::Waiting)
352            .filter_map(|(key, _)| awaited(&def, key).map(str::to_string))
353            .chain(handled)
354            .collect()
355    }
356
357    /// The earliest future time at which a timer, retry, deadline or timeout
358    /// of any unfinished execution fires.
359    fn next_wake_up(&self) -> Result<Option<u64>, String> {
360        let now = self.now();
361        let mut best: Option<u64> = None;
362        let mut consider = |t: u64| {
363            if t > now && best.is_none_or(|b| t < b) {
364                best = Some(t);
365            }
366        };
367        for s in self
368            .engine
369            .list_executions(None, None, 1000)
370            .map_err(|e| e.message)?
371        {
372            let state = self
373                .engine
374                .execution(&s.execution_id)
375                .map_err(|e| e.message)?;
376            if state.status.is_terminal() {
377                continue;
378            }
379            let def = self.definition(&state);
380            if let (Some(def), None) = (&def, &state.abort) {
381                if let Some(t) = def.timeout_ms {
382                    consider(state.created_at_ms + t);
383                }
384            }
385            for (key, u) in &state.units {
386                match u.status() {
387                    UnitStatus::Waiting | UnitStatus::RetryWait => {
388                        if let Some(t) = u.wake_at_ms {
389                            consider(t);
390                        }
391                    }
392                    UnitStatus::Dispatched => {
393                        if let Some(t) = u.deadline_ms {
394                            consider(t);
395                        }
396                    }
397                    UnitStatus::Scheduled => {
398                        let qt = def
399                            .as_ref()
400                            .and_then(|d| crate::decide::invocation(d, key))
401                            .and_then(|i| i.queue_timeout_ms);
402                        if let (Some(qt), Some(at), false) = (qt, u.scheduled_at_ms, u.redelivery) {
403                            consider(at + qt);
404                        }
405                    }
406                    _ => {}
407                }
408            }
409        }
410        Ok(best)
411    }
412
413    fn stuck_reason(&self, state: &ExecutionState, next: Option<&(String, Value)>) -> String {
414        let def = self.definition(state);
415        let mut waits = vec![];
416        for (key, u) in &state.units {
417            match u.status() {
418                UnitStatus::Waiting => {
419                    if let Some(sig) = def.as_ref().and_then(|d| awaited(d, key)) {
420                        let detail = match next {
421                            Some((n, _)) if n != sig => format!(
422                                "signal `{sig}`, but the next signal to send is `{n}` (signals are sent in order)"
423                            ),
424                            Some(_) => format!("signal `{sig}`"),
425                            None => format!("signal `{sig}`, and no signal is left to send"),
426                        };
427                        waits.push(format!("step `{key}` waits for {detail}"));
428                    }
429                }
430                UnitStatus::Dispatched => {
431                    let task = def
432                        .as_ref()
433                        .and_then(|d| crate::decide::invocation(d, key))
434                        .map(|i| i.task.clone())
435                        .unwrap_or_default();
436                    if !task.starts_with(WORKFLOW_CONTRACT_PREFIX) {
437                        waits.push(format!(
438                            "step `{key}` waits for task `{task}`, which never answers and has no timeout"
439                        ));
440                    }
441                }
442                _ => {}
443            }
444        }
445        if waits.is_empty() {
446            format!(
447                "the execution stopped making progress while {}",
448                state.status.as_str()
449            )
450        } else {
451            format!("the execution cannot make progress: {}", waits.join("; "))
452        }
453    }
454}
455
456/// The signal an `await` unit waits for.
457fn awaited<'a>(def: &'a WorkflowIr, key: &str) -> Option<&'a str> {
458    let k = parse_key(key);
459    if k.compensation {
460        return None;
461    }
462    match &def.step(k.step)?.action {
463        Action::Await { signal, .. } => Some(signal),
464        _ => None,
465    }
466}
467
468/// Run `workflow` once with `handler` acting as every worker.
469pub fn simulate(
470    workflows: &[WorkflowIr],
471    workflow: &str,
472    version: Option<&str>,
473    input: Value,
474    signals: &[(String, Value)],
475    handler: &mut dyn TaskHandler,
476) -> Result<Simulation, String> {
477    Simulator::new(workflows)?.run(workflow, version, input, signals, handler)
478}
479
480// ------------------------------------------------------------------- samples
481
482/// A deterministic, type-valid sample value (used by `orbit simulate` for
483/// tasks without a mock). `name` is the field the value is for.
484///
485/// | Type        | Sample                                   |
486/// |-------------|------------------------------------------|
487/// | `String`    | `"<name>-sample"`                        |
488/// | `Int`       | `1`                                      |
489/// | `Float`     | `1.0`                                    |
490/// | `Bool`      | `true`                                   |
491/// | `Bytes`     | `"c2FtcGxl"` (base64 of `sample`)        |
492/// | `Duration`  | `1000` (one second)                      |
493/// | `Timestamp` | `"2026-01-01T00:00:00Z"`                 |
494/// | `UUID`      | `"00000000-0000-4000-8000-000000000001"` |
495/// | `JSON`      | `{}`                                     |
496/// | `List<T>`   | one sample element                       |
497/// | `Map<String, T>` | `{ "key": <sample T> }`             |
498/// | `T?`        | `null`                                   |
499/// | record      | every field: its default, else a sample  |
500pub fn sample_value(ty: &Type, name: &str) -> Value {
501    match ty {
502        Type::String => json!(format!("{name}-sample")),
503        Type::Int => json!(1),
504        Type::Float => json!(1.0),
505        Type::Bool => json!(true),
506        Type::Bytes => json!("c2FtcGxl"),
507        Type::Duration => json!(1000),
508        Type::Timestamp => json!("2026-01-01T00:00:00Z"),
509        Type::Uuid => json!("00000000-0000-4000-8000-000000000001"),
510        Type::Json => json!({}),
511        Type::Null | Type::Optional { .. } => Value::Null,
512        Type::List { item } => json!([sample_value(item, name)]),
513        Type::Map { value, .. } => json!({ "key": sample_value(value, name) }),
514        Type::Record { fields, .. } => Value::Object(
515            fields
516                .iter()
517                .map(|f| {
518                    let v = f
519                        .default
520                        .clone()
521                        .unwrap_or_else(|| sample_value(&f.ty, &f.name));
522                    (f.name.clone(), v)
523                })
524                .collect(),
525        ),
526    }
527}
528
529/// The worker used by `orbit simulate`: fixed results and failures by task
530/// name, and a generated sample for every other task.
531#[derive(Debug, Clone, Default)]
532pub struct SampleHandler {
533    /// Task name -> output to return.
534    pub results: BTreeMap<String, Value>,
535    /// Task name -> error code to fail with (permanently).
536    pub failures: BTreeMap<String, String>,
537}
538
539impl TaskHandler for SampleHandler {
540    fn handle(&mut self, call: &TaskCall) -> TaskOutcome {
541        let task = &call.assignment.task_name;
542        if let Some(code) = self.failures.get(task) {
543            return TaskOutcome::Fail(TaskError::new(
544                code,
545                format!("simulated failure of `{task}` (--fail)"),
546                false,
547            ));
548        }
549        if let Some(v) = self.results.get(task) {
550            return TaskOutcome::Complete(v.clone());
551        }
552        TaskOutcome::Complete(
553            call.contract
554                .map(|c| sample_value(&c.output, "value"))
555                .unwrap_or_else(|| json!({})),
556        )
557    }
558}
559
560// --------------------------------------------------------------------- tests
561
562/// The worker used by `orbit test`: answers from the test's mocks.
563pub struct MockHandler<'a> {
564    pub test: &'a TestCase,
565}
566
567struct TaskScope<'a> {
568    input: &'a Value,
569    assignment: &'a TaskAssignment,
570}
571
572impl Scope for TaskScope<'_> {
573    fn input(&self) -> &Value {
574        self.input
575    }
576    fn step_output(&self, _: &str) -> Option<&Value> {
577        None
578    }
579    fn execution_field(&self, field: &str) -> Option<Value> {
580        Some(Value::String(match field {
581            "id" => self.assignment.execution_id.clone(),
582            "workflow" => self.assignment.workflow.clone(),
583            "version" => self.assignment.workflow_version.clone(),
584            _ => return None,
585        }))
586    }
587}
588
589impl TaskHandler for MockHandler<'_> {
590    fn handle(&mut self, call: &TaskCall) -> TaskOutcome {
591        let a = call.assignment;
592        let Some(behaviours) = self.test.mocks.get(&a.task_name) else {
593            return TaskOutcome::Abort(format!(
594                "task `{}` (step `{}`) is not mocked; add `mock \"{}\" returns {{ ... }}` to the test",
595                a.task_name, a.step_id, a.task_name
596            ));
597        };
598        let mut covered = 0u32;
599        let mut chosen = behaviours.last();
600        for b in behaviours {
601            match b.times {
602                Some(n) => {
603                    covered = covered.saturating_add(n);
604                    if call.call <= covered {
605                        chosen = Some(b);
606                        break;
607                    }
608                }
609                None => {
610                    chosen = Some(b);
611                    break;
612                }
613            }
614        }
615        match chosen.map(|b| &b.outcome) {
616            Some(MockOutcome::Returns { output }) => {
617                let scope = TaskScope {
618                    input: &a.input,
619                    assignment: a,
620                };
621                match eval(output, &scope) {
622                    Ok(v) => TaskOutcome::Complete(v),
623                    Err(e) => TaskOutcome::Abort(format!(
624                        "the mock of `{}` (step `{}`) failed to evaluate: {e}",
625                        a.task_name, a.step_id
626                    )),
627                }
628            }
629            Some(MockOutcome::Fails {
630                code,
631                message,
632                retryable,
633            }) => TaskOutcome::Fail(TaskError::new(code, message.clone(), *retryable)),
634            Some(MockOutcome::Hangs) => TaskOutcome::Hang,
635            None => TaskOutcome::Abort(format!("the mock of `{}` has no behaviour", a.task_name)),
636        }
637    }
638}
639
640/// The outcome of one test.
641#[derive(Debug, Clone, Serialize)]
642pub struct TestResult {
643    pub name: String,
644    pub workflow: String,
645    pub version: String,
646    pub location: SourceLocation,
647    pub passed: bool,
648    /// One message per failed expectation (or why the test could not run).
649    pub failures: Vec<String>,
650    #[serde(skip)]
651    pub simulation: Option<Simulation>,
652}
653
654/// Run one compiled test against `workflows` (which must include the tested
655/// workflow and its children).
656pub fn run_test(workflows: &[WorkflowIr], test: &TestCase) -> TestResult {
657    let mut result = TestResult {
658        name: test.name.clone(),
659        workflow: test.workflow.clone(),
660        version: test.version.clone(),
661        location: test.location.clone(),
662        passed: false,
663        failures: vec![],
664        simulation: None,
665    };
666    let signals: Vec<(String, Value)> = test
667        .signals
668        .iter()
669        .map(|s| (s.name.clone(), s.payload.clone()))
670        .collect();
671    let mut handler = MockHandler { test };
672    let sim = match simulate(
673        workflows,
674        &test.workflow,
675        Some(&test.version),
676        test.input.clone(),
677        &signals,
678        &mut handler,
679    ) {
680        Ok(s) => s,
681        Err(e) => {
682            result
683                .failures
684                .push(format!("could not start the execution: {e}"));
685            return result;
686        }
687    };
688    result.failures.extend(sim.problems.iter().cloned());
689    if sim.finished() {
690        for e in &test.expectations {
691            if let Some(f) = check(e, &sim.root.state) {
692                result.failures.push(f);
693            }
694        }
695        for s in &sim.unsent_signals {
696            result.failures.push(format!(
697                "signal `{s}` was never sent: the execution finished without waiting for it"
698            ));
699        }
700    }
701    result.passed = result.failures.is_empty();
702    result.simulation = Some(sim);
703    result
704}
705
706fn compact(v: &Value) -> String {
707    serde_json::to_string(v).unwrap_or_default()
708}
709
710fn status_name(s: UnitStatus) -> String {
711    serde_json::to_value(s)
712        .ok()
713        .and_then(|v| v.as_str().map(str::to_string))
714        .unwrap_or_default()
715}
716
717fn describe_end(state: &ExecutionState) -> String {
718    match &state.error {
719        Some(e) => format!("{} ({}: {})", state.status.as_str(), e.code, e.message),
720        None => state.status.as_str().to_string(),
721    }
722}
723
724/// `None` if the expectation holds, else what went wrong. A step written
725/// `on_event:<signal>:<step>` stands for that step in every instance of the
726/// block: the expectation must hold for each, and at least one must exist.
727fn check(e: &Expectation, state: &ExecutionState) -> Option<String> {
728    let event_step = match e {
729        Expectation::StepStatus { step, .. }
730        | Expectation::StepOutput { step, .. }
731        | Expectation::StepAttempts { step, .. } => step
732            .strip_prefix("on_event:")
733            .and_then(|r| r.split_once(':'))
734            .map(|(signal, id)| (step.clone(), signal.to_string(), id.to_string())),
735        _ => None,
736    };
737    if let Some((written, signal, id)) = event_step {
738        let instances: Vec<String> = state
739            .signals
740            .iter()
741            .filter_map(|r| r.delivered_to.clone())
742            .filter(|k| k.starts_with(&format!("on_event:{signal}@")))
743            .collect();
744        if instances.is_empty() {
745            return Some(format!(
746                "expected step `{written}`, but no `{signal}` signal started the `on event {signal}` block"
747            ));
748        }
749        for instance in instances {
750            let key = format!("{instance}:{id}");
751            let mut one = e.clone();
752            match &mut one {
753                Expectation::StepStatus { step, .. }
754                | Expectation::StepOutput { step, .. }
755                | Expectation::StepAttempts { step, .. } => *step = key,
756                _ => {}
757            }
758            if let Some(why) = check_one(&one, state) {
759                return Some(why.replace(&format!("{instance}:{id}"), &written));
760            }
761        }
762        return None;
763    }
764    check_one(e, state)
765}
766
767fn check_one(e: &Expectation, state: &ExecutionState) -> Option<String> {
768    let unit = |step: &str| state.units.get(step).cloned().unwrap_or_default();
769    match e {
770        Expectation::Runs { runs } => (state.run != *runs).then(|| {
771            format!(
772                "expected the execution to end in run {runs}, it ended in run {}",
773                state.run
774            )
775        }),
776        Expectation::Status { status } => (state.status.as_str() != status)
777            .then(|| format!("expected status {status}, got {}", describe_end(state))),
778        Expectation::Output { expected } => {
779            let actual = state.output.clone().unwrap_or(Value::Null);
780            (!value_matches(expected, &actual)).then(|| {
781                if state.output.is_none() {
782                    format!(
783                        "expected output {}, but the workflow produced none (it ended {})",
784                        compact(expected),
785                        describe_end(state)
786                    )
787                } else {
788                    format!(
789                        "expected output {}, got {}",
790                        compact(expected),
791                        compact(&actual)
792                    )
793                }
794            })
795        }
796        Expectation::Error { code } => match &state.error {
797            Some(err) if err.code == *code => None,
798            Some(err) => Some(format!(
799                "expected error {code:?}, got {:?} ({})",
800                err.code, err.message
801            )),
802            None => Some(format!(
803                "expected error {code:?}, but the workflow ended {} without an error",
804                state.status.as_str()
805            )),
806        },
807        Expectation::StepStatus { step, status } => {
808            let actual = status_name(unit(step).status());
809            (actual != *status).then(|| {
810                let u = unit(step);
811                let why = u
812                    .error
813                    .as_ref()
814                    .map(|e| format!(" ({}: {})", e.code, e.message))
815                    .unwrap_or_default();
816                format!("expected step `{step}` to be {status}, got {actual}{why}")
817            })
818        }
819        Expectation::StepOutput { step, expected } => {
820            let u = unit(step);
821            let actual = u.output.clone().unwrap_or(Value::Null);
822            (!value_matches(expected, &actual)).then(|| {
823                format!(
824                    "expected step `{step}` output {}, got {} (step is {})",
825                    compact(expected),
826                    compact(&actual),
827                    status_name(u.status())
828                )
829            })
830        }
831        Expectation::StepAttempts { step, attempts } => {
832            let actual = unit(step).attempt;
833            (actual != *attempts).then(|| {
834                format!("expected step `{step}` to take {attempts} attempt(s), took {actual}")
835            })
836        }
837        Expectation::Compensated { step } => {
838            let element = format!("{}[", compensation_key(step));
839            let units: Vec<(&String, &UnitState)> = state
840                .units
841                .iter()
842                .filter(|(k, _)| **k == compensation_key(step) || k.starts_with(&element))
843                .collect();
844            if units.is_empty() {
845                return Some(format!(
846                    "expected step `{step}` to be compensated, but its compensation never ran"
847                ));
848            }
849            units
850                .iter()
851                .find(|(_, u)| u.status() != UnitStatus::Succeeded)
852                .map(|(k, u)| {
853                    let why = u
854                        .error
855                        .as_ref()
856                        .map(|e| format!(" ({}: {})", e.code, e.message))
857                        .unwrap_or_default();
858                    format!(
859                        "expected step `{step}` to be compensated, but `{k}` is {}{why}",
860                        status_name(u.status())
861                    )
862                })
863        }
864    }
865}