use std::rc::Rc; use std::sync::{Arc, Mutex}; use avril_core::{Extension, Op, PublicModule}; use avril_ext_stdio::Stdio; /// Harness options shared by every per-file runtime of one `avril test` run. #[derive(Clone, Default)] pub struct Config { pub filter: Option, pub no_run: bool, } #[derive(Clone, Copy, Default)] struct Counts { passed: usize, failed: usize, ignored: usize, filtered: usize, found: usize, } struct File { display: String, counts: Counts, error: Option, } trait Files { fn files(self) -> String; } /// Reporter colors follow `console` so test verdicts match `avril add`: /// colored only for a terminal without `NO_COLOR`. All test output goes to /// stdout, so only stdout decides. fn use_colors() -> bool { avril_ext_console::colors(false) } fn paint(colors: bool, style: &str, text: &str) -> String { if colors { format!("\x1b[{style}m{text}\x1b[0m") } else { text.to_owned() } } impl Files for usize { fn files(self) -> String { if self == 1 { "1 file".to_owned() } else { format!("{self} files") } } } /// Reporter state shared across the sequential per-file runtimes of a run. /// Guest test results arrive through `op_test_event` while its file runs. pub struct Session { config: Config, stdio: Rc, files: Vec, watch: Arc>, } /// Per-file watchdog state, shared with the thread holding the isolate handle. /// The guest runner arms deadlines through `start` events; the thread /// terminates execution when one passes (a spinning test never yields to the /// runner's own timers). `done` and test-end events re-arm or clear them. pub struct WatchState { deadline: Option, running: Option<(String, u64)>, stopped: bool, fired: bool, } /// What a fired watchdog interrupted. pub enum WatchdogOutcome { /// A test overran its own timeout while spinning. Casualty { name: String, timeout_ms: u64 }, /// Nothing was running: top-level evaluation or a gap between tests /// made no progress within the file budget. Backstop, } impl WatchState { fn reset(&mut self) { self.deadline = None; self.running = None; self.stopped = false; self.fired = false; } /// Tells the watcher thread to exit; the run calls this after every file. pub fn shutdown(&mut self) { self.stopped = true; } /// Whether an armed deadline has passed while the thread is still wanted. pub fn expired(&self) -> bool { !self.stopped && self .deadline .is_some_and(|deadline| std::time::Instant::now() >= deadline) } pub fn stopped(&self) -> bool { self.stopped } /// Records that the isolate was terminated; called by the watcher thread. pub fn terminated(&mut self) { self.fired = true; } /// Arms a test's deadline: its own timeout plus slack for the runner's /// timers to win every observable race, or the file budget when the test /// opted out of timeouts. fn arm_test(&mut self, name: String, timeout_ms: Option) { let budget = timeout_ms.map_or(FILE_BUDGET, |ms| { std::time::Duration::from_millis(ms) + WATCHDOG_SLACK }); self.running = Some((name, timeout_ms.unwrap_or(0))); self.deadline = Some(std::time::Instant::now() + budget); } fn arm_file(&mut self) { self.running = None; self.deadline = Some(std::time::Instant::now() + FILE_BUDGET); } fn disarm(&mut self) { self.running = None; self.deadline = None; } /// What the watchdog interrupted, if it fired. pub fn outcome(&mut self) -> Option { if !self.fired { return None; } Some(match self.running.take() { Some((name, timeout_ms)) => WatchdogOutcome::Casualty { name, timeout_ms }, None => WatchdogOutcome::Backstop, }) } } /// Extra wall time after a test's own timeout before the watchdog treats it /// as spinning: the runner's timers win every race they can observe. const WATCHDOG_SLACK: std::time::Duration = std::time::Duration::from_secs(2); /// Progress budget for a file with no running test (top-level evaluation, /// gaps between tests): afterwards the file is terminated outright. const FILE_BUDGET: std::time::Duration = std::time::Duration::from_secs(60); pub type Shared = Rc>; impl Session { pub fn new(config: Config, stdio: Rc) -> Shared { Rc::new(Mutex::new(Self { config, stdio, files: Vec::new(), watch: Arc::new(Mutex::new(WatchState { deadline: None, running: None, stopped: false, fired: false, })), })) } /// Hands the current file's watchdog state to its watcher thread. /// Resets any previous file's outcome first, then re-arms the file /// budget: reset() would otherwise leave top-level evaluation with no /// deadline until the first test starts. pub fn arm_watchdog(&mut self) -> Arc> { if let Ok(mut watch) = self.watch.lock() { watch.reset(); watch.arm_file(); } self.watch.clone() } pub fn begin_file(&mut self, display: String) { let colors = use_colors(); let _ = self .stdio .print(false, format!("Check {}\n", paint(colors, "1", &display))); self.files.push(File { display, counts: Counts::default(), error: None, }); // Top-level evaluation gets the file budget until the first test // arms its own deadline. if let Ok(mut watch) = self.watch.lock() { watch.arm_file(); } } pub fn end_file(&mut self, error: Option) { let Some(file) = self.files.last_mut() else { return; }; file.error = error; let counts = file.counts; let colors = use_colors(); let line = if let Some(error) = &file.error { // File errors never reach a test: prepare and evaluation failures // surface here, so the footer carries their detail. Runner frames // add nothing, as in per-test stacks. let mut line = format!( "{} - {}\n", paint(colors, "1;31", "FAILED"), paint(colors, "1", &file.display), ); for text in error.lines() { if text.contains("ext:test/runner.js") || text.contains("ext:test/main.js") || text.contains("ext:test/shared.js") { continue; } line.push_str(&format!(" {text}\n")); } line } else if self.config.no_run { format!( "{} - {} ({} tests, not run)\n", paint(colors, "36", "found"), paint(colors, "1", &file.display), counts.found, ) } else { let verdict = if counts.failed == 0 { "ok" } else { "FAILED" }; let style = if counts.failed == 0 { "1;32" } else { "1;31" }; format!( "{} - {} ({} passed, {} failed, {} ignored, {} filtered)\n", paint(colors, style, verdict), paint(colors, "1", &file.display), counts.passed, counts.failed, counts.ignored, counts.filtered, ) }; let _ = self.stdio.print(false, line); } fn event(&mut self, event: TestEvent) { // The runner's bookkeeping never reaches the report. if event.kind == "start" { if event.depth == 0 && let Ok(mut watch) = self.watch.lock() { watch.arm_test(event.name, event.timeout_ms); } return; } if event.kind == "done" { if let Ok(mut watch) = self.watch.lock() { watch.disarm(); } return; } let Some(file) = self.files.last_mut() else { return; }; // Steps report indented under their test but never touch the file // totals: only top-level tests count, as in the final summary. let count = event.depth == 0; let indent = " ".repeat(event.depth.min(8)); let colors = use_colors(); let line = match event.kind.as_str() { "pass" => { if count { file.counts.passed += 1; } format!( "{indent}{} - {} ({}ms)\n", paint(colors, "1;32", "ok"), event.name, event.elapsed_ms, ) } "fail" => { if count { file.counts.failed += 1; } let mut line = format!( "{indent}{} - {} ({}ms)\n", paint(colors, "1;31", "FAILED"), event.name, event.elapsed_ms, ); for text in event.text.lines() { line.push_str(&format!("{indent} {text}\n")); } line } "ignore" => { if count { file.counts.ignored += 1; } format!( "{indent}{} - {}\n", paint(colors, "33", "ignored"), event.name, ) } "filtered" => { file.counts.filtered += 1; String::new() } "found" => { file.counts.found += 1; format!("{indent}{} - {}\n", paint(colors, "36", "test"), event.name,) } _ => String::new(), }; if !line.is_empty() { let _ = self.stdio.print(false, line); } // Between tests the file budget guards gaps in the runner itself. if count && matches!(event.kind.as_str(), "pass" | "fail" | "ignore") && let Ok(mut watch) = self.watch.lock() { watch.arm_file(); } } /// Reports a test the watchdog terminated while spinning: the failure the /// runner's own timers could never observe. Reads like any other timeout. pub fn timeout_casualty(&mut self, name: String, timeout_ms: u64) { self.event(TestEvent { kind: "fail".to_owned(), name: name.clone(), depth: 0, elapsed_ms: timeout_ms, text: format!("Error: Test \"{name}\" timed out after {timeout_ms}ms"), timeout_ms: None, }); } /// Prints the final summary; true when the run failed. pub fn finish(&self) -> bool { let mut passed = 0; let mut failed = 0; let mut ignored = 0; let mut filtered = 0; let mut found = 0; let mut errors = 0; for file in &self.files { passed += file.counts.passed; failed += file.counts.failed; ignored += file.counts.ignored; filtered += file.counts.filtered; found += file.counts.found; errors += usize::from(file.error.is_some()); } let failed_run = failed > 0 || errors > 0; let files = self.files.len(); let colors = use_colors(); let verdict = if failed_run { "FAILED" } else { "ok" }; let style = if failed_run { "1;31" } else { "1;32" }; let line = if self.config.no_run { format!( "test result: {}. found {found} tests in {} (not run).\n", paint(colors, style, verdict), files.files(), ) } else { format!( "test result: {}. {passed} passed; {failed} failed; {ignored} ignored; {filtered} filtered; {}.\n", paint(colors, style, verdict), files.files(), ) }; let _ = self.stdio.print(false, line); failed_run } } #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] struct ConfigSnapshot { filter: Option, no_run: bool, } #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct TestEvent { kind: String, #[serde(default)] name: String, #[serde(default)] depth: usize, #[serde(default)] elapsed_ms: u64, #[serde(default)] text: String, #[serde(default)] timeout_ms: Option, } pub fn init(session: Shared) -> Extension { let config = session.clone(); let events = session.clone(); Extension { name: "ext:test", dependencies: &["ext:timers", "ext:web"], ops: vec![ Op::new("op_test_config", move |_: ()| { let session = config .lock() .map_err(|_| std::io::Error::other("Test session is unavailable"))?; Ok::<_, std::io::Error>(ConfigSnapshot { filter: session.config.filter.clone(), no_run: session.config.no_run, }) }), Op::new("op_test_event", move |event: TestEvent| { // Worker isolates use an idle session nobody reads. events .lock() .map_err(|_| std::io::Error::other("Test session is unavailable"))? .event(event); Ok::<_, std::io::Error>(()) }), ], modules: const { &[ ( "ext:test/runner.js", include_str!(concat!(env!("OUT_DIR"), "/runner.js")), ), ( "ext:test/shared.js", include_str!(concat!(env!("OUT_DIR"), "/shared.js")), ), PUBLIC_MODULES[0].module, ] }, entry_point: Some("ext:test/main.js"), } } pub const PUBLIC_MODULES: &[PublicModule] = &[avril_core::public_module!("avril:test", "ext:test", "main")]; /// A throwaway session for runtimes outside `avril test`, where `test()` /// registers harmlessly and the runner never executes. pub fn idle(stdio: &Rc) -> Shared { Session::new(Config::default(), stdio.clone()) } #[cfg(test)] mod tests { use super::paint; #[test] fn disabled_colors_leave_text_untouched() { assert_eq!(paint(false, "1;32", "ok"), "ok"); assert_eq!(paint(false, "1;31", "FAILED - name"), "FAILED - name"); } #[test] fn enabled_colors_wrap_text_in_a_reset_pair() { for (style, text) in [("1;32", "ok"), ("1;31", "FAILED"), ("33", "ignored")] { let painted = paint(true, style, text); assert!(painted.starts_with(&format!("\x1b[{style}m"))); assert!(painted.ends_with("\x1b[0m")); assert!(painted.contains(text)); assert_ne!(painted, text); } } }