use std::path::{Path, PathBuf}; use std::sync::Arc; use avril_runtime::caps::Capabilities; use crate::cli::ParseError; use crate::fragments::{Arg, Entry, Parser, flag_value, reject}; /// A discovered test target: a local file or an explicit remote URL. pub enum Target { File(PathBuf), Remote(String), } /// How one file's run concluded: cleanly (its tests reported themselves), /// or with a message for the file footer. enum FileResult { Clean, Failed(String), } /// Display a discovered file relative to the working directory when possible. pub fn display(cwd: &Path, path: &Path) -> String { match path.strip_prefix(cwd) { Ok(relative) if !relative.as_os_str().is_empty() => { format!("./{}", relative.to_string_lossy().replace('\\', "/")) } _ => path.to_string_lossy().into_owned(), } } fn is_test_file(path: &Path) -> bool { let Some(name) = path.file_name().and_then(|name| name.to_str()) else { return false; }; // Dot-files stay out of discovery, matching the walked-directory rule. if name.starts_with('.') { return false; } if name.ends_with(".d.ts") { return false; } let (stem, extension) = match name.rsplit_once('.') { Some((stem, extension)) => (stem, extension), None => return false, }; if !matches!( extension, "js" | "mjs" | "cjs" | "cts" | "jsx" | "ts" | "mts" | "tsx" ) { return false; } stem == "test" || stem.ends_with("_test") || stem.ends_with(".test") } fn is_ignored_dir(name: &str) -> bool { name.starts_with('.') || matches!(name, "node_modules" | "vendor" | "target" | "__pycache__") } fn walk(directory: &Path, files: &mut Vec) { let Ok(entries) = std::fs::read_dir(directory) else { return; }; let mut entries: Vec<_> = entries.filter_map(|entry| entry.ok()).collect(); entries.sort_by_key(|entry| entry.file_name()); for entry in entries { let path = entry.path(); let file_type = match entry.file_type() { Ok(file_type) => file_type, Err(_) => continue, }; // Never follow symlinks while walking: linked trees stay out of discovery. if file_type.is_symlink() { continue; } if file_type.is_dir() { if is_ignored_dir(entry.file_name().to_string_lossy().as_ref()) { continue; } walk(&path, files); } else if file_type.is_file() && is_test_file(&path) { files.push(path); } } } fn is_remote(argument: &str) -> bool { // URL parsing (not a prefix check) so upper-case schemes match too. avril_runtime::modules::http_url(argument).is_some() } /// Expand test arguments into canonical files and remote URLs. /// /// No arguments walks the working directory. Explicit directories are walked; /// explicit files run even when their names do not match the test pattern. pub fn discover(cwd: &Path, args: &[String]) -> Result, String> { let mut targets = Vec::new(); if args.is_empty() { let mut files = Vec::new(); walk(cwd, &mut files); targets.extend(files.into_iter().map(Target::File)); } else { for argument in args { if is_remote(argument) { let url = url::Url::parse(argument).map_err(|error| error.to_string())?; if !matches!(url.scheme(), "http" | "https") { return Err(format!("Invalid test URL: {argument}")); } targets.push(Target::Remote(url.to_string())); continue; } let path = cwd.join(argument); let metadata = path .symlink_metadata() .map_err(|_| format!("No such file or directory: {argument}"))?; // An explicit symlink names its target: a linked directory walks // like any explicit directory instead of failing at load time. if metadata.is_symlink() { let canonical = path.canonicalize().map_err(|error| error.to_string())?; if canonical.is_dir() { let mut files = Vec::new(); walk(&canonical, &mut files); targets.extend(files.into_iter().map(Target::File)); continue; } targets.push(Target::File(canonical)); continue; } if metadata.is_dir() { let canonical = path.canonicalize().map_err(|error| error.to_string())?; let mut files = Vec::new(); walk(&canonical, &mut files); targets.extend(files.into_iter().map(Target::File)); } else { let canonical = path.canonicalize().map_err(|error| error.to_string())?; targets.push(Target::File(canonical)); } } } targets.sort_by(|a, b| match (a, b) { (Target::File(a), Target::File(b)) => a.cmp(b), (Target::Remote(a), Target::Remote(b)) => a.cmp(b), (Target::File(_), Target::Remote(_)) => std::cmp::Ordering::Less, (Target::Remote(_), Target::File(_)) => std::cmp::Ordering::Greater, }); targets.dedup_by(|a, b| match (a, b) { (Target::File(a), Target::File(b)) => a == b, (Target::Remote(a), Target::Remote(b)) => a == b, _ => false, }); if targets.is_empty() { return Err("No test files found".into()); } Ok(targets) } pub struct TestArgs { pub base: Entry, pub args: Vec, pub targets: Vec, pub filter: Option, pub no_run: bool, } /// `avril test` argument parsing failed. #[derive(Debug, thiserror::Error)] pub enum TestParseError { #[error(transparent)] Generic(#[from] ParseError), #[error("{0}")] Discover(String), } pub fn parse( argv: Vec, script: Vec, cwd: PathBuf, ) -> Result { let mut parser = Parser::new("test", argv, &cwd)?; let (mut filter, mut no_run, mut positionals) = (None, false, Vec::new()); while let Some(arg) = parser.next()? { match arg { Arg::Positional(arg) => positionals.push(arg), Arg::Flag(arg) if arg == "--filter" => { filter = Some(parser.value(ParseError::FilterPattern)?); } Arg::Flag(arg) if arg == "--no-run" => no_run = true, Arg::Flag(arg) => match flag_value(&arg)? { ("--filter", value) => filter = Some(value.to_owned()), _ => return Err(reject(&arg, &["--no-run"]).into()), }, } } let mut targets = Vec::new(); for target in discover(&cwd, &positionals).map_err(TestParseError::Discover)? { match &target { Target::File(path) => parser.common.initial.entry(path), Target::Remote(url) => { let parsed = url::Url::parse(url).map_err(ParseError::Url)?; let initial = &mut parser.common.initial; initial.remote_entry(&parsed).map_err(ParseError::from)?; } } targets.push(target); } // The lock path is resolved per file at run time (each file loads in // its own package root); this placeholder only fills the field. // project_root is likewise fixed per file later; explicit --lock // never changes it. Path and module are placeholders too: per-file // entries clone this base and set their own. let base = parser .common .finish(cwd.clone(), String::new(), cwd.join("avril.lock"))?; Ok(TestArgs { base, args: script, targets, filter, no_run, }) } /// Runs every discovered test file in its own fresh isolate, sequentially, /// and prints the human-readable report shared with the guest runner. /// Mirrors how `run`/`serve` build their runtime. Each file gets a child of /// the CLI capabilities (so a pledge() in one file narrows only that file) /// with its own packages, store, and graph (so narrowing still applies to /// that file's loading exactly as under `run`). Returns Err on any failure /// so the process exits nonzero after the summary. pub fn run(args: &mut TestArgs) -> Result<(), Box> { let cwd = args.base.caps.cwd().to_owned(); // File paths must be valid UTF-8 before anything runs: a bad path aborts // the whole run here, exactly as when the modules list was built up front. for target in &args.targets { if let Target::File(path) = target { path.to_str().ok_or("Module paths must be valid UTF-8")?; } } let stdio = avril_ext_stdio::Stdio::new()?; // Declared before the JS runtimes so it outlives pending host work. let host = avril_host::Host::new()?; let broker: Arc = Arc::default(); let session = avril_ext_test::Session::new( avril_ext_test::Config { filter: args.filter.clone(), no_run: args.no_run, }, stdio.clone(), ); for target in &args.targets { // The upfront validation above makes the UTF-8 check below // unreachable, keeping this loop total without panicking. let (display, path, name) = match target { Target::File(path) => ( display(&cwd, path), path.clone(), path.to_str() .ok_or("Module paths must be valid UTF-8")? .to_owned(), ), Target::Remote(url) => (url.clone(), cwd.join(".avril-remote.js"), url.clone()), }; session .lock() .map_err(|error| error.to_string())? .begin_file(display); let result = (|| -> Result> { // A pledge() in this file must not narrow any other file: each // file snapshots the CLI capabilities, so restrictions stay local. let file = Entry { caps: Capabilities::inherit(args.base.caps.clone()), path, module: name.clone(), ..args.base.clone() }; let (authority, root, packages) = file.project(false)?; let storage = avril_webstorage::Persistent::new(&packages.cache, &root.join("avril.conf"), &name); let graph = crate::fragments::prepare_graph( host.handle(), authority, packages, &name, &file.includes, true, None, )?; graph.set_entry(&name); let mut runtime = avril_runtime::assembly::runtime( graph.clone(), avril_runtime::assembly::Settings { snapshot: Some(crate::snapshots::embedded()), storage, caps: file.caps.clone(), environment: file.environment.clone(), args: args.args.clone(), store: graph.store().clone(), runtime: host.handle(), broker: broker.clone(), process_signal: avril_ext_process::SignalMode::Run, }, stdio.clone(), None, Some(session.clone()), )?; // A spinning test never yields to the runner's own timers, so a // thread holding the isolate handle terminates it on overrun. let watch = session .lock() .map_err(|error| error.to_string())? .arm_watchdog(); let handle = runtime.isolate_handle(); let watcher = std::thread::Builder::new() .name("avril:test-watchdog".to_owned()) .spawn({ let watch = watch.clone(); move || { loop { std::thread::sleep(std::time::Duration::from_millis(20)); let Ok(mut state) = watch.lock() else { return; }; if state.stopped() { return; } if state.expired() { handle.terminate_execution(); state.terminated(); return; } } } }) .map_err(|error| error.to_string())?; let outcome = runtime.execute_module_with_bootstrap(&name, "ext:test/runner.js", "runTests"); if let Ok(mut state) = watch.lock() { state.shutdown(); } watcher.join().map_err(|_| "Test watchdog thread failed")?; // A clean return settles every question the watchdog could ask: // a termination landing afterwards hits an idle, dying isolate. let fired = if outcome.is_err() { match watch.lock() { Ok(mut state) => state.outcome(), Err(_) => None, } } else { None }; match (outcome, fired) { // The runner's timers observed every failure themselves. (Ok(()), _) => Ok(FileResult::Clean), // A spinning test died by termination: report its timeout and // fail the rest of the file with it. The raw termination // error explains nothing, so it never reaches the report. (Err(_), Some(avril_ext_test::WatchdogOutcome::Casualty { name, timeout_ms })) => { session .lock() .map_err(|error| error.to_string())? .timeout_casualty(name, timeout_ms); Ok(FileResult::Clean) } // No test was running: evaluation or the runner itself stalled. (Err(_), Some(avril_ext_test::WatchdogOutcome::Backstop)) => { Ok(FileResult::Failed( "Test run made no progress for 60s; execution was terminated".to_owned(), )) } (Err(error), None) => Ok(FileResult::Failed(error.to_string())), } })(); stdio.flush()?; match result { Ok(FileResult::Clean) => session .lock() .map_err(|error| error.to_string())? .end_file(None), Ok(FileResult::Failed(message)) => session .lock() .map_err(|error| error.to_string())? .end_file(Some(message)), Err(error) => session .lock() .map_err(|error| error.to_string())? .end_file(Some(error.to_string())), } } stdio.flush()?; if session.lock().map_err(|error| error.to_string())?.finish() { return Err("test failed".into()); } Ok(()) }