char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
7325e8c
main
1use std:: rc:: Rc ; 2use std:: sync::{ Arc , Mutex }; 3 4use avril_core::{ Extension , Op , PublicModule }; 5 6use avril_ext_stdio:: Stdio ; 7 8/// Harness options shared by every per-file runtime of one `avril test` run. 9# [ derive ( Clone , Default )] 10pub struct Config { 11pub filter : Option < String >, 12pub no_run : bool , 13} 14 15# [ derive ( Clone , Copy , Default )] 16struct Counts { 17passed : usize , 18failed : usize , 19ignored : usize , 20filtered : usize , 21found : usize , 22} 23 24struct File { 25display : String , 26counts : Counts , 27error : Option < String >, 28} 29 30trait Files { 31fn files ( self ) ->String ; 32} 33 34/// Reporter colors follow `console` so test verdicts match `avril add`: 35/// colored only for a terminal without `NO_COLOR`. All test output goes to 36/// stdout, so only stdout decides. 37fn use_colors () ->bool { 38 avril_ext_console:: colors ( false ) 39} 40 41fn paint ( colors : bool , style : & str , text : & str ) ->String { 42if colors{ 43format! ( "\x1b[{style}m{text}\x1b[0m" ) 44} else { 45 text. to_owned () 46} 47} 48 49impl Files for usize { 50fn files ( self ) ->String { 51if self ==1 { 52"1 file" . to_owned () 53} else { 54format! ( "{self} files" ) 55} 56} 57} 58 59/// Reporter state shared across the sequential per-file runtimes of a run. 60/// Guest test results arrive through `op_test_event` while its file runs. 61pub struct Session { 62config : Config , 63stdio : Rc < Stdio >, 64files : Vec < File >, 65watch : Arc < Mutex < WatchState >>, 66} 67 68/// Per-file watchdog state, shared with the thread holding the isolate handle. 69/// The guest runner arms deadlines through `start` events; the thread 70/// terminates execution when one passes (a spinning test never yields to the 71/// runner's own timers). `done` and test-end events re-arm or clear them. 72pub struct WatchState { 73deadline : Option < std:: time:: Instant >, 74running : Option <( String , u64 )>, 75stopped : bool , 76fired : bool , 77} 78 79/// What a fired watchdog interrupted. 80pub enum WatchdogOutcome { 81/// A test overran its own timeout while spinning. 82Casualty { name : String , timeout_ms : u64 }, 83/// Nothing was running: top-level evaluation or a gap between tests 84/// made no progress within the file budget. 85Backstop , 86} 87 88impl WatchState { 89fn reset ( & mut self ) { 90self . deadline =None ; 91self . running =None ; 92self . stopped =false ; 93self . fired =false ; 94} 95 96/// Tells the watcher thread to exit; the run calls this after every file. 97pub fn shutdown ( & mut self ) { 98self . stopped =true ; 99} 100 101/// Whether an armed deadline has passed while the thread is still wanted. 102pub fn expired ( & self ) ->bool { 103 !self . stopped 104 &&self 105. deadline 106. is_some_and ( |deadline| std:: time:: Instant :: now () >= deadline) 107} 108 109pub fn stopped ( & self ) ->bool { 110self . stopped 111} 112 113/// Records that the isolate was terminated; called by the watcher thread. 114pub fn terminated ( & mut self ) { 115self . fired =true ; 116} 117 118/// Arms a test's deadline: its own timeout plus slack for the runner's 119/// timers to win every observable race, or the file budget when the test 120/// opted out of timeouts. 121fn arm_test ( & mut self , name : String , timeout_ms : Option < u64 >) { 122let budget = timeout_ms. map_or ( FILE_BUDGET , |ms|{ 123 std:: time:: Duration :: from_millis ( ms) +WATCHDOG_SLACK 124}); 125self . running =Some (( name, timeout_ms. unwrap_or ( 0 ))); 126self . deadline =Some ( std:: time:: Instant :: now () + budget); 127} 128 129fn arm_file ( & mut self ) { 130self . running =None ; 131self . deadline =Some ( std:: time:: Instant :: now () +FILE_BUDGET ); 132} 133 134fn disarm ( & mut self ) { 135self . running =None ; 136self . deadline =None ; 137} 138 139/// What the watchdog interrupted, if it fired. 140pub fn outcome ( & mut self ) ->Option < WatchdogOutcome > { 141if !self . fired { 142return None ; 143} 144Some ( match self . running . take () { 145Some (( name, timeout_ms)) =>WatchdogOutcome :: Casualty { name, timeout_ms}, 146None =>WatchdogOutcome :: Backstop , 147}) 148} 149} 150 151/// Extra wall time after a test's own timeout before the watchdog treats it 152/// as spinning: the runner's timers win every race they can observe. 153const WATCHDOG_SLACK : std:: time:: Duration = std:: time:: Duration :: from_secs ( 2 ); 154/// Progress budget for a file with no running test (top-level evaluation, 155/// gaps between tests): afterwards the file is terminated outright. 156const FILE_BUDGET : std:: time:: Duration = std:: time:: Duration :: from_secs ( 60 ); 157 158pub type Shared =Rc < Mutex < Session >>; 159 160impl Session { 161pub fn new ( config : Config , stdio : Rc < Stdio >) ->Shared { 162Rc :: new ( Mutex :: new ( Self { 163 config, 164 stdio, 165files : Vec :: new (), 166watch : Arc :: new ( Mutex :: new ( WatchState { 167deadline : None , 168running : None , 169stopped : false , 170fired : false , 171})), 172})) 173} 174 175/// Hands the current file's watchdog state to its watcher thread. 176/// Resets any previous file's outcome first, then re-arms the file 177/// budget: reset() would otherwise leave top-level evaluation with no 178/// deadline until the first test starts. 179pub fn arm_watchdog ( & mut self ) ->Arc < Mutex < WatchState >> { 180if let Ok ( mut watch) =self . watch . lock () { 181 watch. reset (); 182 watch. arm_file (); 183} 184self . watch . clone () 185} 186 187pub fn begin_file ( & mut self , display : String ) { 188let colors =use_colors (); 189let _ =self 190. stdio 191. ( false , format! ( "Check {}\n" , paint ( colors, "1" , & display))); 192self . files . push ( File { 193 display, 194counts : Counts :: default (), 195error : None , 196}); 197// Top-level evaluation gets the file budget until the first test 198// arms its own deadline. 199if let Ok ( mut watch) =self . watch . lock () { 200 watch. arm_file (); 201} 202} 203 204pub fn end_file ( & mut self , error : Option < String >) { 205let Some ( file) =self . files . last_mut () else { 206return ; 207}; 208 file. error = error; 209let counts = file. counts ; 210let colors =use_colors (); 211let line =if let Some ( error) =& file. error { 212// File errors never reach a test: prepare and evaluation failures 213// surface here, so the footer carries their detail. Runner frames 214// add nothing, as in per-test stacks. 215let mut line =format! ( 216"{} - {}\n" , 217paint ( colors, "1;31" , "FAILED" ), 218paint ( colors, "1" , & file. display ), 219); 220for textin error. lines () { 221if text. contains ( "ext:test/runner.js" ) 222 || text. contains ( "ext:test/main.js" ) 223 || text. contains ( "ext:test/shared.js" ) 224{ 225continue ; 226} 227 line. push_str ( & format! ( " {text}\n" )); 228} 229 line 230} else if self . config . no_run { 231format! ( 232"{} - {} ({} tests, not run)\n" , 233paint ( colors, "36" , "found" ), 234paint ( colors, "1" , & file. display ), 235 counts. found , 236) 237} else { 238let verdict =if counts. failed ==0 { "ok" } else { "FAILED" }; 239let style =if counts. failed ==0 { "1;32" } else { "1;31" }; 240format! ( 241"{} - {} ({} passed, {} failed, {} ignored, {} filtered)\n" , 242paint ( colors, style, verdict), 243paint ( colors, "1" , & file. display ), 244 counts. passed , 245 counts. failed , 246 counts. ignored , 247 counts. filtered , 248) 249}; 250let _ =self . stdio . ( false , line); 251} 252 253fn event ( & mut self , event : TestEvent ) { 254// The runner's bookkeeping never reaches the report. 255if event. kind =="start" { 256if event. depth ==0 257 &&let Ok ( mut watch) =self . watch . lock () 258{ 259 watch. arm_test ( event. name , event. timeout_ms ); 260} 261return ; 262} 263if event. kind =="done" { 264if let Ok ( mut watch) =self . watch . lock () { 265 watch. disarm (); 266} 267return ; 268} 269let Some ( file) =self . files . last_mut () else { 270return ; 271}; 272// Steps report indented under their test but never touch the file 273// totals: only top-level tests count, as in the final summary. 274let count = event. depth ==0 ; 275let indent =" " . repeat ( event. depth . min ( 8 )); 276let colors =use_colors (); 277let line =match event. kind . as_str () { 278"pass" =>{ 279if count{ 280 file. counts . passed +=1 ; 281} 282format! ( 283"{indent}{} - {} ({}ms)\n" , 284paint ( colors, "1;32" , "ok" ), 285 event. name , 286 event. elapsed_ms , 287) 288} 289"fail" =>{ 290if count{ 291 file. counts . failed +=1 ; 292} 293let mut line =format! ( 294"{indent}{} - {} ({}ms)\n" , 295paint ( colors, "1;31" , "FAILED" ), 296 event. name , 297 event. elapsed_ms , 298); 299for textin event. text . lines () { 300 line. push_str ( & format! ( "{indent} {text}\n" )); 301} 302 line 303} 304"ignore" =>{ 305if count{ 306 file. counts . ignored +=1 ; 307} 308format! ( 309"{indent}{} - {}\n" , 310paint ( colors, "33" , "ignored" ), 311 event. name , 312) 313} 314"filtered" =>{ 315 file. counts . filtered +=1 ; 316String :: new () 317} 318"found" =>{ 319 file. counts . found +=1 ; 320format! ( "{indent}{} - {}\n" , paint ( colors, "36" , "test" ), event. name ,) 321} 322 _ =>String :: new (), 323}; 324if !line. is_empty () { 325let _ =self . stdio . ( false , line); 326} 327// Between tests the file budget guards gaps in the runner itself. 328if count 329 &&matches! ( event. kind . as_str (), "pass" |"fail" |"ignore" ) 330 &&let Ok ( mut watch) =self . watch . lock () 331{ 332 watch. arm_file (); 333} 334} 335 336/// Reports a test the watchdog terminated while spinning: the failure the 337/// runner's own timers could never observe. Reads like any other timeout. 338pub fn timeout_casualty ( & mut self , name : String , timeout_ms : u64 ) { 339self . event ( TestEvent { 340kind : "fail" . to_owned (), 341name : name. clone (), 342depth : 0 , 343elapsed_ms : timeout_ms, 344text : format! ( "Error: Test \"{name}\" timed out after {timeout_ms}ms" ), 345timeout_ms : None , 346}); 347} 348 349/// Prints the final summary; true when the run failed. 350pub fn finish ( & self ) ->bool { 351let mut passed =0 ; 352let mut failed =0 ; 353let mut ignored =0 ; 354let mut filtered =0 ; 355let mut found =0 ; 356let mut errors =0 ; 357for filein & self . files { 358 passed += file. counts . passed ; 359 failed += file. counts . failed ; 360 ignored += file. counts . ignored ; 361 filtered += file. counts . filtered ; 362 found += file. counts . found ; 363 errors += usize:: from ( file. error . is_some ()); 364} 365let failed_run = failed >0 || errors >0 ; 366let files =self . files . len (); 367let colors =use_colors (); 368let verdict =if failed_run{ "FAILED" } else { "ok" }; 369let style =if failed_run{ "1;31" } else { "1;32" }; 370let line =if self . config . no_run { 371format! ( 372"test result: {}. found {found} tests in {} (not run).\n" , 373paint ( colors, style, verdict), 374 files. files (), 375) 376} else { 377format! ( 378"test result: {}. {passed} passed; {failed} failed; {ignored} ignored; {filtered} filtered; {}.\n" , 379paint ( colors, style, verdict), 380 files. files (), 381) 382}; 383let _ =self . stdio . ( false , line); 384 failed_run 385} 386} 387 388# [ derive ( serde :: Serialize )] 389# [ serde ( rename_all = "camelCase" )] 390struct ConfigSnapshot { 391filter : Option < String >, 392no_run : bool , 393} 394 395# [ derive ( serde :: Deserialize )] 396# [ serde ( rename_all = "camelCase" )] 397struct TestEvent { 398kind : String , 399# [ serde ( default )] 400name : String , 401# [ serde ( default )] 402depth : usize , 403# [ serde ( default )] 404elapsed_ms : u64 , 405# [ serde ( default )] 406text : String , 407# [ serde ( default )] 408timeout_ms : Option < u64 >, 409} 410 411pub fn init ( session : Shared ) ->Extension { 412let config = session. clone (); 413let events = session. clone (); 414Extension { 415name : "ext:test" , 416dependencies : & [ "ext:timers" , "ext:web" ], 417ops : vec! [ 418Op :: new ( "op_test_config" , move |_: () |{ 419let session = config 420. lock () 421. map_err ( |_| std:: io:: Error :: other ( "Test session is unavailable" )) ?; 422Ok ::< _ , std:: io:: Error >( ConfigSnapshot { 423filter : session. config . filter . clone (), 424no_run : session. config . no_run , 425}) 426}), 427Op :: new ( "op_test_event" , move |event : TestEvent |{ 428// Worker isolates use an idle session nobody reads. 429 events 430. lock () 431. map_err ( |_| std:: io:: Error :: other ( "Test session is unavailable" )) ? 432. event ( event); 433Ok ::< _ , std:: io:: Error >(()) 434}), 435], 436modules : const { 437& [ 438( 439"ext:test/runner.js" , 440include_str! ( concat! ( env! ( "OUT_DIR" ), "/runner.js" )), 441), 442( 443"ext:test/shared.js" , 444include_str! ( concat! ( env! ( "OUT_DIR" ), "/shared.js" )), 445), 446PUBLIC_MODULES [ 0 ]. module , 447] 448}, 449entry_point : Some ( "ext:test/main.js" ), 450} 451} 452 453pub const PUBLIC_MODULES : & [ PublicModule ] = 454& [ avril_core:: public_module!( "avril:test" , "ext:test" , "main" )]; 455 456/// A throwaway session for runtimes outside `avril test`, where `test()` 457/// registers harmlessly and the runner never executes. 458pub fn idle ( stdio : & Rc < Stdio >) ->Shared { 459Session :: new ( Config :: default (), stdio. clone ()) 460} 461 462# [ cfg ( test )] 463mod tests{ 464use super :: paint; 465 466# [ test ] 467fn disabled_colors_leave_text_untouched () { 468assert_eq! ( paint ( false , "1;32" , "ok" ), "ok" ); 469assert_eq! ( paint ( false , "1;31" , "FAILED - name" ), "FAILED - name" ); 470} 471 472# [ test ] 473fn enabled_colors_wrap_text_in_a_reset_pair () { 474for ( style, text) in [( "1;32" , "ok" ), ( "1;31" , "FAILED" ), ( "33" , "ignored" )] { 475let painted =paint ( true , style, text); 476assert! ( painted. starts_with ( & format! ( "\x1b[{style}m" ))); 477assert! ( painted. ends_with ( "\x1b[0m" )); 478assert! ( painted. contains ( text)); 479assert_ne! ( painted, text); 480} 481} 482}