1use std::collections::HashMap;
2use std::collections::HashSet;
3use std::path::Path;
4use std::path::PathBuf;
5use std::sync::Arc;
6use std::time::Duration;
7use std::time::Instant;
8
9use std::collections::VecDeque;
10use std::sync::OnceLock;
11
12use tokio::sync::Mutex as TokioMutex;
13use tokio_util::sync::CancellationToken;
14
15pub(crate) enum CancelReason {
16 SuiteTimeout,
17 FailFast,
18}
19
20use crate::effect::CleanupSource;
21use crate::effect::EffectManager;
22use crate::effect::Warning;
23use crate::effect::registry::EffectRegistry;
24use crate::observe::event_sink::EventSink;
25use crate::report::result::Failure;
26use crate::report::result::Outcome;
27use crate::report::result::TestResult;
28use crate::vm::Vm;
29use crate::vm::context::ExecutionContext;
30use crate::vm::context::Scope;
31use crate::vm::context::ShellState;
32use relux_core::diagnostics::Cause;
33use relux_core::diagnostics::CauseId;
34use relux_core::diagnostics::CauseTable;
35use relux_core::diagnostics::WarningId;
36use relux_core::pure::Env;
37use relux_core::pure::LayeredEnv;
38use relux_core::pure::VarScope;
39use relux_core::table::SourceTable;
40use relux_ir::IrNode;
41use relux_ir::IrTest;
42use relux_ir::IrTestItem;
43use relux_ir::IrTimeout;
44use relux_ir::Plan;
45use relux_ir::Suite;
46
47pub mod effect;
48pub mod observe;
49pub mod report;
50pub mod runtime_context;
51pub mod vm;
52
53pub use runtime_context::RuntimeContext;
54pub use runtime_context::ShellConfig;
55
56use relux_core::config;
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum RunStrategy {
62 All,
63 FailFast,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum ProgressMode {
70 Auto,
72 Plain,
74 Tui,
76}
77
78pub struct RunContext {
81 pub run_id: String,
82 pub run_dir: PathBuf,
83 pub artifacts_dir: PathBuf,
84 pub project_root: PathBuf,
85 pub shell_command: String,
86 pub shell_prompt: String,
87 pub default_timeout: IrTimeout,
88 pub test_timeout: IrTimeout,
89 pub suite_timeout: Duration,
90 pub strategy: RunStrategy,
91 pub flaky: relux_core::config::FlakyConfig,
92 pub jobs: usize,
93 pub progress: ProgressMode,
94}
95
96fn build_env(ctx: &RunContext) -> Arc<LayeredEnv> {
99 let mut env = Env::capture();
100 env.insert("__RELUX_RUN_ID".into(), ctx.run_id.clone());
101 env.insert(
102 "__RELUX_RUN_ARTIFACTS".into(),
103 ctx.artifacts_dir.display().to_string(),
104 );
105 env.insert("__RELUX_SHELL_PROMPT".into(), ctx.shell_prompt.clone());
106 env.insert(
107 "__RELUX_SUITE_ROOT".into(),
108 ctx.project_root.display().to_string(),
109 );
110 if let Ok(exe) = std::env::current_exe() {
111 env.insert("__RELUX".into(), exe.display().to_string());
112 }
113 Arc::new(env.into())
114}
115
116fn make_test_env(
117 base: &Arc<LayeredEnv>,
118 test_file: &Path,
119 artifacts_dir: &Path,
120) -> Arc<LayeredEnv> {
121 let mut test_vars = Env::new();
122 if let Some(dir) = test_file.parent() {
123 test_vars.insert("__RELUX_TEST_ROOT".into(), dir.display().to_string());
124 }
125 test_vars.insert(
126 "__RELUX_TEST_ARTIFACTS".into(),
127 artifacts_dir.display().to_string(),
128 );
129 Arc::new(LayeredEnv::child(base.clone(), test_vars))
130}
131
132fn test_log_dir(
135 run_dir: &Path,
136 source_table: &SourceTable,
137 meta: &relux_ir::TestMeta,
138 project_root: &Path,
139) -> PathBuf {
140 let file_id = meta.span().file();
141 let source_path = source_table
142 .get(file_id)
143 .map(|sf| sf.path.clone())
144 .unwrap_or_else(|| file_id.path().clone());
145 let relative = source_path
146 .strip_prefix(project_root)
147 .unwrap_or(&source_path);
148 run_dir
149 .join("logs")
150 .join(relative.with_extension(""))
151 .join(slugify(meta.name()))
152}
153
154fn test_path_from_meta(
155 source_table: &SourceTable,
156 meta: &relux_ir::TestMeta,
157 project_root: &Path,
158) -> String {
159 let file_id = meta.span().file();
160 let source_path = source_table
161 .get(file_id)
162 .map(|sf| sf.path.clone())
163 .unwrap_or_else(|| file_id.path().clone());
164 let tests_dir = config::tests_dir(project_root);
165 source_path
166 .strip_prefix(&tests_dir)
167 .unwrap_or(&source_path)
168 .display()
169 .to_string()
170}
171
172fn format_cause_tags(
176 causes: &[CauseId],
177 warnings: &[WarningId],
178 cause_table: &CauseTable,
179) -> String {
180 let mut parts = Vec::new();
181
182 let mut invalid_ids = Vec::new();
183 let mut skip_ids = Vec::new();
184 for id in causes {
185 match cause_table.get(id) {
186 Some(Cause::Invalid(_)) => invalid_ids.push(id.to_string()),
187 Some(Cause::Skip(_)) => skip_ids.push(id.to_string()),
188 None => {}
189 }
190 }
191
192 if !invalid_ids.is_empty() {
193 parts.push(format!("[invalid: {}]", invalid_ids.join(", ")));
194 }
195 if !skip_ids.is_empty() {
196 parts.push(format!("[skip: {}]", skip_ids.join(", ")));
197 }
198 if !warnings.is_empty() {
199 let ids: Vec<String> = warnings.iter().map(|w| w.to_string()).collect();
200 parts.push(format!("[warning: {}]", ids.join(", ")));
201 }
202
203 if parts.is_empty() {
204 String::new()
205 } else {
206 format!(" {}", parts.join(" "))
207 }
208}
209
210pub fn test_display_id(test_path: &str, test_name: &str) -> String {
212 format!("{}/{}", test_path, slugify(test_name))
213}
214
215pub fn slugify(name: &str) -> String {
217 name.chars()
218 .map(|c| {
219 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
220 c.to_ascii_lowercase()
221 } else {
222 '-'
223 }
224 })
225 .collect::<String>()
226 .trim_matches('-')
227 .to_string()
228}
229
230pub struct ExecuteResult {
233 pub results: Vec<TestResult>,
234 pub wall_duration: Duration,
235}
236
237pub async fn execute(suite: &Suite, run_ctx: &RunContext) -> ExecuteResult {
238 let wall_start = Instant::now();
239 let base_env = build_env(run_ctx);
240 let jobs = run_ctx.jobs;
241
242 if jobs > 1 {
243 eprintln!("\nrunning {} tests ({jobs} workers)", suite.plans.len());
244 } else {
245 eprintln!("\nrunning {} tests", suite.plans.len());
246 }
247
248 let cancel = CancellationToken::new();
249 let cancel_reason: Arc<OnceLock<CancelReason>> = Arc::new(OnceLock::new());
250
251 let watchdog = {
253 let timeout = run_ctx.suite_timeout;
254 let watchdog_cancel = cancel.clone();
255 let watchdog_reason = cancel_reason.clone();
256 Some(tokio::spawn(async move {
257 tokio::time::sleep(timeout).await;
258 let _ = watchdog_reason.set(CancelReason::SuiteTimeout);
259 watchdog_cancel.cancel();
260 }))
261 };
262
263 let is_tty = match run_ctx.progress {
265 ProgressMode::Auto => std::io::IsTerminal::is_terminal(&std::io::stderr()),
266 ProgressMode::Plain => false,
267 ProgressMode::Tui => true,
268 };
269 let (tui_tx, tui_rx) = observe::tui::channel();
270 let tui_handle = observe::tui::spawn_tui(
271 tui_rx,
272 jobs,
273 is_tty,
274 suite.tables.sources.clone(),
275 run_ctx.project_root.clone(),
276 );
277
278 let queue: Arc<std::sync::Mutex<VecDeque<(usize, &Plan)>>> = Arc::new(std::sync::Mutex::new(
280 suite.plans.iter().enumerate().collect(),
281 ));
282
283 let mut worker_futs = Vec::with_capacity(jobs);
285 for slot in 0..jobs {
286 let ctx = WorkerContext {
287 queue: queue.clone(),
288 cancel: cancel.clone(),
289 cancel_reason: cancel_reason.clone(),
290 suite,
291 run_ctx,
292 base_env: base_env.clone(),
293 tui_tx: tui_tx.clone(),
294 };
295 worker_futs.push(run_worker(ctx, slot));
296 }
297
298 let worker_results = futures::future::join_all(worker_futs).await;
300
301 drop(tui_tx);
303 tui_handle.await.ok();
304
305 if let Some(handle) = watchdog {
307 handle.abort();
308 }
309
310 let mut all_results: Vec<(usize, TestResult)> = worker_results.into_iter().flatten().collect();
312 all_results.sort_by_key(|(idx, _)| *idx);
313 ExecuteResult {
314 results: all_results.into_iter().map(|(_, r)| r).collect(),
315 wall_duration: wall_start.elapsed(),
316 }
317}
318
319struct WorkerContext<'a> {
320 queue: Arc<std::sync::Mutex<VecDeque<(usize, &'a Plan)>>>,
321 cancel: CancellationToken,
322 cancel_reason: Arc<OnceLock<CancelReason>>,
323 suite: &'a Suite,
324 run_ctx: &'a RunContext,
325 base_env: Arc<LayeredEnv>,
326 tui_tx: observe::tui::TuiTx,
327}
328
329async fn run_worker(ctx: WorkerContext<'_>, slot: usize) -> Vec<(usize, TestResult)> {
330 let mut results = Vec::new();
331 let mut generation: u64 = 0;
332 loop {
333 if ctx.cancel.is_cancelled() {
334 break;
335 }
336
337 let entry = {
338 let mut q = ctx.queue.lock().expect("queue lock poisoned");
339 q.pop_front()
340 };
341 let Some((plan_idx, plan)) = entry else {
342 break;
343 };
344
345 let test_path = test_path_from_meta(
346 &ctx.suite.tables.sources,
347 plan.meta(),
348 &ctx.run_ctx.project_root,
349 );
350
351 let result = match plan {
352 Plan::Runnable {
353 meta,
354 test,
355 warnings: plan_warnings,
356 } => {
357 let tags = format_cause_tags(&[], plan_warnings, &ctx.suite.causes);
358 let display_id = test_display_id(&test_path, meta.name());
359 generation += 1;
360 let _ = ctx.tui_tx.send(observe::tui::TuiEvent::TestStarted {
361 slot,
362 test_id: display_id.clone(),
363 generation,
364 });
365
366 let mut result = run_test_cancellable(
367 meta,
368 test,
369 ctx.run_ctx,
370 ctx.base_env.clone(),
371 &test_path,
372 &tags,
373 &ctx.cancel,
374 &ctx.suite.tables,
375 &ctx.suite.causes,
376 1.0,
377 slot,
378 &ctx.tui_tx,
379 generation,
380 )
381 .await;
382
383 if meta.flaky()
385 && result.is_failure()
386 && ctx.run_ctx.flaky.max_retries > 0
387 && !ctx.cancel.is_cancelled()
388 {
389 let mut retries = 0u32;
390 for retry in 1..=ctx.run_ctx.flaky.max_retries {
391 if ctx.cancel.is_cancelled() {
392 break;
393 }
394 retries += 1;
395 let flaky_m = ctx.run_ctx.flaky.timeout_multiplier.powi(retry as i32);
396 let retry_test_path = format!("{test_path}-flaky-rerun-{retry}");
397 result = run_test_cancellable(
398 meta,
399 test,
400 ctx.run_ctx,
401 ctx.base_env.clone(),
402 &retry_test_path,
403 &tags,
404 &ctx.cancel,
405 &ctx.suite.tables,
406 &ctx.suite.causes,
407 flaky_m,
408 slot,
409 &ctx.tui_tx,
410 generation,
411 )
412 .await;
413 if !result.is_failure() {
414 break;
415 }
416 }
417 result.flaky_retries = retries;
418 result.test_path = test_path.clone();
419 }
420
421 let (progress_oneshot_tx, progress_oneshot_rx) = tokio::sync::oneshot::channel();
423 let result_line = format_result_line(&display_id, &result, &tags);
424 let failure = match &result.outcome {
425 Outcome::Fail(f) => Some((f.clone(), result.log_dir.clone())),
426 _ => None,
427 };
428 let _ = ctx.tui_tx.send(observe::tui::TuiEvent::TestFinished {
429 slot,
430 result_line,
431 failure,
432 progress_tx: progress_oneshot_tx,
433 });
434 if let Ok(progress) = progress_oneshot_rx.await {
435 result.progress = progress;
436 }
437
438 result
439 }
440 Plan::Skipped {
441 meta,
442 causes,
443 warnings,
444 } => {
445 let tags = format_cause_tags(causes, warnings, &ctx.suite.causes);
446 let display_id = test_display_id(&test_path, meta.name());
447 let result_line = format!(
448 "test {display_id}: {}{tags}",
449 colored::Colorize::yellow("skipped")
450 );
451 let _ = ctx
452 .tui_tx
453 .send(observe::tui::TuiEvent::Skipped { result_line });
454 TestResult {
455 test_name: meta.name().to_string(),
456 test_path: test_path.clone(),
457 outcome: Outcome::Skipped("skipped".to_string()),
458 duration: Duration::ZERO,
459 progress: String::new(),
460 log_dir: None,
461 warnings: Vec::new(),
462 flaky_retries: 0,
463 }
464 }
465 Plan::Invalid {
466 meta,
467 causes,
468 warnings,
469 } => {
470 let tags = format_cause_tags(causes, warnings, &ctx.suite.causes);
471 let display_id = test_display_id(&test_path, meta.name());
472 let result_line = format!(
473 "test {display_id}: {}{tags}",
474 colored::Colorize::red("INVALID")
475 );
476 let _ = ctx
477 .tui_tx
478 .send(observe::tui::TuiEvent::Skipped { result_line });
479 TestResult {
480 test_name: meta.name().to_string(),
481 test_path: test_path.clone(),
482 outcome: Outcome::Invalid("invalid".to_string()),
483 duration: Duration::ZERO,
484 progress: String::new(),
485 log_dir: None,
486 warnings: Vec::new(),
487 flaky_retries: 0,
488 }
489 }
490 };
491
492 let failed = matches!(result.outcome, Outcome::Fail(_));
493 results.push((plan_idx, result));
494
495 if failed && ctx.run_ctx.strategy == RunStrategy::FailFast {
496 let _ = ctx.cancel_reason.set(CancelReason::FailFast);
497 ctx.cancel.cancel();
498 break;
499 }
500 }
501
502 let skip_reason = if ctx.cancel.is_cancelled() {
504 match ctx.cancel_reason.get() {
505 Some(CancelReason::FailFast) => "fail fast",
506 Some(CancelReason::SuiteTimeout) => "suite timeout",
507 None => "cancelled",
508 }
509 } else {
510 return results;
511 };
512
513 let remaining: Vec<(usize, &Plan)> = {
514 let mut q = ctx.queue.lock().expect("queue lock poisoned");
515 q.drain(..).collect()
516 };
517 for (plan_idx, plan) in remaining {
518 let test_path = test_path_from_meta(
519 &ctx.suite.tables.sources,
520 plan.meta(),
521 &ctx.run_ctx.project_root,
522 );
523 let display_id = test_display_id(&test_path, plan.meta().name());
524 let result_line = format!(
525 "test {display_id}: {}",
526 colored::Colorize::yellow("skipped")
527 );
528 let _ = ctx
529 .tui_tx
530 .send(observe::tui::TuiEvent::Skipped { result_line });
531 results.push((
532 plan_idx,
533 TestResult {
534 test_name: plan.meta().name().to_string(),
535 test_path,
536 outcome: Outcome::Skipped(skip_reason.to_string()),
537 duration: Duration::ZERO,
538 progress: String::new(),
539 log_dir: None,
540 warnings: Vec::new(),
541 flaky_retries: 0,
542 },
543 ));
544 }
545
546 results
547}
548
549fn format_result_line(display_id: &str, result: &TestResult, cause_tags: &str) -> String {
550 use crate::report::result::format_duration;
551 use colored::Colorize;
552 let outcome_str = match &result.outcome {
553 Outcome::Pass => format!("{}", "ok".green()),
554 Outcome::Fail(_) => format!("{}", "FAILED".red()),
555 Outcome::Skipped(_) => format!("{}", "skipped".yellow()),
556 Outcome::Invalid(_) => format!("{}", "INVALID".red()),
557 };
558 format!(
559 "test {display_id}: {outcome_str} ({}){cause_tags}",
560 format_duration(result.duration)
561 )
562}
563
564#[allow(clippy::too_many_arguments)]
567async fn run_test_cancellable(
568 meta: &relux_ir::TestMeta,
569 test: &IrTest,
570 run_ctx: &RunContext,
571 base_env: Arc<LayeredEnv>,
572 test_path: &str,
573 cause_tags: &str,
574 cancel: &CancellationToken,
575 tables: &relux_ir::Tables,
576 _causes: &CauseTable,
577 flaky_timeout_multiplier: f64,
578 slot: usize,
579 tui_tx: &observe::tui::TuiTx,
580 generation: u64,
581) -> TestResult {
582 let test_cancel = cancel.child_token();
584
585 let effective_timeout = meta
586 .timeout()
587 .map(|t| t.adjusted_duration_with_flaky(flaky_timeout_multiplier))
588 .unwrap_or_else(|| {
589 run_ctx
590 .test_timeout
591 .adjusted_duration_with_flaky(flaky_timeout_multiplier)
592 });
593
594 let test_watchdog = Some({
596 let timeout = effective_timeout;
597 let timeout_cancel = test_cancel.clone();
598 tokio::spawn(async move {
599 tokio::time::sleep(timeout).await;
600 timeout_cancel.cancel();
601 })
602 });
603
604 let mut result = run_test(
605 meta,
606 test,
607 run_ctx,
608 base_env,
609 test_path,
610 cause_tags,
611 &test_cancel,
612 tables,
613 flaky_timeout_multiplier,
614 slot,
615 tui_tx,
616 generation,
617 )
618 .await;
619
620 if let Some(handle) = test_watchdog {
622 handle.abort();
623 }
624
625 if test_cancel.is_cancelled()
628 && !cancel.is_cancelled()
629 && matches!(result.outcome, Outcome::Fail(Failure::Cancelled { .. }))
630 {
631 result.outcome = Outcome::Fail(Failure::Runtime {
632 message: format!("test timeout ({effective_timeout:?}) exceeded"),
633 span: None,
634 shell: None,
635 });
636 }
637
638 result
639}
640
641fn make_tui_progress_tx(
645 tui_tx: &observe::tui::TuiTx,
646 slot: usize,
647 generation: u64,
648) -> observe::progress::ProgressTx {
649 let (tx, mut rx) = observe::progress::channel();
650 let tui_tx = tui_tx.clone();
651 tokio::spawn(async move {
652 while let Some(event) = rx.recv().await {
653 let _ = tui_tx.send(observe::tui::TuiEvent::Progress {
654 slot,
655 event,
656 generation,
657 });
658 }
659 });
660 tx
661}
662
663#[allow(clippy::too_many_arguments)]
664async fn run_test(
665 meta: &relux_ir::TestMeta,
666 test: &IrTest,
667 run_ctx: &RunContext,
668 base_env: Arc<LayeredEnv>,
669 test_path: &str,
670 _cause_tags: &str,
671 cancel: &CancellationToken,
672 tables: &relux_ir::Tables,
673 flaky_timeout_multiplier: f64,
674 slot: usize,
675 tui_tx: &observe::tui::TuiTx,
676 generation: u64,
677) -> TestResult {
678 let test_start = Instant::now();
679 let source_table = &tables.sources;
680 let log_dir = test_log_dir(&run_ctx.run_dir, source_table, meta, &run_ctx.project_root);
681 let _ = std::fs::create_dir_all(&log_dir);
682
683 let progress_tx = make_tui_progress_tx(tui_tx, slot, generation);
684
685 let file_id = meta.span().file();
686 let source_file = source_table
687 .get(file_id)
688 .map(|sf| sf.path.clone())
689 .unwrap_or_else(|| file_id.path().clone());
690 let artifacts_dir = log_dir.join("artifacts");
691 let _ = std::fs::create_dir_all(&artifacts_dir);
692 let test_env = make_test_env(&base_env, &source_file, &artifacts_dir);
693 let mut warnings = Vec::new();
694
695 let shell_config = ShellConfig {
696 command: Arc::from(run_ctx.shell_command.as_str()),
697 prompt: Arc::from(run_ctx.shell_prompt.as_str()),
698 default_timeout: run_ctx.default_timeout.clone(),
699 };
700
701 let events = EventSink::new(progress_tx.clone(), test_start);
702
703 let rt_ctx = RuntimeContext {
704 events: events.clone(),
705 shell: shell_config,
706 log_dir: Arc::from(log_dir.as_path()),
707 tables: tables.clone(),
708 env: test_env.clone(),
709 cancel: cancel.clone(),
710 test_start,
711 flaky_timeout_multiplier,
712 };
713
714 let test_manager = EffectManager::new(Arc::new(EffectRegistry::new()), rt_ctx.clone());
716
717 let outcome = run_test_body(meta, test, &test_manager, &mut warnings, &rt_ctx).await;
718
719 if outcome.is_err() {
720 events.emit_failure("");
721 }
722
723 let effect_warnings = test_manager.cleanup_all().await;
725 warnings.extend(effect_warnings);
726
727 let log_events = events.take();
729
730 drop(test_manager);
732 drop(rt_ctx);
733 drop(progress_tx);
734 let duration = test_start.elapsed();
735
736 crate::report::html::generate_html_logs(&log_dir, meta.name(), &log_events, &run_ctx.run_dir);
737
738 match outcome {
739 Ok(()) => TestResult {
740 test_name: meta.name().to_string(),
741 test_path: test_path.to_string(),
742 outcome: Outcome::Pass,
743 duration,
744 progress: String::new(),
745 log_dir: Some(log_dir),
746 warnings,
747 flaky_retries: 0,
748 },
749 Err(failure) => TestResult {
750 test_name: meta.name().to_string(),
751 test_path: test_path.to_string(),
752 outcome: Outcome::Fail(failure),
753 duration,
754 progress: String::new(),
755 log_dir: Some(log_dir),
756 warnings,
757 flaky_retries: 0,
758 },
759 }
760}
761
762async fn run_test_body(
765 meta: &relux_ir::TestMeta,
766 test: &IrTest,
767 manager: &EffectManager,
768 warnings: &mut Vec<Warning>,
769 rt_ctx: &RuntimeContext,
770) -> Result<(), Failure> {
771 let scope = Scope::Test {
773 name: meta.name().to_string(),
774 vars: Arc::new(TokioMutex::new(VarScope::new())),
775 timeout: meta.timeout().cloned(),
776 };
777
778 for item in test.body() {
780 if let IrTestItem::Let { stmt, .. } = item {
781 let mut vars = scope.vars().lock().await;
782 let value = if let Some(expr) = stmt.value() {
783 relux_ir::evaluator::eval_pure_expr(
784 expr,
785 &vars,
786 &rt_ctx.env,
787 &rt_ctx.tables.pure_fns,
788 )
789 } else {
790 String::new()
791 };
792 vars.insert(stmt.name().name().to_string(), value);
793 }
794 }
795
796 let caller_vars = scope.vars().lock().await.clone();
798 let root_env = rt_ctx.env.clone();
799 let exported = manager
800 .instantiate(test.starts(), &caller_vars, &root_env)
801 .await?;
802
803 let mut shells: HashMap<String, Arc<TokioMutex<Vm>>> = HashMap::new();
807 let mut effect_shells: HashMap<String, HashMap<String, Arc<TokioMutex<Vm>>>> = HashMap::new();
808 let mut reset_seen = HashSet::new();
809 for (start, (_key, exported_map)) in test.starts().iter().zip(exported) {
810 for vm_arc in exported_map.values() {
811 let ptr = Arc::as_ptr(vm_arc) as usize;
812 if reset_seen.insert(ptr) {
813 vm_arc.lock().await.reset_for_export(scope.clone());
814 }
815 }
816 if let Some(alias) = start.alias() {
817 if exported_map.len() == 1 {
820 let vm_arc = exported_map.values().next().unwrap().clone();
821 let source = vm_arc.lock().await.current_name();
822 rt_ctx.events.emit_shell_alias(alias, source);
823 shells.insert(alias.to_string(), vm_arc);
824 }
825 effect_shells.insert(alias.to_string(), exported_map);
826 }
827 }
828
829 let cleanup_block = test.body().iter().find_map(|item| match item {
831 IrTestItem::Cleanup { block, .. } => Some(block.clone()),
832 _ => None,
833 });
834 let body_result: Result<(), Failure> = async {
835 for item in test.body() {
836 match item {
837 IrTestItem::Comment { .. } | IrTestItem::DocString { .. } => continue,
838 IrTestItem::Start { .. } => continue,
839 IrTestItem::Let { .. } => continue,
840 IrTestItem::Shell { block, .. } => {
841 if let Some(qualifier) = block.qualifier() {
842 let alias = qualifier.name();
844 let shell_name = block.name().name();
845 let display = format!("{alias}.{shell_name}");
846 rt_ctx.events.emit_shell_switch(&display);
847 let dep = effect_shells.get(alias).ok_or_else(|| Failure::Runtime {
848 message: format!("unknown effect alias `{alias}`"),
849 span: None,
850 shell: None,
851 })?;
852 let vm_arc = dep.get(shell_name).ok_or_else(|| Failure::Runtime {
853 message: format!(
854 "effect alias `{alias}` does not expose shell `{shell_name}`"
855 ),
856 span: None,
857 shell: None,
858 })?;
859 let mut vm = vm_arc.lock().await;
860 rt_ctx.events.emit_shell_switch(vm.current_name());
861 vm.exec_stmts(block.body()).await?;
862 } else {
863 let name = block.name().name().to_string();
865 rt_ctx.events.emit_shell_switch(&name);
866 if !shells.contains_key(&name) {
867 let shell_state = ShellState::new(name.clone(), None);
868 let ctx = ExecutionContext::new(
869 scope.clone(),
870 shell_state,
871 rt_ctx.shell.default_timeout.clone(),
872 rt_ctx.env.clone(),
873 );
874 let vm = Vm::new(name.clone(), ctx, rt_ctx).await?;
875 shells.insert(name.clone(), Arc::new(TokioMutex::new(vm)));
876 }
877 let vm_arc = shells.get(&name).expect("shell just inserted above");
878 let mut vm = vm_arc.lock().await;
879 let display_name = vm.current_name().to_string();
880 rt_ctx.events.emit_shell_switch(&display_name);
881 vm.exec_stmts(block.body()).await?;
882 }
883 }
884 IrTestItem::Cleanup { .. } => continue,
885 }
886 }
887 Ok(())
888 }
889 .await;
890
891 let mut seen = HashSet::new();
893 for (_, vm_arc) in shells.drain() {
894 let ptr = Arc::as_ptr(&vm_arc) as usize;
895 if seen.insert(ptr) {
896 vm_arc.lock().await.shutdown().await;
897 }
898 }
899
900 if let Some(cleanup) = &cleanup_block {
902 rt_ctx.events.emit_cleanup("__cleanup");
903 let shell_state = ShellState::new("__cleanup".to_string(), None);
904 let ctx = ExecutionContext::new(
905 scope.clone(),
906 shell_state,
907 rt_ctx.shell.default_timeout.clone(),
908 rt_ctx.env.clone(),
909 );
910 let mut cleanup_rt_ctx = rt_ctx.clone();
912 cleanup_rt_ctx.cancel = CancellationToken::new();
913 match Vm::new("__cleanup".to_string(), ctx, &cleanup_rt_ctx).await {
914 Ok(mut cleanup_vm) => {
915 if let Err(failure) = cleanup_vm.exec_stmts(cleanup.body()).await {
916 rt_ctx
917 .events
918 .emit_warning("__cleanup", "test cleanup failed");
919 warnings.push(Warning::CleanupFailed {
920 source: CleanupSource::Test,
921 failure,
922 });
923 }
924 cleanup_vm.shutdown().await;
925 }
926 Err(e) => {
927 rt_ctx
928 .events
929 .emit_warning("__cleanup", "failed to spawn cleanup shell");
930 warnings.push(Warning::CleanupFailed {
931 source: CleanupSource::Test,
932 failure: Failure::Runtime {
933 message: format!("failed to spawn cleanup shell: {e:?}"),
934 span: None,
935 shell: None,
936 },
937 });
938 }
939 }
940 }
941
942 body_result
943}
944
945#[cfg(test)]
946mod tests {
947 use super::*;
948
949 #[test]
950 fn slugify_simple() {
951 assert_eq!(slugify("Hello World"), "hello-world");
952 }
953
954 #[test]
955 fn slugify_special_chars() {
956 assert_eq!(slugify("test: foo/bar"), "test--foo-bar");
957 }
958
959 #[test]
960 fn slugify_alphanumeric() {
961 assert_eq!(slugify("abc-123_def"), "abc-123_def");
962 }
963
964 #[test]
965 fn slugify_leading_trailing_dashes() {
966 assert_eq!(slugify(" hello "), "hello");
967 }
968
969 #[test]
970 fn test_display_id_format() {
971 assert_eq!(
972 test_display_id("basic/test.relux", "my test"),
973 "basic/test.relux/my-test"
974 );
975 }
976}