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