char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
eae10ac
main
1import { 2ArrayPrototypeJoin , 3ArrayPrototypePush , 4ArrayPrototypeSplice , 5Error , 6ErrorGetStack , 7ObjectCreate , 8Promise , 9String , 10TypeError , 11uncurryThis , 12} from "ext:core/primordials" ; 13import { op_test_config , op_test_event } from "ext:core/ops" ; 14import type { TestEvent } from "ext:core/ops" ; 15import type { TestContext } from "./main.js" ; 16import { clearTimeout , setTimeout } from "ext:timers/timers.js" ; 17import { required , usv } from "ext:web/idl.js" ; 18import { flag , optionsOf , registry , type Definition , type ParsedStep } from "./shared.js" ; 19 20type Report = ( event :TestEvent ) => void ; 21interface Shared { 22settled :boolean ; 23test :string ; 24} 25interface Local { 26running :boolean ; 27pending :Promise < boolean >[]; 28failed :number ; 29} 30const indexOf = uncurryThis ( String . prototype . indexOf ); 31const { now :dateNow } = Date ; 32 33function parseStep ( nameOrOptions :unknown , fn :unknown , count :number ) :ParsedStep { 34required ( count , 1 ); 35if ( typeof nameOrOptions === "string" ) { 36if ( count > 2 || ( count === 2 && typeof fn !== "function" )) { 37throw new TypeError ( "Expected step(name, fn)" ); 38} 39// The string form cannot express ignore, so a missing function is always 40// an error here rather than a late internal TypeError. 41if ( fn === undefined ) throw new TypeError ( `Step " ${ usv ( nameOrOptions )} " requires a function` ); 42return { name :usv ( nameOrOptions ), fn, ignore :false } as ParsedStep ; 43} 44if ( count > 1 ) throw new TypeError ( "Expected step(options)" ); 45const options = optionsOf ( nameOrOptions , [ "name" , "fn" , "ignore" ], "step" ); 46if ( options . name === undefined ) throw new TypeError ( "Step requires a name" ); 47const ignore = flag ( options , "ignore" , "Step" ); 48const func = options . fn ; 49if ( func === undefined ) { 50if ( ! ignore ) throw new TypeError ( `Step " ${ usv ( options . name )} " requires a function` ); 51} else if ( typeof func !== "function" ) { 52throw new TypeError ( `Step " ${ usv ( options . name )} " requires a function` ); 53} 54return { name :usv ( options . name ), fn :func , ignore} as ParsedStep ; 55} 56 57function formatError ( error :unknown ) :string { 58let text ; 59try { 60if ( error instanceof Error ) { 61const stack = ErrorGetStack ( error ); 62text = typeof stack === "string" && stack !== "" ?stack :String ( error ); 63} else { 64text = String ( error ); 65} 66} catch { 67return "Unknown error" ; 68} 69// The runner's own frames never explain a failure. 70const lines = text . split ( "\n" ); 71const kept = [ lines [ 0 ]]; 72for ( let index = 1 ; index < lines . length ; index ++ ) { 73if ( 74indexOf ( lines [ index ], "ext:test/runner.js" ) === - 1 && 75indexOf ( lines [ index ], "ext:test/main.js" ) === - 1 && 76indexOf ( lines [ index ], "ext:test/shared.js" ) === - 1 77) { 78ArrayPrototypePush ( kept , lines [ index ]); 79} 80} 81return ArrayPrototypeJoin ( kept , "\n" ); 82} 83 84function withTimeout ( work :() => Promise < void >, ms :number , name :string ) :Promise < void > { 85let timer :number | undefined ; 86const started = (() => { 87try { 88return { failed :false , value :work () }; 89} catch ( error ) { 90return { failed :true , error}; 91} 92})(); 93const attempt = started . failed 94 ?Promise . reject ( started . error ) 95 :Promise . resolve ( started . value ); 96if ( ! ( ms < Infinity )) return attempt ; 97const expired = new Promise < never >(( _ , reject ) => { 98timer = setTimeout ( 99() => reject ( new Error ( `Test " ${ name } " timed out after ${ ms } ms` )), 100ms , 101); 102}); 103return Promise . race ([ attempt , expired ]). then ( 104( value ) => { 105clearTimeout ( timer ); 106return value ; 107}, 108( error ) => { 109clearTimeout ( timer ); 110throw error ; 111}, 112); 113} 114 115// One test's contexts: `shared` observes the whole test (its name for 116// stale-step errors and the settled flag), while each `local` tracks only 117// its own running step, pending children, and failed children — so a 118// concurrent violation fails its caller and ancestors, never its victim. 119function context ( name :string , depth :number , report :Report , shared :Shared , local :Local ) :TestContext { 120const self :{ name :string ; step :TestContext [ "step" ] } = ObjectCreate ( null ); 121self . name = name ; 122self . step = function step ( nameOrOptions :unknown , fn ?:unknown ) :Promise < boolean > { 123required ( arguments . length , 1 ); 124const parsed = parseStep ( nameOrOptions , fn , arguments . length ); 125if ( shared . settled ) { 126throw new TypeError ( `Step " ${ parsed . name } " called after " ${ shared . test } " settled` ); 127} 128if ( local . running ) { 129local . failed += 1 ; 130report ({ 131kind :"fail" , 132name :parsed . name , 133depth :depth + 1 , 134elapsedMs :0 , 135text :`Error: Started step " ${ parsed . name } " while another step was running` , 136}); 137return Promise . resolve ( false ); 138} 139local . running = true ; 140const outcome = runStep ( local , parsed , depth + 1 , report , shared ); 141ArrayPrototypePush ( local . pending , outcome ); 142return outcome ; 143}; 144return self ; 145} 146 147// Waits out every step a body started without awaiting: an unawaited step 148// still decides its parent. Loops so a step spawned from a step callback 149// joins the same drain; the test timeout bounds evasive patterns. 150async function drainSteps ( local :Local ) :Promise < void > { 151let waited = 0 ; 152while ( waited < local . pending . length ) { 153waited = local . pending . length ; 154await Promise . all ( local . pending ); 155} 156} 157 158function failedStepsText ( failed :number ) :string { 159return `due to ${ failed } failed step ${ failed === 1 ? "" : "s" } ` ; 160} 161 162// A step never throws its body's failure: it reports and resolves false, 163// while the failure still fails every ancestor up to the test. 164async function runStep ( owner :Local , parsed :ParsedStep , depth :number , report :Report , shared :Shared ) :Promise < boolean > { 165const local :Local = { running :false , pending :[], failed :0 }; 166const start = dateNow (); 167try { 168if ( parsed . ignore ) { 169report ({ kind :"ignore" , name :parsed . name , depth}); 170return false ; 171} 172await parsed . fn ( context ( parsed . name , depth , report , shared , local )); 173await drainSteps ( local ); 174// A completion after the test settled (a timed-out body waking up) has 175// no parent left to report under, so it is dropped. 176if ( shared . settled ) return false ; 177if ( local . failed > 0 ) throw new Error ( failedStepsText ( local . failed )); 178report ({ kind :"pass" , name :parsed . name , depth, elapsedMs :dateNow () - start }); 179return true ; 180} catch ( error ) { 181if ( shared . settled ) return false ; 182report ({ 183kind :"fail" , 184name :parsed . name , 185 depth, 186elapsedMs :dateNow () - start , 187text :formatError ( error ), 188}); 189owner . failed += 1 ; 190return false ; 191} finally { 192owner . running = false ; 193} 194} 195 196async function runTest ( definition :Definition , report :Report ) :Promise < void > { 197if ( definition . ignore ) { 198report ({ kind :"ignore" , name :definition . name , depth :0 }); 199return ; 200} 201const shared = { settled :false , test :definition . name }; 202const local :Local = { running :false , pending :[], failed :0 }; 203const start = dateNow (); 204try { 205await withTimeout ( async () => { 206await definition . fn ( context ( definition . name , 0 , report , shared , local )); 207await drainSteps ( local ); 208if ( local . failed > 0 ) throw new Error ( failedStepsText ( local . failed )); 209}, definition . timeout , definition . name ); 210report ({ 211kind :"pass" , 212name :definition . name , 213depth :0 , 214elapsedMs :dateNow () - start , 215}); 216} catch ( error ) { 217report ({ 218kind :"fail" , 219name :definition . name , 220depth :0 , 221elapsedMs :dateNow () - start , 222text :formatError ( error ), 223}); 224} finally { 225shared . settled = true ; 226} 227} 228 229export async function runTests () :Promise < void > { 230registry . sealed = true ; 231const config = op_test_config (); 232const filter = config . filter ?? null ; 233const matches = ( name :string ) :boolean => 234filter === null || indexOf ( name , filter ) !== - 1 ; 235const selected :Definition [] = []; 236for ( let index = 0 ; index < registry . definitions . length ; index ++ ) { 237if ( matches ( registry . definitions [ index ]. name )) { 238ArrayPrototypePush ( selected , registry . definitions [ index ]); 239} else { 240op_test_event ({ kind :"filtered" , name :registry . definitions [ index ]. name , depth :0 }); 241} 242} 243if ( config . noRun ) { 244for ( let index = 0 ; index < selected . length ; index ++ ) { 245const definition = selected [ index ]; 246op_test_event ({ 247kind :definition . ignore ?"ignore" :"found" , 248name :definition . name , 249depth :0 , 250}); 251} 252op_test_event ({ kind :"done" }); 253return ; 254} 255let focused = false ; 256for ( let index = 0 ; index < selected . length ; index ++ ) { 257if ( selected [ index ]. only ) focused = true ; 258} 259// Rejections nobody handles would otherwise abort the file through the 260// host with no attribution, or vanish once the run resolves. Collect them 261// under the running test and fail the file with the full list instead. 262const strays :{ promise :Promise < unknown >; reason :unknown ; test :string | null }[] = []; 263let current :string | null = null ; 264function onUnhandled ( event :PromiseRejectionEvent ) :void { 265try { 266event . preventDefault (); 267} catch { 268// The host decides; the list below still records what was lost. 269} 270ArrayPrototypePush ( 271strays , 272{ promise :event . promise , reason :event . reason , test :current }, 273); 274} 275function onHandled ( event :PromiseRejectionEvent ) :void { 276for ( let index = 0 ; index < strays . length ; index ++ ) { 277if ( strays [ index ]. promise === event . promise ) { 278ArrayPrototypeSplice ( strays , index , 1 ); 279break ; 280} 281} 282} 283globalThis . addEventListener ( "unhandledrejection" , onUnhandled ); 284globalThis . addEventListener ( "rejectionhandled" , onHandled ); 285try { 286for ( let index = 0 ; index < selected . length ; index ++ ) { 287const definition = selected [ index ]; 288if ( focused && ! definition . only ) { 289op_test_event ({ kind :"filtered" , name :definition . name , depth :0 }); 290continue ; 291} 292op_test_event ({ 293kind :"start" , 294name :definition . name , 295depth :0 , 296timeoutMs :definition . timeout === Infinity ?null :definition . timeout , 297}); 298current = definition . name ; 299await runTest ( definition , op_test_event ); 300current = null ; 301} 302op_test_event ({ kind :"done" }); 303// Let strays queued during the run surface before judging them. 304await new Promise < void >(( resolve ) => setTimeout ( resolve , 0 )); 305await new Promise < void >(( resolve ) => setTimeout ( resolve , 0 )); 306if ( strays . length > 0 ) { 307const lines = [ "Unhandled rejections during the test run:" ]; 308for ( let index = 0 ; index < strays . length ; index ++ ) { 309const entry = strays [ index ]; 310const during = entry . test === null ?"after the run" :`during " ${ entry . test } "` ; 311ArrayPrototypePush ( lines , ` ${ during } :` ); 312const text = formatError ( entry . reason ). split ( "\n" ); 313for ( let line = 0 ; line < text . length ; line ++ ) { 314ArrayPrototypePush ( lines , ` ${ text [ line ]} ` ); 315} 316} 317throw new Error ( ArrayPrototypeJoin ( lines , "\n" )); 318} 319} finally { 320globalThis . removeEventListener ( "unhandledrejection" , onUnhandled ); 321globalThis . removeEventListener ( "rejectionhandled" , onHandled ); 322} 323}