Skip to main content

relux_runtime/
lib.rs

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// ─── RunStrategy ────────────────────────────────────────────
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum RunStrategy {
62    All,
63    FailFast,
64}
65
66// ─── ProgressMode ───────────────────────────────────────────
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum ProgressMode {
70    /// Detect TTY on stderr; use TUI if interactive, plain otherwise.
71    Auto,
72    /// Always use plain output (result lines only, no cursor control).
73    Plain,
74    /// Always use TUI (live progress, even if not a TTY).
75    Tui,
76}
77
78// ─── RunContext ─────────────────────────────────────────────
79
80pub 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
96// ─── Environment Helpers ────────────────────────────────────
97
98fn 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
132// ─── Log / Display Helpers ──────────────────────────────────
133
134fn 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
172/// Format cause/warning IDs as typed groups for test line output.
173///
174/// Example: ` [invalid: cheap-walrus-0042] [warning: worn-falcon-5678]`
175fn 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
210/// Format a test identifier for display: `path/slugified-name`.
211pub fn test_display_id(test_path: &str, test_name: &str) -> String {
212    format!("{}/{}", test_path, slugify(test_name))
213}
214
215/// Convert a test name to a filesystem-safe slug.
216pub 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
230// ─── Execute (Suite Entry Point) ────────────────────────────
231
232pub 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    // Spawn suite timeout watchdog
252    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    // Spawn TUI renderer
264    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    // Build shared test queue with original indices for deterministic ordering
279    let queue: Arc<std::sync::Mutex<VecDeque<(usize, &Plan)>>> = Arc::new(std::sync::Mutex::new(
280        suite.plans.iter().enumerate().collect(),
281    ));
282
283    // Spawn N workers as concurrent futures
284    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    // Await all workers concurrently
299    let worker_results = futures::future::join_all(worker_futs).await;
300
301    // Drop our copy of tui_tx so the renderer can finish
302    drop(tui_tx);
303    tui_handle.await.ok();
304
305    // Abort suite timeout watchdog if it's still running
306    if let Some(handle) = watchdog {
307        handle.abort();
308    }
309
310    // Merge and sort results by original plan index
311    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                // Flaky retry loop
384                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                // Send finish event and get progress string back
422                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    // Drain remaining queue as skipped
503    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/// Run a single test with cancellation support. On cancellation, cleanup
565/// still runs and a partial result is returned.
566#[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    // Create a child token for test-level timeout
583    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    // Spawn test-level timeout watchdog
595    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    // Abort test timeout watchdog if it's still running
621    if let Some(handle) = test_watchdog {
622        handle.abort();
623    }
624
625    // If the test was cancelled due to its own timeout (not the parent suite
626    // cancel), rewrite the Cancelled failure into a specific timeout message.
627    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
641// ─── Run Test ───────────────────────────────────────────────
642
643/// Create a ProgressTx that forwards events to the TUI renderer tagged with slot.
644fn 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    // Create a per-test EffectManager
715    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    // Release effects (always runs, even after cancellation)
724    let effect_warnings = test_manager.cleanup_all().await;
725    warnings.extend(effect_warnings);
726
727    // Collect events (consumes the EventSink, releasing its ProgressTx)
728    let log_events = events.take();
729
730    // Drop all remaining ProgressTx holders so the forwarder task can finish
731    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
762// ─── Run Test Body ──────────────────────────────────────────
763
764async 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    // 1. Create test scope
772    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    // 2. Evaluate test-level lets into scope (parser enforces lets come before starts)
779    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    // 3. Instantiate effects (overlays can now see test-level vars)
797    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    // 4. Build shell map from exposed effect shells
804    //    Each start returns a map of exposed shells. We store them
805    //    keyed by (alias, shell_name) for dot-access resolution.
806    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            // For backwards compat: if effect exposes exactly one shell,
818            // also insert it under the alias name directly
819            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    // 5. Walk IrTestItems (lets already evaluated, starts already instantiated)
830    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                        // Qualified shell block: alias.shell { ... }
843                        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                        // Unqualified shell block: shell name { ... }
864                        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    // 6. Terminate all test shells (deduplicated by Arc pointer)
892    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    // 7. Run test cleanup (fresh shell, best-effort)
901    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        // Cleanup uses its own uncancellable token
911        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}