char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
7b01146
main
1use std:: path::{ Path , PathBuf }; 2use std:: sync:: Arc ; 3 4use avril_runtime:: caps:: Capabilities ; 5 6use crate :: cli:: ParseError ; 7use crate :: fragments::{ Arg , Entry , Parser , flag_value, reject}; 8 9/// A discovered test target: a local file or an explicit remote URL. 10pub enum Target { 11File ( PathBuf ), 12Remote ( String ), 13} 14 15/// How one file's run concluded: cleanly (its tests reported themselves), 16/// or with a message for the file footer. 17enum FileResult { 18Clean , 19Failed ( String ), 20} 21 22/// Display a discovered file relative to the working directory when possible. 23pub fn display ( cwd : & Path , path : & Path ) ->String { 24match path. strip_prefix ( cwd) { 25Ok ( relative) if !relative. as_os_str (). is_empty () =>{ 26format! ( "./{}" , relative. to_string_lossy (). replace ( '\\' , "/" )) 27} 28 _ => path. to_string_lossy (). into_owned (), 29} 30} 31 32fn is_test_file ( path : & Path ) ->bool { 33let Some ( name) = path. file_name (). and_then ( |name| name. to_str ()) else { 34return false ; 35}; 36// Dot-files stay out of discovery, matching the walked-directory rule. 37if name. starts_with ( '.' ) { 38return false ; 39} 40if name. ends_with ( ".d.ts" ) { 41return false ; 42} 43let ( stem, extension) =match name. rsplit_once ( '.' ) { 44Some (( stem, extension)) =>( stem, extension), 45None =>return false , 46}; 47if !matches! ( 48 extension, 49"js" |"mjs" |"cjs" |"cts" |"jsx" |"ts" |"mts" |"tsx" 50) { 51return false ; 52} 53 stem =="test" || stem. ends_with ( "_test" ) || stem. ends_with ( ".test" ) 54} 55 56fn is_ignored_dir ( name : & str ) ->bool { 57 name. starts_with ( '.' ) ||matches! ( name, "node_modules" |"vendor" |"target" |"__pycache__" ) 58} 59 60fn walk ( directory : & Path , files : & mut Vec < PathBuf >) { 61let Ok ( entries) = std:: fs:: read_dir ( directory) else { 62return ; 63}; 64let mut entries: Vec < _ > = entries. filter_map ( |entry| entry. ok ()). collect (); 65 entries. sort_by_key ( |entry| entry. file_name ()); 66for entryin entries{ 67let path = entry. path (); 68let file_type =match entry. file_type () { 69Ok ( file_type) => file_type, 70Err ( _) =>continue , 71}; 72// Never follow symlinks while walking: linked trees stay out of discovery. 73if file_type. is_symlink () { 74continue ; 75} 76if file_type. is_dir () { 77if is_ignored_dir ( entry. file_name (). to_string_lossy (). as_ref ()) { 78continue ; 79} 80walk ( & path, files); 81} else if file_type. is_file () &&is_test_file ( & path) { 82 files. push ( path); 83} 84} 85} 86 87fn is_remote ( argument : & str ) ->bool { 88// URL parsing (not a prefix check) so upper-case schemes match too. 89 avril_runtime:: modules:: http_url ( argument). is_some () 90} 91 92/// Expand test arguments into canonical files and remote URLs. 93/// 94/// No arguments walks the working directory. Explicit directories are walked; 95/// explicit files run even when their names do not match the test pattern. 96pub fn discover ( cwd : & Path , args : & [ String ]) ->Result < Vec < Target >, String > { 97let mut targets =Vec :: new (); 98if args. is_empty () { 99let mut files =Vec :: new (); 100walk ( cwd, & mut files); 101 targets. extend ( files. into_iter (). map ( Target :: File )); 102} else { 103for argumentin args{ 104if is_remote ( argument) { 105let url = url:: Url :: parse ( argument). map_err ( |error| error. to_string ()) ?; 106if !matches! ( url. scheme (), "http" |"https" ) { 107return Err ( format! ( "Invalid test URL: {argument}" )); 108} 109 targets. push ( Target :: Remote ( url. to_string ())); 110continue ; 111} 112let path = cwd. join ( argument); 113let metadata = path 114. symlink_metadata () 115. map_err ( |_|format! ( "No such file or directory: {argument}" )) ?; 116// An explicit symlink names its target: a linked directory walks 117// like any explicit directory instead of failing at load time. 118if metadata. is_symlink () { 119let canonical = path. canonicalize (). map_err ( |error| error. to_string ()) ?; 120if canonical. is_dir () { 121let mut files =Vec :: new (); 122walk ( & canonical, & mut files); 123 targets. extend ( files. into_iter (). map ( Target :: File )); 124continue ; 125} 126 targets. push ( Target :: File ( canonical)); 127continue ; 128} 129if metadata. is_dir () { 130let canonical = path. canonicalize (). map_err ( |error| error. to_string ()) ?; 131let mut files =Vec :: new (); 132walk ( & canonical, & mut files); 133 targets. extend ( files. into_iter (). map ( Target :: File )); 134} else { 135let canonical = path. canonicalize (). map_err ( |error| error. to_string ()) ?; 136 targets. push ( Target :: File ( canonical)); 137} 138} 139} 140 targets. sort_by ( |a, b|match ( a, b) { 141( Target :: File ( a), Target :: File ( b)) => a. cmp ( b), 142( Target :: Remote ( a), Target :: Remote ( b)) => a. cmp ( b), 143( Target :: File ( _), Target :: Remote ( _)) => std:: cmp:: Ordering :: Less , 144( Target :: Remote ( _), Target :: File ( _)) => std:: cmp:: Ordering :: Greater , 145}); 146 targets. dedup_by ( |a, b|match ( a, b) { 147( Target :: File ( a), Target :: File ( b)) => a == b, 148( Target :: Remote ( a), Target :: Remote ( b)) => a == b, 149 _ =>false , 150}); 151if targets. is_empty () { 152return Err ( "No test files found" . into ()); 153} 154Ok ( targets) 155} 156 157pub struct TestArgs { 158pub base : Entry , 159pub args : Vec < String >, 160pub targets : Vec < Target >, 161pub filter : Option < String >, 162pub no_run : bool , 163} 164 165/// `avril test` argument parsing failed. 166# [ derive ( Debug , thiserror :: Error )] 167pub enum TestParseError { 168# [ error ( transparent )] 169Generic ( # [ from ] ParseError ), 170# [ error ( "{0}" )] 171Discover ( String ), 172} 173 174pub fn parse ( 175argv : Vec < String >, 176script : Vec < String >, 177cwd : PathBuf , 178) ->Result < TestArgs , TestParseError > { 179let mut parser =Parser :: new ( "test" , argv, & cwd) ?; 180let ( mut filter, mut no_run, mut positionals) =( None , false , Vec :: new ()); 181while let Some ( arg) = parser. next () ?{ 182match arg{ 183Arg :: Positional ( arg) => positionals. push ( arg), 184Arg :: Flag ( arg) if arg =="--filter" =>{ 185 filter =Some ( parser. value ( ParseError :: FilterPattern ) ?); 186} 187Arg :: Flag ( arg) if arg =="--no-run" => no_run =true , 188Arg :: Flag ( arg) =>match flag_value ( & arg) ?{ 189( "--filter" , value) => filter =Some ( value. to_owned ()), 190 _ =>return Err ( reject ( & arg, & [ "--no-run" ]). into ()), 191}, 192} 193} 194let mut targets =Vec :: new (); 195for targetin discover ( & cwd, & positionals). map_err ( TestParseError :: Discover ) ?{ 196match & target{ 197Target :: File ( path) => parser. common . initial . entry ( path), 198Target :: Remote ( url) =>{ 199let parsed = url:: Url :: parse ( url). map_err ( ParseError :: Url ) ?; 200let initial =& mut parser. common . initial ; 201 initial. remote_entry ( & parsed). map_err ( ParseError :: from) ?; 202} 203} 204 targets. push ( target); 205} 206// The lock path is resolved per file at run time (each file loads in 207// its own package root); this placeholder only fills the field. 208// project_root is likewise fixed per file later; explicit --lock 209// never changes it. Path and module are placeholders too: per-file 210// entries clone this base and set their own. 211let base = parser 212. common 213. finish ( cwd. clone (), String :: new (), cwd. join ( "avril.lock" )) ?; 214Ok ( TestArgs { 215 base, 216args : script, 217 targets, 218 filter, 219 no_run, 220}) 221} 222 223/// Runs every discovered test file in its own fresh isolate, sequentially, 224/// and prints the human-readable report shared with the guest runner. 225/// Mirrors how `run`/`serve` build their runtime. Each file gets a child of 226/// the CLI capabilities (so a pledge() in one file narrows only that file) 227/// with its own packages, store, and graph (so narrowing still applies to 228/// that file's loading exactly as under `run`). Returns Err on any failure 229/// so the process exits nonzero after the summary. 230pub fn run ( args : & mut TestArgs ) ->Result <(), Box < dyn std:: error:: Error >> { 231let cwd = args. base . caps . cwd (). to_owned (); 232// File paths must be valid UTF-8 before anything runs: a bad path aborts 233// the whole run here, exactly as when the modules list was built up front. 234for targetin & args. targets { 235if let Target :: File ( path) = target{ 236 path. to_str (). ok_or ( "Module paths must be valid UTF-8" ) ?; 237} 238} 239let stdio = avril_ext_stdio:: Stdio :: new () ?; 240// Declared before the JS runtimes so it outlives pending host work. 241let host = avril_host:: Host :: new () ?; 242let broker: Arc < avril_host:: messaging:: Broker > =Arc :: default (); 243let session = avril_ext_test:: Session :: new ( 244 avril_ext_test:: Config { 245filter : args. filter . clone (), 246no_run : args. no_run , 247}, 248 stdio. clone (), 249); 250for targetin & args. targets { 251// The upfront validation above makes the UTF-8 check below 252// unreachable, keeping this loop total without panicking. 253let ( display, path, name) =match target{ 254Target :: File ( path) =>( 255display ( & cwd, path), 256 path. clone (), 257 path. to_str () 258. ok_or ( "Module paths must be valid UTF-8" ) ? 259. to_owned (), 260), 261Target :: Remote ( url) =>( url. clone (), cwd. join ( ".avril-remote.js" ), url. clone ()), 262}; 263 session 264. lock () 265. map_err ( |error| error. to_string ()) ? 266. begin_file ( display); 267let result =( || ->Result < FileResult , Box < dyn std:: error:: Error >> { 268// A pledge() in this file must not narrow any other file: each 269// file snapshots the CLI capabilities, so restrictions stay local. 270let file =Entry { 271caps : Capabilities :: inherit ( args. base . caps . clone ()), 272 path, 273module : name. clone (), 274 ..args. base . clone () 275}; 276let ( authority, root, packages) = file. project ( false ) ?; 277let storage = 278 avril_webstorage:: Persistent :: new ( & packages. cache , & root. join ( "avril.conf" ), & name); 279let graph =crate :: fragments:: prepare_graph ( 280 host. handle (), 281 authority, 282 packages, 283& name, 284& file. includes , 285true , 286None , 287) ?; 288 graph. set_entry ( & name); 289let mut runtime = avril_runtime:: assembly:: runtime ( 290 graph. clone (), 291 avril_runtime:: assembly:: Settings { 292snapshot : Some ( crate :: snapshots:: embedded ()), 293 storage, 294caps : file. caps . clone (), 295environment : file. environment . clone (), 296args : args. args . clone (), 297store : graph. store (). clone (), 298runtime : host. handle (), 299broker : broker. clone (), 300process_signal : avril_ext_process:: SignalMode :: Run , 301}, 302 stdio. clone (), 303None , 304Some ( session. clone ()), 305) ?; 306// A spinning test never yields to the runner's own timers, so a 307// thread holding the isolate handle terminates it on overrun. 308let watch = session 309. lock () 310. map_err ( |error| error. to_string ()) ? 311. arm_watchdog (); 312let handle = runtime. isolate_handle (); 313let watcher = std:: thread:: Builder :: new () 314. name ( "avril:test-watchdog" . to_owned ()) 315. spawn ({ 316let watch = watch. clone (); 317move ||{ 318loop { 319 std:: thread:: sleep ( std:: time:: Duration :: from_millis ( 20 )); 320let Ok ( mut state) = watch. lock () else { 321return ; 322}; 323if state. stopped () { 324return ; 325} 326if state. expired () { 327 handle. terminate_execution (); 328 state. terminated (); 329return ; 330} 331} 332} 333}) 334. map_err ( |error| error. to_string ()) ?; 335let outcome = 336 runtime. execute_module_with_bootstrap ( & name, "ext:test/runner.js" , "runTests" ); 337if let Ok ( mut state) = watch. lock () { 338 state. shutdown (); 339} 340 watcher. join (). map_err ( |_|"Test watchdog thread failed" ) ?; 341// A clean return settles every question the watchdog could ask: 342// a termination landing afterwards hits an idle, dying isolate. 343let fired =if outcome. is_err () { 344match watch. lock () { 345Ok ( mut state) => state. outcome (), 346Err ( _) =>None , 347} 348} else { 349None 350}; 351match ( outcome, fired) { 352// The runner's timers observed every failure themselves. 353( Ok (()), _) =>Ok ( FileResult :: Clean ), 354// A spinning test died by termination: report its timeout and 355// fail the rest of the file with it. The raw termination 356// error explains nothing, so it never reaches the report. 357( Err ( _), Some ( avril_ext_test:: WatchdogOutcome :: Casualty { name, timeout_ms})) =>{ 358 session 359. lock () 360. map_err ( |error| error. to_string ()) ? 361. timeout_casualty ( name, timeout_ms); 362Ok ( FileResult :: Clean ) 363} 364// No test was running: evaluation or the runner itself stalled. 365( Err ( _), Some ( avril_ext_test:: WatchdogOutcome :: Backstop )) =>{ 366Ok ( FileResult :: Failed ( 367"Test run made no progress for 60s; execution was terminated" . to_owned (), 368)) 369} 370( Err ( error), None ) =>Ok ( FileResult :: Failed ( error. to_string ())), 371} 372})(); 373 stdio. flush () ?; 374match result{ 375Ok ( FileResult :: Clean ) => session 376. lock () 377. map_err ( |error| error. to_string ()) ? 378. end_file ( None ), 379Ok ( FileResult :: Failed ( message)) => session 380. lock () 381. map_err ( |error| error. to_string ()) ? 382. end_file ( Some ( message)), 383Err ( error) => session 384. lock () 385. map_err ( |error| error. to_string ()) ? 386. end_file ( Some ( error. to_string ())), 387} 388} 389 stdio. flush () ?; 390if session. lock (). map_err ( |error| error. to_string ()) ?. finish () { 391return Err ( "test failed" . into ()); 392} 393Ok (()) 394}