1use std::collections::BTreeMap;
16use std::sync::mpsc;
17use std::sync::{Arc, Mutex};
18use std::time::{Duration, Instant};
19
20use crate::cancel::CancellationToken;
21use crate::diag::Diag;
22use crate::engine::{ArtifactRef, EngineFactory, HttpDefaults, ScenarioCtx};
23use crate::error::ExitCode;
24use crate::event::{EVENT_SCHEMA_VERSION, Event, EventSink};
25use crate::step::{Status, StepBatch, StepOutcome};
26use crate::world::{GlobalStore, World};
27
28pub struct ScenarioSpec {
31 pub file: Arc<str>,
33 pub name: Arc<str>,
35 pub line: usize,
37 pub tags: Vec<String>,
39 pub file_root: Option<std::path::PathBuf>,
42 pub prepare: PrepareFn,
44}
45
46pub type PrepareFn = Box<dyn FnOnce(&World) -> Result<Prepared, Vec<Diag>> + Send>;
48
49pub struct Prepared {
51 pub batches: Vec<StepBatch>,
53 pub artifact: Option<ArtifactRef>,
55}
56
57#[derive(Clone)]
59pub struct RunConfig {
60 pub run_id: Arc<str>,
62 pub jobs: usize,
64 pub default_batch_budget: Duration,
66 pub secrets: Arc<BTreeMap<String, String>>,
68 pub http: HttpDefaults,
70}
71
72#[derive(Debug)]
74pub struct RunSummary {
75 pub outcomes: Vec<ScenarioOutcome>,
77 pub passed: usize,
79 pub failed: usize,
81 pub skipped: usize,
83 pub cancelled: bool,
85}
86
87impl RunSummary {
88 pub fn exit_code(&self) -> ExitCode {
92 let mut worst = if self.cancelled {
93 ExitCode::TestFailure
94 } else {
95 ExitCode::Success
96 };
97 for outcome in &self.outcomes {
98 let code = match (&outcome.fault, outcome.status) {
99 (Some(Fault::System(_)), _) => ExitCode::SystemError,
100 (Some(Fault::User(_)), _) => ExitCode::UserError,
101 (None, Status::Failed) => ExitCode::TestFailure,
102 _ => ExitCode::Success,
103 };
104 worst = pick_worse(worst, code);
105 }
106 worst
107 }
108}
109
110fn pick_worse(a: ExitCode, b: ExitCode) -> ExitCode {
112 let rank = |c: ExitCode| match c {
113 ExitCode::SystemError => 3,
114 ExitCode::UserError => 2,
115 ExitCode::TestFailure => 1,
116 ExitCode::Success => 0,
117 };
118 if rank(b) > rank(a) { b } else { a }
119}
120
121#[derive(Debug)]
123pub struct ScenarioOutcome {
124 pub file: Arc<str>,
126 pub name: Arc<str>,
128 pub line: usize,
130 pub status: Status,
132 pub steps: Vec<StepOutcome>,
134 pub fault: Option<Fault>,
136}
137
138#[derive(Debug)]
140pub enum Fault {
141 User(String),
143 System(String),
145}
146
147enum Msg {
148 BatchBegin {
149 scenario: usize,
150 deadline: Instant,
151 },
152 Done {
153 scenario: usize,
154 outcome: ScenarioOutcome,
155 },
156}
157
158#[allow(clippy::too_many_lines)]
162pub fn run(
163 specs: Vec<ScenarioSpec>,
164 engines: &Arc<Vec<Box<dyn EngineFactory>>>,
165 store: &Arc<Mutex<GlobalStore>>,
166 config: &RunConfig,
167 events: &EventSink,
168 cancel: &CancellationToken,
169) -> RunSummary {
170 events.emit(&Event::RunStarted {
171 schema: EVENT_SCHEMA_VERSION,
172 run_id: Arc::clone(&config.run_id),
173 });
174
175 let (tx, rx) = mpsc::channel::<Msg>();
176 let identities: Vec<(Arc<str>, Arc<str>, usize)> = specs
179 .iter()
180 .map(|spec| (Arc::clone(&spec.file), Arc::clone(&spec.name), spec.line))
181 .collect();
182 let mut queue: std::collections::VecDeque<(usize, ScenarioSpec)> =
183 specs.into_iter().enumerate().collect();
184 let total = queue.len();
185 let mut active: BTreeMap<usize, Instant> = BTreeMap::new();
186 let mut outcomes: Vec<ScenarioOutcome> = Vec::new();
187 let grace = Duration::from_secs(2);
188
189 while outcomes.len() < total {
190 while active.len() < config.jobs.max(1) {
192 let Some((index, spec)) = queue.pop_front() else {
193 break;
194 };
195 if cancel.is_cancelled() {
196 events.emit(&Event::ScenarioFinished {
197 scenario: Arc::clone(&spec.name),
198 status: Status::Skipped,
199 });
200 outcomes.push(ScenarioOutcome {
201 file: spec.file,
202 name: spec.name,
203 line: spec.line,
204 status: Status::Skipped,
205 steps: Vec::new(),
206 fault: None,
207 });
208 continue;
209 }
210 let initial_deadline = Instant::now() + config.default_batch_budget + grace;
211 active.insert(index, initial_deadline);
212 spawn_scenario(
213 index,
214 spec,
215 Arc::clone(engines),
216 Arc::clone(store),
217 config.clone(),
218 events.clone(),
219 cancel.child_token(),
220 tx.clone(),
221 );
222 }
223 if active.is_empty() {
224 continue; }
226
227 let now = Instant::now();
229 let next_deadline = active.values().min().copied().unwrap_or(now + grace);
230 let wait = next_deadline
231 .saturating_duration_since(now)
232 .max(Duration::from_millis(20));
233 match rx.recv_timeout(wait) {
234 Ok(Msg::BatchBegin { scenario, deadline }) => {
235 if let Some(entry) = active.get_mut(&scenario) {
236 *entry = deadline + grace;
237 }
238 }
239 Ok(Msg::Done { scenario, outcome }) => {
240 if active.remove(&scenario).is_some() {
241 events.emit(&Event::ScenarioFinished {
242 scenario: Arc::clone(&outcome.name),
243 status: outcome.status,
244 });
245 outcomes.push(outcome);
246 }
247 }
249 Err(mpsc::RecvTimeoutError::Timeout) => {}
250 Err(mpsc::RecvTimeoutError::Disconnected) => break,
251 }
252 sweep_expired(&mut active, &mut outcomes, events, &identities);
256 }
257
258 let passed = outcomes
259 .iter()
260 .filter(|o| matches!(o.status, Status::Passed | Status::Warned))
261 .count();
262 let failed = outcomes
263 .iter()
264 .filter(|o| o.status == Status::Failed)
265 .count();
266 let skipped = outcomes
267 .iter()
268 .filter(|o| o.status == Status::Skipped)
269 .count();
270 let cancelled = cancel.is_cancelled();
271 events.emit(&Event::RunFinished {
272 passed,
273 failed,
274 skipped,
275 cancelled,
276 });
277 RunSummary {
278 outcomes,
279 passed,
280 failed,
281 skipped,
282 cancelled,
283 }
284}
285
286fn sweep_expired(
289 active: &mut BTreeMap<usize, Instant>,
290 outcomes: &mut Vec<ScenarioOutcome>,
291 events: &EventSink,
292 identities: &[(Arc<str>, Arc<str>, usize)],
293) {
294 let now = Instant::now();
295 let expired: Vec<usize> = active
296 .iter()
297 .filter(|(_, deadline)| **deadline <= now)
298 .map(|(index, _)| *index)
299 .collect();
300 for index in expired {
301 active.remove(&index);
302 let (file, name, line) = identities.get(index).map_or_else(
303 || {
304 (
305 Arc::from("(unknown)"),
306 Arc::from(format!("scenario #{index}")),
307 0,
308 )
309 },
310 |(f, n, l)| (Arc::clone(f), Arc::clone(n), *l),
311 );
312 let outcome = ScenarioOutcome {
313 file,
314 name,
315 line,
316 status: Status::Failed,
317 steps: Vec::new(),
318 fault: Some(Fault::System(
319 "batch budget exceeded — scenario thread abandoned (ADR-0007)".to_owned(),
320 )),
321 };
322 events.emit(&Event::ScenarioFinished {
323 scenario: Arc::clone(&outcome.name),
324 status: Status::Failed,
325 });
326 outcomes.push(outcome);
327 }
328}
329
330#[allow(clippy::too_many_arguments)]
331fn spawn_scenario(
332 index: usize,
333 spec: ScenarioSpec,
334 engines: Arc<Vec<Box<dyn EngineFactory>>>,
335 store: Arc<Mutex<GlobalStore>>,
336 config: RunConfig,
337 events: EventSink,
338 cancel: CancellationToken,
339 tx: mpsc::Sender<Msg>,
340) {
341 std::thread::spawn(move || {
342 let identity = (Arc::clone(&spec.file), Arc::clone(&spec.name), spec.line);
343 let heartbeat_tx = tx.clone();
344 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
348 run_scenario(
349 spec,
350 &engines,
351 &store,
352 &config,
353 &events,
354 &cancel,
355 |budget| {
356 let _ = heartbeat_tx.send(Msg::BatchBegin {
357 scenario: index,
358 deadline: Instant::now() + budget,
359 });
360 },
361 )
362 }));
363 let outcome = result.unwrap_or_else(|panic| {
364 let message = panic
365 .downcast_ref::<&str>()
366 .map(ToString::to_string)
367 .or_else(|| panic.downcast_ref::<String>().cloned())
368 .unwrap_or_else(|| "opaque panic payload".to_owned());
369 ScenarioOutcome {
370 file: identity.0,
371 name: identity.1,
372 line: identity.2,
373 status: Status::Failed,
374 steps: Vec::new(),
375 fault: Some(Fault::System(format!(
376 "scenario thread panicked: {message}"
377 ))),
378 }
379 });
380 let _ = tx.send(Msg::Done {
381 scenario: index,
382 outcome,
383 });
384 });
385}
386
387#[allow(clippy::too_many_lines)]
389fn run_scenario(
390 spec: ScenarioSpec,
391 engines: &[Box<dyn EngineFactory>],
392 store: &Mutex<GlobalStore>,
393 config: &RunConfig,
394 events: &EventSink,
395 cancel: &CancellationToken,
396 heartbeat: impl Fn(Duration),
397) -> ScenarioOutcome {
398 let (file, name, line) = (Arc::clone(&spec.file), Arc::clone(&spec.name), spec.line);
399 let outcome = move |status, steps, fault| ScenarioOutcome {
400 file: Arc::clone(&file),
401 name: Arc::clone(&name),
402 line,
403 status,
404 steps,
405 fault,
406 };
407
408 events.emit(&Event::ScenarioStarted {
409 scenario: Arc::clone(&spec.name),
410 file: Arc::clone(&spec.file),
411 });
412
413 let snapshot = match store.lock() {
415 Ok(guard) => guard.clone(),
416 Err(_) => {
417 return outcome(
418 Status::Failed,
419 Vec::new(),
420 Some(Fault::System("global store lock poisoned".to_owned())),
421 );
422 }
423 };
424 let mut world = World::new(snapshot);
425 let prepared = match (spec.prepare)(&world) {
426 Ok(prepared) => prepared,
427 Err(diags) => {
428 let detail = diags
429 .iter()
430 .map(|d| d.message.clone())
431 .collect::<Vec<_>>()
432 .join("; ");
433 return outcome(Status::Failed, Vec::new(), Some(Fault::User(detail)));
434 }
435 };
436
437 let mut sessions: Vec<(String, Box<dyn crate::engine::EngineSession>)> = Vec::new();
438 let mut steps: Vec<StepOutcome> = Vec::new();
439 let mut fault: Option<Fault> = None;
440 let mut failed = false;
441
442 let mut interrupted = false;
443 let mut processed = 0usize;
444 for batch in &prepared.batches {
445 if failed {
446 break;
447 }
448 if cancel.is_cancelled() {
449 interrupted = true;
452 break;
453 }
454 let engine_id = batch.engine.as_str().to_owned();
455 if !sessions.iter().any(|(id, _)| *id == engine_id) {
456 let Some(factory) = engines.iter().find(|f| f.id() == engine_id) else {
457 fault = Some(Fault::System(format!(
458 "no engine registered for `{engine_id}`"
459 )));
460 failed = true;
461 break;
462 };
463 let ctx = ScenarioCtx {
464 run_id: Arc::clone(&config.run_id),
465 scenario: Arc::clone(&spec.name),
466 artifact: prepared.artifact.clone(),
467 secrets: Arc::clone(&config.secrets),
468 http: config.http,
469 file_root: spec.file_root.clone(),
470 };
471 match factory.open(&ctx) {
472 Ok(session) => sessions.push((engine_id.clone(), session)),
473 Err(err) => {
474 fault = Some(Fault::System(format!(
475 "cannot open engine `{engine_id}`: {err}"
476 )));
477 failed = true;
478 break;
479 }
480 }
481 }
482 let Some((_, session)) = sessions.iter_mut().find(|(id, _)| *id == engine_id) else {
483 break; };
485
486 let budget = session
487 .batch_budget(batch)
488 .unwrap_or(config.default_batch_budget);
489 heartbeat(budget);
490 events.emit(&Event::BatchStarted {
491 scenario: Arc::clone(&spec.name),
492 engine: Arc::from(engine_id.as_str()),
493 steps: batch.steps.len(),
494 });
495
496 let result = session.run_batch(batch, &mut world, events, cancel);
497 let all_optional = batch.steps.iter().all(|s| s.optional);
498 for mut step_outcome in result.steps {
499 if step_outcome.status == Status::Failed && all_optional {
500 step_outcome.status = Status::Warned;
501 }
502 steps.push(step_outcome);
503 }
504 if let Some(err) = result.error {
505 if all_optional {
506 continue;
508 }
509 match err.class {
510 crate::error::EngineErrorClass::AssertFailed => {}
511 crate::error::EngineErrorClass::Infra | crate::error::EngineErrorClass::Setup => {
512 fault = Some(Fault::System(err.message.clone()));
513 }
514 }
515 failed = true;
516 }
517 processed += 1;
518 }
519
520 let unreached_reason = if interrupted {
524 "not run (run cancelled)"
525 } else {
526 "not run (an earlier step failed)"
527 };
528 for batch in prepared.batches.iter().skip(processed) {
529 for step in &batch.steps {
530 events.emit(&Event::StepFinished {
531 scenario: Arc::clone(&spec.name),
532 engine: Arc::from(batch.engine.as_str()),
533 step: step.step.clone(),
534 status: Status::Skipped,
535 attempts: 0,
536 duration_ms: 0,
537 captures: Vec::new(),
538 detail: Some(unreached_reason.to_owned()),
539 });
540 steps.push(StepOutcome {
541 step: step.step.clone(),
542 status: Status::Skipped,
543 attempts: 0,
544 duration: std::time::Duration::ZERO,
545 detail: Some(unreached_reason.to_owned()),
546 artifact_span: None,
547 });
548 }
549 }
550
551 for (_, session) in sessions.iter_mut().rev() {
552 let _ = session.finish();
553 }
554
555 if let Ok(mut guard) = store.lock() {
559 for (key, value) in world.promotions() {
560 guard.insert(key, value.clone());
561 }
562 }
563
564 let status = if failed || steps.iter().any(|s| s.status == Status::Failed) {
565 Status::Failed
566 } else if interrupted {
567 Status::Skipped
568 } else {
569 Status::Passed
570 };
571 outcome(status, steps, fault)
572}