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