camel_integration_test/runner.rs
1//! The scenario action runner (ADR-0069 §5, §7).
2//!
3//! Executes a scenario's ordered actions against a
4//! [`PartnerRouter`](crate::adapters::PartnerRouter): `send`
5//! dispatches through the adapter, `receive` awaits with the action's
6//! deadline and applies `extract` into [`ScenarioVars`], `sleep` uses
7//! tokio time, and `validate` evaluates the matcher grammar against
8//! the last received message or an extracted variable, or asserts a
9//! partner's recorded-request count (feature `http`).
10//!
11//! Failure taxonomy (ADR-0069 §7), encoded by variant and named in
12//! `Display`, never by message text alone:
13//!
14//! - Verdict class — the scenario ran and the system under test
15//! failed it: [`ScenarioFailure::ReceiveTimeout`],
16//! [`ScenarioFailure::ValidationMismatch`],
17//! [`ScenarioFailure::VarUnresolved`].
18//! - Apparatus class — the scenario never got a meaningful answer:
19//! [`ScenarioFailure::ActionTransport`],
20//! [`ScenarioFailure::PartnerStartup`],
21//! [`ScenarioFailure::ShutdownFailure`],
22//! [`ScenarioFailure::LogCaptureUnavailable`].
23//!
24//! Verdict-class failures map to exit 1 at the CLI; apparatus-class
25//! failures map to exit 2, as do doc-validation failures before the
26//! runner ever runs.
27//!
28//! Every await is bounded: `receive` carries the action deadline, and
29//! `send` is bounded by the document's `sendDeadline`, defaulting to
30//! [`SEND_DEADLINE`].
31
32use std::collections::BTreeMap;
33use std::sync::Arc;
34use std::time::{Duration, Instant};
35
36use camel_api::datasource::DatasourceCatalog;
37use camel_api::{Body, Exchange, Value};
38use camel_matchers::{expectation_matches, stringify};
39
40use crate::adapters::redact_wire_path;
41use crate::adapters::{
42 IncomingMessage, OutgoingMessage, PartnerRouter, ReceiveError, TransportError, lanes_suffix,
43};
44use crate::document::{
45 EndpointRef, Expectation, LogLevel, LogsAssertion, Provisioning, ScenarioAction,
46 ScenarioDocument, ScenarioTarget, ValidateExpectation,
47};
48
49/// Partner verification for the `validate` action's `partner` target
50/// (ADR-0069 §5): the filtered recorded-request count, the deadline
51/// poll, and the mismatch-detail renderers.
52mod partner_validate;
53
54// Test-only re-exports: these primitives are exercised directly by
55// `runner_test`, while the runner itself only calls
56// `partner_validate_action`.
57use partner_validate::partner_validate_action;
58#[cfg(all(test, feature = "http"))]
59pub(crate) use partner_validate::{
60 matching_requests, partner_mismatch_detail, render_bound, render_filters,
61};
62
63/// SQL row-shape verification for the `validate` action's `sql`
64/// target (bd rc-25lup.2): pool resolution, the fail-closed row
65/// mapping, the by-name projection, the non-monotone poll lattice,
66/// and the cell-free mismatch renderer.
67mod sql_validate;
68
69// The dispatch target of the runner's sql validate arm; the
70// re-exports below are its direct unit tests (`sql_validate_test`,
71// which compiles in BOTH feature configurations — hence the twin).
72#[cfg(all(test, feature = "sql"))]
73pub(crate) use sql_validate::any_row_to_tuple;
74pub(crate) use sql_validate::sql_validate_action;
75
76/// The default bounded deadline for every `send` action (ADR-0069
77/// §7: every adapter operation carries a deadline). A document-level
78/// `sendDeadline` overrides it (rc-tr4w).
79const SEND_DEADLINE: Duration = Duration::from_secs(30);
80
81/// The effective send bound of a document: its declared
82/// `sendDeadline`, or the thirty-second [`SEND_DEADLINE`] default.
83/// Real time only (ADR-0069 §6: no virtual time).
84pub(crate) fn effective_send_deadline(doc: &ScenarioDocument) -> Duration {
85 doc.send_deadline.unwrap_or(SEND_DEADLINE)
86}
87
88/// Mutable run state carried across actions: scenario variables set by
89/// `extract`, and the last message received per endpoint for
90/// `lastReceived` validation.
91#[derive(Debug, Default)]
92pub struct ScenarioVars {
93 /// Variables extracted from received messages, by name.
94 variables: BTreeMap<String, Value>,
95 /// Last message received per endpoint URI.
96 last_received: BTreeMap<String, IncomingMessage>,
97}
98
99impl ScenarioVars {
100 /// Empty run state.
101 pub fn new() -> Self {
102 Self::default()
103 }
104
105 /// The variable set by an earlier `extract`, if any.
106 pub fn get(&self, name: &str) -> Option<&Value> {
107 self.variables.get(name)
108 }
109
110 /// Sets a variable, overwriting any earlier value.
111 pub fn set(&mut self, name: impl Into<String>, value: Value) {
112 self.variables.insert(name.into(), value);
113 }
114
115 /// The last message received on the endpoint URI, if any.
116 pub fn last_received(&self, endpoint: &str) -> Option<&IncomingMessage> {
117 self.last_received.get(endpoint)
118 }
119
120 /// Records the last message received on an endpoint URI.
121 fn remember(&mut self, endpoint: String, message: IncomingMessage) {
122 self.last_received.insert(endpoint, message);
123 }
124}
125
126/// Resolves `${name}` placeholders in a scenario string against `vars`.
127///
128/// Grammar: `$${` escapes to a literal `${`; `${name}` substitutes the
129/// variable when `name` matches `[A-Za-z0-9_]+` and is immediately
130/// followed by `}`. Anything else — including `${env:FOO}`, where a
131/// colon follows the name — stays literal, so `${env:}` never resolves
132/// in scenarios. A non-string variable substitutes its JSON
133/// representation (`Value::to_string`), so a number 42 yields `42`.
134/// Substituted text is not re-scanned. An unset variable fails with
135/// [`ScenarioFailure::VarUnresolved`].
136pub(crate) fn resolve_placeholders(
137 input: &str,
138 vars: &ScenarioVars,
139) -> Result<String, ScenarioFailure> {
140 let bytes = input.as_bytes();
141 let mut out = Vec::with_capacity(bytes.len());
142 let mut i = 0;
143 while i < bytes.len() {
144 if bytes[i] == b'$' {
145 // `$${` escapes to a literal `${`.
146 if i + 2 < bytes.len() && bytes[i + 1] == b'$' && bytes[i + 2] == b'{' {
147 out.extend_from_slice(b"${");
148 i += 3;
149 continue;
150 }
151 // `${name}` with name in [A-Za-z0-9_]+ immediately followed
152 // by `}`; a colon or any other character after the name
153 // keeps the whole span literal.
154 if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
155 let name_start = i + 2;
156 let mut j = name_start;
157 while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
158 j += 1;
159 }
160 if j > name_start && j < bytes.len() && bytes[j] == b'}' {
161 let name = &input[name_start..j];
162 match vars.get(name) {
163 Some(value) => {
164 let replacement = stringify(value);
165 out.extend_from_slice(replacement.as_bytes());
166 i = j + 1;
167 continue;
168 }
169 None => {
170 return Err(ScenarioFailure::VarUnresolved {
171 name: name.to_string(),
172 });
173 }
174 }
175 }
176 }
177 out.push(b'$');
178 i += 1;
179 continue;
180 }
181 out.push(bytes[i]);
182 i += 1;
183 }
184 // The output is a byte-for-byte copy of the input except for
185 // substituted spans, so it stays valid UTF-8.
186 Ok(String::from_utf8(out).expect("placeholder output preserves input UTF-8")) // allow-unwrap
187}
188
189/// Recursively interpolates `${name}` placeholders in a value: maps
190/// and arrays are rebuilt with interpolated values, string leaves go
191/// through [`resolve_placeholders`], and every other leaf is cloned
192/// untouched. An unset variable propagates
193/// [`ScenarioFailure::VarUnresolved`] from any depth.
194pub(crate) fn interpolate_value(
195 value: &Value,
196 vars: &ScenarioVars,
197) -> Result<Value, ScenarioFailure> {
198 match value {
199 Value::String(text) => Ok(Value::String(resolve_placeholders(text, vars)?)),
200 Value::Array(items) => items
201 .iter()
202 .map(|item| interpolate_value(item, vars))
203 .collect::<Result<Vec<_>, _>>()
204 .map(Value::Array),
205 Value::Object(map) => {
206 let rebuilt = map
207 .iter()
208 .map(|(key, item)| Ok((key.clone(), interpolate_value(item, vars)?)))
209 .collect::<Result<_, _>>()?;
210 Ok(Value::Object(rebuilt))
211 }
212 other => Ok(other.clone()),
213 }
214}
215
216/// The outcome of a scenario that ran to completion: every action
217/// succeeded and every validation passed.
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219#[non_exhaustive]
220pub enum ScenarioVerdict {
221 /// All actions completed and all validations passed.
222 Pass,
223}
224
225/// Why a scenario failed (ADR-0069 §7). Verdict-class variants mean
226/// the system under test failed the scenario; apparatus-class
227/// variants mean the scenario never got a meaningful answer. The
228/// CLI maps verdict-class failures to exit 1 and apparatus-class
229/// failures to exit 2; doc validation also maps to exit 2.
230#[derive(Debug, Clone, PartialEq, thiserror::Error)]
231#[non_exhaustive]
232pub enum ScenarioFailure {
233 /// Nothing reached the partner before the deadline (verdict
234 /// class, `receive-timeout`).
235 #[error("receive-timeout: {endpoint} delivered nothing within {deadline:?}{lanes}")]
236 ReceiveTimeout {
237 /// The endpoint URI that delivered nothing.
238 endpoint: String,
239 /// The deadline that elapsed.
240 deadline: Duration,
241 /// Rendered lane evidence (already redacted, ADR-0051): the
242 /// `; no arrival matched; lanes recorded: [...]` suffix, or
243 /// empty when the construction site saw no lanes.
244 lanes: String,
245 },
246 /// A validation failed (verdict class, `validation-mismatch`).
247 #[error("validation-mismatch: action {action}: {detail}")]
248 ValidationMismatch {
249 /// Index of the failing action, zero-based.
250 action: usize,
251 /// What was expected and what arrived.
252 detail: String,
253 },
254 /// A referenced variable was never set (verdict class,
255 /// `scenario-var-unresolved`).
256 #[error("scenario-var-unresolved: {name}")]
257 VarUnresolved {
258 /// The variable name no `extract` ever set.
259 name: String,
260 },
261 /// A send or receive failed at the transport before any assertion
262 /// ran (apparatus class, `action-transport-failure`).
263 #[error("action-transport-failure: action {action}: {source}")]
264 ActionTransport {
265 /// Index of the failing action, zero-based.
266 action: usize,
267 /// The transport failure.
268 source: TransportError,
269 },
270 /// A partner listener bound but its handler failed to start
271 /// (apparatus class, `partner-startup-failure`). Reserved in v1:
272 /// no adapter separates bind from handler start, and the CLI maps
273 /// bind failures to `partner-bind-failure` doc errors.
274 #[error("partner-startup-failure: {message}")]
275 PartnerStartup {
276 /// Startup failure detail.
277 message: String,
278 },
279 /// The partner's arrival lane dropped arrivals while the scenario
280 /// was not receiving (apparatus class, `arrival-lane-overflow`):
281 /// the harness lost them before the system under test could fail
282 /// the scenario on substance.
283 #[error("arrival-lane-overflow: {endpoint} dropped {dropped} arrivals")]
284 ArrivalLaneOverflow {
285 /// The endpoint URI whose lane dropped arrivals.
286 endpoint: String,
287 /// How many arrivals the lane dropped while full.
288 dropped: usize,
289 },
290 /// Teardown of the boot or a partner timed out or erred after the
291 /// verdict was recorded (apparatus class, `shutdown-failure`).
292 #[error("shutdown-failure: {message}")]
293 ShutdownFailure {
294 /// Teardown failure detail.
295 message: String,
296 },
297 /// The document declares a `logs:` block but the harness's capture
298 /// subscriber does not own the process's tracing seat (apparatus
299 /// class, `log-capture-unavailable`, rc-tdgh5): a foreign tracing
300 /// subscriber won the first-wins `try_init`, so the events the
301 /// block asserts against never reach the harness. The scenario
302 /// never got a meaningful answer.
303 #[error("log-capture-unavailable: {detail}")]
304 LogCaptureUnavailable {
305 /// Why capture cannot run (the foreign-subscriber condition).
306 detail: String,
307 },
308}
309
310/// Fills the harness bind variables into `vars` (ADR-0069 §9): every
311/// wired reference with `provisioning: harness` and a `bindVar` gets
312/// its partner's bound `host:port` authority from the router, so a
313/// scenario string can address the partner as
314/// `http://${NAME}/path`.
315///
316/// Two-layer split: the scenario variable carries `host:port` only;
317/// the env-tier binding that route files interpolate keeps its
318/// `http://host:port` form (owned by the CLI driver, unchanged here).
319/// A reference with no registered adapter or no bound authority is
320/// skipped: the variable stays unset, and a later use fails with the
321/// verdict-class `VarUnresolved`.
322pub fn fill_bind_vars(wired: &[EndpointRef], router: &PartnerRouter, vars: &mut ScenarioVars) {
323 for reference in wired {
324 if reference.provisioning != Some(Provisioning::Harness) {
325 continue;
326 }
327 let Some(bind_var) = reference.bind_var.as_deref() else {
328 continue;
329 };
330 let Some(authority) = router
331 .adapter(&reference.endpoint)
332 .and_then(|adapter| adapter.bound_authority())
333 else {
334 continue;
335 };
336 vars.set(bind_var, Value::String(authority));
337 }
338}
339
340/// Runs a scenario's actions in order against the router.
341///
342/// On success every action completed; on failure the variant names
343/// the ADR-0069 §7 class. `vars` carries extraction results and
344/// last-received state both into and out of the run.
345pub async fn run_scenario(
346 doc: &ScenarioDocument,
347 router: &PartnerRouter,
348 vars: &mut ScenarioVars,
349) -> Result<ScenarioVerdict, ScenarioFailure> {
350 // The scenario-start anchor every `elapsedAtLeast` bound measures
351 // against; taken once per run, before the first action.
352 let started_at = Instant::now();
353 let send_deadline = effective_send_deadline(doc);
354 for (index, action) in doc.scenario.iter().enumerate() {
355 // The single-action loop has no boot of its own, so a `sql:`
356 // action here has no datasource catalog and fails closed.
357 run_action(action, index, router, vars, started_at, send_deadline, None).await?;
358 }
359 Ok(ScenarioVerdict::Pass)
360}
361
362/// The outcome of executing a whole scenario document
363/// (ADR-0069 sections 5 and 7).
364///
365/// [`run_scenario_document`](self::run_scenario_document) fills
366/// `per_action` with one outcome per executed action and stops at the
367/// first failure; `verdict` is `Some(Pass)` only when every action
368/// passed. `final_failure` is the post-verdict slot: the caller that
369/// owns the boot (the CLI, after `BootHandle::shutdown`) records a
370/// `ShutdownFailure` there without masking the recorded verdict.
371#[derive(Debug, Clone, PartialEq)]
372pub struct DocumentOutcome {
373 /// One outcome per executed action, in action order; actions after
374 /// the first failure never ran.
375 pub per_action: Vec<Result<ScenarioVerdict, ScenarioFailure>>,
376 /// `Some(Pass)` when every action completed; `None` after any
377 /// failure.
378 pub verdict: Option<ScenarioVerdict>,
379 /// Post-verdict shutdown failure, recorded by the boot-owning
380 /// caller; empty when teardown is clean or never ran.
381 pub final_failure: Option<ScenarioFailure>,
382 /// The bound address of the document's `inbound:` listener
383 /// (rc-5yon, ADR-0070), filled by the boot-owning caller from
384 /// [`crate::boot_scenario::ScenarioRun::inbound_bound`] after the
385 /// boot, so tests target the ephemeral listener without re-deriving
386 /// it. `None` when the document declares no `inbound:` listener or
387 /// the caller never filled it; the post-boot slot, as
388 /// `final_failure` is the post-verdict slot.
389 pub inbound_bound: Option<std::net::SocketAddr>,
390 /// Document-level `logs:` block violation (rc-tdgh5): a rendered
391 /// diagnostic naming each violated clause — for `noLevelAbove`,
392 /// each offending event's level, target, and message. `Some` only
393 /// when every action passed and the logs evaluation then failed,
394 /// so `verdict` is `None` alongside it. `None` when the document
395 /// declares no `logs:` block, the block passed, an action failed
396 /// first (the block never evaluated), or capture was unavailable
397 /// (the apparatus failure lives in `per_action`).
398 pub logs_failure: Option<String>,
399}
400
401/// Executes a scenario document's actions in order against the
402/// router, one recorded outcome per action, stopping at the first
403/// failure (the whole-document contract, library-level).
404///
405/// `datasource_catalog` is the booted cascade's single datasource
406/// catalog (bd rc-25lup.1): a `sql:` action resolves its pool through
407/// it, so the seeds land in the same pools the routes use. Callers
408/// without a boot pass `None`; a `sql:` action then fails closed
409/// instead of silently seeding nothing.
410///
411/// When the document declares a `logs:` block (rc-tdgh5), a capture
412/// window opens at document start (behind the harness's process-seat
413/// ownership — a foreign subscriber fails the document through
414/// [`ScenarioFailure::LogCaptureUnavailable`] first) and the block
415/// evaluates after the action loop against the window's events: a
416/// violation fills [`DocumentOutcome::logs_failure`] with the verdict
417/// `None`.
418///
419/// Partners route through `router`; a `send` addressed to a context
420/// component reaches the booted system under test through the
421/// context-stimulus adapter the caller registered for that endpoint
422/// (see [`crate::adapters`]). The single-action
423/// [`run_scenario`] loop and this loop share [`run_action`].
424pub async fn run_scenario_document(
425 doc: &ScenarioDocument,
426 router: &PartnerRouter,
427 vars: &mut ScenarioVars,
428 datasource_catalog: Option<&Arc<dyn DatasourceCatalog>>,
429) -> DocumentOutcome {
430 // Log-capture window (rc-tdgh5): open at document start when the
431 // document declares a `logs:` block — and only when the harness's
432 // capture subscriber owns the process's tracing seat. A foreign
433 // subscriber won the first-wins race: the document fails through
434 // the apparatus class before any action runs, because the events
435 // the block asserts against would never reach the harness.
436 let capture_window = match &doc.logs {
437 None => None,
438 Some(_) if crate::log_capture::capture_installed() => {
439 Some(crate::log_capture::open_window())
440 }
441 Some(_) => {
442 return DocumentOutcome {
443 per_action: vec![Err(ScenarioFailure::LogCaptureUnavailable {
444 detail: "the `logs:` block needs the harness log-capture subscriber, but a foreign tracing subscriber owns this process (first-wins try_init); install nothing before the scenario harness".to_string(),
445 })],
446 verdict: None,
447 final_failure: None,
448 logs_failure: None,
449 inbound_bound: None,
450 };
451 }
452 };
453 // The scenario-start anchor every `elapsedAtLeast` bound measures
454 // against; taken once per run, before the first action.
455 let started_at = Instant::now();
456 let send_deadline = effective_send_deadline(doc);
457 let mut per_action = Vec::with_capacity(doc.scenario.len());
458 let mut failed = false;
459 for (index, action) in doc.scenario.iter().enumerate() {
460 if failed {
461 break;
462 }
463 match run_action(
464 action,
465 index,
466 router,
467 vars,
468 started_at,
469 send_deadline,
470 datasource_catalog,
471 )
472 .await
473 {
474 Ok(()) => per_action.push(Ok(ScenarioVerdict::Pass)),
475 Err(failure) => {
476 per_action.push(Err(failure));
477 failed = true;
478 }
479 }
480 }
481 // Logs evaluation (rc-tdgh5): only when no action failed, against
482 // the window that spanned the run. Closing unregisters the window
483 // (conservative attribution for every later window); an action
484 // failure skips evaluation and drops the handle, which unregisters
485 // the window the same way.
486 let logs_failure = match (&doc.logs, capture_window) {
487 (Some(assertion), Some(window)) if !failed => {
488 let events = window.close();
489 evaluate_logs(assertion, &events)
490 }
491 _ => None,
492 };
493 let verdict = if failed || logs_failure.is_some() {
494 None
495 } else {
496 Some(ScenarioVerdict::Pass)
497 };
498 DocumentOutcome {
499 per_action,
500 verdict,
501 final_failure: None,
502 logs_failure,
503 inbound_bound: None,
504 }
505}
506
507/// Evaluates the document-level `logs:` block against the closed
508/// window's events (rc-tdgh5). Conjunction across clauses: every
509/// `contains` marker must appear in at least one event message, every
510/// `regex` entry must match at least one (unanchored), and no event
511/// may carry a level above `noLevelAbove`. `Some(diagnostic)` names
512/// every violated clause; for `noLevelAbove` it lists each offending
513/// event's level, target, and message.
514fn evaluate_logs(
515 assertion: &LogsAssertion,
516 events: &[crate::log_capture::LogEvent],
517) -> Option<String> {
518 let mut violations: Vec<String> = Vec::new();
519 for marker in &assertion.contains {
520 if !events
521 .iter()
522 .any(|event| event.message.contains(marker.as_str()))
523 {
524 violations.push(format!(
525 "`logs.contains` entry `{marker}` matched no captured event"
526 ));
527 }
528 }
529 for pattern in &assertion.regex {
530 // The load-time gate compiled every pattern; a compile failure
531 // here is unreachable, reported rather than panicked (defense
532 // in depth).
533 match regex::Regex::new(pattern) {
534 Ok(compiled) => {
535 if !events.iter().any(|event| compiled.is_match(&event.message)) {
536 violations.push(format!(
537 "`logs.regex` entry `{pattern}` matched no captured event"
538 ));
539 }
540 }
541 Err(error) => violations.push(format!(
542 "`logs.regex` entry `{pattern}` does not compile: {error}"
543 )),
544 }
545 }
546 if let Some(cap) = assertion.no_level_above {
547 let offenders: Vec<&crate::log_capture::LogEvent> = events
548 .iter()
549 .filter(|event| event.level < as_tracing_level(cap))
550 .collect();
551 if !offenders.is_empty() {
552 let listed = offenders
553 .iter()
554 .map(|event| format!("{} {} {}", event.level, event.target, event.message))
555 .collect::<Vec<_>>()
556 .join("; ");
557 violations.push(format!(
558 "`logs.noLevelAbove` violated by {} event(s): {listed}",
559 offenders.len()
560 ));
561 }
562 }
563 if violations.is_empty() {
564 None
565 } else {
566 Some(violations.join("; "))
567 }
568}
569
570/// Maps the document grammar's level onto `tracing`'s ordering.
571/// `tracing` orders levels by verbosity — `TRACE` is the greatest,
572/// `ERROR` the least — so an event more SEVERE than the cap compares
573/// LESS than the cap's level (`event.level < cap`).
574fn as_tracing_level(level: LogLevel) -> tracing::Level {
575 match level {
576 LogLevel::Trace => tracing::Level::TRACE,
577 LogLevel::Debug => tracing::Level::DEBUG,
578 LogLevel::Info => tracing::Level::INFO,
579 LogLevel::Warn => tracing::Level::WARN,
580 LogLevel::Error => tracing::Level::ERROR,
581 }
582}
583
584/// Executes one action at its scenario index. The shared primitive of
585/// [`run_scenario`] and [`run_scenario_document`]; every failure
586/// carries the action index. A `sql:` action seeds through
587/// `datasource_catalog` (`None` fails closed — see
588/// [`run_scenario_document`]).
589async fn run_action(
590 action: &ScenarioAction,
591 index: usize,
592 router: &PartnerRouter,
593 vars: &mut ScenarioVars,
594 started_at: Instant,
595 send_deadline: Duration,
596 datasource_catalog: Option<&Arc<dyn DatasourceCatalog>>,
597) -> Result<(), ScenarioFailure> {
598 match action {
599 ScenarioAction::Send {
600 to,
601 body,
602 headers,
603 method,
604 expect_reply,
605 } => {
606 send_action(
607 index,
608 to,
609 body.as_ref(),
610 headers.as_ref(),
611 method,
612 expect_reply.as_ref(),
613 router,
614 vars,
615 send_deadline,
616 )
617 .await?;
618 }
619 ScenarioAction::Receive {
620 from,
621 deadline,
622 extract,
623 } => {
624 receive_action(index, from, *deadline, extract.as_ref(), router, vars).await?;
625 }
626 ScenarioAction::Sleep { duration } => {
627 tokio::time::sleep(*duration).await;
628 }
629 ScenarioAction::Validate { .. } => {
630 validate_action(action, index, started_at, router, vars, datasource_catalog).await?;
631 }
632 ScenarioAction::Sql {
633 datasource,
634 prepare,
635 } => {
636 // Apparatus class (exit 2): seeding is harness-side state
637 // preparation — a failure here means the scenario never
638 // got its declared preconditions, never that the system
639 // under test misbehaved.
640 #[cfg(feature = "sql")]
641 {
642 let Some(catalog) = datasource_catalog else {
643 return Err(ScenarioFailure::ActionTransport {
644 action: index,
645 source: TransportError::Other {
646 message: "sql action: no datasource catalog is available; the \
647 boot-owning caller must pass the cascade's catalog"
648 .to_string(),
649 },
650 });
651 };
652 let sql = crate::sql_action::SqlAction {
653 datasource: datasource.clone(),
654 prepare: prepare.clone(),
655 };
656 crate::sql_action::execute_sql_prepare(catalog, &sql)
657 .await
658 .map_err(|message| ScenarioFailure::ActionTransport {
659 action: index,
660 source: TransportError::Other { message },
661 })?;
662 }
663 // Defense-in-depth: the document parser rejects `sql:`
664 // without the feature, so only a directly-constructed
665 // document reaches this arm (the boot-level inbound
666 // precedent).
667 #[cfg(not(feature = "sql"))]
668 {
669 let _ = (datasource, prepare, datasource_catalog);
670 return Err(ScenarioFailure::ActionTransport {
671 action: index,
672 source: TransportError::Other {
673 message: "the `sql:` action requires the `sql` feature, which this \
674 harness build does not enable: rebuild with \
675 `--features sql` (demand-gated activation)"
676 .to_string(),
677 },
678 });
679 }
680 }
681 }
682 Ok(())
683}
684
685/// Dispatches a `send` action, bounded by the document's effective
686/// send deadline ([`effective_send_deadline`]: the declared
687/// `sendDeadline`, or the thirty-second default).
688///
689/// The endpoint reference, the body's string leaves, and the header
690/// values are the complete interpolation surface: each resolves its
691/// `${name}` placeholders against `vars` before dispatch, and an
692/// unresolved variable fails with the verdict-class `VarUnresolved`.
693/// The dial target comes from the router's address math: a
694/// harness-declared `:0` reference (or a dynamic reference resolving
695/// to a partner authority) dials the partner's bound address with the
696/// interpolated path preserved; anything else dials the interpolated
697/// URI literally.
698///
699/// A declared `expectReply` (rc-qvz6, `direct:` sends only — the
700/// grammar rejected every other scheme at load) asserts the
701/// synchronous reply the adapter returned: a non-matching reply is a
702/// verdict-class [`ScenarioFailure::ValidationMismatch`] naming the
703/// rendered expectation and the actual body, and a missing reply is
704/// an apparatus-class [`ScenarioFailure::ActionTransport`] — the
705/// scenario never got an answer to assert against.
706// The action's flat decomposition (index, endpoint, body, headers,
707// method, reply expectation, router, vars) plus the document send
708// bound threaded from run_action (rc-tr4w).
709#[allow(clippy::too_many_arguments)]
710async fn send_action(
711 index: usize,
712 to: &EndpointRef,
713 body: Option<&Value>,
714 headers: Option<&BTreeMap<String, Value>>,
715 method: &str,
716 expect_reply: Option<&Expectation>,
717 router: &PartnerRouter,
718 vars: &ScenarioVars,
719 send_deadline: Duration,
720) -> Result<(), ScenarioFailure> {
721 let declared = to.endpoint.as_str();
722 let interpolated = resolve_placeholders(declared, vars)?;
723 let body = body
724 .map(|value| interpolate_value(value, vars))
725 .transpose()?;
726 let headers = headers
727 .map(|map| -> Result<BTreeMap<String, Value>, ScenarioFailure> {
728 map.iter()
729 .map(|(name, value)| Ok((name.clone(), interpolate_value(value, vars)?)))
730 .collect()
731 })
732 .transpose()?;
733 let msg = OutgoingMessage {
734 body: body.unwrap_or(Value::Null),
735 headers: headers.unwrap_or_default(),
736 method: method.to_string(),
737 };
738 let bounded =
739 tokio::time::timeout(send_deadline, router.send(declared, &interpolated, msg)).await;
740 let sent = bounded.map_err(|_| ScenarioFailure::ActionTransport {
741 action: index,
742 source: TransportError::Deadline {
743 after: send_deadline,
744 },
745 })?;
746 let reply = sent.map_err(|source| {
747 // Render-site defense: the lane key is the declared endpoint
748 // URI, and a third-party adapter may hand the overflow over
749 // RAW; the runner holds the secret set, and redaction is
750 // idempotent on already-masked output (ADR-0051).
751 let source = match source {
752 TransportError::LaneFifoOverflow { lane_key, bound } => {
753 TransportError::LaneFifoOverflow {
754 lane_key: redact_wire_path(&lane_key, &router.secret_query_keys()),
755 bound,
756 }
757 }
758 other => other,
759 };
760 ScenarioFailure::ActionTransport {
761 action: index,
762 source,
763 }
764 })?;
765 if let Some(expectation) = expect_reply {
766 let Some(reply) = reply else {
767 // Fail closed: the grammar promised a direct reply, but
768 // the adapter produced none — an apparatus defect, never
769 // a silently-skipped assertion.
770 return Err(ScenarioFailure::ActionTransport {
771 action: index,
772 source: TransportError::Other {
773 message: "direct send produced no reply".to_string(),
774 },
775 });
776 };
777 let value = reply_body_value(&reply);
778 if !expectation_matches(expectation, &value) {
779 return Err(ScenarioFailure::ValidationMismatch {
780 action: index,
781 detail: format!(
782 "direct reply on {}: expected {}, got {}",
783 to.endpoint,
784 render_expectation(expectation),
785 stringify(&value)
786 ),
787 });
788 }
789 }
790 Ok(())
791}
792
793/// Converts a synchronous `direct:` reply exchange's body into the
794/// matcher value an `expectReply` assertion reads (rc-qvz6): the
795/// reply message is the exchange's output when the route produced
796/// one, the (route-mutated — `set_body` writes it) input otherwise.
797/// Feature-free by design: the partner-body extractors stay
798/// `http`-gated; this path never touches the wire. Crate-visible for
799/// the runner's unit tests, like the interpolation primitives.
800pub(crate) fn reply_body_value(exchange: &Exchange) -> Value {
801 let message = exchange.output.as_ref().unwrap_or(&exchange.input);
802 match &message.body {
803 Body::Json(value) => value.clone(),
804 Body::Text(text) => reply_bytes_value(text.as_bytes()),
805 Body::Xml(text) => reply_bytes_value(text.as_bytes()),
806 Body::Bytes(bytes) => reply_bytes_value(bytes),
807 // Empty and consumed-stream bodies carry no reply bytes, and
808 // foreign `#[non_exhaustive]` body kinds (none today) expose
809 // none either; the value reads as the empty string.
810 _ => Value::String(String::new()),
811 }
812}
813
814/// Parses reply bytes as JSON, falling back to a lossy-UTF-8 string
815/// when they are not JSON text: a text body holding JSON is observed
816/// as the structured value the matcher verbs expect, and any other
817/// text stays textual. Shared with the sql validate executor, whose
818/// blob cells obey the same law.
819pub(crate) fn reply_bytes_value(bytes: &[u8]) -> Value {
820 serde_json::from_slice(bytes)
821 .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(bytes).into_owned()))
822}
823
824/// Renders an expectation for an `expectReply` mismatch detail: the
825/// verb and its payload in the document grammar's own terms.
826fn render_expectation(expectation: &Expectation) -> String {
827 match expectation {
828 Expectation::Equals(expected) => format!("equals {expected}"),
829 Expectation::Regex(pattern) => format!("matches regex `{pattern}`"),
830 Expectation::Contains(needle) => format!("contains `{needle}`"),
831 Expectation::StartsWith(prefix) => format!("startsWith `{prefix}`"),
832 Expectation::EndsWith(suffix) => format!("endsWith `{suffix}`"),
833 Expectation::Exists => "exists".to_string(),
834 Expectation::JsonSubset(pattern) => format!("is a superset of {pattern}"),
835 // Foreign `#[non_exhaustive]` variants (none today): no verb
836 // renders, but the matcher already failed closed.
837 _ => "the expected value".to_string(),
838 }
839}
840
841/// Awaits a `receive` action until the deadline, records the message,
842/// and applies `extract` into `vars`.
843async fn receive_action(
844 index: usize,
845 from: &EndpointRef,
846 deadline: Duration,
847 extract: Option<&BTreeMap<String, String>>,
848 router: &PartnerRouter,
849 vars: &mut ScenarioVars,
850) -> Result<(), ScenarioFailure> {
851 // The lane is read under the two-key contract: the declared
852 // string names the registered lane when it can, and the
853 // interpolated URI resolves a dynamic reference's lane by
854 // authority (`lane_key_for`).
855 let declared = from.endpoint.as_str();
856 let interpolated = resolve_placeholders(declared, vars)?;
857 let message = router
858 .receive(declared, &interpolated, deadline)
859 .await
860 .map_err(|source| {
861 // Render-site defense: a third-party adapter may hand
862 // over RAW endpoint and lane evidence; the runner holds
863 // the secret set, and redaction is idempotent on
864 // already-masked output (ADR-0051).
865 let keys = router.secret_query_keys();
866 match source {
867 ReceiveError::Timeout(timeout) => ScenarioFailure::ReceiveTimeout {
868 endpoint: redact_wire_path(&timeout.endpoint, &keys),
869 deadline,
870 lanes: lanes_suffix(
871 &timeout
872 .lanes_recorded
873 .iter()
874 .map(|lane| redact_wire_path(lane, &keys))
875 .collect::<Vec<_>>(),
876 ),
877 },
878 ReceiveError::Overflow(overflow) => ScenarioFailure::ArrivalLaneOverflow {
879 endpoint: redact_wire_path(&overflow.endpoint, &keys),
880 dropped: overflow.dropped,
881 },
882 ReceiveError::Transport(source) => ScenarioFailure::ActionTransport {
883 action: index,
884 source,
885 },
886 }
887 })?;
888 if let Some(extract) = extract {
889 for (name, selector) in extract {
890 let value = select_from(&message, selector).ok_or_else(|| {
891 ScenarioFailure::ValidationMismatch {
892 action: index,
893 detail: format!(
894 "extract of `{selector}` into variable `{name}` resolved to nothing"
895 ),
896 }
897 })?;
898 vars.set(name.clone(), value);
899 }
900 }
901 vars.remember(from.endpoint.clone(), message);
902 Ok(())
903}
904
905/// Evaluates a `validate` action (ADR-0069 §5).
906///
907/// The `partner` target asserts the exact filtered count of the
908/// requests the harness partner recorded, read as the router's
909/// snapshot — one immediate read without a deadline, a polled one
910/// with it ([`partner_validate_action`]). The `sql` target asserts
911/// the doc-authored read's row shape against the named datasource's
912/// pool through [`sql_validate_action`], which owns the deadline
913/// poll (SQL state is non-monotone, so its lattice differs from the
914/// partner's). Every other target applies the message grammar
915/// against `vars`; the deadline is partner/sql-only (the grammar
916/// rejected it on these targets at parse time, so the message arm
917/// ignores it). An `elapsedAtLeast` bound on a `lastReceived` target
918/// checks the message's wire arrival against the scenario-start
919/// anchor before the grammar runs; the grammar rejected it on every
920/// other target at parse time. Mismatch details name the validation
921/// subject — the variable's name, the receiving endpoint, or the
922/// partner URI — so a corrupted-header regression is diagnosable
923/// from the failure text.
924async fn validate_action(
925 action: &ScenarioAction,
926 index: usize,
927 started_at: Instant,
928 router: &PartnerRouter,
929 vars: &ScenarioVars,
930 datasource_catalog: Option<&Arc<dyn DatasourceCatalog>>,
931) -> Result<(), ScenarioFailure> {
932 // run_action dispatches only the Validate variant here; the
933 // fallback mirrors the impossible pairing arms below.
934 let ScenarioAction::Validate {
935 target,
936 expectation,
937 deadline,
938 elapsed_at_least,
939 } = action
940 else {
941 return Err(unpaired_validate(index));
942 };
943 match (target, expectation) {
944 // The parser pairs a `partner` target with the partner count
945 // grammar; this arm reads the router's snapshot and owns the
946 // deadline.
947 (ScenarioTarget::Partner(endpoint), ValidateExpectation::Partner(expected)) => {
948 partner_validate_action(index, &endpoint.endpoint, expected, *deadline, router).await
949 }
950 // The parser pairs a `sql` target with the row-shape grammar;
951 // this arm reads the datasource's live state and owns the
952 // deadline poll. A `None` catalog fails closed inside (the
953 // sql-action precedent), so a scenario never silently skips
954 // its assertion.
955 (ScenarioTarget::Sql(target), ValidateExpectation::Rows(expected)) => {
956 sql_validate_action(index, target, expected, *deadline, datasource_catalog).await
957 }
958 (_, ValidateExpectation::Message(expectation)) => {
959 let (value, subject) = match target {
960 ScenarioTarget::LastReceived(endpoint) => {
961 // The declared endpoint may carry query bytes: the
962 // subject renders redacted like every diagnostic
963 // that quotes a wire path (ADR-0051).
964 let redacted =
965 redact_wire_path(&endpoint.endpoint, &router.secret_query_keys());
966 let message = vars.last_received(&endpoint.endpoint).ok_or_else(|| {
967 ScenarioFailure::ValidationMismatch {
968 action: index,
969 detail: format!(
970 "no message has been received on {redacted} to validate"
971 ),
972 }
973 })?;
974 // The elapsed bound anchors to the message's wire
975 // arrival, never the consumption time: a message
976 // consumed late can still have arrived early (the
977 // wire is the proof, ADR-0069 §5).
978 if let Some(bound) = elapsed_at_least {
979 let actual = message
980 .arrival
981 .checked_duration_since(started_at)
982 .unwrap_or_default();
983 if actual < *bound {
984 return Err(ScenarioFailure::ValidationMismatch {
985 action: index,
986 detail: format!(
987 "{redacted}: arrived {} after the scenario started; `elapsedAtLeast` requires {}",
988 humantime::format_duration(actual),
989 humantime::format_duration(*bound)
990 ),
991 });
992 }
993 }
994 (
995 message.body.clone(),
996 format!("body last received on {redacted}"),
997 )
998 }
999 ScenarioTarget::Variable(name) => (
1000 vars.get(name)
1001 .cloned()
1002 .ok_or_else(|| ScenarioFailure::VarUnresolved { name: name.clone() })?,
1003 format!("variable `{name}`"),
1004 ),
1005 // Taken by the arm above: the grammar never pairs a
1006 // `partner` target with the message expectation.
1007 ScenarioTarget::Partner(_) => return Err(unpaired_validate(index)),
1008 // Taken by the arm above: the grammar pairs a `sql`
1009 // target with the rows grammar only; a message
1010 // expectation here means a caller bypassed the
1011 // parser.
1012 ScenarioTarget::Sql(_) => return Err(unpaired_validate(index)),
1013 };
1014 // The per-form booleans delegate to the shared core
1015 // (`camel_matchers::expectation_matches`); the detail
1016 // strings stay here, where subject rendering and
1017 // redaction live.
1018 match expectation {
1019 Expectation::Equals(expected) => check(
1020 index,
1021 expectation_matches(expectation, &value),
1022 format!("{subject}: expected {expected}, got {value}"),
1023 ),
1024 // The parser pre-verifies regex patterns at load time,
1025 // so the invalid-regex arm is unreachable through the
1026 // harness; it stays for the byte-identical verdicts,
1027 // short-circuiting before the core delegation (core's
1028 // Regex arm returns false on compile-fail).
1029 Expectation::Regex(pattern) => {
1030 if let Err(error) = regex::Regex::new(pattern) {
1031 return Err(ScenarioFailure::ValidationMismatch {
1032 action: index,
1033 detail: format!("invalid regex `{pattern}`: {error}"),
1034 });
1035 }
1036 check(
1037 index,
1038 expectation_matches(expectation, &value),
1039 format!("{subject}: `{pattern}` did not match {value}"),
1040 )
1041 }
1042 Expectation::Contains(needle) => check(
1043 index,
1044 expectation_matches(expectation, &value),
1045 format!("{subject}: did not contain `{needle}`: {value}"),
1046 ),
1047 Expectation::StartsWith(prefix) => check(
1048 index,
1049 expectation_matches(expectation, &value),
1050 format!("{subject}: did not start with `{prefix}`: {value}"),
1051 ),
1052 Expectation::EndsWith(suffix) => check(
1053 index,
1054 expectation_matches(expectation, &value),
1055 format!("{subject}: did not end with `{suffix}`: {value}"),
1056 ),
1057 Expectation::Exists => check(
1058 index,
1059 expectation_matches(expectation, &value),
1060 format!("{subject}: expected a value, got null"),
1061 ),
1062 Expectation::JsonSubset(pattern) => check(
1063 index,
1064 expectation_matches(expectation, &value),
1065 format!("{subject}: not a superset of {pattern}: {value}"),
1066 ),
1067 // Foreign `#[non_exhaustive]` variants (none today):
1068 // the harness has no matcher for them, so they fail
1069 // closed.
1070 _ => Err(ScenarioFailure::ValidationMismatch {
1071 action: index,
1072 detail: "validate expectation kind is not supported by the message grammar"
1073 .to_string(),
1074 }),
1075 }
1076 }
1077 // The parser never pairs a partner or sql target with another
1078 // kind's grammar, and never a rows expectation with a
1079 // non-sql target: a `partner` target pairs with the partner
1080 // count grammar, a `sql` target with the rows grammar
1081 // (`rows` or a count bound), every other target with the
1082 // message grammar.
1083 _ => Err(unpaired_validate(index)),
1084 }
1085}
1086
1087/// The failure for a target/expectation pairing the grammar never
1088/// produces: the parser pairs `partner` targets with the partner
1089/// count grammar, `sql` targets with the rows grammar, and every
1090/// other target with the message grammar, so only a caller bypassing
1091/// the parser reaches these arms.
1092fn unpaired_validate(index: usize) -> ScenarioFailure {
1093 ScenarioFailure::ValidationMismatch {
1094 action: index,
1095 detail: "validate target kind does not pair with the expectation kind: `partner` pairs \
1096 with the partner count grammar, `sql` with the rows grammar, and every other \
1097 target with the message grammar"
1098 .to_string(),
1099 }
1100}
1101
1102/// Turns a validation predicate into a [`ScenarioFailure`] on `false`.
1103fn check(index: usize, passed: bool, detail: String) -> Result<(), ScenarioFailure> {
1104 if passed {
1105 Ok(())
1106 } else {
1107 Err(ScenarioFailure::ValidationMismatch {
1108 action: index,
1109 detail,
1110 })
1111 }
1112}
1113
1114/// Reads a value out of a received message by dotted selector.
1115///
1116/// Grammar: the first segment selects `body`, `headers`, `status`,
1117/// `method`, or `path`; the rest is a literal header name
1118/// (`headers.X-Id`, dots allowed in the name) or a dotted object path
1119/// under the body (`body.user.id`). A bare `body` or `headers` selects
1120/// the whole part.
1121///
1122/// Header lookup is ASCII-case-insensitive: adapters differ in header
1123/// casing (hyper lowercases wire names; the fake preserves author
1124/// casing), and the same selector must behave identically per adapter.
1125/// Wire recording stays lowercase.
1126fn select_from(message: &IncomingMessage, selector: &str) -> Option<Value> {
1127 let (head, rest) = match selector.split_once('.') {
1128 Some((head, rest)) => (head, Some(rest)),
1129 None => (selector, None),
1130 };
1131 match head {
1132 "body" => match rest {
1133 None => Some(message.body.clone()),
1134 Some(path) => walk_path(&message.body, path),
1135 },
1136 "headers" => match rest {
1137 None => Some(Value::Object(
1138 message
1139 .headers
1140 .iter()
1141 .map(|(name, value)| (name.clone(), value.clone()))
1142 .collect(),
1143 )),
1144 Some(name) => lookup_header(&message.headers, name),
1145 },
1146 // The transport-scalar heads carry no sub-path: `status.why` is
1147 // not part of the grammar and resolves to nothing.
1148 "status" if rest.is_none() => Some(
1149 message
1150 .status
1151 .map_or(Value::Null, |code| Value::Number(code.into())),
1152 ),
1153 "method" if rest.is_none() => {
1154 Some(message.method.clone().map_or(Value::Null, Value::String))
1155 }
1156 "path" if rest.is_none() => Some(message.path.clone().map_or(Value::Null, Value::String)),
1157 _ => None,
1158 }
1159}
1160
1161/// Case-insensitive header lookup: the first header whose name matches
1162/// the selector ASCII-case-insensitively wins; header maps are
1163/// case-unique per adapter, so the fold is deterministic.
1164fn lookup_header(headers: &BTreeMap<String, Value>, name: &str) -> Option<Value> {
1165 headers
1166 .iter()
1167 .find(|(key, _)| key.eq_ignore_ascii_case(name))
1168 .map(|(_, value)| value.clone())
1169}
1170
1171/// Walks a dotted object path under a body value; arrays and scalars
1172/// resolve to nothing.
1173fn walk_path(value: &Value, path: &str) -> Option<Value> {
1174 let mut current = value;
1175 for key in path.split('.') {
1176 current = current.as_object()?.get(key)?;
1177 }
1178 Some(current.clone())
1179}