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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum RunStrategy {
79 All,
80 FailFast,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum ProgressMode {
87 Auto,
89 Plain,
91 Tui,
93}
94
95pub 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
113fn 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
135fn 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
168fn 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
208fn 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
246pub fn test_display_id(test_path: &str, test_name: &str) -> String {
248 format!("{}/{}", test_path, slugify(test_name))
249}
250
251fn 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
266pub 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
281pub 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 {
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 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 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 let queue: Arc<std::sync::Mutex<VecDeque<(usize, &Plan)>>> = Arc::new(std::sync::Mutex::new(
341 suite.plans.iter().enumerate().collect(),
342 ));
343
344 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 let worker_results = futures::future::join_all(worker_futs).await;
360
361 drop(tui_tx);
363 tui_handle.await.ok();
364
365 if let Some(handle) = watchdog {
367 handle.abort();
368 }
369
370 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 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 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 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 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#[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 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 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 if let Some(handle) = test_watchdog {
702 handle.abort();
703 }
704
705 result
706}
707
708fn 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 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 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 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 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 let effect_warnings = test_manager.cleanup_all().await;
859 warnings.extend(effect_warnings);
860
861 drop(test_manager);
863 drop(rt_ctx);
864 drop(progress_tx);
865 let duration = test_start.elapsed();
866
867 test_span.close();
868
869 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 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
965async 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 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 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 IrTestItem::Comment { .. }
1030 | IrTestItem::DocString { .. }
1031 | IrTestItem::Start { .. }
1032 | IrTestItem::Shell { .. }
1033 | IrTestItem::Cleanup { .. } => {}
1034 }
1035 }
1036
1037 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 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 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 {
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 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 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 } else {
1141 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 }
1187 }
1188 IrTestItem::Cleanup { .. } => continue,
1189 }
1190 }
1191 Ok(())
1192 }
1193 .await;
1194
1195 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 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 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 }
1273
1274 body_result
1275}
1276
1277pub(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#[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 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 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 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
1467pub(crate) struct MarkerHandle {
1474 pub span: crate::observe::structured::SpanId,
1475 pub event_seq: crate::observe::structured::EventSeq,
1476}
1477
1478pub(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 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 }
1508 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()); 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 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")); assert_eq!(env.get("__RELUX_RUN_ID"), Some("run123"));
1583 assert_eq!(env.get("DB"), Some("postgres")); assert_eq!(env.get("PATH_LIKE"), Some("base")); assert_eq!(env.source(), &LayeredEnvSource::ReluxInternal); }
1587
1588 #[test]
1589 fn test_mnemonic_id_is_stable_and_distinct() {
1590 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 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 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 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 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 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 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 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 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 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 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 assert_ne!(handles[0].span, handles[1].span);
1983 assert_ne!(handles[0].event_seq, handles[1].event_seq);
1984 }
1985}