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;
10
11use tokio::sync::Mutex as TokioMutex;
12
13use crate::cancel::CancelReason;
14use crate::cancel::CancelToken;
15use crate::effect::CleanupSource;
16use crate::effect::EffectManager;
17use crate::effect::Warning;
18use crate::effect::registry::EffectRegistry;
19use crate::effect::registry::ShellInstanceKey;
20use crate::observe::structured::EnvInfo;
21use crate::observe::structured::EnvValue;
22use crate::observe::structured::MarkerEvalDecision;
23use crate::observe::structured::MarkerEvalDetail;
24use crate::observe::structured::MarkerEvalKind;
25use crate::observe::structured::MarkerEvalModifier;
26use crate::observe::structured::MatchContext;
27use crate::observe::structured::SpanId;
28use crate::observe::structured::SpanKind;
29use crate::observe::structured::StructuredLogBuilder;
30use crate::observe::structured::TestInfo;
31use crate::observe::structured::TestOutcome;
32use crate::observe::structured::log_sink::LogSink;
33use crate::report::result::ExecError;
34use crate::report::result::Failure;
35use crate::report::result::FailureContext;
36use crate::report::result::Outcome;
37use crate::report::result::TestResult;
38use crate::scan::scan_artifacts;
39use crate::vm::Vm;
40use crate::vm::context::ExecutionContext;
41use crate::vm::context::Scope;
42use crate::vm::context::ShellState;
43use relux_core::diagnostics::Cause;
44use relux_core::diagnostics::CauseId;
45use relux_core::diagnostics::CauseTable;
46use relux_core::diagnostics::WarningId;
47use relux_core::pure::Env;
48use relux_core::pure::LayeredEnv;
49use relux_core::pure::LayeredEnvSource;
50use relux_core::pure::VarScope;
51use relux_core::table::SourceTable;
52use relux_ir::IrNode;
53use relux_ir::IrTest;
54use relux_ir::IrTestItem;
55use relux_ir::IrTimeout;
56use relux_ir::Plan;
57use relux_ir::Suite;
58
59pub mod cancel;
60pub mod effect;
61pub(crate) mod marker_walk;
62pub mod observe;
63pub(crate) mod preamble;
64pub mod report;
65pub mod runtime_context;
66pub(crate) mod scan;
67pub mod viewer;
68pub mod vm;
69
70pub use runtime_context::RuntimeContext;
71pub use runtime_context::ShellConfig;
72
73use relux_core::config;
74
75// --- RunStrategy -----------------------------------------
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum RunStrategy {
79    All,
80    FailFast,
81}
82
83// --- ProgressMode ----------------------------------------
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum ProgressMode {
87    /// Detect TTY on stderr; use TUI if interactive, plain otherwise.
88    Auto,
89    /// Always use plain output (result lines only, no cursor control).
90    Plain,
91    /// Always use TUI (live progress, even if not a TTY).
92    Tui,
93}
94
95// --- RunContext ------------------------------------------
96
97pub struct RunContext {
98    pub run_id: String,
99    pub run_dir: PathBuf,
100    pub artifacts_dir: PathBuf,
101    pub project_root: PathBuf,
102    pub shell_command: String,
103    pub shell_prompt: String,
104    pub default_timeout: IrTimeout,
105    pub test_timeout: IrTimeout,
106    pub suite_timeout: Duration,
107    pub strategy: RunStrategy,
108    pub flaky: relux_core::config::FlakyConfig,
109    pub jobs: usize,
110    pub progress: ProgressMode,
111}
112
113// --- Environment Helpers ---------------------------------
114
115/// The `__RELUX_*` run-level internals, as a standalone `Env`. Layered above
116/// each test's `.env` stack (as `ReluxInternal`) so no `.env` can shadow them.
117fn build_relux_internal(ctx: &RunContext) -> Env {
118    let mut env = Env::new();
119    env.insert("__RELUX_RUN_ID".into(), ctx.run_id.clone());
120    env.insert(
121        "__RELUX_RUN_ARTIFACTS".into(),
122        ctx.artifacts_dir.display().to_string(),
123    );
124    env.insert("__RELUX_SHELL_PROMPT".into(), ctx.shell_prompt.clone());
125    env.insert(
126        "__RELUX_SUITE_ROOT".into(),
127        ctx.project_root.display().to_string(),
128    );
129    if let Ok(exe) = std::env::current_exe() {
130        env.insert("__RELUX".into(), exe.display().to_string());
131    }
132    env
133}
134
135/// Compose a test's runtime env from its pre-resolved `.env` stack. The
136/// run-level `run_internal` is augmented with this test's own `__RELUX_TEST_*`
137/// values, then layered over `dotenv_stack` (Base -> DotEnv...) as a single
138/// `ReluxInternal` overlay. Precedence high->low: ReluxInternal -> DotEnv ->
139/// Base.
140///
141/// There is no separate `Test` env layer: the `__RELUX_TEST_*` values are Relux
142/// internals that merely happen to be per-test, so they share the internals'
143/// provenance and appear in the bootstrap snapshot (which any host-inherited
144/// copy of the same reserved key is shadowed by).
145fn assemble_test_env(
146    dotenv_stack: Arc<LayeredEnv>,
147    run_internal: &Env,
148    test_root: Option<PathBuf>,
149    artifacts_dir: &Path,
150    test_id: &str,
151) -> Arc<LayeredEnv> {
152    let mut internal = run_internal.clone();
153    if let Some(dir) = test_root {
154        internal.insert("__RELUX_TEST_ROOT".into(), dir.display().to_string());
155    }
156    internal.insert(
157        "__RELUX_TEST_ARTIFACTS".into(),
158        artifacts_dir.display().to_string(),
159    );
160    internal.insert("__RELUX_TEST_ID".into(), test_id.to_string());
161    Arc::new(LayeredEnv::child_with_source(
162        dotenv_stack,
163        internal,
164        LayeredEnvSource::ReluxInternal,
165    ))
166}
167
168// --- Log / Display Helpers -------------------------------
169
170fn test_log_dir(
171    run_dir: &Path,
172    source_table: &SourceTable,
173    meta: &relux_ir::TestMeta,
174    project_root: &Path,
175) -> PathBuf {
176    let file_id = meta.span().file();
177    let source_path = source_table
178        .get(file_id)
179        .map(|sf| sf.path.clone())
180        .unwrap_or_else(|| file_id.path().clone());
181    let relative = source_path
182        .strip_prefix(project_root)
183        .unwrap_or(&source_path);
184    run_dir
185        .join("logs")
186        .join(relative.with_extension(""))
187        .join(slugify(meta.name()))
188}
189
190fn test_path_from_meta(
191    source_table: &SourceTable,
192    meta: &relux_ir::TestMeta,
193    project_root: &Path,
194) -> String {
195    let file_id = meta.span().file();
196    let source_path = source_table
197        .get(file_id)
198        .map(|sf| sf.path.clone())
199        .unwrap_or_else(|| file_id.path().clone());
200    let tests_dir = config::tests_dir(project_root);
201    source_path
202        .strip_prefix(&tests_dir)
203        .unwrap_or(&source_path)
204        .display()
205        .to_string()
206}
207
208/// Format cause/warning IDs as typed groups for test line output.
209///
210/// Example: ` [invalid: cheap-walrus-0042] [warning: worn-falcon-5678]`
211fn format_cause_tags(
212    causes: &[CauseId],
213    warnings: &[WarningId],
214    cause_table: &CauseTable,
215) -> String {
216    let mut parts = Vec::new();
217
218    let mut invalid_ids = Vec::new();
219    let mut skip_ids = Vec::new();
220    for id in causes {
221        match cause_table.get(id) {
222            Some(Cause::Invalid(_)) => invalid_ids.push(id.to_string()),
223            Some(Cause::Skip(_)) => skip_ids.push(id.to_string()),
224            None => {}
225        }
226    }
227
228    if !invalid_ids.is_empty() {
229        parts.push(format!("[invalid: {}]", invalid_ids.join(", ")));
230    }
231    if !skip_ids.is_empty() {
232        parts.push(format!("[skip: {}]", skip_ids.join(", ")));
233    }
234    if !warnings.is_empty() {
235        let ids: Vec<String> = warnings.iter().map(|w| w.to_string()).collect();
236        parts.push(format!("[warning: {}]", ids.join(", ")));
237    }
238
239    if parts.is_empty() {
240        String::new()
241    } else {
242        format!(" {}", parts.join(" "))
243    }
244}
245
246/// Format a test identifier for display: `path/slugified-name`.
247pub fn test_display_id(test_path: &str, test_name: &str) -> String {
248    format!("{}/{}", test_path, slugify(test_name))
249}
250
251/// Stable, per-test mnemonic id (e.g. `"broken-walrus-0042"`), derived from the
252/// test's suite-root-relative source path and its name. Deterministic: the same
253/// test always yields the same id -- so a `pure fn` reading `__RELUX_TEST_ID`
254/// stays stable across the multiple call sites within one test (and across
255/// reruns), while distinct tests get distinct ids, making it safe as a per-test
256/// resource key under `-j`. Reuses the diagnostics mnemonic formatter.
257fn test_mnemonic_id(rel_path: &Path, test_name: &str) -> String {
258    use std::hash::Hash;
259    use std::hash::Hasher;
260    let mut hasher = relux_core::hash::StableHasher::new();
261    rel_path.hash(&mut hasher);
262    test_name.hash(&mut hasher);
263    relux_core::diagnostics::format_mnemonic(hasher.finish())
264}
265
266/// Convert a test name to a filesystem-safe slug.
267pub fn slugify(name: &str) -> String {
268    name.chars()
269        .map(|c| {
270            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
271                c.to_ascii_lowercase()
272            } else {
273                '-'
274            }
275        })
276        .collect::<String>()
277        .trim_matches('-')
278        .to_string()
279}
280
281// --- Execute (Suite Entry Point) -------------------------
282
283pub struct ExecuteResult {
284    pub results: Vec<TestResult>,
285    pub wall_duration: Duration,
286}
287
288pub async fn execute(suite: &Suite, run_ctx: &RunContext) -> ExecuteResult {
289    let wall_start = Instant::now();
290    let relux_internal = build_relux_internal(run_ctx);
291    let jobs = run_ctx.jobs;
292
293    if jobs > 1 {
294        eprintln!("\nrunning {} tests ({jobs} workers)", suite.plans.len());
295    } else {
296        eprintln!("\nrunning {} tests", suite.plans.len());
297    }
298
299    let cancel = CancelToken::new();
300
301    // SIGINT handler: flip the run-wide cancel with `Sigint` reason. The
302    // task ends when ctrl_c fires once or when `execute()` returns
303    // (whichever comes first) - the spawned task is detached and dies with
304    // the runtime.
305    {
306        let sigint_cancel = cancel.clone();
307        tokio::spawn(async move {
308            if tokio::signal::ctrl_c().await.is_ok() {
309                sigint_cancel.cancel_with(CancelReason::Sigint);
310            }
311        });
312    }
313
314    // Spawn suite timeout watchdog
315    let watchdog = {
316        let timeout = run_ctx.suite_timeout;
317        let watchdog_cancel = cancel.clone();
318        Some(tokio::spawn(async move {
319            tokio::time::sleep(timeout).await;
320            watchdog_cancel.cancel_with(CancelReason::SuiteTimeout { duration: timeout });
321        }))
322    };
323
324    // Spawn TUI renderer
325    let is_tty = match run_ctx.progress {
326        ProgressMode::Auto => std::io::IsTerminal::is_terminal(&std::io::stderr()),
327        ProgressMode::Plain => false,
328        ProgressMode::Tui => true,
329    };
330    let (tui_tx, tui_rx) = observe::tui::channel();
331    let tui_handle = observe::tui::spawn_tui(
332        tui_rx,
333        jobs,
334        is_tty,
335        suite.tables.sources.clone(),
336        run_ctx.project_root.clone(),
337    );
338
339    // Build shared test queue with original indices for deterministic ordering
340    let queue: Arc<std::sync::Mutex<VecDeque<(usize, &Plan)>>> = Arc::new(std::sync::Mutex::new(
341        suite.plans.iter().enumerate().collect(),
342    ));
343
344    // Spawn N workers as concurrent futures
345    let mut worker_futs = Vec::with_capacity(jobs);
346    for slot in 0..jobs {
347        let ctx = WorkerContext {
348            queue: queue.clone(),
349            cancel: cancel.clone(),
350            suite,
351            run_ctx,
352            relux_internal: relux_internal.clone(),
353            tui_tx: tui_tx.clone(),
354        };
355        worker_futs.push(run_worker(ctx, slot));
356    }
357
358    // Await all workers concurrently
359    let worker_results = futures::future::join_all(worker_futs).await;
360
361    // Drop our copy of tui_tx so the renderer can finish
362    drop(tui_tx);
363    tui_handle.await.ok();
364
365    // Abort suite timeout watchdog if it's still running
366    if let Some(handle) = watchdog {
367        handle.abort();
368    }
369
370    // Merge and sort results by original plan index
371    let mut all_results: Vec<(usize, TestResult)> = worker_results.into_iter().flatten().collect();
372    all_results.sort_by_key(|(idx, _)| *idx);
373    ExecuteResult {
374        results: all_results.into_iter().map(|(_, r)| r).collect(),
375        wall_duration: wall_start.elapsed(),
376    }
377}
378
379struct WorkerContext<'a> {
380    queue: Arc<std::sync::Mutex<VecDeque<(usize, &'a Plan)>>>,
381    cancel: CancelToken,
382    suite: &'a Suite,
383    run_ctx: &'a RunContext,
384    relux_internal: Env,
385    tui_tx: observe::tui::TuiTx,
386}
387
388async fn run_worker(ctx: WorkerContext<'_>, slot: usize) -> Vec<(usize, TestResult)> {
389    let mut results = Vec::new();
390    let mut generation: u64 = 0;
391    loop {
392        if ctx.cancel.is_cancelled() {
393            break;
394        }
395
396        let entry = {
397            let mut q = ctx.queue.lock().expect("queue lock poisoned");
398            q.pop_front()
399        };
400        let Some((plan_idx, plan)) = entry else {
401            break;
402        };
403
404        let test_path = test_path_from_meta(
405            &ctx.suite.tables.sources,
406            plan.meta(),
407            &ctx.run_ctx.project_root,
408        );
409
410        let result = match plan {
411            Plan::Runnable {
412                meta,
413                test,
414                warnings: plan_warnings,
415                env: dotenv_stack,
416            } => {
417                let tags = format_cause_tags(&[], plan_warnings, &ctx.suite.causes);
418                let display_id = test_display_id(&test_path, meta.name());
419                generation += 1;
420                let _ = ctx.tui_tx.send(observe::tui::TuiEvent::TestStarted {
421                    slot,
422                    test_id: display_id.clone(),
423                    generation,
424                });
425
426                let mut result = run_test_cancellable(
427                    meta,
428                    test,
429                    ctx.run_ctx,
430                    dotenv_stack.clone(),
431                    &ctx.relux_internal,
432                    &test_path,
433                    &tags,
434                    &ctx.cancel,
435                    &ctx.suite.tables,
436                    &ctx.suite.causes,
437                    1.0,
438                    slot,
439                    &ctx.tui_tx,
440                    generation,
441                )
442                .await;
443
444                // Flaky retry loop
445                if meta.flaky()
446                    && result.outcome.is_retryable()
447                    && ctx.run_ctx.flaky.max_retries > 0
448                    && !ctx.cancel.is_cancelled()
449                {
450                    let mut retries = 0u32;
451                    for retry in 1..=ctx.run_ctx.flaky.max_retries {
452                        if ctx.cancel.is_cancelled() {
453                            break;
454                        }
455                        retries += 1;
456                        let flaky_m = ctx.run_ctx.flaky.timeout_multiplier.powi(retry as i32);
457                        let retry_test_path = format!("{test_path}-flaky-rerun-{retry}");
458                        result = run_test_cancellable(
459                            meta,
460                            test,
461                            ctx.run_ctx,
462                            dotenv_stack.clone(),
463                            &ctx.relux_internal,
464                            &retry_test_path,
465                            &tags,
466                            &ctx.cancel,
467                            &ctx.suite.tables,
468                            &ctx.suite.causes,
469                            flaky_m,
470                            slot,
471                            &ctx.tui_tx,
472                            generation,
473                        )
474                        .await;
475                        if !result.outcome.is_retryable() {
476                            break;
477                        }
478                    }
479                    result.flaky_retries = retries;
480                    result.test_path = test_path.clone();
481                }
482
483                // Send finish event and get progress string back
484                let (progress_oneshot_tx, progress_oneshot_rx) = tokio::sync::oneshot::channel();
485                let result_line = format_result_line(&display_id, &result, &tags);
486                let failure = match &result.outcome {
487                    Outcome::Fail(f) => Some(Box::new((f.clone(), result.log_dir.clone()))),
488                    _ => None,
489                };
490                let _ = ctx.tui_tx.send(observe::tui::TuiEvent::TestFinished {
491                    slot,
492                    result_line,
493                    failure,
494                    progress_tx: progress_oneshot_tx,
495                });
496                if let Ok(progress) = progress_oneshot_rx.await {
497                    result.progress = progress;
498                }
499
500                result
501            }
502            Plan::Skipped {
503                meta,
504                causes,
505                warnings,
506                env: dotenv_stack,
507            } => {
508                let tags = format_cause_tags(causes, warnings, &ctx.suite.causes);
509                let display_id = test_display_id(&test_path, meta.name());
510                let result_line = format!(
511                    "test {display_id}: {}{tags}",
512                    colored::Colorize::yellow("skipped")
513                );
514                let _ = ctx
515                    .tui_tx
516                    .send(observe::tui::TuiEvent::Skipped { result_line });
517                // The plan's own `.env` stack was already resolved before the
518                // decision pass marked it Skipped - use it for the bootstrap
519                // snapshot (Base -> DotEnv... -> ReluxInternal) instead of the
520                // suite's base env.
521                let bootstrap_env = Arc::new(LayeredEnv::child_with_source(
522                    dotenv_stack.clone(),
523                    ctx.relux_internal.clone(),
524                    LayeredEnvSource::ReluxInternal,
525                ));
526                let stack = relux_ir::StackHash(dotenv_stack.stack_hash());
527                log_skipped_test(
528                    meta,
529                    causes,
530                    &ctx.suite.causes,
531                    ctx.run_ctx,
532                    bootstrap_env,
533                    &test_path,
534                    &ctx.suite.tables,
535                    stack,
536                )
537                .await
538            }
539            Plan::Invalid {
540                meta,
541                causes,
542                warnings,
543            } => {
544                let tags = format_cause_tags(causes, warnings, &ctx.suite.causes);
545                let display_id = test_display_id(&test_path, meta.name());
546                let result_line = format!(
547                    "test {display_id}: {}{tags}",
548                    colored::Colorize::red("INVALID")
549                );
550                let _ = ctx
551                    .tui_tx
552                    .send(observe::tui::TuiEvent::Skipped { result_line });
553                TestResult {
554                    test_name: meta.name().to_string(),
555                    test_path: test_path.clone(),
556                    outcome: Outcome::Invalid("invalid".to_string()),
557                    duration: Duration::ZERO,
558                    progress: String::new(),
559                    log_dir: None,
560                    warnings: Vec::new(),
561                    flaky_retries: 0,
562                }
563            }
564        };
565
566        let failed = matches!(result.outcome, Outcome::Fail(_));
567        let trigger_test = result.test_name.clone();
568        results.push((plan_idx, result));
569
570        if failed && ctx.run_ctx.strategy == RunStrategy::FailFast {
571            ctx.cancel
572                .cancel_with(CancelReason::FailFast { trigger_test });
573            break;
574        }
575    }
576
577    // Drain remaining queue as skipped
578    let skip_reason = if ctx.cancel.is_cancelled() {
579        match ctx.cancel.reason() {
580            Some(CancelReason::FailFast { .. }) => "fail fast",
581            Some(CancelReason::SuiteTimeout { .. }) => "suite timeout",
582            Some(CancelReason::TestTimeout { .. }) => "test timeout",
583            Some(CancelReason::Sigint) => "sigint",
584            None => "cancelled",
585        }
586    } else {
587        return results;
588    };
589
590    let remaining: Vec<(usize, &Plan)> = {
591        let mut q = ctx.queue.lock().expect("queue lock poisoned");
592        q.drain(..).collect()
593    };
594    for (plan_idx, plan) in remaining {
595        let test_path = test_path_from_meta(
596            &ctx.suite.tables.sources,
597            plan.meta(),
598            &ctx.run_ctx.project_root,
599        );
600        let display_id = test_display_id(&test_path, plan.meta().name());
601        let result_line = format!(
602            "test {display_id}: {}",
603            colored::Colorize::yellow("skipped")
604        );
605        let _ = ctx
606            .tui_tx
607            .send(observe::tui::TuiEvent::Skipped { result_line });
608        results.push((
609            plan_idx,
610            TestResult {
611                test_name: plan.meta().name().to_string(),
612                test_path,
613                outcome: Outcome::Skipped(skip_reason.to_string()),
614                duration: Duration::ZERO,
615                progress: String::new(),
616                log_dir: None,
617                warnings: Vec::new(),
618                flaky_retries: 0,
619            },
620        ));
621    }
622
623    results
624}
625
626fn format_result_line(display_id: &str, result: &TestResult, cause_tags: &str) -> String {
627    use crate::report::result::format_duration;
628    use colored::Colorize;
629    let outcome_str = match &result.outcome {
630        Outcome::Pass => format!("{}", "ok".green()),
631        Outcome::Fail(_) => format!("{}", "FAILED".red()),
632        Outcome::Cancelled(c) => format!("{} ({})", "cancelled".yellow(), c.reason_tag()),
633        Outcome::Skipped(_) => format!("{}", "skipped".yellow()),
634        Outcome::Invalid(_) => format!("{}", "INVALID".red()),
635    };
636    format!(
637        "test {display_id}: {outcome_str} ({}){cause_tags}",
638        format_duration(result.duration)
639    )
640}
641
642/// Run a single test with cancellation support. On cancellation, cleanup
643/// still runs and a partial result is returned.
644#[allow(clippy::too_many_arguments)]
645async fn run_test_cancellable(
646    meta: &relux_ir::TestMeta,
647    test: &IrTest,
648    run_ctx: &RunContext,
649    dotenv_stack: Arc<LayeredEnv>,
650    relux_internal: &Env,
651    test_path: &str,
652    cause_tags: &str,
653    cancel: &CancelToken,
654    tables: &relux_ir::Tables,
655    _causes: &CauseTable,
656    flaky_timeout_multiplier: f64,
657    slot: usize,
658    tui_tx: &observe::tui::TuiTx,
659    generation: u64,
660) -> TestResult {
661    // Create a child token for test-level timeout
662    let test_cancel = cancel.child();
663
664    let effective_timeout = meta
665        .timeout()
666        .map(|t| t.adjusted_duration_with_flaky(flaky_timeout_multiplier))
667        .unwrap_or_else(|| {
668            run_ctx
669                .test_timeout
670                .adjusted_duration_with_flaky(flaky_timeout_multiplier)
671        });
672
673    // Spawn test-level timeout watchdog
674    let test_watchdog = Some({
675        let timeout = effective_timeout;
676        let timeout_cancel = test_cancel.clone();
677        tokio::spawn(async move {
678            tokio::time::sleep(timeout).await;
679            timeout_cancel.cancel_with(CancelReason::TestTimeout { duration: timeout });
680        })
681    });
682
683    let result = run_test(
684        meta,
685        test,
686        run_ctx,
687        dotenv_stack,
688        relux_internal,
689        test_path,
690        cause_tags,
691        &test_cancel,
692        tables,
693        flaky_timeout_multiplier,
694        slot,
695        tui_tx,
696        generation,
697    )
698    .await;
699
700    // Abort test timeout watchdog if it's still running
701    if let Some(handle) = test_watchdog {
702        handle.abort();
703    }
704
705    result
706}
707
708// --- Run Test --------------------------------------------
709
710/// Create a ProgressTx that forwards events to the TUI renderer tagged with slot.
711fn make_tui_progress_tx(
712    tui_tx: &observe::tui::TuiTx,
713    slot: usize,
714    generation: u64,
715) -> observe::progress::ProgressTx {
716    let (tx, mut rx) = observe::progress::channel();
717    let tui_tx = tui_tx.clone();
718    tokio::spawn(async move {
719        while let Some(event) = rx.recv().await {
720            let _ = tui_tx.send(observe::tui::TuiEvent::Progress {
721                slot,
722                event,
723                generation,
724            });
725        }
726    });
727    tx
728}
729
730#[allow(clippy::too_many_arguments)]
731async fn run_test(
732    meta: &relux_ir::TestMeta,
733    test: &IrTest,
734    run_ctx: &RunContext,
735    dotenv_stack: Arc<LayeredEnv>,
736    relux_internal: &Env,
737    test_path: &str,
738    _cause_tags: &str,
739    cancel: &CancelToken,
740    tables: &relux_ir::Tables,
741    flaky_timeout_multiplier: f64,
742    slot: usize,
743    tui_tx: &observe::tui::TuiTx,
744    generation: u64,
745) -> TestResult {
746    let test_start = Instant::now();
747    let source_table = &tables.sources;
748    let log_dir = test_log_dir(&run_ctx.run_dir, source_table, meta, &run_ctx.project_root);
749    let _ = std::fs::create_dir_all(&log_dir);
750
751    let progress_tx = make_tui_progress_tx(tui_tx, slot, generation);
752
753    let file_id = meta.span().file();
754    let source_file = source_table
755        .get(file_id)
756        .map(|sf| sf.path.clone())
757        .unwrap_or_else(|| file_id.path().clone());
758    let artifacts_dir = log_dir.join("artifacts");
759    let _ = std::fs::create_dir_all(&artifacts_dir);
760
761    // The test's env: Base -> DotEnv... -> ReluxInternal (the run internals plus
762    // this test's own `__RELUX_TEST_*` values). No separate `Test` layer, so
763    // this doubles as the bootstrap snapshot dumped for the artifact below.
764    let test_root = source_file.parent().map(Path::to_path_buf);
765    let rel_path = source_file
766        .strip_prefix(&run_ctx.project_root)
767        .unwrap_or(source_file.as_path());
768    let test_id = test_mnemonic_id(rel_path, meta.name());
769    let test_env = assemble_test_env(
770        dotenv_stack.clone(),
771        relux_internal,
772        test_root,
773        &artifacts_dir,
774        &test_id,
775    );
776    let mut warnings = Vec::new();
777
778    let shell_config = ShellConfig {
779        command: Arc::from(run_ctx.shell_command.as_str()),
780        prompt: Arc::from(run_ctx.shell_prompt.as_str()),
781        default_timeout: run_ctx.default_timeout.clone(),
782    };
783
784    let log = StructuredLogBuilder::new(
785        progress_tx.clone(),
786        test_start,
787        tables.sources.clone(),
788        Arc::from(run_ctx.project_root.as_path()),
789    );
790
791    // Replay marker evaluations under a synthetic `markers` root span.
792    // Always opened (the viewer filters out empty markers roots).
793    // The runtime walks the test's IR transitively (Relux is
794    // deterministic: every reachable fn-call and effect-start is
795    // guaranteed to execute) and concatenates marker recordings from
796    // the test, every reachable effect, and every reachable function.
797    // All recordings become flat `marker-eval` children of the markers
798    // root - no nesting under fn-call or effect-setup, since markers
799    // run before any test execution.
800    //
801    // This re-walks against the test's own `dotenv_stack`, the exact env the
802    // resolver's decision pass already decided every reachable def against,
803    // so every lookup is a cache hit against `tables.marker_decisions` - the
804    // `Err` arm (a decision-time failure) is unreachable in practice for a
805    // `Runnable` plan, but is handled gracefully rather than panicking.
806    let agg = crate::marker_walk::collect_test_decision(test, meta, tables, &dotenv_stack)
807        .unwrap_or_else(|_| relux_ir::reachability::AggregatedDecision {
808            skip: None,
809            flaky: false,
810            recordings: Vec::new(),
811        });
812    let _ = replay_markers(&log, &agg.recordings);
813
814    // Open the root span for this test. Every emission inside the test body
815    // (effect setup, shell block, fn call, cleanup block) is parented on this.
816    let test_span = log.open_span(
817        SpanKind::Test {
818            name: meta.name().to_string(),
819        },
820        None,
821        Some(meta.span()),
822    );
823    let test_span_id = test_span.id();
824
825    let rt_ctx = RuntimeContext {
826        log: log.clone(),
827        shell: shell_config,
828        log_dir: Arc::from(log_dir.as_path()),
829        tables: tables.clone(),
830        env: test_env.clone(),
831        cancel: cancel.clone(),
832        test_start,
833        flaky_timeout_multiplier,
834    };
835
836    // Create a per-test EffectManager
837    let test_manager = EffectManager::new(
838        Arc::new(EffectRegistry::new()),
839        rt_ctx.clone(),
840        test_span_id,
841    );
842
843    let outcome = run_test_body(
844        meta,
845        test,
846        &test_manager,
847        &mut warnings,
848        &rt_ctx,
849        test_span_id,
850    )
851    .await;
852
853    if outcome.is_err() {
854        log.emit_failure_progress();
855    }
856
857    // Release effects (always runs, even after cancellation)
858    let effect_warnings = test_manager.cleanup_all().await;
859    warnings.extend(effect_warnings);
860
861    // Drop all remaining ProgressTx holders so the forwarder task can finish.
862    drop(test_manager);
863    drop(rt_ctx);
864    drop(progress_tx);
865    let duration = test_start.elapsed();
866
867    test_span.close();
868
869    // Snapshot the test's env (Base -> DotEnv... -> ReluxInternal, the latter
870    // carrying this test's `__RELUX_TEST_*` internals) for the artifact. Sorted
871    // for deterministic JSON output across runs.
872    let mut bootstrap: Vec<EnvValue> = test_env
873        .iter_with_source()
874        .map(|(k, v, src)| EnvValue {
875            key: k.to_string(),
876            value: v.to_string(),
877            source: src.into(),
878        })
879        .collect();
880    bootstrap.sort_by(|a, b| a.key.cmp(&b.key));
881
882    // Build the structured log. The verdict is a tagged enum:
883    // Pass / Fail(FailureRecord) / Cancelled(CancellationRecord) / Skip.
884    // Runnable tests here produce Pass, Fail, or Cancelled - Skip is
885    // emitted by `log_skipped_test`.
886    let test_outcome = match &outcome {
887        Ok(()) => TestOutcome::Pass,
888        Err(ExecError::Failure(f)) => TestOutcome::Fail(log.failure_record(f)),
889        Err(ExecError::Cancelled(c)) => TestOutcome::Cancelled(log.cancellation_record(c)),
890    };
891    let artifacts = scan_artifacts(&artifacts_dir);
892    let structured = log.build(
893        TestInfo {
894            name: meta.name().to_string(),
895            path: test_path.to_string(),
896            duration_ms: duration.as_millis() as u64,
897        },
898        EnvInfo { bootstrap },
899        test_outcome,
900        artifacts,
901    );
902
903    let events_json_path = log_dir.join("events.json");
904    match serde_json::to_vec_pretty(&structured) {
905        Ok(bytes) => {
906            if let Err(e) = std::fs::write(&events_json_path, &bytes) {
907                eprintln!(
908                    "warning: failed to write {}: {}",
909                    events_json_path.display(),
910                    e
911                );
912            }
913        }
914        Err(e) => {
915            eprintln!(
916                "warning: failed to serialize structured log for {}: {}",
917                events_json_path.display(),
918                e
919            );
920        }
921    }
922
923    if let Err(e) = crate::report::event_html::write(&log_dir, &structured) {
924        eprintln!(
925            "warning: failed to write {}: {}",
926            log_dir.join("event.html").display(),
927            e
928        );
929    }
930
931    match outcome {
932        Ok(()) => TestResult {
933            test_name: meta.name().to_string(),
934            test_path: test_path.to_string(),
935            outcome: Outcome::Pass,
936            duration,
937            progress: String::new(),
938            log_dir: Some(log_dir),
939            warnings,
940            flaky_retries: 0,
941        },
942        Err(ExecError::Failure(f)) => TestResult {
943            test_name: meta.name().to_string(),
944            test_path: test_path.to_string(),
945            outcome: Outcome::Fail(f),
946            duration,
947            progress: String::new(),
948            log_dir: Some(log_dir),
949            warnings,
950            flaky_retries: 0,
951        },
952        Err(ExecError::Cancelled(c)) => TestResult {
953            test_name: meta.name().to_string(),
954            test_path: test_path.to_string(),
955            outcome: Outcome::Cancelled(c),
956            duration,
957            progress: String::new(),
958            log_dir: Some(log_dir),
959            warnings,
960            flaky_retries: 0,
961        },
962    }
963}
964
965// --- Run Test Body ---------------------------------------
966
967async fn run_test_body(
968    meta: &relux_ir::TestMeta,
969    test: &IrTest,
970    manager: &EffectManager,
971    warnings: &mut Vec<Warning>,
972    rt_ctx: &RuntimeContext,
973    test_span: SpanId,
974) -> Result<(), ExecError> {
975    // 1. Create test scope
976    let scope = Scope::Test {
977        name: meta.name().to_string(),
978        vars: Arc::new(TokioMutex::new(VarScope::new())),
979        timeout: meta.timeout().cloned(),
980    };
981
982    // 2. Evaluate test-level preamble (lets + pure-matches) into scope.
983    //    The parser enforces these come before starts. `body_captures`
984    //    is hoisted across the whole preamble so a regex pure-match's
985    //    `$n` captures are visible to later lets and pure-matches, and
986    //    ultimately to effect overlays (see the instantiate call below).
987    let mut body_captures: HashMap<String, String> = HashMap::new();
988    let tc = MatchContext::TestPreamble {
989        name: test.name().to_string(),
990    };
991    for item in test.body() {
992        match item {
993            IrTestItem::Let { stmt, span } => {
994                crate::preamble::eval_preamble_let(
995                    &rt_ctx.log,
996                    &rt_ctx.env,
997                    &rt_ctx.tables.pure_fns,
998                    &scope,
999                    test_span,
1000                    &tc,
1001                    stmt,
1002                    span,
1003                    &body_captures,
1004                )
1005                .await?;
1006            }
1007            IrTestItem::PureMatch {
1008                lhs,
1009                pattern,
1010                is_regex,
1011                span,
1012            } => {
1013                crate::preamble::eval_preamble_pure_match(
1014                    &rt_ctx.log,
1015                    &rt_ctx.env,
1016                    &rt_ctx.tables.pure_fns,
1017                    &scope,
1018                    test_span,
1019                    &tc,
1020                    lhs,
1021                    pattern,
1022                    *is_regex,
1023                    span,
1024                    &mut body_captures,
1025                )
1026                .await?;
1027            }
1028            // Non-preamble items run in the body walk below.
1029            IrTestItem::Comment { .. }
1030            | IrTestItem::DocString { .. }
1031            | IrTestItem::Start { .. }
1032            | IrTestItem::Shell { .. }
1033            | IrTestItem::Cleanup { .. } => {}
1034        }
1035    }
1036
1037    // 3. Instantiate effects (overlays can now see test-level vars + captures)
1038    let caller_vars = scope.vars().lock().await.clone();
1039    let root_env = rt_ctx.env.clone();
1040    let exported = manager
1041        .instantiate_top_level(test.starts(), &caller_vars, &root_env, &body_captures)
1042        .await?;
1043
1044    // 4. Build shell map from exposed effect shells
1045    //    Each start returns a map of exposed shells. We store them
1046    //    keyed by (alias, shell_name) for dot-access resolution.
1047    let mut shells: HashMap<String, Arc<TokioMutex<Vm>>> = HashMap::new();
1048    let mut effect_shells: HashMap<String, HashMap<String, Arc<TokioMutex<Vm>>>> = HashMap::new();
1049    let mut effect_vars: HashMap<String, HashMap<String, String>> = HashMap::new();
1050    let mut reset_seen = HashSet::new();
1051    for (start, exported) in test.starts().iter().zip(exported) {
1052        let source_effect_name = start.effect().name.0.clone();
1053        let alias = start.alias().map(str::to_string);
1054        for (shell_local_name, vm_arc) in exported.shells.iter() {
1055            let ptr = Arc::as_ptr(vm_arc) as usize;
1056            if reset_seen.insert(ptr) {
1057                vm_arc.lock().await.reset_for_export(
1058                    scope.clone(),
1059                    alias.clone(),
1060                    Some(source_effect_name.clone()),
1061                    shell_local_name.clone(),
1062                );
1063            }
1064        }
1065        if let Some(alias) = start.alias() {
1066            // For backwards compat: if effect exposes exactly one shell,
1067            // also insert it under the alias name directly
1068            if exported.shells.len() == 1 {
1069                let vm_arc = exported.shells.values().next().unwrap().clone();
1070                shells.insert(alias.to_string(), vm_arc);
1071            }
1072            effect_shells.insert(alias.to_string(), exported.shells);
1073            if !exported.vars.is_empty() {
1074                effect_vars.insert(alias.to_string(), exported.vars);
1075            }
1076        }
1077    }
1078
1079    // Inject effect-exposed variables into the test scope so they're
1080    // accessible via ${Alias.var_name} in shell blocks.
1081    {
1082        let mut vars = scope.vars().lock().await;
1083        for (alias, var_map) in &effect_vars {
1084            for (var_name, value) in var_map {
1085                vars.insert(format!("{alias}.{var_name}"), value.clone());
1086            }
1087        }
1088    }
1089
1090    // 5. Walk IrTestItems (lets already evaluated, starts already instantiated)
1091    let cleanup_block = test.body().iter().find_map(|item| match item {
1092        IrTestItem::Cleanup { block, span } => Some((block.clone(), span.clone())),
1093        _ => None,
1094    });
1095    let body_result: Result<(), ExecError> = async {
1096        for item in test.body() {
1097            match item {
1098                IrTestItem::Comment { .. } | IrTestItem::DocString { .. } => continue,
1099                IrTestItem::Start { .. } => continue,
1100                IrTestItem::Let { .. } => continue,
1101                IrTestItem::PureMatch { .. } => continue,
1102                IrTestItem::Shell { block, .. } => {
1103                    let switch_span = block.name().span();
1104                    if let Some(qualifier) = block.qualifier() {
1105                        // Qualified shell block: alias.shell { ... }
1106                        let alias = qualifier.name();
1107                        let shell_name = block.name().name();
1108                        let display = format!("{alias}.{shell_name}");
1109                        let block_span = rt_ctx.log.open_span(
1110                            SpanKind::ShellBlock {
1111                                shell: display.clone(),
1112                            },
1113                            Some(test_span),
1114                            Some(switch_span),
1115                        );
1116                        let block_span_id = block_span.id();
1117                        let dep = effect_shells.get(alias).ok_or_else(|| Failure::Runtime {
1118                            message: format!("unknown effect alias `{alias}`"),
1119                            span: qualifier.span().clone(),
1120                            shell: None,
1121                            context: FailureContext::pre_vm_with_span(block_span_id),
1122                        })?;
1123                        let vm_arc = dep.get(shell_name).ok_or_else(|| Failure::Runtime {
1124                            message: format!(
1125                                "effect alias `{alias}` does not expose shell `{shell_name}`"
1126                            ),
1127                            span: switch_span.clone(),
1128                            shell: None,
1129                            context: FailureContext::pre_vm_with_span(block_span_id),
1130                        })?;
1131                        let mut vm = vm_arc.lock().await;
1132                        let vm_name = vm.current_name();
1133                        let vm_marker = vm.shell_marker().to_string();
1134                        rt_ctx
1135                            .log
1136                            .emit_shell_switch(block_span_id, &vm_name, &vm_marker, None);
1137                        vm.set_block_span(block_span_id);
1138                        vm.exec_stmts(block.body()).await?;
1139                        // block_span drops here, closing the span.
1140                    } else {
1141                        // Unqualified shell block: shell name { ... }
1142                        let name = block.name().name().to_string();
1143                        let block_span = rt_ctx.log.open_span(
1144                            SpanKind::ShellBlock {
1145                                shell: name.clone(),
1146                            },
1147                            Some(test_span),
1148                            Some(switch_span),
1149                        );
1150                        let block_span_id = block_span.id();
1151                        if !shells.contains_key(&name) {
1152                            let shell_state = ShellState::new(name.clone());
1153                            let ctx = ExecutionContext::new(
1154                                scope.clone(),
1155                                shell_state,
1156                                rt_ctx.shell.default_timeout.clone(),
1157                                rt_ctx.env.clone(),
1158                                block_span_id,
1159                            );
1160                            let shell_key = ShellInstanceKey::Test {
1161                                shell_name: name.clone(),
1162                            };
1163                            let vm = Vm::new(
1164                                name.clone(),
1165                                shell_key.marker(),
1166                                ctx,
1167                                rt_ctx,
1168                                block.span().clone(),
1169                            )
1170                            .await?;
1171                            shells.insert(name.clone(), Arc::new(TokioMutex::new(vm)));
1172                        }
1173                        let vm_arc = shells.get(&name).expect("shell just inserted above");
1174                        let mut vm = vm_arc.lock().await;
1175                        let display_name = vm.current_name();
1176                        let display_marker = vm.shell_marker().to_string();
1177                        rt_ctx.log.emit_shell_switch(
1178                            block_span_id,
1179                            &display_name,
1180                            &display_marker,
1181                            None,
1182                        );
1183                        vm.set_block_span(block_span_id);
1184                        vm.exec_stmts(block.body()).await?;
1185                        // block_span drops here, closing the span.
1186                    }
1187                }
1188                IrTestItem::Cleanup { .. } => continue,
1189            }
1190        }
1191        Ok(())
1192    }
1193    .await;
1194
1195    // 6. Terminate all test shells (deduplicated by Arc pointer)
1196    let mut seen = HashSet::new();
1197    for (_, vm_arc) in shells.drain() {
1198        let ptr = Arc::as_ptr(&vm_arc) as usize;
1199        if seen.insert(ptr) {
1200            vm_arc.lock().await.shutdown().await;
1201        }
1202    }
1203
1204    // 7. Run test cleanup (fresh shell, best-effort)
1205    if let Some((cleanup, cleanup_span)) = &cleanup_block {
1206        let cleanup_block_span =
1207            rt_ctx
1208                .log
1209                .open_span(SpanKind::CleanupBlock, Some(test_span), Some(cleanup_span));
1210        let cleanup_block_span_id = cleanup_block_span.id();
1211        let shell_state = ShellState::new("__cleanup".to_string());
1212        let ctx = ExecutionContext::new(
1213            scope.clone(),
1214            shell_state,
1215            rt_ctx.shell.default_timeout.clone(),
1216            rt_ctx.env.clone(),
1217            cleanup_block_span_id,
1218        );
1219        // Cleanup uses its own uncancellable token
1220        let mut cleanup_rt_ctx = rt_ctx.clone();
1221        cleanup_rt_ctx.cancel = CancelToken::new();
1222        let cleanup_shell_key = ShellInstanceKey::Test {
1223            shell_name: "__cleanup".into(),
1224        };
1225        let cleanup_marker = cleanup_shell_key.marker();
1226        match Vm::new(
1227            "__cleanup".to_string(),
1228            cleanup_marker.clone(),
1229            ctx,
1230            &cleanup_rt_ctx,
1231            cleanup_span.clone(),
1232        )
1233        .await
1234        {
1235            Ok(mut cleanup_vm) => {
1236                if let Err(failure) = cleanup_vm.exec_stmts(cleanup.body()).await {
1237                    rt_ctx.log.emit_warning(
1238                        cleanup_block_span_id,
1239                        "__cleanup",
1240                        &cleanup_marker,
1241                        "test cleanup failed",
1242                        None,
1243                    );
1244                    warnings.push(Warning::CleanupFailed {
1245                        source: CleanupSource::Test,
1246                        failure,
1247                    });
1248                }
1249                cleanup_vm.shutdown().await;
1250            }
1251            Err(e) => {
1252                rt_ctx.log.emit_warning(
1253                    cleanup_block_span_id,
1254                    "__cleanup",
1255                    &cleanup_marker,
1256                    "failed to spawn cleanup shell",
1257                    None,
1258                );
1259                warnings.push(Warning::CleanupFailed {
1260                    source: CleanupSource::Test,
1261                    failure: Failure::Runtime {
1262                        message: format!("failed to spawn cleanup shell: {e:?}"),
1263                        span: cleanup_span.clone(),
1264                        shell: None,
1265                        context: FailureContext::pre_vm_with_span(cleanup_block_span_id),
1266                    }
1267                    .into(),
1268                });
1269            }
1270        }
1271        // cleanup_block_span drops here, closing the span.
1272    }
1273
1274    body_result
1275}
1276
1277// --- Marker replay ---------------------------------------
1278
1279pub(crate) fn marker_kind_to_runtime(k: relux_ir::marker::MarkerEvalKind) -> MarkerEvalKind {
1280    match k {
1281        relux_ir::marker::MarkerEvalKind::Skip => MarkerEvalKind::Skip,
1282        relux_ir::marker::MarkerEvalKind::Run => MarkerEvalKind::Run,
1283        relux_ir::marker::MarkerEvalKind::Flaky => MarkerEvalKind::Flaky,
1284    }
1285}
1286
1287pub(crate) fn marker_modifier_to_runtime(
1288    m: relux_ir::marker::MarkerEvalModifier,
1289) -> MarkerEvalModifier {
1290    match m {
1291        relux_ir::marker::MarkerEvalModifier::If => MarkerEvalModifier::If,
1292        relux_ir::marker::MarkerEvalModifier::Unless => MarkerEvalModifier::Unless,
1293    }
1294}
1295
1296pub(crate) fn marker_decision_to_runtime(
1297    d: relux_ir::marker::MarkerEvalDecision,
1298) -> MarkerEvalDecision {
1299    match d {
1300        relux_ir::marker::MarkerEvalDecision::Pass => MarkerEvalDecision::Pass,
1301        relux_ir::marker::MarkerEvalDecision::Mark => MarkerEvalDecision::Mark,
1302    }
1303}
1304
1305pub(crate) fn marker_detail_from_evaluation(
1306    e: &relux_core::diagnostics::SkipEvaluation,
1307) -> MarkerEvalDetail {
1308    use relux_core::diagnostics::SkipEvaluation::*;
1309    match e {
1310        Unconditional => MarkerEvalDetail::Unconditional,
1311        Bare { value, met } => MarkerEvalDetail::Bare {
1312            value: value.clone(),
1313            met: *met,
1314        },
1315        PureMatch {
1316            value,
1317            pattern,
1318            is_regex,
1319            met,
1320        } => MarkerEvalDetail::PureMatch {
1321            value: value.clone(),
1322            pattern: pattern.clone(),
1323            is_regex: *is_regex,
1324            met: *met,
1325        },
1326    }
1327}
1328
1329// --- log_skipped_test ------------------------------------
1330
1331/// Emit a markers-only `event.html` and `events.json` for a `Plan::Skipped`
1332/// test. Does NOT run the test (no PTY, no shells, no body); the structured
1333/// log contains only the synthetic `markers` root and its `marker-eval`
1334/// children. The triggering marker (`(Skip, Mark)` or `(Run, Pass)`) is
1335/// pointed to by `TestOutcome::Skip(SkipRecord { ... })`.
1336#[allow(clippy::too_many_arguments)]
1337async fn log_skipped_test(
1338    meta: &relux_ir::TestMeta,
1339    causes: &[relux_core::diagnostics::CauseId],
1340    suite_causes: &relux_core::diagnostics::CauseTable,
1341    run_ctx: &RunContext,
1342    bootstrap_env: Arc<LayeredEnv>,
1343    test_path: &str,
1344    tables: &relux_ir::Tables,
1345    stack: relux_ir::StackHash,
1346) -> TestResult {
1347    let test_start = Instant::now();
1348    let source_table = &tables.sources;
1349    let log_dir = test_log_dir(&run_ctx.run_dir, source_table, meta, &run_ctx.project_root);
1350    let _ = std::fs::create_dir_all(&log_dir);
1351
1352    let (progress_tx, _progress_rx) = crate::observe::progress::channel();
1353    let log = StructuredLogBuilder::new(
1354        progress_tx,
1355        test_start,
1356        source_table.clone(),
1357        Arc::from(run_ctx.project_root.as_path()),
1358    );
1359
1360    // Look up the originating definition's decision via the cause's
1361    // SkipReport.definition, keyed by the resolution stack. Works uniformly
1362    // for test-level skips (key: DefinitionRef::Test{..}) and for skips
1363    // propagated from fn/effect (key: DefinitionRef::Fn(..) / DefinitionRef::Effect(..)).
1364    let report = causes
1365        .iter()
1366        .find_map(|id| match suite_causes.get(id) {
1367            Some(relux_core::diagnostics::Cause::Skip(r)) => Some(r.clone()),
1368            _ => None,
1369        })
1370        .expect("Plan::Skipped must carry a Cause::Skip");
1371    let recordings_owned: Vec<relux_ir::marker::MarkerRecording> = tables
1372        .marker_decisions
1373        .get(&(report.definition.clone(), stack))
1374        .map(|d| d.recordings.clone())
1375        .unwrap_or_default();
1376    let recordings: &[relux_ir::marker::MarkerRecording] = &recordings_owned;
1377    let handles = replay_markers(&log, recordings);
1378
1379    // Locate the triggering marker. eval_marker returns early on trigger,
1380    // so the triggering recording is always the last one - but scan
1381    // defensively in case future changes alter recording order.
1382    let trigger_idx = recordings
1383        .iter()
1384        .position(|r| {
1385            use relux_ir::marker::MarkerEvalDecision;
1386            use relux_ir::marker::MarkerEvalKind;
1387            matches!(
1388                (r.kind, r.decision),
1389                (MarkerEvalKind::Skip, MarkerEvalDecision::Mark)
1390                    | (MarkerEvalKind::Run, MarkerEvalDecision::Pass)
1391            )
1392        })
1393        .expect("decision entry for skipped definition must contain a triggering marker");
1394    let handle = &handles[trigger_idx];
1395    let rec = &recordings[trigger_idx];
1396
1397    let outcome = TestOutcome::Skip(crate::observe::structured::SkipRecord {
1398        span: handle.span,
1399        event_seq: handle.event_seq,
1400        marker_kind: marker_kind_to_runtime(rec.kind),
1401        evaluation: marker_detail_from_evaluation(&rec.evaluation),
1402        location: log.resolve_location(&rec.marker_span),
1403    });
1404
1405    // Bootstrap env snapshot (sorted for deterministic JSON).
1406    let mut bootstrap: Vec<EnvValue> = bootstrap_env
1407        .iter_with_source()
1408        .map(|(k, v, src)| EnvValue {
1409            key: k.to_string(),
1410            value: v.to_string(),
1411            source: src.into(),
1412        })
1413        .collect();
1414    bootstrap.sort_by(|a, b| a.key.cmp(&b.key));
1415
1416    let structured = log.build(
1417        TestInfo {
1418            name: meta.name().to_string(),
1419            path: test_path.to_string(),
1420            duration_ms: 0,
1421        },
1422        EnvInfo { bootstrap },
1423        outcome,
1424        Vec::new(),
1425    );
1426
1427    let events_json_path = log_dir.join("events.json");
1428    match serde_json::to_vec_pretty(&structured) {
1429        Ok(bytes) => {
1430            if let Err(e) = std::fs::write(&events_json_path, &bytes) {
1431                eprintln!(
1432                    "warning: failed to write {}: {}",
1433                    events_json_path.display(),
1434                    e
1435                );
1436            }
1437        }
1438        Err(e) => {
1439            eprintln!(
1440                "warning: failed to serialize structured log for {}: {}",
1441                events_json_path.display(),
1442                e
1443            );
1444        }
1445    }
1446
1447    if let Err(e) = crate::report::event_html::write(&log_dir, &structured) {
1448        eprintln!(
1449            "warning: failed to write {}: {}",
1450            log_dir.join("event.html").display(),
1451            e
1452        );
1453    }
1454
1455    TestResult {
1456        test_name: meta.name().to_string(),
1457        test_path: test_path.to_string(),
1458        outcome: Outcome::Skipped("skipped".to_string()),
1459        duration: Duration::ZERO,
1460        progress: String::new(),
1461        log_dir: Some(log_dir),
1462        warnings: Vec::new(),
1463        flaky_retries: 0,
1464    }
1465}
1466
1467// --- Marker replay ---------------------------------------
1468
1469/// Output of `replay_markers`: one handle per input recording, positionally
1470/// aligned. `span` is the `marker-eval` span; `event_seq` is the bool-check
1471/// event under it. Used by `log_skipped_test` to build the `SkipRecord`
1472/// focus pointer for the triggering marker.
1473pub(crate) struct MarkerHandle {
1474    pub span: crate::observe::structured::SpanId,
1475    pub event_seq: crate::observe::structured::EventSeq,
1476}
1477
1478/// Lay down the synthetic `markers` root span and every recorded
1479/// `marker-eval` child. Always emits the root (even when empty); the
1480/// viewer filters it. Returns one `MarkerHandle` per input recording,
1481/// positionally aligned.
1482pub(crate) fn replay_markers(
1483    log: &StructuredLogBuilder,
1484    recordings: &[relux_ir::marker::MarkerRecording],
1485) -> Vec<MarkerHandle> {
1486    let markers_guard = log.open_markers_span(None);
1487    let mut handles = Vec::with_capacity(recordings.len());
1488    for rec in recordings {
1489        let me_guard = log.open_marker_eval_span(
1490            markers_guard.id(),
1491            marker_kind_to_runtime(rec.kind),
1492            marker_modifier_to_runtime(rec.modifier),
1493            marker_decision_to_runtime(rec.decision),
1494            Some(&rec.marker_span),
1495        );
1496        let span = me_guard.id();
1497        let mut sink = LogSink::new(log, span);
1498        sink.replay(&rec.ops);
1499        // Final truthy/falsy outcome event, after the sink-op trail.
1500        let event_seq = log.emit_bool_check(
1501            span,
1502            marker_detail_from_evaluation(&rec.evaluation),
1503            Some(&rec.marker_span),
1504        );
1505        handles.push(MarkerHandle { span, event_seq });
1506        // me_guard drops here, closing the marker-eval span.
1507    }
1508    // markers_guard drops here, closing the markers root.
1509    handles
1510}
1511
1512#[cfg(test)]
1513mod tests {
1514    use super::*;
1515
1516    #[test]
1517    fn slugify_simple() {
1518        assert_eq!(slugify("Hello World"), "hello-world");
1519    }
1520
1521    #[test]
1522    fn slugify_special_chars() {
1523        assert_eq!(slugify("test: foo/bar"), "test--foo-bar");
1524    }
1525
1526    #[test]
1527    fn slugify_alphanumeric() {
1528        assert_eq!(slugify("abc-123_def"), "abc-123_def");
1529    }
1530
1531    #[test]
1532    fn slugify_leading_trailing_dashes() {
1533        assert_eq!(slugify("  hello  "), "hello");
1534    }
1535
1536    #[test]
1537    fn test_display_id_format() {
1538        assert_eq!(
1539            test_display_id("basic/test.relux", "my test"),
1540            "basic/test.relux/my-test"
1541        );
1542    }
1543
1544    #[test]
1545    fn assemble_test_env_precedence_and_provenance() {
1546        use relux_core::pure::Env;
1547        use relux_core::pure::LayeredEnv;
1548        use relux_core::pure::LayeredEnvSource;
1549
1550        let mut base_env = Env::new();
1551        base_env.insert("PATH_LIKE".into(), "base".into());
1552        base_env.insert("SHARED".into(), "from-base".into());
1553        let base = std::sync::Arc::new(LayeredEnv::root(base_env));
1554
1555        let mut dotenv = Env::new();
1556        dotenv.insert("SHARED".into(), "from-dotenv".into());
1557        dotenv.insert("DB".into(), "postgres".into());
1558        let dotenv_stack = std::sync::Arc::new(LayeredEnv::child_with_source(
1559            base,
1560            dotenv,
1561            LayeredEnvSource::DotEnv("/p/.env".into()),
1562        ));
1563
1564        let mut internal = Env::new();
1565        internal.insert("__RELUX_RUN_ID".into(), "run123".into());
1566        internal.insert("SHARED".into(), "from-internal".into()); // outranks .env
1567
1568        let env = assemble_test_env(
1569            dotenv_stack,
1570            &internal,
1571            Some(std::path::PathBuf::from("/p/relux/tests")),
1572            std::path::Path::new("/p/out/artifacts"),
1573            "brave-otter-0001",
1574        );
1575
1576        // The per-test `__RELUX_TEST_*` values live in the ReluxInternal layer
1577        // alongside the run internals, not a separate `Test` overlay.
1578        assert_eq!(env.get("__RELUX_TEST_ROOT"), Some("/p/relux/tests"));
1579        assert_eq!(env.get("__RELUX_TEST_ARTIFACTS"), Some("/p/out/artifacts"));
1580        assert_eq!(env.get("__RELUX_TEST_ID"), Some("brave-otter-0001"));
1581        assert_eq!(env.get("SHARED"), Some("from-internal")); // internal > dotenv
1582        assert_eq!(env.get("__RELUX_RUN_ID"), Some("run123"));
1583        assert_eq!(env.get("DB"), Some("postgres")); // .env value present
1584        assert_eq!(env.get("PATH_LIKE"), Some("base")); // base reachable
1585        assert_eq!(env.source(), &LayeredEnvSource::ReluxInternal); // top overlay
1586    }
1587
1588    #[test]
1589    fn test_mnemonic_id_is_stable_and_distinct() {
1590        // Same (path, name) -> same id, at every call site within a test.
1591        let a1 = test_mnemonic_id(Path::new("tests/smoke/login.relux"), "succeeds");
1592        let a2 = test_mnemonic_id(Path::new("tests/smoke/login.relux"), "succeeds");
1593        assert_eq!(a1, a2, "same (path, name) must yield the same id");
1594
1595        // Distinct name or path -> distinct id (safe as a per-test key under -j).
1596        let diff_name = test_mnemonic_id(Path::new("tests/smoke/login.relux"), "fails");
1597        assert_ne!(a1, diff_name, "different test name must differ");
1598        let diff_path = test_mnemonic_id(Path::new("tests/other/login.relux"), "succeeds");
1599        assert_ne!(a1, diff_path, "different path must differ");
1600
1601        // Shape: `<adjective>-<noun>-NNNN`, four-digit numeric suffix.
1602        let suffix = a1.rsplit('-').next().unwrap();
1603        assert_eq!(suffix.len(), 4, "suffix must be 4 digits: {a1}");
1604        assert!(
1605            suffix.chars().all(|c| c.is_ascii_digit()),
1606            "suffix must be numeric: {a1}"
1607        );
1608    }
1609
1610    #[tokio::test]
1611    async fn log_skipped_test_writes_skip_record_artifact() {
1612        use crate::observe::structured::StructuredLog;
1613        use crate::observe::structured::TestOutcome;
1614        use relux_core::diagnostics::Cause;
1615        use relux_core::diagnostics::DefinitionRef;
1616        use relux_core::diagnostics::IrSpan;
1617        use relux_core::diagnostics::ModulePath;
1618        use relux_core::diagnostics::SkipEvaluation;
1619        use relux_core::diagnostics::SkipReport;
1620        use relux_core::pure::Env;
1621        use relux_core::pure::LayeredEnv;
1622        use relux_core::table::SharedTable;
1623        use relux_ir::IrTimeout;
1624        use relux_ir::TestMeta;
1625        use relux_ir::marker::MarkerEvalDecision;
1626        use relux_ir::marker::MarkerEvalKind;
1627        use relux_ir::marker::MarkerEvalModifier;
1628        use relux_ir::marker::MarkerRecording;
1629
1630        // Synthesize the test-level definition + meta.
1631        let definition = DefinitionRef::Test {
1632            name: "always-skipped".into(),
1633            module: ModulePath("tests/synthetic".into()),
1634        };
1635        let meta = TestMeta::new(
1636            "always-skipped",
1637            None,
1638            None as Option<IrTimeout>,
1639            definition.clone(),
1640            IrSpan::synthetic(),
1641        );
1642
1643        // Pre-populate the side table with the test's recordings plus a
1644        // flaky entry to assert flaky markers survive into the rendered tree.
1645        let recordings = vec![
1646            MarkerRecording {
1647                marker_span: IrSpan::synthetic(),
1648                kind: MarkerEvalKind::Flaky,
1649                modifier: MarkerEvalModifier::If,
1650                evaluation: SkipEvaluation::Unconditional,
1651                decision: MarkerEvalDecision::Mark,
1652                ops: Vec::new(),
1653            },
1654            MarkerRecording {
1655                marker_span: IrSpan::synthetic(),
1656                kind: MarkerEvalKind::Skip,
1657                modifier: MarkerEvalModifier::If,
1658                evaluation: SkipEvaluation::Unconditional,
1659                decision: MarkerEvalDecision::Mark,
1660                ops: Vec::new(),
1661            },
1662        ];
1663
1664        let base_env = std::sync::Arc::new(LayeredEnv::root(Env::new()));
1665        let stack = relux_ir::StackHash(base_env.stack_hash());
1666
1667        let tables = relux_ir::Tables::new();
1668        tables.marker_decisions.insert(
1669            (definition.clone(), stack),
1670            relux_ir::marker::MarkerDecision {
1671                skip: Some(SkipReport {
1672                    definition: definition.clone(),
1673                    marker_span: IrSpan::synthetic(),
1674                    evaluation: SkipEvaluation::Unconditional,
1675                }),
1676                flaky: true,
1677                recordings,
1678            },
1679        );
1680
1681        // Register a Cause::Skip whose definition points at the meta. The
1682        // production register_cause path uses `skip.cause_id()` as the key,
1683        // so do the same here for symmetry.
1684        let report = SkipReport {
1685            definition: definition.clone(),
1686            marker_span: IrSpan::synthetic(),
1687            evaluation: SkipEvaluation::Unconditional,
1688        };
1689        let cause_id = report.cause_id();
1690        let suite_causes: relux_core::diagnostics::CauseTable = SharedTable::new();
1691        suite_causes.insert(cause_id.clone(), Cause::skip(report));
1692
1693        let scratch = std::env::temp_dir().join(format!(
1694            "relux-log-skipped-test-{}-{}",
1695            std::process::id(),
1696            std::time::SystemTime::now()
1697                .duration_since(std::time::UNIX_EPOCH)
1698                .unwrap()
1699                .as_nanos(),
1700        ));
1701        std::fs::create_dir_all(&scratch).unwrap();
1702
1703        let run_ctx = RunContext {
1704            run_id: "test".into(),
1705            run_dir: scratch.clone(),
1706            artifacts_dir: scratch.join("artifacts"),
1707            project_root: scratch.clone(),
1708            shell_command: "/bin/sh".into(),
1709            shell_prompt: "$ ".into(),
1710            default_timeout: IrTimeout::tolerance(std::time::Duration::from_secs(5)),
1711            test_timeout: IrTimeout::tolerance(std::time::Duration::from_secs(60)),
1712            suite_timeout: std::time::Duration::from_secs(300),
1713            strategy: RunStrategy::FailFast,
1714            flaky: relux_core::config::FlakyConfig::default(),
1715            jobs: 1,
1716            progress: ProgressMode::Plain,
1717        };
1718
1719        let result = log_skipped_test(
1720            &meta,
1721            std::slice::from_ref(&cause_id),
1722            &suite_causes,
1723            &run_ctx,
1724            base_env,
1725            "tests/synthetic.relux",
1726            &tables,
1727            stack,
1728        )
1729        .await;
1730
1731        assert!(matches!(result.outcome, Outcome::Skipped(_)));
1732        let log_dir = result
1733            .log_dir
1734            .clone()
1735            .expect("skipped test must have log_dir");
1736        assert!(
1737            log_dir.join("events.json").exists(),
1738            "events.json must exist"
1739        );
1740        assert!(log_dir.join("event.html").exists(), "event.html must exist");
1741
1742        // Verify the JSON: outcome.kind == "skip" and SkipRecord.span resolves
1743        // to a marker-eval span in the spans map.
1744        let bytes = std::fs::read(log_dir.join("events.json")).unwrap();
1745        let log: StructuredLog = serde_json::from_slice(&bytes).unwrap();
1746        match &log.outcome {
1747            TestOutcome::Skip(rec) => {
1748                let span = log
1749                    .spans
1750                    .get(&rec.span)
1751                    .expect("SkipRecord.span must exist in spans");
1752                assert!(
1753                    matches!(
1754                        span.kind,
1755                        crate::observe::structured::SpanKind::MarkerEval { .. }
1756                    ),
1757                    "SkipRecord.span must point to a marker-eval span, got: {:?}",
1758                    span.kind
1759                );
1760                // The recording in this fixture uses a synthetic
1761                // marker_span (no file_id in the sources table), so
1762                // resolve_location returns None. The integration tests
1763                // exercise the populated-location path against real
1764                // .relux files.
1765                assert!(
1766                    rec.location.is_none(),
1767                    "synthetic marker_span must resolve to no location, got: {:?}",
1768                    rec.location
1769                );
1770            }
1771            other => panic!("expected TestOutcome::Skip, got {other:?}"),
1772        }
1773
1774        // Flaky markers must reach the rendered MARKERS tree alongside the
1775        // skip-triggering one - even on a skipped test.
1776        let has_flaky = log.spans.values().any(|s| {
1777            matches!(
1778                s.kind,
1779                crate::observe::structured::SpanKind::MarkerEval {
1780                    marker_kind: crate::observe::structured::MarkerEvalKind::Flaky,
1781                    ..
1782                }
1783            )
1784        });
1785        assert!(
1786            has_flaky,
1787            "expected a flaky marker-eval span in the skipped-test artifact"
1788        );
1789
1790        let _ = std::fs::remove_dir_all(&scratch);
1791    }
1792
1793    #[tokio::test]
1794    async fn log_skipped_test_handles_propagated_skip_from_effect() {
1795        // Propagated case: the test's own definition has no recordings; the
1796        // cause's SkipReport.definition points at the originating effect,
1797        // and the effect's recordings live in the side table under that key.
1798        use crate::observe::structured::StructuredLog;
1799        use crate::observe::structured::TestOutcome;
1800        use relux_core::diagnostics::Cause;
1801        use relux_core::diagnostics::DefinitionRef;
1802        use relux_core::diagnostics::EffectId;
1803        use relux_core::diagnostics::EffectName;
1804        use relux_core::diagnostics::IrSpan;
1805        use relux_core::diagnostics::ModulePath;
1806        use relux_core::diagnostics::SkipEvaluation;
1807        use relux_core::diagnostics::SkipReport;
1808        use relux_core::pure::Env;
1809        use relux_core::pure::LayeredEnv;
1810        use relux_core::table::SharedTable;
1811        use relux_ir::IrTimeout;
1812        use relux_ir::TestMeta;
1813        use relux_ir::marker::MarkerEvalDecision;
1814        use relux_ir::marker::MarkerEvalKind;
1815        use relux_ir::marker::MarkerEvalModifier;
1816        use relux_ir::marker::MarkerRecording;
1817
1818        let test_def = DefinitionRef::Test {
1819            name: "depends-on-skipped-effect".into(),
1820            module: ModulePath("tests/synthetic".into()),
1821        };
1822        let effect_id = EffectId {
1823            module: ModulePath("tests/synthetic".into()),
1824            name: EffectName("Mock".into()),
1825        };
1826        let effect_def = DefinitionRef::Effect(effect_id);
1827
1828        let meta = TestMeta::new(
1829            "depends-on-skipped-effect",
1830            None,
1831            None as Option<IrTimeout>,
1832            test_def.clone(),
1833            IrSpan::synthetic(),
1834        );
1835
1836        // Effect's recordings (the originating skip lives here, not on the test).
1837        let recordings = vec![MarkerRecording {
1838            marker_span: IrSpan::synthetic(),
1839            kind: MarkerEvalKind::Skip,
1840            modifier: MarkerEvalModifier::If,
1841            evaluation: SkipEvaluation::Bare {
1842                value: "yes".into(),
1843                met: true,
1844            },
1845            decision: MarkerEvalDecision::Mark,
1846            ops: Vec::new(),
1847        }];
1848        let base_env = std::sync::Arc::new(LayeredEnv::root(Env::new()));
1849        let stack = relux_ir::StackHash(base_env.stack_hash());
1850
1851        let tables = relux_ir::Tables::new();
1852        tables.marker_decisions.insert(
1853            (effect_def.clone(), stack),
1854            relux_ir::marker::MarkerDecision {
1855                skip: Some(SkipReport {
1856                    definition: effect_def.clone(),
1857                    marker_span: IrSpan::synthetic(),
1858                    evaluation: SkipEvaluation::Bare {
1859                        value: "yes".into(),
1860                        met: true,
1861                    },
1862                }),
1863                flaky: false,
1864                recordings,
1865            },
1866        );
1867
1868        let report = SkipReport {
1869            definition: effect_def,
1870            marker_span: IrSpan::synthetic(),
1871            evaluation: SkipEvaluation::Bare {
1872                value: "yes".into(),
1873                met: true,
1874            },
1875        };
1876        let cause_id = report.cause_id();
1877        let suite_causes: relux_core::diagnostics::CauseTable = SharedTable::new();
1878        suite_causes.insert(cause_id.clone(), Cause::skip(report));
1879
1880        let scratch = std::env::temp_dir().join(format!(
1881            "relux-log-skipped-propagated-{}-{}",
1882            std::process::id(),
1883            std::time::SystemTime::now()
1884                .duration_since(std::time::UNIX_EPOCH)
1885                .unwrap()
1886                .as_nanos(),
1887        ));
1888        std::fs::create_dir_all(&scratch).unwrap();
1889
1890        let run_ctx = RunContext {
1891            run_id: "test".into(),
1892            run_dir: scratch.clone(),
1893            artifacts_dir: scratch.join("artifacts"),
1894            project_root: scratch.clone(),
1895            shell_command: "/bin/sh".into(),
1896            shell_prompt: "$ ".into(),
1897            default_timeout: IrTimeout::tolerance(std::time::Duration::from_secs(5)),
1898            test_timeout: IrTimeout::tolerance(std::time::Duration::from_secs(60)),
1899            suite_timeout: std::time::Duration::from_secs(300),
1900            strategy: RunStrategy::FailFast,
1901            flaky: relux_core::config::FlakyConfig::default(),
1902            jobs: 1,
1903            progress: ProgressMode::Plain,
1904        };
1905
1906        let result = log_skipped_test(
1907            &meta,
1908            &[cause_id],
1909            &suite_causes,
1910            &run_ctx,
1911            base_env,
1912            "tests/synthetic.relux",
1913            &tables,
1914            stack,
1915        )
1916        .await;
1917
1918        let log_dir = result
1919            .log_dir
1920            .clone()
1921            .expect("propagated-skip artifact must have log_dir");
1922        let bytes = std::fs::read(log_dir.join("events.json")).unwrap();
1923        let log: StructuredLog = serde_json::from_slice(&bytes).unwrap();
1924        match &log.outcome {
1925            TestOutcome::Skip(rec) => {
1926                let span = log
1927                    .spans
1928                    .get(&rec.span)
1929                    .expect("SkipRecord.span must exist");
1930                assert!(matches!(
1931                    span.kind,
1932                    crate::observe::structured::SpanKind::MarkerEval { .. }
1933                ));
1934            }
1935            other => panic!("expected TestOutcome::Skip, got {other:?}"),
1936        }
1937
1938        let _ = std::fs::remove_dir_all(&scratch);
1939    }
1940
1941    #[test]
1942    fn replay_markers_returns_handles_aligned_with_recordings() {
1943        use crate::observe::structured::StructuredLogBuilder;
1944        use relux_core::diagnostics::SkipEvaluation;
1945        use relux_ir::marker::MarkerEvalDecision;
1946        use relux_ir::marker::MarkerEvalKind;
1947        use relux_ir::marker::MarkerEvalModifier;
1948        use relux_ir::marker::MarkerRecording;
1949
1950        let (tx, _rx) = crate::observe::progress::channel();
1951        let sources = relux_core::table::SharedTable::new();
1952        let log = StructuredLogBuilder::new(
1953            tx,
1954            std::time::Instant::now(),
1955            sources,
1956            std::sync::Arc::from(std::path::Path::new(".")),
1957        );
1958
1959        let span = relux_core::diagnostics::IrSpan::synthetic();
1960        let recordings = vec![
1961            MarkerRecording {
1962                marker_span: span.clone(),
1963                kind: MarkerEvalKind::Skip,
1964                modifier: MarkerEvalModifier::If,
1965                evaluation: SkipEvaluation::Unconditional,
1966                decision: MarkerEvalDecision::Mark,
1967                ops: Vec::new(),
1968            },
1969            MarkerRecording {
1970                marker_span: span.clone(),
1971                kind: MarkerEvalKind::Flaky,
1972                modifier: MarkerEvalModifier::If,
1973                evaluation: SkipEvaluation::Unconditional,
1974                decision: MarkerEvalDecision::Mark,
1975                ops: Vec::new(),
1976            },
1977        ];
1978
1979        let handles = replay_markers(&log, &recordings);
1980        assert_eq!(handles.len(), recordings.len(), "handles must align 1:1");
1981        // Distinct marker-eval spans and distinct bool-check events.
1982        assert_ne!(handles[0].span, handles[1].span);
1983        assert_ne!(handles[0].event_seq, handles[1].event_seq);
1984    }
1985}