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 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
170#[allow(clippy::too_many_lines)]
174pub fn run(
175 specs: Vec<ScenarioSpec>,
176 engines: &Arc<Vec<Box<dyn EngineFactory>>>,
177 store: &Arc<Mutex<GlobalStore>>,
178 config: &RunConfig,
179 events: &EventSink,
180 cancel: &CancellationToken,
181) -> RunSummary {
182 events.emit(&Event::RunStarted {
183 schema: EVENT_SCHEMA_VERSION,
184 run_id: Arc::clone(&config.run_id),
185 });
186
187 let (tx, rx) = mpsc::channel::<Msg>();
188 let identities: Vec<(Arc<str>, Arc<str>, usize)> = specs
191 .iter()
192 .map(|spec| (Arc::clone(&spec.file), Arc::clone(&spec.name), spec.line))
193 .collect();
194 let mut queue: std::collections::VecDeque<(usize, ScenarioSpec)> =
195 specs.into_iter().enumerate().collect();
196 let total = queue.len();
197 let mut active: BTreeMap<usize, (Instant, CancellationToken)> = BTreeMap::new();
202 let mut outcomes: Vec<ScenarioOutcome> = Vec::new();
203 let grace = Duration::from_secs(2);
204
205 while outcomes.len() < total {
206 while active.len() < config.jobs.max(1) {
208 let Some((index, spec)) = queue.pop_front() else {
209 break;
210 };
211 if cancel.is_cancelled() {
212 events.emit(&Event::ScenarioFinished {
213 scenario: Arc::clone(&spec.name),
214 file: Arc::clone(&spec.file),
215 status: Status::Skipped,
216 timestamp_ms: None,
217 worker: None,
218 });
219 outcomes.push(ScenarioOutcome {
220 file: spec.file,
221 name: spec.name,
222 line: spec.line,
223 status: Status::Skipped,
224 steps: Vec::new(),
225 fault: None,
226 artifact_slug: None,
227 });
228 continue;
229 }
230 let initial_deadline = Instant::now() + config.default_batch_budget + grace;
231 let child = cancel.child_token();
232 active.insert(index, (initial_deadline, child.clone()));
233 spawn_scenario(
234 index,
235 spec,
236 Arc::clone(engines),
237 Arc::clone(store),
238 config.clone(),
239 events.clone(),
240 child,
241 tx.clone(),
242 );
243 }
244 if active.is_empty() {
245 continue; }
247
248 let now = Instant::now();
250 let next_deadline = active
251 .values()
252 .map(|(deadline, _)| *deadline)
253 .min()
254 .unwrap_or(now + grace);
255 let wait = next_deadline
256 .saturating_duration_since(now)
257 .max(Duration::from_millis(20));
258 match rx.recv_timeout(wait) {
259 Ok(Msg::BatchBegin { scenario, deadline }) => {
260 if let Some(entry) = active.get_mut(&scenario) {
261 entry.0 = deadline + grace;
262 }
263 }
264 Ok(Msg::Done { scenario, outcome }) => {
265 if active.remove(&scenario).is_some() {
266 events.emit(&Event::ScenarioFinished {
267 scenario: Arc::clone(&outcome.name),
268 file: Arc::clone(&outcome.file),
269 status: outcome.status,
270 timestamp_ms: None,
271 worker: None,
272 });
273 outcomes.push(outcome);
274 }
275 }
277 Err(mpsc::RecvTimeoutError::Timeout) => {}
278 Err(mpsc::RecvTimeoutError::Disconnected) => break,
279 }
280 sweep_expired(&mut active, &mut outcomes, events, &identities);
284 }
285
286 let passed = outcomes
287 .iter()
288 .filter(|o| matches!(o.status, Status::Passed | Status::Warned))
289 .count();
290 let failed = outcomes
291 .iter()
292 .filter(|o| o.status == Status::Failed)
293 .count();
294 let skipped = outcomes
295 .iter()
296 .filter(|o| o.status == Status::Skipped)
297 .count();
298 let cancelled = cancel.is_cancelled();
299 events.emit(&Event::RunFinished {
300 passed,
301 failed,
302 skipped,
303 cancelled,
304 });
305 RunSummary {
306 outcomes,
307 passed,
308 failed,
309 skipped,
310 cancelled,
311 }
312}
313
314fn sweep_expired(
319 active: &mut BTreeMap<usize, (Instant, CancellationToken)>,
320 outcomes: &mut Vec<ScenarioOutcome>,
321 events: &EventSink,
322 identities: &[(Arc<str>, Arc<str>, usize)],
323) {
324 let now = Instant::now();
325 let expired: Vec<usize> = active
326 .iter()
327 .filter(|(_, (deadline, _))| *deadline <= now)
328 .map(|(index, _)| *index)
329 .collect();
330 for index in expired {
331 if let Some((_, token)) = active.remove(&index) {
332 token.cancel();
333 }
334 let (file, name, line) = &identities[index];
337 let outcome = ScenarioOutcome {
338 file: Arc::clone(file),
339 name: Arc::clone(name),
340 line: *line,
341 status: Status::Failed,
342 steps: Vec::new(),
343 fault: Some(Fault::System(
344 "batch budget exceeded — scenario thread abandoned (ADR-0007)".to_owned(),
345 )),
346 artifact_slug: None,
347 };
348 events.emit(&Event::ScenarioFinished {
349 scenario: Arc::clone(&outcome.name),
350 file: Arc::clone(&outcome.file),
351 status: Status::Failed,
352 timestamp_ms: None,
353 worker: None,
354 });
355 outcomes.push(outcome);
356 }
357}
358
359#[allow(clippy::too_many_arguments)]
360fn spawn_scenario(
361 index: usize,
362 spec: ScenarioSpec,
363 engines: Arc<Vec<Box<dyn EngineFactory>>>,
364 store: Arc<Mutex<GlobalStore>>,
365 config: RunConfig,
366 events: EventSink,
367 cancel: CancellationToken,
368 tx: mpsc::Sender<Msg>,
369) {
370 std::thread::spawn(move || {
371 let identity = (Arc::clone(&spec.file), Arc::clone(&spec.name), spec.line);
372 let heartbeat_tx = tx.clone();
373 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
377 run_scenario(
378 spec,
379 &engines,
380 &store,
381 &config,
382 &events,
383 &cancel,
384 |budget| {
385 let _ = heartbeat_tx.send(Msg::BatchBegin {
386 scenario: index,
387 deadline: Instant::now() + budget,
388 });
389 },
390 )
391 }));
392 let outcome = result.unwrap_or_else(|panic| {
393 let message = panic
394 .downcast_ref::<&str>()
395 .map(ToString::to_string)
396 .or_else(|| panic.downcast_ref::<String>().cloned())
397 .unwrap_or_else(|| "opaque panic payload".to_owned());
398 ScenarioOutcome {
399 file: identity.0,
400 name: identity.1,
401 line: identity.2,
402 status: Status::Failed,
403 steps: Vec::new(),
404 fault: Some(Fault::System(format!(
405 "scenario thread panicked: {message}"
406 ))),
407 artifact_slug: None,
408 }
409 });
410 let _ = tx.send(Msg::Done {
411 scenario: index,
412 outcome,
413 });
414 });
415}
416
417#[allow(clippy::too_many_lines)]
419fn run_scenario(
420 spec: ScenarioSpec,
421 engines: &[Box<dyn EngineFactory>],
422 store: &Mutex<GlobalStore>,
423 config: &RunConfig,
424 events: &EventSink,
425 cancel: &CancellationToken,
426 heartbeat: impl Fn(Duration),
427) -> ScenarioOutcome {
428 let (file, name, line) = (Arc::clone(&spec.file), Arc::clone(&spec.name), spec.line);
429 let outcome = move |status, steps, fault, artifact_slug| ScenarioOutcome {
430 file: Arc::clone(&file),
431 name: Arc::clone(&name),
432 line,
433 status,
434 steps,
435 fault,
436 artifact_slug,
437 };
438
439 events.emit(&Event::ScenarioStarted {
440 scenario: Arc::clone(&spec.name),
441 file: Arc::clone(&spec.file),
442 timestamp_ms: None,
443 worker: None,
444 });
445
446 let snapshot = match store.lock() {
448 Ok(guard) => guard.clone(),
449 Err(_) => {
450 return outcome(
451 Status::Failed,
452 Vec::new(),
453 Some(Fault::System("global store lock poisoned".to_owned())),
454 None,
455 );
456 }
457 };
458 let mut world = World::new(snapshot);
459 let prepared = match (spec.prepare)(&world) {
460 Ok(prepared) => prepared,
461 Err(diags) => {
462 let detail = diags
463 .iter()
464 .map(|d| d.message.clone())
465 .collect::<Vec<_>>()
466 .join("; ");
467 return outcome(Status::Failed, Vec::new(), Some(Fault::User(detail)), None);
468 }
469 };
470
471 let mut sessions: Vec<(String, Box<dyn crate::engine::EngineSession>)> = Vec::new();
472 let mut steps: Vec<StepOutcome> = Vec::new();
473 let mut fault: Option<Fault> = None;
474 let mut failed = false;
475
476 let mut interrupted = false;
477 let mut processed = 0usize;
478 for batch in &prepared.batches {
479 if failed {
480 break;
481 }
482 if cancel.is_cancelled() {
483 interrupted = true;
486 break;
487 }
488 let engine_id = batch.engine.as_str().to_owned();
489 if !sessions.iter().any(|(id, _)| *id == engine_id) {
490 let Some(factory) = engines.iter().find(|f| f.id() == engine_id) else {
491 fault = Some(Fault::System(format!(
492 "no engine registered for `{engine_id}`"
493 )));
494 failed = true;
495 break;
496 };
497 let ctx = ScenarioCtx {
498 run_id: Arc::clone(&config.run_id),
499 scenario: Arc::clone(&spec.name),
500 artifact: prepared.artifact.clone(),
501 secrets: Arc::clone(&config.secrets),
502 http: config.http,
503 file_root: spec.file_root.clone(),
504 };
505 match factory.open(&ctx) {
506 Ok(session) => sessions.push((engine_id.clone(), session)),
507 Err(err) => {
508 fault = Some(Fault::System(format!(
509 "cannot open engine `{engine_id}`: {err}"
510 )));
511 failed = true;
512 break;
513 }
514 }
515 }
516 let Some((_, session)) = sessions.iter_mut().find(|(id, _)| *id == engine_id) else {
517 break; };
519
520 let budget = session
521 .batch_budget(batch)
522 .unwrap_or(config.default_batch_budget);
523 heartbeat(budget);
524 events.emit(&Event::BatchStarted {
525 scenario: Arc::clone(&spec.name),
526 engine: Arc::from(engine_id.as_str()),
527 steps: batch.steps.len(),
528 });
529
530 let result = session.run_batch(batch, &mut world, events, cancel);
531 let all_optional = batch.steps.iter().all(|s| s.optional);
532 for mut step_outcome in result.steps {
533 if step_outcome.status == Status::Failed && all_optional {
534 step_outcome.status = Status::Warned;
535 }
536 steps.push(step_outcome);
537 }
538 if let Some(err) = result.error {
539 if all_optional {
540 processed += 1;
545 continue;
546 }
547 match err.class {
548 crate::error::EngineErrorClass::AssertFailed => {}
549 crate::error::EngineErrorClass::UserInput => {
550 fault = Some(Fault::User(err.message.clone()));
551 }
552 crate::error::EngineErrorClass::Infra | crate::error::EngineErrorClass::Setup => {
553 fault = Some(Fault::System(err.message.clone()));
554 }
555 }
556 failed = true;
557 }
558 processed += 1;
559 }
560
561 let unreached_reason = if interrupted {
565 "not run (run cancelled)"
566 } else {
567 "not run (an earlier step failed)"
568 };
569 for batch in prepared.batches.iter().skip(processed) {
570 for step in &batch.steps {
571 events.emit(&Event::StepFinished {
572 scenario: Arc::clone(&spec.name),
573 engine: Arc::from(batch.engine.as_str()),
574 step: step.step.clone(),
575 status: Status::Skipped,
576 attempts: 0,
577 duration_ms: 0,
578 captures: Vec::new(),
579 detail: Some(unreached_reason.to_owned()),
580 attempt_details: Vec::new(),
581 });
582 steps.push(StepOutcome {
583 step: step.step.clone(),
584 status: Status::Skipped,
585 attempts: 0,
586 duration: std::time::Duration::ZERO,
587 detail: Some(unreached_reason.to_owned()),
588 attempt_details: Vec::new(),
589 reproduce_hint: None,
590 });
591 }
592 }
593
594 for (engine_id, session) in sessions.iter_mut().rev() {
595 if let Err(err) = session.finish()
596 && fault.is_none()
597 {
598 fault = Some(Fault::System(format!(
602 "engine `{engine_id}` teardown failed: {}",
603 err.message
604 )));
605 }
606 }
607
608 {
616 let mut guard = store
617 .lock()
618 .unwrap_or_else(std::sync::PoisonError::into_inner);
619 for (key, value) in world.promotions() {
620 guard.insert(key, value.clone());
621 }
622 }
623
624 let status = if failed || steps.iter().any(|s| s.status == Status::Failed) {
625 Status::Failed
626 } else if interrupted {
627 Status::Skipped
628 } else {
629 Status::Passed
630 };
631 let artifact_slug = prepared
632 .artifact
633 .as_ref()
634 .map(|artifact| Arc::clone(&artifact.slug));
635 outcome(status, steps, fault, artifact_slug)
636}