char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
688e96e
main
1mod common; 2 3use std:: fs; 4use std:: path:: Path ; 5use std:: process::{ Child , Command , Stdio }; 6use std:: time::{ Duration , Instant }; 7 8const HELPERS : & str =r#" 9const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 10async function nextWithin(it, ms, label) { 11let timer; 12const timeout = new Promise((_, reject) => { 13timer = setTimeout(() => reject(new Error(`Timed out waiting for ${label}`)), ms); 14}); 15try { return await Promise.race([it.next(), timeout]); } 16finally { clearTimeout(timer); } 17} 18async function until(it, predicate, label) { 19const seen = []; 20const deadline = Date.now() + 8000; 21while (Date.now() < deadline) { 22const step = await nextWithin(it, Math.max(1, deadline - Date.now()), label); 23if (step.done) throw new Error(`Watcher ended waiting for ${label}: ${JSON.stringify(seen)}`); 24seen.push(step.value); 25if (predicate(step.value, seen)) return { event: step.value, seen }; 26} 27throw new Error(`Timed out waiting for ${label}: ${JSON.stringify(seen)}`); 28} 29async function untilDone(it, label) { 30const seen = []; 31const deadline = Date.now() + 8000; 32while (Date.now() < deadline) { 33const step = await nextWithin(it, Math.max(1, deadline - Date.now()), label); 34if (step.done) return seen; 35seen.push(step.value); 36} 37throw new Error(`Timed out waiting for watcher to end: ${JSON.stringify(seen)}`); 38} 39function hasPath(event, suffix) { 40return event.paths.some((path) => path.endsWith(suffix)); 41} 42"# ; 43 44fn spawn_with ( source : & str , setup : impl FnOnce ( & Path )) ->( tempfile:: TempDir , Child ) { 45let dir = tempfile:: tempdir (). unwrap (); 46setup ( dir. path ()); 47 fs:: write ( dir. path (). join ( "main.js" ), format! ( "{HELPERS}\n{source}" )). unwrap (); 48let child =Command :: new ( env! ( "CARGO_BIN_EXE_avril" )) 49. arg ( "run" ) 50. current_dir ( dir. path ()) 51. arg ( "main.js" ) 52. stdin ( Stdio :: null ()) 53. stdout ( Stdio :: piped ()) 54. stderr ( Stdio :: piped ()) 55. spawn () 56. unwrap (); 57( dir, child) 58} 59 60fn run_with ( 61source : & str , 62flags : & [ & str ], 63setup : impl FnOnce ( & Path ), 64) ->( tempfile:: TempDir , std:: process:: Output ) { 65let dir = tempfile:: tempdir (). unwrap (); 66setup ( dir. path ()); 67 fs:: write ( dir. path (). join ( "main.js" ), format! ( "{HELPERS}\n{source}" )). unwrap (); 68let child =Command :: new ( env! ( "CARGO_BIN_EXE_avril" )) 69. arg ( "run" ) 70. current_dir ( dir. path ()) 71. args ( flags) 72. arg ( "main.js" ) 73. stdin ( Stdio :: null ()) 74. stdout ( Stdio :: piped ()) 75. stderr ( Stdio :: piped ()) 76. spawn () 77. unwrap (); 78let output = common:: wait ( child); 79assert! ( 80 output. status . success (), 81"{}" , 82String :: from_utf8_lossy ( & output. stderr ) 83); 84( dir, output) 85} 86 87fn run ( source : & str , flags : & [ & str ]) ->( tempfile:: TempDir , std:: process:: Output ) { 88run_with ( source, flags, |_|{}) 89} 90 91# [ test ] 92fn reports_file_and_recursive_directory_events () { 93let ( _dir, output) =run ( 94r#" 95import {FsWatcher, errors, mkdir, realPath, remove, rename, watchFs} from 'avril:std'; 96import {mkdirs, writeTextFile} from 'avril:fs'; 97try { new FsWatcher(); throw Error('Constructed watcher'); } 98catch (error) { if (!(error instanceof TypeError)) throw error; } 99await mkdirs('root/sub'); 100await writeTextFile('data.txt', 'one'); 101const root = await realPath('root'); 102const watcher = watchFs(new URL('./root', import.meta.url)); 103if (!(watcher instanceof FsWatcher)) throw Error('Watcher brand'); 104if (Object.prototype.toString.call(watcher) !== '[object FsWatcher]') throw Error('Watcher tag'); 105try { structuredClone(watcher); throw Error('Cloned watcher'); } 106catch (error) { if (error.name !== 'DataCloneError') throw error; } 107const it = watcher[Symbol.asyncIterator](); 108await writeTextFile('root/hello.txt', 'x'); 109const created = await until(it, (event) => event.kind === 'create' && event.paths[0] === `${root}/hello.txt`, 'create'); 110if (!Object.isFrozen(created.event) || !Object.isFrozen(created.event.paths)) throw Error('Watcher event was mutable'); 111await writeTextFile('root/hello.txt', 'longer'); 112await until(it, (event) => event.kind === 'modify' && hasPath(event, '/hello.txt'), 'modify'); 113await mkdir('root/sub/new'); 114await until(it, (event) => hasPath(event, '/sub/new'), 'new directory'); 115await writeTextFile('root/sub/new/inner.txt', 'inner'); 116await until(it, (event) => hasPath(event, '/sub/new/inner.txt'), 'file in new directory'); 117await rename('root/hello.txt', 'root/renamed.txt'); 118await until(it, (event, seen) => 119event.kind === 'rename' && event.paths[0] === `${root}/hello.txt` && event.paths[1] === `${root}/renamed.txt` || 120hasPath(event, '/renamed.txt') && seen.some((item) => hasPath(item, '/hello.txt')), 'rename'); 121await remove('root/renamed.txt'); 122await until(it, (event) => event.kind === 'remove' && hasPath(event, '/renamed.txt'), 'remove'); 123watcher.close(); 124if (!(await it.next()).done) throw Error('Closed watcher produced an event'); 125try { watcher.close(); throw Error('Closed watcher twice'); } 126catch (error) { if (!(error instanceof errors.BadResource)) throw error; } 127watcher[Symbol.dispose](); 128 129const fileWatcher = watchFs('data.txt'); 130const fileIt = fileWatcher[Symbol.asyncIterator](); 131await writeTextFile('data.txt', 'two'); 132await until(fileIt, (event) => hasPath(event, '/data.txt'), 'file root event'); 133await writeTextFile('data.tmp', 'replacement'); 134await rename('data.tmp', 'data.txt'); 135await until(fileIt, (event) => hasPath(event, '/data.txt'), 'file root replacement'); 136await writeTextFile('data.txt', 'three'); 137await until(fileIt, (event) => hasPath(event, '/data.txt'), 'replaced file root event'); 138fileWatcher.close(); 139console.log('watch events'); 140"# , 141& [], 142); 143assert_eq! ( output. stdout , b"watch events\n" ); 144} 145 146# [ test ] 147fn honors_nonrecursive_mode_and_multiple_roots () { 148let ( _dir, output) =run ( 149r#" 150import {watchFs} from 'avril:std'; 151import {mkdirs, writeTextFile} from 'avril:fs'; 152await mkdirs('a/sub'); 153await mkdirs('b'); 154const watcher = watchFs(['a', 'b'], {recursive: false}); 155const it = watcher[Symbol.asyncIterator](); 156await writeTextFile('b/direct.txt', 'b'); 157await until(it, (event) => hasPath(event, '/b/direct.txt'), 'second root'); 158await writeTextFile('a/sub/hidden.txt', 'hidden'); 159await writeTextFile('a/direct.txt', 'a'); 160const {seen} = await until(it, (event) => hasPath(event, '/a/direct.txt'), 'first root direct child'); 161if (seen.some((event) => hasPath(event, '/hidden.txt'))) throw Error('Non-recursive watcher reported a descendant'); 162watcher[Symbol.dispose](); 163if (!(await it.next()).done) throw Error('Disposed watcher produced an event'); 164console.log('watch scope'); 165"# , 166& [], 167); 168assert_eq! ( output. stdout , b"watch scope\n" ); 169} 170 171# [ test ] 172fn lifecycle_covers_close_abort_break_and_invalid_arguments () { 173let ( _dir, output) =run ( 174r#" 175import {errors, watchFs} from 'avril:std'; 176import {mkdirs, writeTextFile} from 'avril:fs'; 177await mkdirs('root'); 178for (const call of [ 179() => watchFs(), 180() => watchFs([]), 181() => watchFs(42), 182() => watchFs(['root', {}]), 183() => watchFs('root', {recursive: 'yes'}), 184() => watchFs('root', {signal: {}}), 185]) { 186try { call(); throw Error('Accepted invalid watcher arguments'); } 187catch (error) { if (!(error instanceof TypeError)) throw error; } 188} 189try { watchFs('missing'); throw Error('Watched a missing path'); } 190catch (error) { if (!(error instanceof errors.NotFound)) throw error; } 191const duplicate = watchFs(['root', 'root']); 192duplicate.close(); 193 194const controller = new AbortController(); 195const aborted = watchFs('root', {signal: controller.signal}); 196const abortedIt = aborted[Symbol.asyncIterator](); 197const pending = abortedIt.next(); 198controller.abort(); 199if (!(await pending).done) throw Error('Abort did not end iteration'); 200try { aborted.close(); throw Error('Aborted watcher closed twice'); } 201catch (error) { if (!(error instanceof errors.BadResource)) throw error; } 202 203const preAborted = new AbortController(); 204const reason = new Error('already stopped'); 205preAborted.abort(reason); 206try { watchFs('root', {signal: preAborted.signal}); throw Error('Accepted aborted signal'); } 207catch (error) { if (error !== reason) throw error; } 208 209await writeTextFile('root/break.txt', 'x'); 210const breaking = watchFs('root'); 211let count = 0; 212await writeTextFile('root/break-2.txt', 'y'); 213for await (const event of breaking) { count++; break; } 214if (count !== 1) throw Error('Break did not receive one event'); 215try { breaking.close(); throw Error('Iterator return did not close watcher'); } 216catch (error) { if (!(error instanceof errors.BadResource)) throw error; } 217console.log('watch lifecycle'); 218"# , 219& [], 220); 221assert_eq! ( output. stdout , b"watch lifecycle\n" ); 222} 223 224# [ test ] 225fn watched_files_survive_recreation_and_directory_removal_ends_iteration () { 226let ( _dir, output) =run ( 227r#" 228import {remove, watchFs} from 'avril:std'; 229import {mkdirs, writeTextFile} from 'avril:fs'; 230await mkdirs('root'); 231await writeTextFile('root/file.txt', 'x'); 232await writeTextFile('watched.txt', 'x'); 233const fileWatcher = watchFs('watched.txt'); 234const fileIt = fileWatcher[Symbol.asyncIterator](); 235await remove('watched.txt'); 236await until(fileIt, (event) => event.kind === 'remove' && hasPath(event, '/watched.txt'), 'watched file removal'); 237await writeTextFile('watched.txt', 'recreated'); 238await until(fileIt, (event) => hasPath(event, '/watched.txt'), 'watched file recreation'); 239fileWatcher.close(); 240 241const watcher = watchFs('root'); 242const it = watcher[Symbol.asyncIterator](); 243await remove('root', {recursive: true}); 244const seen = await untilDone(it, 'watched directory iteration end'); 245if (!seen.some((event) => hasPath(event, '/root'))) throw Error(`Root removal was not reported: ${JSON.stringify(seen)}`); 246console.log('watch root removal'); 247"# , 248& [], 249); 250assert_eq! ( output. stdout , b"watch root removal\n" ); 251} 252 253# [ test ] 254fn capabilities_gate_creation_and_filter_delivery () { 255let ( _dir, output) =run_with ( 256r#" 257import {errors, pledge, watchFs} from 'avril:std'; 258import {writeTextFile} from 'avril:fs'; 259try { watchFs('root', {recursive: true}); throw Error('Recursive watch crossed a denied subtree'); } 260catch (error) { if (!(error instanceof errors.PermissionDenied)) throw error; } 261const shallow = watchFs('root', {recursive: false}); 262shallow.close(); 263try { watchFs('root/missing'); throw Error('Watched missing path'); } 264catch (error) { if (!(error instanceof errors.NotFound)) throw error; } 265 266const watcher = watchFs('root/allowed'); 267const it = watcher[Symbol.asyncIterator](); 268pledge({read: {deny: ['root/allowed/private']}}); 269await writeTextFile('root/allowed/private/hidden.txt', 'hidden'); 270await writeTextFile('root/allowed/visible.txt', 'visible'); 271const {seen} = await until(it, (event) => hasPath(event, '/visible.txt'), 'authorized event after pledge'); 272if (seen.some((event) => hasPath(event, '/hidden.txt') || hasPath(event, '/private/'))) { 273throw Error(`Denied event leaked: ${JSON.stringify(seen)}`); 274} 275pledge({read: false}); 276await writeTextFile('root/allowed/after.txt', 'after'); 277try { 278await nextWithin(it, 3000, 'revoked watcher'); 279throw Error('Revoked watcher produced an event'); 280} catch (error) { 281if (!(error instanceof errors.PermissionDenied)) throw error; 282} 283console.log('watch caps'); 284"# , 285& [ 286"-0" , 287"--allow-read=root/" , 288"--allow-write=root/" , 289"--deny-read=root/secret" , 290], 291 |root|{ 292 fs:: create_dir_all ( root. join ( "root/allowed/private" )). unwrap (); 293 fs:: create_dir_all ( root. join ( "root/secret" )). unwrap (); 294}, 295); 296assert_eq! ( output. stdout , b"watch caps\n" ); 297} 298 299# [ cfg ( unix )] 300# [ test ] 301fn recursive_watch_does_not_follow_symlinked_directories () { 302let ( _dir, output) =run_with ( 303r#" 304import {symlink, watchFs} from 'avril:std'; 305import {writeTextFile} from 'avril:fs'; 306await symlink('outside', 'root/link', {type: 'dir'}); 307const watcher = watchFs('root'); 308const it = watcher[Symbol.asyncIterator](); 309await writeTextFile('outside/changed.txt', 'outside'); 310await writeTextFile('root/visible.txt', 'visible'); 311const {seen} = await until(it, (event) => hasPath(event, '/root/visible.txt'), 'visible event'); 312if (seen.some((event) => event.paths.some((path) => path.includes('/link/') || path.endsWith('/outside/changed.txt')))) { 313throw Error(`Symlink target leaked: ${JSON.stringify(seen)}`); 314} 315watcher.close(); 316console.log('watch symlink'); 317"# , 318& [], 319 |root|{ 320 fs:: create_dir_all ( root. join ( "root" )). unwrap (); 321 fs:: create_dir_all ( root. join ( "outside" )). unwrap (); 322}, 323); 324assert_eq! ( output. stdout , b"watch symlink\n" ); 325} 326 327# [ test ] 328fn unreferenced_watchers_do_not_keep_the_process_alive () { 329let ( _dir, output) =run_with ( 330r#" 331import {watchFs} from 'avril:std'; 332const watcher = watchFs('root'); 333watcher.unref(); 334watcher.unref(); 335console.log('watch unref'); 336"# , 337& [], 338 |root|{ 339 fs:: create_dir_all ( root. join ( "root" )). unwrap (); 340}, 341); 342assert_eq! ( output. stdout , b"watch unref\n" ); 343} 344 345# [ test ] 346fn referenced_watchers_keep_the_process_alive_until_closed () { 347let ( _dir, mut child) =spawn_with ( 348r#" 349import {watchFs} from 'avril:std'; 350globalThis.watcher = watchFs('root'); 351console.log('watch ref'); 352"# , 353 |root|{ 354 fs:: create_dir_all ( root. join ( "root" )). unwrap (); 355}, 356); 357let deadline =Instant :: now () +Duration :: from_millis ( 750 ); 358while Instant :: now () < deadline{ 359assert! ( 360 child. try_wait (). unwrap (). is_none (), 361"referenced watcher allowed the process to exit" 362); 363 std:: thread:: sleep ( Duration :: from_millis ( 25 )); 364} 365 child. kill (). unwrap (); 366let output = common:: wait ( child); 367assert_eq! ( output. stdout , b"watch ref\n" ); 368} 369 370# [ test ] 371fn typescript_watch_types_are_usable () { 372let dir = tempfile:: tempdir (). unwrap (); 373 fs:: create_dir_all ( dir. path (). join ( "root" )). unwrap (); 374 fs:: write ( 375 dir. path (). join ( "main.ts" ), 376r#" 377import {watchFs} from 'avril:std'; 378import type {FsEvent, FsWatcher, WatchOptions} from 'avril:std'; 379const options: WatchOptions = {recursive: false}; 380const watcher: FsWatcher = watchFs('root', options); 381const events: AsyncIterable<FsEvent> = watcher; 382void events; 383watcher.close(); 384console.log('watch types'); 385"# , 386) 387. unwrap (); 388let child =Command :: new ( env! ( "CARGO_BIN_EXE_avril" )) 389. arg ( "run" ) 390. current_dir ( dir. path ()) 391. arg ( "main.ts" ) 392. stdin ( Stdio :: null ()) 393. stdout ( Stdio :: piped ()) 394. stderr ( Stdio :: piped ()) 395. spawn () 396. unwrap (); 397let output = common:: wait ( child); 398assert! ( 399 output. status . success (), 400"{}" , 401String :: from_utf8_lossy ( & output. stderr ) 402); 403assert_eq! ( output. stdout , b"watch types\n" ); 404}