1use std::collections::{BTreeMap, HashSet};
16use std::sync::mpsc;
17use std::sync::{Arc, Mutex, PoisonError};
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 file_root: Option<std::path::PathBuf>,
40 pub prepare: PrepareFn,
42}
43
44pub type PrepareFn = Box<dyn FnOnce(&World) -> Result<Prepared, Vec<Diag>> + Send>;
46
47pub struct Prepared {
49 pub batches: Vec<StepBatch>,
51 pub artifact: Option<ArtifactRef>,
53}
54
55#[derive(Clone)]
57pub struct RunConfig {
58 pub run_id: Arc<str>,
60 pub jobs: usize,
62 pub default_batch_budget: Duration,
64 pub secrets: Arc<BTreeMap<String, String>>,
66 pub http: HttpDefaults,
68}
69
70#[derive(Debug)]
72pub struct RunSummary {
73 pub outcomes: Vec<ScenarioOutcome>,
75 pub passed: usize,
77 pub failed: usize,
79 pub skipped: usize,
81 pub cancelled: bool,
83}
84
85impl RunSummary {
86 pub fn exit_code(&self) -> ExitCode {
90 self.exit_code_excluding(&[])
91 }
92
93 pub fn exit_code_excluding(&self, non_gating: &[(String, String)]) -> ExitCode {
98 let mut worst = if self.cancelled {
99 ExitCode::TestFailure
100 } else {
101 ExitCode::Success
102 };
103 for outcome in &self.outcomes {
104 let quarantined = non_gating.iter().any(|(file, name)| {
105 file.as_str() == outcome.file.as_ref() && name.as_str() == outcome.name.as_ref()
106 });
107 let code = match (&outcome.fault, outcome.status) {
108 (Some(Fault::System(_)), _) => ExitCode::SystemError,
109 (Some(Fault::User(_)), _) => ExitCode::UserError,
110 (None, Status::Failed) if !quarantined => ExitCode::TestFailure,
111 _ => ExitCode::Success,
112 };
113 worst = pick_worse(worst, code);
114 }
115 worst
116 }
117}
118
119fn pick_worse(a: ExitCode, b: ExitCode) -> ExitCode {
121 let rank = |c: ExitCode| match c {
122 ExitCode::SystemError => 3,
123 ExitCode::UserError => 2,
124 ExitCode::TestFailure => 1,
125 ExitCode::Success => 0,
126 };
127 if rank(b) > rank(a) { b } else { a }
128}
129
130#[derive(Debug)]
132pub struct ScenarioOutcome {
133 pub file: Arc<str>,
135 pub name: Arc<str>,
137 pub line: usize,
139 pub status: Status,
141 pub steps: Vec<StepOutcome>,
143 pub fault: Option<Fault>,
145 pub artifact_slug: Option<Arc<str>>,
148}
149
150#[derive(Debug)]
152pub enum Fault {
153 User(String),
155 System(String),
157}
158
159enum Msg {
160 BatchBegin {
161 scenario: usize,
162 deadline: Instant,
163 },
164 Done {
165 scenario: usize,
166 outcome: ScenarioOutcome,
167 },
168}
169
170type ScenarioId = (Arc<str>, Arc<str>);
173
174struct GateState {
176 closed: HashSet<ScenarioId>,
179 run_closed: bool,
182}
183
184#[derive(Clone)]
202struct RecordGate {
203 inner: EventSink,
204 state: Arc<Mutex<GateState>>,
205}
206
207impl RecordGate {
208 fn new(inner: EventSink) -> Self {
209 Self {
210 inner,
211 state: Arc::new(Mutex::new(GateState {
212 closed: HashSet::new(),
213 run_closed: false,
214 })),
215 }
216 }
217
218 fn scenario_sink(&self, file: Arc<str>, scenario: Arc<str>) -> EventSink {
228 let inner = self.inner.clone();
229 let state = Arc::clone(&self.state);
230 let id: ScenarioId = (file, scenario);
231 EventSink::new(move |event| {
232 let guard = state.lock().unwrap_or_else(PoisonError::into_inner);
233 if guard.run_closed || guard.closed.contains(&id) {
234 return;
235 }
236 inner.emit(event);
237 })
239 }
240
241 fn finish_scenario(&self, event: &Event, file: &Arc<str>, scenario: &Arc<str>) {
248 let mut guard = self.state.lock().unwrap_or_else(PoisonError::into_inner);
249 if !guard.run_closed {
250 self.inner.emit(event);
251 }
252 guard
253 .closed
254 .insert((Arc::clone(file), Arc::clone(scenario)));
255 }
256
257 fn emit_run_level(&self, event: &Event) {
260 let guard = self.state.lock().unwrap_or_else(PoisonError::into_inner);
261 if !guard.run_closed {
262 self.inner.emit(event);
263 }
264 }
265
266 fn close_run(&self) {
270 self.state
271 .lock()
272 .unwrap_or_else(PoisonError::into_inner)
273 .run_closed = true;
274 }
275}
276
277#[allow(clippy::too_many_lines)]
281pub fn run(
282 specs: Vec<ScenarioSpec>,
283 engines: &Arc<Vec<Box<dyn EngineFactory>>>,
284 store: &Arc<Mutex<GlobalStore>>,
285 config: &RunConfig,
286 events: &EventSink,
287 cancel: &CancellationToken,
288) -> RunSummary {
289 let gate = RecordGate::new(events.clone());
294 gate.emit_run_level(&Event::RunStarted {
295 schema: EVENT_SCHEMA_VERSION,
296 run_id: Arc::clone(&config.run_id),
297 });
298
299 let (tx, rx) = mpsc::channel::<Msg>();
300 let identities: Vec<(Arc<str>, Arc<str>, usize)> = specs
303 .iter()
304 .map(|spec| (Arc::clone(&spec.file), Arc::clone(&spec.name), spec.line))
305 .collect();
306 let mut queue: std::collections::VecDeque<(usize, ScenarioSpec)> =
307 specs.into_iter().enumerate().collect();
308 let total = queue.len();
309 let mut active: BTreeMap<usize, (Instant, CancellationToken)> = BTreeMap::new();
314 let mut outcomes: Vec<ScenarioOutcome> = Vec::new();
315 let grace = Duration::from_secs(2);
316
317 while outcomes.len() < total {
318 while active.len() < config.jobs.max(1) {
320 let Some((index, spec)) = queue.pop_front() else {
321 break;
322 };
323 if cancel.is_cancelled() {
324 gate.finish_scenario(
325 &Event::ScenarioFinished {
326 scenario: Arc::clone(&spec.name),
327 file: Arc::clone(&spec.file),
328 status: Status::Skipped,
329 timestamp_ms: None,
330 worker: None,
331 phase: None,
332 },
333 &spec.file,
334 &spec.name,
335 );
336 outcomes.push(ScenarioOutcome {
337 file: spec.file,
338 name: spec.name,
339 line: spec.line,
340 status: Status::Skipped,
341 steps: Vec::new(),
342 fault: None,
343 artifact_slug: None,
344 });
345 continue;
346 }
347 let initial_deadline = Instant::now() + config.default_batch_budget + grace;
348 let child = cancel.child_token();
349 active.insert(index, (initial_deadline, child.clone()));
350 let scenario_events =
351 gate.scenario_sink(Arc::clone(&spec.file), Arc::clone(&spec.name));
352 spawn_scenario(
353 index,
354 spec,
355 Arc::clone(engines),
356 Arc::clone(store),
357 config.clone(),
358 scenario_events,
359 child,
360 tx.clone(),
361 );
362 }
363 if active.is_empty() {
364 continue; }
366
367 let now = Instant::now();
369 let next_deadline = active
370 .values()
371 .map(|(deadline, _)| *deadline)
372 .min()
373 .unwrap_or(now + grace);
374 let wait = next_deadline
375 .saturating_duration_since(now)
376 .max(Duration::from_millis(20));
377 match rx.recv_timeout(wait) {
378 Ok(Msg::BatchBegin { scenario, deadline }) => {
379 if let Some(entry) = active.get_mut(&scenario) {
380 entry.0 = deadline + grace;
381 }
382 }
383 Ok(Msg::Done { scenario, outcome }) => {
384 if active.remove(&scenario).is_some() {
385 gate.finish_scenario(
386 &Event::ScenarioFinished {
387 scenario: Arc::clone(&outcome.name),
388 file: Arc::clone(&outcome.file),
389 status: outcome.status,
390 timestamp_ms: None,
391 worker: None,
392 phase: None,
393 },
394 &outcome.file,
395 &outcome.name,
396 );
397 outcomes.push(outcome);
398 }
399 }
401 Err(mpsc::RecvTimeoutError::Timeout) => {}
402 Err(mpsc::RecvTimeoutError::Disconnected) => break,
403 }
404 sweep_expired(&mut active, &mut outcomes, &gate, &identities);
408 }
409
410 let passed = outcomes
411 .iter()
412 .filter(|o| matches!(o.status, Status::Passed | Status::Warned))
413 .count();
414 let failed = outcomes
415 .iter()
416 .filter(|o| o.status == Status::Failed)
417 .count();
418 let skipped = outcomes
419 .iter()
420 .filter(|o| o.status == Status::Skipped)
421 .count();
422 let cancelled = cancel.is_cancelled();
423 gate.emit_run_level(&Event::RunFinished {
424 passed,
425 failed,
426 skipped,
427 cancelled,
428 });
429 gate.close_run();
433 RunSummary {
434 outcomes,
435 passed,
436 failed,
437 skipped,
438 cancelled,
439 }
440}
441
442fn sweep_expired(
450 active: &mut BTreeMap<usize, (Instant, CancellationToken)>,
451 outcomes: &mut Vec<ScenarioOutcome>,
452 gate: &RecordGate,
453 identities: &[(Arc<str>, Arc<str>, usize)],
454) {
455 let now = Instant::now();
456 let expired: Vec<usize> = active
457 .iter()
458 .filter(|(_, (deadline, _))| *deadline <= now)
459 .map(|(index, _)| *index)
460 .collect();
461 for index in expired {
462 if let Some((_, token)) = active.remove(&index) {
463 token.cancel();
464 }
465 let (file, name, line) = &identities[index];
468 let outcome = ScenarioOutcome {
469 file: Arc::clone(file),
470 name: Arc::clone(name),
471 line: *line,
472 status: Status::Failed,
473 steps: Vec::new(),
474 fault: Some(Fault::System(
475 "batch budget exceeded — scenario thread abandoned (ADR-0007)".to_owned(),
476 )),
477 artifact_slug: None,
478 };
479 gate.finish_scenario(
480 &Event::ScenarioFinished {
481 scenario: Arc::clone(&outcome.name),
482 file: Arc::clone(&outcome.file),
483 status: Status::Failed,
484 timestamp_ms: None,
485 worker: None,
486 phase: None,
487 },
488 &outcome.file,
489 &outcome.name,
490 );
491 outcomes.push(outcome);
492 }
493}
494
495#[allow(clippy::too_many_arguments)]
496fn spawn_scenario(
497 index: usize,
498 spec: ScenarioSpec,
499 engines: Arc<Vec<Box<dyn EngineFactory>>>,
500 store: Arc<Mutex<GlobalStore>>,
501 config: RunConfig,
502 events: EventSink,
503 cancel: CancellationToken,
504 tx: mpsc::Sender<Msg>,
505) {
506 std::thread::spawn(move || {
507 let identity = (Arc::clone(&spec.file), Arc::clone(&spec.name), spec.line);
508 let heartbeat_tx = tx.clone();
509 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
513 run_scenario(
514 spec,
515 &engines,
516 &store,
517 &config,
518 &events,
519 &cancel,
520 |budget| {
521 let _ = heartbeat_tx.send(Msg::BatchBegin {
522 scenario: index,
523 deadline: Instant::now() + budget,
524 });
525 },
526 )
527 }));
528 let outcome = result.unwrap_or_else(|panic| {
529 let message = panic
530 .downcast_ref::<&str>()
531 .map(ToString::to_string)
532 .or_else(|| panic.downcast_ref::<String>().cloned())
533 .unwrap_or_else(|| "opaque panic payload".to_owned());
534 ScenarioOutcome {
535 file: identity.0,
536 name: identity.1,
537 line: identity.2,
538 status: Status::Failed,
539 steps: Vec::new(),
540 fault: Some(Fault::System(format!(
541 "scenario thread panicked: {message}"
542 ))),
543 artifact_slug: None,
544 }
545 });
546 let _ = tx.send(Msg::Done {
547 scenario: index,
548 outcome,
549 });
550 });
551}
552
553#[allow(clippy::too_many_lines)]
555fn run_scenario(
556 spec: ScenarioSpec,
557 engines: &[Box<dyn EngineFactory>],
558 store: &Mutex<GlobalStore>,
559 config: &RunConfig,
560 events: &EventSink,
561 cancel: &CancellationToken,
562 heartbeat: impl Fn(Duration),
563) -> ScenarioOutcome {
564 let (file, name, line) = (Arc::clone(&spec.file), Arc::clone(&spec.name), spec.line);
565 let outcome = move |status, steps, fault, artifact_slug| ScenarioOutcome {
566 file: Arc::clone(&file),
567 name: Arc::clone(&name),
568 line,
569 status,
570 steps,
571 fault,
572 artifact_slug,
573 };
574
575 events.emit(&Event::ScenarioStarted {
576 scenario: Arc::clone(&spec.name),
577 file: Arc::clone(&spec.file),
578 timestamp_ms: None,
579 worker: None,
580 phase: None,
581 });
582
583 let snapshot = match store.lock() {
585 Ok(guard) => guard.clone(),
586 Err(_) => {
587 return outcome(
588 Status::Failed,
589 Vec::new(),
590 Some(Fault::System("global store lock poisoned".to_owned())),
591 None,
592 );
593 }
594 };
595 let mut world = World::new(snapshot);
596 let prepared = match (spec.prepare)(&world) {
597 Ok(prepared) => prepared,
598 Err(diags) => {
599 let detail = diags
600 .iter()
601 .map(|d| d.message.clone())
602 .collect::<Vec<_>>()
603 .join("; ");
604 return outcome(Status::Failed, Vec::new(), Some(Fault::User(detail)), None);
605 }
606 };
607
608 let mut sessions: Vec<(String, Box<dyn crate::engine::EngineSession>)> = Vec::new();
609 let mut steps: Vec<StepOutcome> = Vec::new();
610 let mut fault: Option<Fault> = None;
611 let mut failed = false;
612
613 let mut interrupted = false;
614 let mut processed = 0usize;
615 for batch in &prepared.batches {
616 if failed {
617 break;
618 }
619 if cancel.is_cancelled() {
620 interrupted = true;
623 break;
624 }
625 let engine_id = batch.engine.as_str().to_owned();
626 if !sessions.iter().any(|(id, _)| *id == engine_id) {
627 let Some(factory) = engines.iter().find(|f| f.id() == engine_id) else {
628 fault = Some(Fault::System(format!(
629 "no engine registered for `{engine_id}`"
630 )));
631 failed = true;
632 break;
633 };
634 let ctx = ScenarioCtx {
635 run_id: Arc::clone(&config.run_id),
636 scenario: Arc::clone(&spec.name),
637 artifact: prepared.artifact.clone(),
638 secrets: Arc::clone(&config.secrets),
639 http: config.http,
640 file_root: spec.file_root.clone(),
641 };
642 match factory.open(&ctx) {
643 Ok(session) => sessions.push((engine_id.clone(), session)),
644 Err(err) => {
645 fault = Some(Fault::System(format!(
646 "cannot open engine `{engine_id}`: {err}"
647 )));
648 failed = true;
649 break;
650 }
651 }
652 }
653 let Some((_, session)) = sessions.iter_mut().find(|(id, _)| *id == engine_id) else {
654 break; };
656
657 let budget = session
658 .batch_budget(batch)
659 .unwrap_or(config.default_batch_budget);
660 heartbeat(budget);
661 events.emit(&Event::BatchStarted {
662 scenario: Arc::clone(&spec.name),
663 engine: Arc::from(engine_id.as_str()),
664 steps: batch.steps.len(),
665 });
666
667 let result = session.run_batch(batch, &mut world, events, cancel);
668 let all_optional = batch.steps.iter().all(|s| s.optional);
669 for mut step_outcome in result.steps {
670 if step_outcome.status == Status::Failed && all_optional {
671 step_outcome.status = Status::Warned;
672 }
673 steps.push(step_outcome);
674 }
675 if let Some(err) = result.error {
676 if all_optional {
677 processed += 1;
682 continue;
683 }
684 match err.class {
685 crate::error::EngineErrorClass::AssertFailed => {}
686 crate::error::EngineErrorClass::UserInput => {
687 fault = Some(Fault::User(err.message.clone()));
688 }
689 crate::error::EngineErrorClass::Infra | crate::error::EngineErrorClass::Setup => {
690 fault = Some(Fault::System(err.message.clone()));
691 }
692 }
693 failed = true;
694 }
695 processed += 1;
696 }
697
698 let unreached_reason = if interrupted {
702 "not run (run cancelled)"
703 } else {
704 "not run (an earlier step failed)"
705 };
706 for batch in prepared.batches.iter().skip(processed) {
707 for step in &batch.steps {
708 events.emit(&Event::StepFinished {
709 scenario: Arc::clone(&spec.name),
710 engine: Arc::from(batch.engine.as_str()),
711 step: step.step.clone(),
712 status: Status::Skipped,
713 attempts: 0,
714 duration_ms: 0,
715 captures: Vec::new(),
716 detail: Some(unreached_reason.to_owned()),
717 attempt_details: Vec::new(),
718 });
719 steps.push(StepOutcome {
720 step: step.step.clone(),
721 status: Status::Skipped,
722 attempts: 0,
723 duration: std::time::Duration::ZERO,
724 detail: Some(unreached_reason.to_owned()),
725 attempt_details: Vec::new(),
726 reproduce_hint: None,
727 });
728 }
729 }
730
731 for (engine_id, session) in sessions.iter_mut().rev() {
732 if let Err(err) = session.finish()
733 && fault.is_none()
734 {
735 fault = Some(Fault::System(format!(
739 "engine `{engine_id}` teardown failed: {}",
740 err.message
741 )));
742 }
743 }
744
745 {
753 let mut guard = store
754 .lock()
755 .unwrap_or_else(std::sync::PoisonError::into_inner);
756 for (key, value) in world.promotions() {
757 guard.insert(key, value.clone());
758 }
759 }
760
761 let status = if failed || steps.iter().any(|s| s.status == Status::Failed) {
762 Status::Failed
763 } else if interrupted {
764 Status::Skipped
765 } else {
766 Status::Passed
767 };
768 let artifact_slug = prepared
769 .artifact
770 .as_ref()
771 .map(|artifact| Arc::clone(&artifact.slug));
772 outcome(status, steps, fault, artifact_slug)
773}