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//!
23//! Verdict-class failures map to exit 1 at the CLI; apparatus-class
24//! failures map to exit 2, as do doc-validation failures before the
25//! runner ever runs.
26//!
27//! Every await is bounded: `receive` carries the action deadline, and
28//! `send` is bounded by the document's `sendDeadline`, defaulting to
29//! [`SEND_DEADLINE`].
30
31use std::collections::BTreeMap;
32use std::time::{Duration, Instant};
33
34use camel_api::{Body, Exchange, Value};
35use camel_matchers::{expectation_matches, stringify};
36
37use crate::adapters::redact_wire_path;
38use crate::adapters::{
39 IncomingMessage, OutgoingMessage, PartnerRouter, ReceiveError, TransportError, lanes_suffix,
40};
41use crate::document::{
42 EndpointRef, Expectation, Provisioning, ScenarioAction, ScenarioDocument, ScenarioTarget,
43 ValidateExpectation,
44};
45
46/// Partner verification for the `validate` action's `partner` target
47/// (ADR-0069 §5): the filtered recorded-request count, the deadline
48/// poll, and the mismatch-detail renderers.
49mod partner_validate;
50
51// Test-only re-exports: these primitives are exercised directly by
52// `runner_test`, while the runner itself only calls
53// `partner_validate_action`.
54use partner_validate::partner_validate_action;
55#[cfg(all(test, feature = "http"))]
56pub(crate) use partner_validate::{
57 matching_requests, partner_mismatch_detail, render_bound, render_filters,
58};
59
60/// The default bounded deadline for every `send` action (ADR-0069
61/// §7: every adapter operation carries a deadline). A document-level
62/// `sendDeadline` overrides it (rc-tr4w).
63const SEND_DEADLINE: Duration = Duration::from_secs(30);
64
65/// The effective send bound of a document: its declared
66/// `sendDeadline`, or the thirty-second [`SEND_DEADLINE`] default.
67/// Real time only (ADR-0069 §6: no virtual time).
68pub(crate) fn effective_send_deadline(doc: &ScenarioDocument) -> Duration {
69 doc.send_deadline.unwrap_or(SEND_DEADLINE)
70}
71
72/// Mutable run state carried across actions: scenario variables set by
73/// `extract`, and the last message received per endpoint for
74/// `lastReceived` validation.
75#[derive(Debug, Default)]
76pub struct ScenarioVars {
77 /// Variables extracted from received messages, by name.
78 variables: BTreeMap<String, Value>,
79 /// Last message received per endpoint URI.
80 last_received: BTreeMap<String, IncomingMessage>,
81}
82
83impl ScenarioVars {
84 /// Empty run state.
85 pub fn new() -> Self {
86 Self::default()
87 }
88
89 /// The variable set by an earlier `extract`, if any.
90 pub fn get(&self, name: &str) -> Option<&Value> {
91 self.variables.get(name)
92 }
93
94 /// Sets a variable, overwriting any earlier value.
95 pub fn set(&mut self, name: impl Into<String>, value: Value) {
96 self.variables.insert(name.into(), value);
97 }
98
99 /// The last message received on the endpoint URI, if any.
100 pub fn last_received(&self, endpoint: &str) -> Option<&IncomingMessage> {
101 self.last_received.get(endpoint)
102 }
103
104 /// Records the last message received on an endpoint URI.
105 fn remember(&mut self, endpoint: String, message: IncomingMessage) {
106 self.last_received.insert(endpoint, message);
107 }
108}
109
110/// Resolves `${name}` placeholders in a scenario string against `vars`.
111///
112/// Grammar: `$${` escapes to a literal `${`; `${name}` substitutes the
113/// variable when `name` matches `[A-Za-z0-9_]+` and is immediately
114/// followed by `}`. Anything else — including `${env:FOO}`, where a
115/// colon follows the name — stays literal, so `${env:}` never resolves
116/// in scenarios. A non-string variable substitutes its JSON
117/// representation (`Value::to_string`), so a number 42 yields `42`.
118/// Substituted text is not re-scanned. An unset variable fails with
119/// [`ScenarioFailure::VarUnresolved`].
120pub(crate) fn resolve_placeholders(
121 input: &str,
122 vars: &ScenarioVars,
123) -> Result<String, ScenarioFailure> {
124 let bytes = input.as_bytes();
125 let mut out = Vec::with_capacity(bytes.len());
126 let mut i = 0;
127 while i < bytes.len() {
128 if bytes[i] == b'$' {
129 // `$${` escapes to a literal `${`.
130 if i + 2 < bytes.len() && bytes[i + 1] == b'$' && bytes[i + 2] == b'{' {
131 out.extend_from_slice(b"${");
132 i += 3;
133 continue;
134 }
135 // `${name}` with name in [A-Za-z0-9_]+ immediately followed
136 // by `}`; a colon or any other character after the name
137 // keeps the whole span literal.
138 if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
139 let name_start = i + 2;
140 let mut j = name_start;
141 while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
142 j += 1;
143 }
144 if j > name_start && j < bytes.len() && bytes[j] == b'}' {
145 let name = &input[name_start..j];
146 match vars.get(name) {
147 Some(value) => {
148 let replacement = stringify(value);
149 out.extend_from_slice(replacement.as_bytes());
150 i = j + 1;
151 continue;
152 }
153 None => {
154 return Err(ScenarioFailure::VarUnresolved {
155 name: name.to_string(),
156 });
157 }
158 }
159 }
160 }
161 out.push(b'$');
162 i += 1;
163 continue;
164 }
165 out.push(bytes[i]);
166 i += 1;
167 }
168 // The output is a byte-for-byte copy of the input except for
169 // substituted spans, so it stays valid UTF-8.
170 Ok(String::from_utf8(out).expect("placeholder output preserves input UTF-8")) // allow-unwrap
171}
172
173/// Recursively interpolates `${name}` placeholders in a value: maps
174/// and arrays are rebuilt with interpolated values, string leaves go
175/// through [`resolve_placeholders`], and every other leaf is cloned
176/// untouched. An unset variable propagates
177/// [`ScenarioFailure::VarUnresolved`] from any depth.
178pub(crate) fn interpolate_value(
179 value: &Value,
180 vars: &ScenarioVars,
181) -> Result<Value, ScenarioFailure> {
182 match value {
183 Value::String(text) => Ok(Value::String(resolve_placeholders(text, vars)?)),
184 Value::Array(items) => items
185 .iter()
186 .map(|item| interpolate_value(item, vars))
187 .collect::<Result<Vec<_>, _>>()
188 .map(Value::Array),
189 Value::Object(map) => {
190 let rebuilt = map
191 .iter()
192 .map(|(key, item)| Ok((key.clone(), interpolate_value(item, vars)?)))
193 .collect::<Result<_, _>>()?;
194 Ok(Value::Object(rebuilt))
195 }
196 other => Ok(other.clone()),
197 }
198}
199
200/// The outcome of a scenario that ran to completion: every action
201/// succeeded and every validation passed.
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203#[non_exhaustive]
204pub enum ScenarioVerdict {
205 /// All actions completed and all validations passed.
206 Pass,
207}
208
209/// Why a scenario failed (ADR-0069 §7). Verdict-class variants mean
210/// the system under test failed the scenario; apparatus-class
211/// variants mean the scenario never got a meaningful answer. The
212/// CLI maps verdict-class failures to exit 1 and apparatus-class
213/// failures to exit 2; doc validation also maps to exit 2.
214#[derive(Debug, Clone, PartialEq, thiserror::Error)]
215#[non_exhaustive]
216pub enum ScenarioFailure {
217 /// Nothing reached the partner before the deadline (verdict
218 /// class, `receive-timeout`).
219 #[error("receive-timeout: {endpoint} delivered nothing within {deadline:?}{lanes}")]
220 ReceiveTimeout {
221 /// The endpoint URI that delivered nothing.
222 endpoint: String,
223 /// The deadline that elapsed.
224 deadline: Duration,
225 /// Rendered lane evidence (already redacted, ADR-0051): the
226 /// `; no arrival matched; lanes recorded: [...]` suffix, or
227 /// empty when the construction site saw no lanes.
228 lanes: String,
229 },
230 /// A validation failed (verdict class, `validation-mismatch`).
231 #[error("validation-mismatch: action {action}: {detail}")]
232 ValidationMismatch {
233 /// Index of the failing action, zero-based.
234 action: usize,
235 /// What was expected and what arrived.
236 detail: String,
237 },
238 /// A referenced variable was never set (verdict class,
239 /// `scenario-var-unresolved`).
240 #[error("scenario-var-unresolved: {name}")]
241 VarUnresolved {
242 /// The variable name no `extract` ever set.
243 name: String,
244 },
245 /// A send or receive failed at the transport before any assertion
246 /// ran (apparatus class, `action-transport-failure`).
247 #[error("action-transport-failure: action {action}: {source}")]
248 ActionTransport {
249 /// Index of the failing action, zero-based.
250 action: usize,
251 /// The transport failure.
252 source: TransportError,
253 },
254 /// A partner listener bound but its handler failed to start
255 /// (apparatus class, `partner-startup-failure`). Reserved in v1:
256 /// no adapter separates bind from handler start, and the CLI maps
257 /// bind failures to `partner-bind-failure` doc errors.
258 #[error("partner-startup-failure: {message}")]
259 PartnerStartup {
260 /// Startup failure detail.
261 message: String,
262 },
263 /// The partner's arrival lane dropped arrivals while the scenario
264 /// was not receiving (apparatus class, `arrival-lane-overflow`):
265 /// the harness lost them before the system under test could fail
266 /// the scenario on substance.
267 #[error("arrival-lane-overflow: {endpoint} dropped {dropped} arrivals")]
268 ArrivalLaneOverflow {
269 /// The endpoint URI whose lane dropped arrivals.
270 endpoint: String,
271 /// How many arrivals the lane dropped while full.
272 dropped: usize,
273 },
274 /// Teardown of the boot or a partner timed out or erred after the
275 /// verdict was recorded (apparatus class, `shutdown-failure`).
276 #[error("shutdown-failure: {message}")]
277 ShutdownFailure {
278 /// Teardown failure detail.
279 message: String,
280 },
281}
282
283/// Fills the harness bind variables into `vars` (ADR-0069 §9): every
284/// wired reference with `provisioning: harness` and a `bindVar` gets
285/// its partner's bound `host:port` authority from the router, so a
286/// scenario string can address the partner as
287/// `http://${NAME}/path`.
288///
289/// Two-layer split: the scenario variable carries `host:port` only;
290/// the env-tier binding that route files interpolate keeps its
291/// `http://host:port` form (owned by the CLI driver, unchanged here).
292/// A reference with no registered adapter or no bound authority is
293/// skipped: the variable stays unset, and a later use fails with the
294/// verdict-class `VarUnresolved`.
295pub fn fill_bind_vars(wired: &[EndpointRef], router: &PartnerRouter, vars: &mut ScenarioVars) {
296 for reference in wired {
297 if reference.provisioning != Some(Provisioning::Harness) {
298 continue;
299 }
300 let Some(bind_var) = reference.bind_var.as_deref() else {
301 continue;
302 };
303 let Some(authority) = router
304 .adapter(&reference.endpoint)
305 .and_then(|adapter| adapter.bound_authority())
306 else {
307 continue;
308 };
309 vars.set(bind_var, Value::String(authority));
310 }
311}
312
313/// Runs a scenario's actions in order against the router.
314///
315/// On success every action completed; on failure the variant names
316/// the ADR-0069 §7 class. `vars` carries extraction results and
317/// last-received state both into and out of the run.
318pub async fn run_scenario(
319 doc: &ScenarioDocument,
320 router: &PartnerRouter,
321 vars: &mut ScenarioVars,
322) -> Result<ScenarioVerdict, ScenarioFailure> {
323 // The scenario-start anchor every `elapsedAtLeast` bound measures
324 // against; taken once per run, before the first action.
325 let started_at = Instant::now();
326 let send_deadline = effective_send_deadline(doc);
327 for (index, action) in doc.scenario.iter().enumerate() {
328 run_action(action, index, router, vars, started_at, send_deadline).await?;
329 }
330 Ok(ScenarioVerdict::Pass)
331}
332
333/// The outcome of executing a whole scenario document
334/// (ADR-0069 sections 5 and 7).
335///
336/// [`run_scenario_document`](self::run_scenario_document) fills
337/// `per_action` with one outcome per executed action and stops at the
338/// first failure; `verdict` is `Some(Pass)` only when every action
339/// passed. `final_failure` is the post-verdict slot: the caller that
340/// owns the boot (the CLI, after `BootHandle::shutdown`) records a
341/// `ShutdownFailure` there without masking the recorded verdict.
342#[derive(Debug, Clone, PartialEq)]
343pub struct DocumentOutcome {
344 /// One outcome per executed action, in action order; actions after
345 /// the first failure never ran.
346 pub per_action: Vec<Result<ScenarioVerdict, ScenarioFailure>>,
347 /// `Some(Pass)` when every action completed; `None` after any
348 /// failure.
349 pub verdict: Option<ScenarioVerdict>,
350 /// Post-verdict shutdown failure, recorded by the boot-owning
351 /// caller; empty when teardown is clean or never ran.
352 pub final_failure: Option<ScenarioFailure>,
353 /// The bound address of the document's `inbound:` listener
354 /// (rc-5yon, ADR-0070), filled by the boot-owning caller from
355 /// [`crate::boot_scenario::ScenarioRun::inbound_bound`] after the
356 /// boot, so tests target the ephemeral listener without re-deriving
357 /// it. `None` when the document declares no `inbound:` listener or
358 /// the caller never filled it; the post-boot slot, as
359 /// `final_failure` is the post-verdict slot.
360 pub inbound_bound: Option<std::net::SocketAddr>,
361}
362
363/// Executes a scenario document's actions in order against the
364/// router, one recorded outcome per action, stopping at the first
365/// failure (the whole-document contract, library-level).
366///
367/// Partners route through `router`; a `send` addressed to a context
368/// component reaches the booted system under test through the
369/// context-stimulus adapter the caller registered for that endpoint
370/// (see [`crate::adapters`]). The single-action
371/// [`run_scenario`] loop and this loop share [`run_action`].
372pub async fn run_scenario_document(
373 doc: &ScenarioDocument,
374 router: &PartnerRouter,
375 vars: &mut ScenarioVars,
376) -> DocumentOutcome {
377 // The scenario-start anchor every `elapsedAtLeast` bound measures
378 // against; taken once per run, before the first action.
379 let started_at = Instant::now();
380 let send_deadline = effective_send_deadline(doc);
381 let mut per_action = Vec::with_capacity(doc.scenario.len());
382 let mut failed = false;
383 for (index, action) in doc.scenario.iter().enumerate() {
384 if failed {
385 break;
386 }
387 match run_action(action, index, router, vars, started_at, send_deadline).await {
388 Ok(()) => per_action.push(Ok(ScenarioVerdict::Pass)),
389 Err(failure) => {
390 per_action.push(Err(failure));
391 failed = true;
392 }
393 }
394 }
395 let verdict = if failed {
396 None
397 } else {
398 Some(ScenarioVerdict::Pass)
399 };
400 DocumentOutcome {
401 per_action,
402 verdict,
403 final_failure: None,
404 inbound_bound: None,
405 }
406}
407
408/// Executes one action at its scenario index. The shared primitive of
409/// [`run_scenario`] and [`run_scenario_document`]; every failure
410/// carries the action index.
411async fn run_action(
412 action: &ScenarioAction,
413 index: usize,
414 router: &PartnerRouter,
415 vars: &mut ScenarioVars,
416 started_at: Instant,
417 send_deadline: Duration,
418) -> Result<(), ScenarioFailure> {
419 match action {
420 ScenarioAction::Send {
421 to,
422 body,
423 headers,
424 method,
425 expect_reply,
426 } => {
427 send_action(
428 index,
429 to,
430 body.as_ref(),
431 headers.as_ref(),
432 method,
433 expect_reply.as_ref(),
434 router,
435 vars,
436 send_deadline,
437 )
438 .await?;
439 }
440 ScenarioAction::Receive {
441 from,
442 deadline,
443 extract,
444 } => {
445 receive_action(index, from, *deadline, extract.as_ref(), router, vars).await?;
446 }
447 ScenarioAction::Sleep { duration } => {
448 tokio::time::sleep(*duration).await;
449 }
450 ScenarioAction::Validate { .. } => {
451 validate_action(action, index, started_at, router, vars).await?;
452 }
453 }
454 Ok(())
455}
456
457/// Dispatches a `send` action, bounded by the document's effective
458/// send deadline ([`effective_send_deadline`]: the declared
459/// `sendDeadline`, or the thirty-second default).
460///
461/// The endpoint reference, the body's string leaves, and the header
462/// values are the complete interpolation surface: each resolves its
463/// `${name}` placeholders against `vars` before dispatch, and an
464/// unresolved variable fails with the verdict-class `VarUnresolved`.
465/// The dial target comes from the router's address math: a
466/// harness-declared `:0` reference (or a dynamic reference resolving
467/// to a partner authority) dials the partner's bound address with the
468/// interpolated path preserved; anything else dials the interpolated
469/// URI literally.
470///
471/// A declared `expectReply` (rc-qvz6, `direct:` sends only — the
472/// grammar rejected every other scheme at load) asserts the
473/// synchronous reply the adapter returned: a non-matching reply is a
474/// verdict-class [`ScenarioFailure::ValidationMismatch`] naming the
475/// rendered expectation and the actual body, and a missing reply is
476/// an apparatus-class [`ScenarioFailure::ActionTransport`] — the
477/// scenario never got an answer to assert against.
478// The action's flat decomposition (index, endpoint, body, headers,
479// method, reply expectation, router, vars) plus the document send
480// bound threaded from run_action (rc-tr4w).
481#[allow(clippy::too_many_arguments)]
482async fn send_action(
483 index: usize,
484 to: &EndpointRef,
485 body: Option<&Value>,
486 headers: Option<&BTreeMap<String, Value>>,
487 method: &str,
488 expect_reply: Option<&Expectation>,
489 router: &PartnerRouter,
490 vars: &ScenarioVars,
491 send_deadline: Duration,
492) -> Result<(), ScenarioFailure> {
493 let declared = to.endpoint.as_str();
494 let interpolated = resolve_placeholders(declared, vars)?;
495 let body = body
496 .map(|value| interpolate_value(value, vars))
497 .transpose()?;
498 let headers = headers
499 .map(|map| -> Result<BTreeMap<String, Value>, ScenarioFailure> {
500 map.iter()
501 .map(|(name, value)| Ok((name.clone(), interpolate_value(value, vars)?)))
502 .collect()
503 })
504 .transpose()?;
505 let msg = OutgoingMessage {
506 body: body.unwrap_or(Value::Null),
507 headers: headers.unwrap_or_default(),
508 method: method.to_string(),
509 };
510 let bounded =
511 tokio::time::timeout(send_deadline, router.send(declared, &interpolated, msg)).await;
512 let sent = bounded.map_err(|_| ScenarioFailure::ActionTransport {
513 action: index,
514 source: TransportError::Deadline {
515 after: send_deadline,
516 },
517 })?;
518 let reply = sent.map_err(|source| {
519 // Render-site defense: the lane key is the declared endpoint
520 // URI, and a third-party adapter may hand the overflow over
521 // RAW; the runner holds the secret set, and redaction is
522 // idempotent on already-masked output (ADR-0051).
523 let source = match source {
524 TransportError::LaneFifoOverflow { lane_key, bound } => {
525 TransportError::LaneFifoOverflow {
526 lane_key: redact_wire_path(&lane_key, &router.secret_query_keys()),
527 bound,
528 }
529 }
530 other => other,
531 };
532 ScenarioFailure::ActionTransport {
533 action: index,
534 source,
535 }
536 })?;
537 if let Some(expectation) = expect_reply {
538 let Some(reply) = reply else {
539 // Fail closed: the grammar promised a direct reply, but
540 // the adapter produced none — an apparatus defect, never
541 // a silently-skipped assertion.
542 return Err(ScenarioFailure::ActionTransport {
543 action: index,
544 source: TransportError::Other {
545 message: "direct send produced no reply".to_string(),
546 },
547 });
548 };
549 let value = reply_body_value(&reply);
550 if !expectation_matches(expectation, &value) {
551 return Err(ScenarioFailure::ValidationMismatch {
552 action: index,
553 detail: format!(
554 "direct reply on {}: expected {}, got {}",
555 to.endpoint,
556 render_expectation(expectation),
557 stringify(&value)
558 ),
559 });
560 }
561 }
562 Ok(())
563}
564
565/// Converts a synchronous `direct:` reply exchange's body into the
566/// matcher value an `expectReply` assertion reads (rc-qvz6): the
567/// reply message is the exchange's output when the route produced
568/// one, the (route-mutated — `set_body` writes it) input otherwise.
569/// Feature-free by design: the partner-body extractors stay
570/// `http`-gated; this path never touches the wire. Crate-visible for
571/// the runner's unit tests, like the interpolation primitives.
572pub(crate) fn reply_body_value(exchange: &Exchange) -> Value {
573 let message = exchange.output.as_ref().unwrap_or(&exchange.input);
574 match &message.body {
575 Body::Json(value) => value.clone(),
576 Body::Text(text) => reply_bytes_value(text.as_bytes()),
577 Body::Xml(text) => reply_bytes_value(text.as_bytes()),
578 Body::Bytes(bytes) => reply_bytes_value(bytes),
579 // Empty and consumed-stream bodies carry no reply bytes, and
580 // foreign `#[non_exhaustive]` body kinds (none today) expose
581 // none either; the value reads as the empty string.
582 _ => Value::String(String::new()),
583 }
584}
585
586/// Parses reply bytes as JSON, falling back to a lossy-UTF-8 string
587/// when they are not JSON text: a text body holding JSON is observed
588/// as the structured value the matcher verbs expect, and any other
589/// text stays textual.
590fn reply_bytes_value(bytes: &[u8]) -> Value {
591 serde_json::from_slice(bytes)
592 .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(bytes).into_owned()))
593}
594
595/// Renders an expectation for an `expectReply` mismatch detail: the
596/// verb and its payload in the document grammar's own terms.
597fn render_expectation(expectation: &Expectation) -> String {
598 match expectation {
599 Expectation::Equals(expected) => format!("equals {expected}"),
600 Expectation::Regex(pattern) => format!("matches regex `{pattern}`"),
601 Expectation::Contains(needle) => format!("contains `{needle}`"),
602 Expectation::StartsWith(prefix) => format!("startsWith `{prefix}`"),
603 Expectation::EndsWith(suffix) => format!("endsWith `{suffix}`"),
604 Expectation::Exists => "exists".to_string(),
605 Expectation::JsonSubset(pattern) => format!("is a superset of {pattern}"),
606 // Foreign `#[non_exhaustive]` variants (none today): no verb
607 // renders, but the matcher already failed closed.
608 _ => "the expected value".to_string(),
609 }
610}
611
612/// Awaits a `receive` action until the deadline, records the message,
613/// and applies `extract` into `vars`.
614async fn receive_action(
615 index: usize,
616 from: &EndpointRef,
617 deadline: Duration,
618 extract: Option<&BTreeMap<String, String>>,
619 router: &PartnerRouter,
620 vars: &mut ScenarioVars,
621) -> Result<(), ScenarioFailure> {
622 // The lane is read under the two-key contract: the declared
623 // string names the registered lane when it can, and the
624 // interpolated URI resolves a dynamic reference's lane by
625 // authority (`lane_key_for`).
626 let declared = from.endpoint.as_str();
627 let interpolated = resolve_placeholders(declared, vars)?;
628 let message = router
629 .receive(declared, &interpolated, deadline)
630 .await
631 .map_err(|source| {
632 // Render-site defense: a third-party adapter may hand
633 // over RAW endpoint and lane evidence; the runner holds
634 // the secret set, and redaction is idempotent on
635 // already-masked output (ADR-0051).
636 let keys = router.secret_query_keys();
637 match source {
638 ReceiveError::Timeout(timeout) => ScenarioFailure::ReceiveTimeout {
639 endpoint: redact_wire_path(&timeout.endpoint, &keys),
640 deadline,
641 lanes: lanes_suffix(
642 &timeout
643 .lanes_recorded
644 .iter()
645 .map(|lane| redact_wire_path(lane, &keys))
646 .collect::<Vec<_>>(),
647 ),
648 },
649 ReceiveError::Overflow(overflow) => ScenarioFailure::ArrivalLaneOverflow {
650 endpoint: redact_wire_path(&overflow.endpoint, &keys),
651 dropped: overflow.dropped,
652 },
653 ReceiveError::Transport(source) => ScenarioFailure::ActionTransport {
654 action: index,
655 source,
656 },
657 }
658 })?;
659 if let Some(extract) = extract {
660 for (name, selector) in extract {
661 let value = select_from(&message, selector).ok_or_else(|| {
662 ScenarioFailure::ValidationMismatch {
663 action: index,
664 detail: format!(
665 "extract of `{selector}` into variable `{name}` resolved to nothing"
666 ),
667 }
668 })?;
669 vars.set(name.clone(), value);
670 }
671 }
672 vars.remember(from.endpoint.clone(), message);
673 Ok(())
674}
675
676/// Evaluates a `validate` action (ADR-0069 §5).
677///
678/// The `partner` target asserts the exact filtered count of the
679/// requests the harness partner recorded, read as the router's
680/// snapshot — one immediate read without a deadline, a polled one
681/// with it ([`partner_validate_action`]). Every other target applies
682/// the message grammar against `vars`; the deadline is partner-only
683/// (the grammar rejected it on these targets at parse time, so the
684/// message arm ignores it). An `elapsedAtLeast` bound on a
685/// `lastReceived` target checks the message's wire arrival against
686/// the scenario-start anchor before the grammar runs; the grammar
687/// rejected it on every other target at parse time. Mismatch details
688/// name the validation subject — the variable's name, the receiving
689/// endpoint, or the partner URI — so a corrupted-header regression is
690/// diagnosable from the failure text.
691async fn validate_action(
692 action: &ScenarioAction,
693 index: usize,
694 started_at: Instant,
695 router: &PartnerRouter,
696 vars: &ScenarioVars,
697) -> Result<(), ScenarioFailure> {
698 // run_action dispatches only the Validate variant here; the
699 // fallback mirrors the impossible pairing arms below.
700 let ScenarioAction::Validate {
701 target,
702 expectation,
703 deadline,
704 elapsed_at_least,
705 } = action
706 else {
707 return Err(unpaired_validate(index));
708 };
709 match (target, expectation) {
710 // The parser pairs a `partner` target with the partner count
711 // grammar; this arm reads the router's snapshot and owns the
712 // deadline.
713 (ScenarioTarget::Partner(endpoint), ValidateExpectation::Partner(expected)) => {
714 partner_validate_action(index, &endpoint.endpoint, expected, *deadline, router).await
715 }
716 (_, ValidateExpectation::Message(expectation)) => {
717 let (value, subject) = match target {
718 ScenarioTarget::LastReceived(endpoint) => {
719 // The declared endpoint may carry query bytes: the
720 // subject renders redacted like every diagnostic
721 // that quotes a wire path (ADR-0051).
722 let redacted =
723 redact_wire_path(&endpoint.endpoint, &router.secret_query_keys());
724 let message = vars.last_received(&endpoint.endpoint).ok_or_else(|| {
725 ScenarioFailure::ValidationMismatch {
726 action: index,
727 detail: format!(
728 "no message has been received on {redacted} to validate"
729 ),
730 }
731 })?;
732 // The elapsed bound anchors to the message's wire
733 // arrival, never the consumption time: a message
734 // consumed late can still have arrived early (the
735 // wire is the proof, ADR-0069 §5).
736 if let Some(bound) = elapsed_at_least {
737 let actual = message
738 .arrival
739 .checked_duration_since(started_at)
740 .unwrap_or_default();
741 if actual < *bound {
742 return Err(ScenarioFailure::ValidationMismatch {
743 action: index,
744 detail: format!(
745 "{redacted}: arrived {} after the scenario started; `elapsedAtLeast` requires {}",
746 humantime::format_duration(actual),
747 humantime::format_duration(*bound)
748 ),
749 });
750 }
751 }
752 (
753 message.body.clone(),
754 format!("body last received on {redacted}"),
755 )
756 }
757 ScenarioTarget::Variable(name) => (
758 vars.get(name)
759 .cloned()
760 .ok_or_else(|| ScenarioFailure::VarUnresolved { name: name.clone() })?,
761 format!("variable `{name}`"),
762 ),
763 // Taken by the arm above: the grammar never pairs a
764 // `partner` target with the message expectation.
765 ScenarioTarget::Partner(_) => return Err(unpaired_validate(index)),
766 };
767 // The per-form booleans delegate to the shared core
768 // (`camel_matchers::expectation_matches`); the detail
769 // strings stay here, where subject rendering and
770 // redaction live.
771 match expectation {
772 Expectation::Equals(expected) => check(
773 index,
774 expectation_matches(expectation, &value),
775 format!("{subject}: expected {expected}, got {value}"),
776 ),
777 // The parser pre-verifies regex patterns at load time,
778 // so the invalid-regex arm is unreachable through the
779 // harness; it stays for the byte-identical verdicts,
780 // short-circuiting before the core delegation (core's
781 // Regex arm returns false on compile-fail).
782 Expectation::Regex(pattern) => {
783 if let Err(error) = regex::Regex::new(pattern) {
784 return Err(ScenarioFailure::ValidationMismatch {
785 action: index,
786 detail: format!("invalid regex `{pattern}`: {error}"),
787 });
788 }
789 check(
790 index,
791 expectation_matches(expectation, &value),
792 format!("{subject}: `{pattern}` did not match {value}"),
793 )
794 }
795 Expectation::Contains(needle) => check(
796 index,
797 expectation_matches(expectation, &value),
798 format!("{subject}: did not contain `{needle}`: {value}"),
799 ),
800 Expectation::StartsWith(prefix) => check(
801 index,
802 expectation_matches(expectation, &value),
803 format!("{subject}: did not start with `{prefix}`: {value}"),
804 ),
805 Expectation::EndsWith(suffix) => check(
806 index,
807 expectation_matches(expectation, &value),
808 format!("{subject}: did not end with `{suffix}`: {value}"),
809 ),
810 Expectation::Exists => check(
811 index,
812 expectation_matches(expectation, &value),
813 format!("{subject}: expected a value, got null"),
814 ),
815 Expectation::JsonSubset(pattern) => check(
816 index,
817 expectation_matches(expectation, &value),
818 format!("{subject}: not a superset of {pattern}: {value}"),
819 ),
820 // Foreign `#[non_exhaustive]` variants (none today):
821 // the harness has no matcher for them, so they fail
822 // closed.
823 _ => Err(ScenarioFailure::ValidationMismatch {
824 action: index,
825 detail: "validate expectation kind is not supported by the message grammar"
826 .to_string(),
827 }),
828 }
829 }
830 // The parser never pairs a partner expectation with a
831 // non-partner target.
832 _ => Err(unpaired_validate(index)),
833 }
834}
835
836/// The failure for a target/expectation pairing the grammar never
837/// produces: the parser pairs `partner` targets with the partner
838/// count grammar and every other target with the message grammar, so
839/// only a caller bypassing the parser reaches these arms.
840fn unpaired_validate(index: usize) -> ScenarioFailure {
841 ScenarioFailure::ValidationMismatch {
842 action: index,
843 detail: "validate target kind does not pair with the expectation kind".to_string(),
844 }
845}
846
847/// Turns a validation predicate into a [`ScenarioFailure`] on `false`.
848fn check(index: usize, passed: bool, detail: String) -> Result<(), ScenarioFailure> {
849 if passed {
850 Ok(())
851 } else {
852 Err(ScenarioFailure::ValidationMismatch {
853 action: index,
854 detail,
855 })
856 }
857}
858
859/// Reads a value out of a received message by dotted selector.
860///
861/// Grammar: the first segment selects `body`, `headers`, `status`,
862/// `method`, or `path`; the rest is a literal header name
863/// (`headers.X-Id`, dots allowed in the name) or a dotted object path
864/// under the body (`body.user.id`). A bare `body` or `headers` selects
865/// the whole part.
866///
867/// Header lookup is ASCII-case-insensitive: adapters differ in header
868/// casing (hyper lowercases wire names; the fake preserves author
869/// casing), and the same selector must behave identically per adapter.
870/// Wire recording stays lowercase.
871fn select_from(message: &IncomingMessage, selector: &str) -> Option<Value> {
872 let (head, rest) = match selector.split_once('.') {
873 Some((head, rest)) => (head, Some(rest)),
874 None => (selector, None),
875 };
876 match head {
877 "body" => match rest {
878 None => Some(message.body.clone()),
879 Some(path) => walk_path(&message.body, path),
880 },
881 "headers" => match rest {
882 None => Some(Value::Object(
883 message
884 .headers
885 .iter()
886 .map(|(name, value)| (name.clone(), value.clone()))
887 .collect(),
888 )),
889 Some(name) => lookup_header(&message.headers, name),
890 },
891 // The transport-scalar heads carry no sub-path: `status.why` is
892 // not part of the grammar and resolves to nothing.
893 "status" if rest.is_none() => Some(
894 message
895 .status
896 .map_or(Value::Null, |code| Value::Number(code.into())),
897 ),
898 "method" if rest.is_none() => {
899 Some(message.method.clone().map_or(Value::Null, Value::String))
900 }
901 "path" if rest.is_none() => Some(message.path.clone().map_or(Value::Null, Value::String)),
902 _ => None,
903 }
904}
905
906/// Case-insensitive header lookup: the first header whose name matches
907/// the selector ASCII-case-insensitively wins; header maps are
908/// case-unique per adapter, so the fold is deterministic.
909fn lookup_header(headers: &BTreeMap<String, Value>, name: &str) -> Option<Value> {
910 headers
911 .iter()
912 .find(|(key, _)| key.eq_ignore_ascii_case(name))
913 .map(|(_, value)| value.clone())
914}
915
916/// Walks a dotted object path under a body value; arrays and scalars
917/// resolve to nothing.
918fn walk_path(value: &Value, path: &str) -> Option<Value> {
919 let mut current = value;
920 for key in path.split('.') {
921 current = current.as_object()?.get(key)?;
922 }
923 Some(current.clone())
924}