char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
a29d09f
main
1use std:: path::{ Path , PathBuf }; 2use std:: process::{ Child , Command , Output }; 3use std:: time::{ Duration , Instant }; 4 5fn project ( config : & str ) -> tempfile:: TempDir { 6let dir = tempfile:: tempdir (). unwrap (); 7 std:: fs:: write ( dir. path (). join ( "avril.conf" ), config). unwrap (); 8 dir 9} 10 11fn avril ( dir : & Path , args : & [ & str ]) ->Output { 12Command :: new ( env! ( "CARGO_BIN_EXE_avril" )) 13. current_dir ( dir) 14. args ( args) 15. output () 16. unwrap () 17} 18 19fn stdout ( output : & Output ) ->String { 20String :: from_utf8 ( output. stdout . clone ()). unwrap () 21} 22 23fn stderr ( output : & Output ) ->String { 24String :: from_utf8 ( output. stderr . clone ()). unwrap () 25} 26 27# [ test ] 28fn listing_shows_descriptions_commands_and_groupings () { 29let dir =project ( 30r#" 31tasks: { 32build: "echo building", 33serve: {command: "echo serving", description: "Run the server", dependencies: ["build"]}, 34all: {dependencies: ["serve"]}, 35} 36"# , 37); 38let output =avril ( dir. path (), & [ "task" ]); 39assert! ( output. status . success (), "{}" , stderr ( & output)); 40assert_eq! ( 41stdout ( & output), 42"Available tasks:\n- all\n dependencies: serve\n- build\n echo building\n- serve\n Run the server\n" 43); 44let output =avril ( dir. path (), & [ "task" , "nope" ]); 45assert! ( !output. status . success ()); 46assert! ( 47stderr ( & output). contains ( "Unknown task \"nope\"" ), 48"{}" , 49stderr ( & output) 50); 51assert! ( stderr ( & output). contains ( "- build" ), "{}" , stderr ( & output)); 52} 53 54# [ test ] 55fn argv_command_tasks_run_without_a_shell () { 56let dir =project ( 57r#" 58tasks: { 59build: {command: ["printf", "%s", "a b >> $(echo nope)"]}, 60} 61"# , 62); 63let output =avril ( dir. path (), & [ "task" , "build" ]); 64assert! ( output. status . success (), "{}" , stderr ( & output)); 65// Shell metacharacters arrive literally, and the argument containing 66// spaces is a single argument. 67assert_eq! ( stdout ( & output), "a b >> $(echo nope)" ); 68} 69 70# [ test ] 71fn dependencies_run_once_in_order_and_extra_args_are_forwarded () { 72let dir =project ( 73r#" 74tasks: { 75shared: "echo shared", 76a: {command: "echo a", dependencies: ["shared"]}, 77b: {command: "echo b", dependencies: ["shared"]}, 78greet: {command: "echo hello", dependencies: ["a", "b"]}, 79} 80"# , 81); 82let output =avril ( dir. path (), & [ "task" , "greet" , "wide" , "world" ]); 83assert! ( output. status . success (), "{}" , stderr ( & output)); 84assert_eq! ( stdout ( & output), "shared\na\nb\nhello wide world\n" ); 85// Each command header goes to stderr, in execution order. 86let errors =stderr ( & output); 87let headers: Vec < _ > = errors 88. lines () 89. filter ( |line| line. starts_with ( "Task " )) 90. collect (); 91assert_eq! ( 92 headers, 93[ 94"Task shared echo shared" , 95"Task a echo a" , 96"Task b echo b" , 97"Task greet echo hello wide world" 98] 99); 100} 101 102# [ test ] 103fn commands_run_in_the_package_root_from_a_subdirectory () { 104// marker.js only exists at the package root, so `avril run ./marker.js` 105// only succeeds if the command's working directory is the package root. 106let dir =project ( & format! ( 107"tasks: {{ marker: \"{} run ./marker.js\" }}" , 108env! ( "CARGO_BIN_EXE_avril" ). replace ( '\\' , "/" ) 109)); 110 std:: fs:: write ( dir. path (). join ( "marker.js" ), "console.log('ran-at-root');" ). unwrap (); 111 std:: fs:: create_dir_all ( dir. path (). join ( "src/nested" )). unwrap (); 112let nested: PathBuf = dir. path (). join ( "src/nested" ); 113let output =avril ( & nested, & [ "task" , "marker" ]); 114assert! ( output. status . success (), "{}" , stderr ( & output)); 115assert_eq! ( stdout ( & output), "ran-at-root\n" ); 116} 117 118# [ test ] 119fn parallel_dependencies_actually_run_at_the_same_time () { 120// Each script announces itself, then waits for all three announcements: 121// run sequentially, the first would time out instead of finishing. 122let exe =env! ( "CARGO_BIN_EXE_avril" ). replace ( '\\' , "/" ); 123let dir =project ( & format! ( 124"tasks: {{ a: \"{exe} run ./a.js\", b: \"{exe} run ./b.js\", c: \"{exe} run ./c.js\", all: {{command: \"echo all-done\", dependencies: ['a', 'b', 'c'], parallel: true}} }}" 125)); 126for namein [ "a" , "b" , "c" ] { 127 std:: fs:: write ( 128 dir. path (). join ( format! ( "{name}.js" )), 129format! ( 130r#" 131import {{ exists, writeTextFile }} from "avril:fs"; 132await writeTextFile("{name}.started", ""); 133const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 134let ready = false; 135for (let attempt = 0; attempt < 500 && !ready; attempt++) {{ 136ready = true; 137for (const other of ["a", "b", "c"]) {{ 138if (!(await exists(other + ".started"))) {{ ready = false; break; }} 139}} 140if (!ready) await sleep(10); 141}} 142if (!ready) throw new Error("dependencies did not run in parallel"); 143console.log("{name}"); 144"# 145), 146) 147. unwrap (); 148} 149let output =avril ( dir. path (), & [ "task" , "all" ]); 150assert! ( output. status . success (), "{}" , stderr ( & output)); 151let text =stdout ( & output); 152let mut lines: Vec < _ > = text. lines (). collect (); 153 lines. sort_unstable (); 154assert_eq! ( lines, [ "a" , "all-done" , "b" , "c" ]); 155} 156 157# [ test ] 158fn parallel_branches_share_dependencies_and_any_failure_is_status_one () { 159let dir =project ( 160r#" 161tasks: { 162shared: "echo shared", 163a: {command: "echo a", dependencies: ["shared"]}, 164b: {command: "echo b", dependencies: ["shared"]}, 165all: {dependencies: ["a", "b"], parallel: true}, 166} 167"# , 168); 169let output =avril ( dir. path (), & [ "task" , "all" ]); 170assert! ( output. status . success (), "{}" , stderr ( & output)); 171let text =stdout ( & output); 172let mut lines: Vec < _ > = text. lines (). collect (); 173 lines. sort_unstable (); 174assert_eq! ( lines, [ "a" , "b" , "shared" ]); 175 176let dir =project ( 177r#" 178tasks: { 179one: "exit 3", 180two: "echo two-ran", 181three: "exit 5", 182all: {command: "echo parent", dependencies: ["one", "two", "three"], parallel: true}, 183} 184"# , 185); 186let output =avril ( dir. path (), & [ "task" , "all" ]); 187// Not three or five: once failures race, the run is simply status 1. 188assert_eq! ( output. status . code (), Some ( 1 ), "{}" , stderr ( & output)); 189assert! ( stdout ( & output). contains ( "two-ran" ), "{}" , stdout ( & output)); 190assert! ( !stdout ( & output). contains ( "parent" )); 191} 192 193# [ test ] 194fn a_failing_dependency_stops_the_run_and_propagates_its_exit_code () { 195let dir =project ( 196r#" 197tasks: { 198fail: "exit 3", 199after: {command: "echo should-not-run", dependencies: ["fail"]}, 200} 201"# , 202); 203let output =avril ( dir. path (), & [ "task" , "after" ]); 204assert_eq! ( output. status . code (), Some ( 3 ), "{}" , stderr ( & output)); 205assert! ( !stdout ( & output). contains ( "should-not-run" )); 206} 207 208# [ test ] 209fn unknown_dependencies_and_cycles_are_reported_against_the_config () { 210let dir =project ( 211r#" 212tasks: { 213a: {command: "echo a", dependencies: ["missing"]}, 214} 215"# , 216); 217let output =avril ( dir. path (), & [ "task" , "a" ]); 218assert! ( !output. status . success ()); 219let error =stderr ( & output); 220assert! ( error. contains ( "Unknown task \"missing\"" ), "{error}" ); 221assert! ( error. contains ( "avril.conf:3:" ), "{error}" ); 222 223let dir =project ( 224r#" 225tasks: { 226a: {command: "echo a", dependencies: ["b"]}, 227b: {command: "echo b", dependencies: ["a"]}, 228self: {command: "echo self", dependencies: ["self"]}, 229} 230"# , 231); 232for watchin [ false , true ] { 233for ( name, cycle, line) in [( "a" , "a -> b -> a" , 4 ), ( "self" , "self -> self" , 5 )] { 234let args =if watch{ 235vec! [ "task" , "--watch" , name] 236} else { 237vec! [ "task" , name] 238}; 239let output =avril ( dir. path (), & args); 240assert! ( !output. status . success ()); 241let error =stderr ( & output); 242assert! ( 243 error. contains ( & format! ( "Task cycle detected: {cycle}" )), 244"{error}" 245); 246assert! ( error. contains ( & format! ( "avril.conf:{line}:" )), "{error}" ); 247} 248} 249} 250 251# [ cfg ( unix )] 252# [ test ] 253fn task_preserves_native_environment_and_search_paths () { 254use std:: os:: unix::{ ffi:: OsStrExt , fs:: symlink}; 255let dir =project ( r#"tasks: { run: 'printf "%s" "$RAW"' }"# ); 256let binary = dir. path (). join ( std:: ffi:: OsStr :: from_bytes ( b"bin-\xff" )); 257 std:: fs:: create_dir ( & binary). unwrap (); 258symlink ( "/bin/sh" , binary. join ( "sh" )). unwrap (); 259for pathin [ Some ( binary. as_os_str ()), None ] { 260let mut command =Command :: new ( env! ( "CARGO_BIN_EXE_avril" )); 261 command 262. current_dir ( dir. path ()) 263. args ([ "task" , "run" ]) 264. env ( "RAW" , std:: ffi:: OsStr :: from_bytes ( b"\xfe\xff" )) 265. env_remove ( "PATH" ); 266if let Some ( path) = path{ 267 command. env ( "PATH" , path); 268} 269let output = command. output (). unwrap (); 270assert! ( output. status . success (), "{}" , stderr ( & output)); 271assert_eq! ( output. stdout , b"\xfe\xff" ); 272} 273} 274 275# [ test ] 276fn env_files_apply_to_a_task_and_its_dependencies_recursively () { 277let bin =env! ( "CARGO_BIN_EXE_avril" ). replace ( '\\' , "/" ); 278let dir =project ( & format! ( 279r#" 280tasks: {{ 281root: {{command: "{bin} run ./show.js", envFile: ".env.root", dependencies: ["child"]}}, 282child: {{command: "{bin} run ./show.js", envFile: ".env.child", dependencies: ["leaf"]}}, 283leaf: "{bin} run ./show.js", 284}} 285"# 286)); 287 std:: fs:: write ( 288 dir. path (). join ( ".env.root" ), 289"GREETING=root-file\nLEVEL=root\nROOT_ONLY=yes\n" , 290) 291. unwrap (); 292 std:: fs:: write ( 293 dir. path (). join ( ".env.child" ), 294"LEVEL=child\nCHILD_ONLY=yes\n" , 295) 296. unwrap (); 297 std:: fs:: write ( 298 dir. path (). join ( "show.js" ), 299"import {env} from 'avril:std';\nconst show = (name) => env.get(name) ?? '-';\nconsole.log([show('GREETING'), show('LEVEL'), show('ROOT_ONLY'), show('CHILD_ONLY')].join('/'));\n" , 300) 301. unwrap (); 302let output =Command :: new ( env! ( "CARGO_BIN_EXE_avril" )) 303. current_dir ( dir. path ()) 304. args ([ "task" , "root" ]) 305. env ( "GREETING" , "process" ) 306. env_remove ( "LEVEL" ) 307. env_remove ( "ROOT_ONLY" ) 308. env_remove ( "CHILD_ONLY" ) 309. output () 310. unwrap (); 311assert! ( output. status . success (), "{}" , stderr ( & output)); 312// Dependencies run first: leaf and child see the child's file overriding 313// the root's, while the root itself only sees its own file. The process 314// environment wins over every file. 315assert_eq! ( 316stdout ( & output), 317"process/child/yes/yes\nprocess/child/yes/yes\nprocess/root/yes/-\n" 318); 319} 320 321# [ test ] 322fn a_missing_task_env_file_is_reported_against_the_config () { 323let dir =project ( 324"tasks: { root: { command: 'echo hi', envFile: '.env.missing', dependencies: ['leaf'] }, leaf: 'echo leaf' }" , 325); 326let output =avril ( dir. path (), & [ "task" , "root" ]); 327assert! ( !output. status . success ()); 328let errors =stderr ( & output); 329assert! ( errors. contains ( "Cannot load env file" ), "{errors}" ); 330assert! ( errors. contains ( "avril.conf:" ), "{errors}" ); 331} 332 333# [ cfg ( unix )] 334# [ test ] 335fn an_env_file_path_that_does_not_open_is_not_rerouted () { 336let dir =project ( "tasks: { build: { command: 'echo hi', envFile: 'missing/../.env' } }" ); 337 std:: fs:: write ( dir. path (). join ( ".env" ), "FLAVOR=one\n" ). unwrap (); 338let output =avril ( dir. path (), & [ "task" , "build" ]); 339assert! ( !output. status . success ()); 340assert! ( 341stderr ( & output). contains ( "Cannot load env file" ), 342"{}" , 343stderr ( & output) 344); 345} 346 347# [ test ] 348fn only_env_listed_env_file_values_are_part_of_the_cache_key () { 349let dir =project ( 350"tasks: { site: { command: 'echo work', files: ['input.txt'], envFile: '.env', env: ['FLAVOR'] } }" , 351); 352 std:: fs:: write ( dir. path (). join ( "input.txt" ), "input" ). unwrap (); 353 std:: fs:: write ( dir. path (). join ( ".env" ), "FLAVOR=one\nUNRELATED=one\n" ). unwrap (); 354let output =avril_cached ( dir. path (), & [ "task" , "site" ], & []); 355assert! ( output. status . success (), "{}" , stderr ( & output)); 356assert_eq! ( stdout ( & output), "work\n" ); 357let output =avril_cached ( dir. path (), & [ "task" , "site" ], & []); 358assert! ( output. status . success (), "{}" , stderr ( & output)); 359assert_eq! ( stdout ( & output), "" ); 360assert! ( stderr ( & output). contains ( "Task site (cached)" )); 361// A value the task did not declare in `env` does not invalidate it. 362 std:: fs:: write ( dir. path (). join ( ".env" ), "FLAVOR=one\nUNRELATED=two\n" ). unwrap (); 363let output =avril_cached ( dir. path (), & [ "task" , "site" ], & []); 364assert! ( output. status . success (), "{}" , stderr ( & output)); 365assert_eq! ( stdout ( & output), "" ); 366assert! ( stderr ( & output). contains ( "Task site (cached)" )); 367 std:: fs:: write ( dir. path (). join ( ".env" ), "FLAVOR=two\nUNRELATED=two\n" ). unwrap (); 368let output =avril_cached ( dir. path (), & [ "task" , "site" ], & []); 369assert! ( output. status . success (), "{}" , stderr ( & output)); 370assert_eq! ( stdout ( & output), "work\n" ); 371} 372 373/// A spawned watcher that cannot leak past a failed assertion. 374struct WatchGuard ( Child ); 375 376impl Drop for WatchGuard { 377fn drop ( & mut self ) { 378let _ =self . 0 . kill (); 379let _ =self . 0 . wait (); 380} 381} 382 383impl std:: ops:: Deref for WatchGuard { 384type Target =Child ; 385fn deref ( & self ) ->& Child { 386& self . 0 387} 388} 389 390impl std:: ops:: DerefMut for WatchGuard { 391fn deref_mut ( & mut self ) ->& mut Child { 392& mut self . 0 393} 394} 395 396fn spawn_watch ( dir : & Path , name : & str ) ->WatchGuard { 397spawn_watch_args ( dir, & [ "task" , "--watch" , name]) 398} 399 400fn spawn_watch_args ( dir : & Path , args : & [ & str ]) ->WatchGuard { 401WatchGuard ( 402Command :: new ( env! ( "CARGO_BIN_EXE_avril" )) 403. current_dir ( dir) 404. args ( args) 405. env ( "AVRIL_CACHE_DIR" , dir. join ( "cache" )) 406. stdin ( std:: process:: Stdio :: null ()) 407. stdout ( std:: process:: Stdio :: null ()) 408. stderr ( std:: process:: Stdio :: null ()) 409. spawn () 410. unwrap (), 411) 412} 413 414fn read_log ( dir : & Path ) ->String { 415 std:: fs:: read_to_string ( dir. join ( "runs.log" )). unwrap_or_default () 416} 417 418fn wait_for_log ( dir : & Path , expected : & str ) { 419let deadline =Instant :: now () +Duration :: from_secs ( 10 ); 420while Instant :: now () < deadline{ 421if read_log ( dir) == expected{ 422return ; 423} 424 std:: thread:: sleep ( Duration :: from_millis ( 10 )); 425} 426panic! ( 427"timed out waiting for runs.log to become {expected:?}; it is {:?}" , 428read_log ( dir) 429); 430} 431 432fn stop_watch ( child : & mut WatchGuard ) { 433# [ cfg ( unix )] 434{ 435let status =Command :: new ( "kill" ) 436. args ([ "-INT" , & child. id (). to_string ()]) 437. status () 438. unwrap (); 439assert! ( status. success ()); 440let deadline =Instant :: now () +Duration :: from_secs ( 10 ); 441while Instant :: now () < deadline{ 442if child. try_wait (). unwrap (). is_some () { 443return ; 444} 445 std:: thread:: sleep ( Duration :: from_millis ( 10 )); 446} 447} 448let _ = child. kill (); 449let _ = child. wait (); 450} 451 452# [ test ] 453fn watch_requires_files_somewhere_in_the_task_graph () { 454let dir =project ( r#"tasks: { plain: "echo plain" }"# ); 455let output =avril ( dir. path (), & [ "task" , "--watch" , "plain" ]); 456assert! ( !output. status . success ()); 457assert! ( 458stderr ( & output). contains ( "cannot be watched" ), 459"{}" , 460stderr ( & output) 461); 462assert! ( stderr ( & output). contains ( "'files'" ), "{}" , stderr ( & output)); 463let output =avril ( dir. path (), & [ "task" , "--watch" ]); 464assert! ( !output. status . success ()); 465assert! ( stderr ( & output). contains ( "Usage: avril task" )); 466} 467 468# [ cfg ( unix )] 469# [ test ] 470fn watch_reruns_when_inputs_of_a_dependency_change () { 471let dir =project ( 472"tasks: { build: {command: \"cat input.txt >> runs.log\", files: [\"input.txt\"]}, all: {dependencies: [\"build\"]} }" , 473); 474 std:: fs:: write ( dir. path (). join ( "input.txt" ), "one\n" ). unwrap (); 475let mut child =spawn_watch ( dir. path (), "all" ); 476wait_for_log ( dir. path (), "one\n" ); 477// Files outside the task's patterns never rerun it. 478 std:: fs:: write ( dir. path (). join ( "unrelated.txt" ), "x" ). unwrap (); 479 std:: thread:: sleep ( Duration :: from_millis ( 400 )); 480assert_eq! ( read_log ( dir. path ()), "one\n" ); 481// Inputs are compared by size and modification time, so rewriting the 482// same contents still reruns the task. 483 std:: fs:: write ( dir. path (). join ( "input.txt" ), "one\n" ). unwrap (); 484wait_for_log ( dir. path (), "one\none\n" ); 485 std:: fs:: write ( dir. path (). join ( "input.txt" ), "two\n" ). unwrap (); 486wait_for_log ( dir. path (), "one\none\ntwo\n" ); 487stop_watch ( & mut child); 488assert! ( child. wait (). unwrap (). success ()); 489} 490 491# [ cfg ( unix )] 492# [ test ] 493fn watch_debounces_a_burst_of_changes_into_one_rerun () { 494let dir = 495project ( "tasks: { build: {command: \"echo run >> runs.log\", files: [\"input.txt\"]} }" ); 496 std:: fs:: write ( dir. path (). join ( "input.txt" ), "start" ). unwrap (); 497let mut child =spawn_watch ( dir. path (), "build" ); 498wait_for_log ( dir. path (), "run\n" ); 499for indexin 0 ..10 { 500 std:: fs:: write ( dir. path (). join ( "input.txt" ), format! ( "edit-{index}" )). unwrap (); 501} 502wait_for_log ( dir. path (), "run\nrun\n" ); 503 std:: thread:: sleep ( Duration :: from_millis ( 400 )); 504assert_eq! ( read_log ( dir. path ()), "run\nrun\n" ); 505stop_watch ( & mut child); 506} 507 508# [ cfg ( unix )] 509# [ test ] 510fn watch_keeps_watching_after_the_task_fails () { 511let dir =project ( 512"tasks: { build: {command: \"echo ran >> runs.log; exit 3\", files: [\"input.txt\"]} }" , 513); 514 std:: fs:: write ( dir. path (). join ( "input.txt" ), "one" ). unwrap (); 515let mut child =spawn_watch ( dir. path (), "build" ); 516wait_for_log ( dir. path (), "ran\n" ); 517assert! ( child. try_wait (). unwrap (). is_none ()); 518 std:: fs:: write ( dir. path (). join ( "input.txt" ), "two" ). unwrap (); 519wait_for_log ( dir. path (), "ran\nran\n" ); 520assert! ( child. try_wait (). unwrap (). is_none ()); 521stop_watch ( & mut child); 522} 523 524# [ cfg ( unix )] 525# [ test ] 526fn watch_kills_and_restarts_a_running_task () { 527let dir =project ( "tasks: { serve: {command: \"sh serve.sh\", files: [\"input.txt\"]} }" ); 528 std:: fs:: write ( dir. path (). join ( "serve.sh" ), "echo $$ >> serve.pid\necho start >> runs.log\ntrap '' INT\ntrap 'echo term >> runs.log; exit 0' TERM\nwhile :; do sleep 1; done\n" ). unwrap (); 529 std:: fs:: write ( dir. path (). join ( "input.txt" ), "one" ). unwrap (); 530let mut child =spawn_watch ( dir. path (), "serve" ); 531wait_for_log ( dir. path (), "start\n" ); 532 std:: fs:: write ( dir. path (). join ( "input.txt" ), "two" ). unwrap (); 533wait_for_log ( dir. path (), "start\nterm\nstart\n" ); 534stop_watch ( & mut child); 535assert! ( child. wait (). unwrap (). success ()); 536wait_for_log ( dir. path (), "start\nterm\nstart\nterm\n" ); 537let pids = std:: fs:: read_to_string ( dir. path (). join ( "serve.pid" )). unwrap (); 538for pidin pids. lines () { 539let alive =Command :: new ( "kill" ) 540. args ([ "-0" , pid]) 541. status () 542. unwrap () 543. success (); 544assert! ( !alive, "watched process {pid} outlived the watcher" ); 545} 546} 547 548# [ cfg ( unix )] 549# [ test ] 550fn watch_restart_reaches_descendants_after_the_shell_exits () { 551let dir =project ( r#"tasks: { serve: {command: "sh launcher.sh", files: ["input.txt"]} }"# ); 552 std:: fs:: write ( 553 dir. path (). join ( "bg.sh" ), 554"echo $$ >> bg.pid\necho start >> runs.log\ntrap '' INT TERM\nwhile :; do sleep 1; done\n" , 555) 556. unwrap (); 557 std:: fs:: write ( dir. path (). join ( "launcher.sh" ), "sh bg.sh & wait\n" ). unwrap (); 558 std:: fs:: write ( dir. path (). join ( "input.txt" ), "one" ). unwrap (); 559let mut child =spawn_watch ( dir. path (), "serve" ); 560wait_for_log ( dir. path (), "start\n" ); 561 std:: fs:: write ( dir. path (). join ( "input.txt" ), "two" ). unwrap (); 562wait_for_log ( dir. path (), "start\nstart\n" ); 563let pids = std:: fs:: read_to_string ( dir. path (). join ( "bg.pid" )). unwrap (); 564let first = pids. lines (). next (). unwrap (); 565assert! ( 566 !Command :: new ( "kill" ) 567. args ([ "-0" , first]) 568. status () 569. unwrap () 570. success (), 571"first descendant {first} survived the restart" 572); 573stop_watch ( & mut child); 574assert! ( child. wait (). unwrap (). success ()); 575let pids = std:: fs:: read_to_string ( dir. path (). join ( "bg.pid" )). unwrap (); 576for pidin pids. lines () { 577assert! ( 578 !Command :: new ( "kill" ) 579. args ([ "-0" , pid]) 580. status () 581. unwrap () 582. success (), 583"descendant {pid} survived shutdown" 584); 585} 586} 587 588# [ cfg ( unix )] 589# [ test ] 590fn watch_restarts_a_running_task_on_a_same_content_rewrite () { 591let dir =project ( r#"tasks: { serve: {command: "sh serve.sh", files: ["input.txt"]} }"# ); 592 std:: fs:: write ( dir. path (). join ( "serve.sh" ), "echo start >> runs.log\ntrap '' INT\ntrap 'echo term >> runs.log; exit 0' TERM\nwhile :; do sleep 1; done\n" ). unwrap (); 593 std:: fs:: write ( dir. path (). join ( "input.txt" ), "same" ). unwrap (); 594let mut child =spawn_watch ( dir. path (), "serve" ); 595wait_for_log ( dir. path (), "start\n" ); 596// Inputs are compared by size and modification time: the same bytes 597// with a new mtime still restart the task. 598 std:: fs:: write ( dir. path (). join ( "input.txt" ), "same" ). unwrap (); 599wait_for_log ( dir. path (), "start\nterm\nstart\n" ); 600 std:: fs:: write ( dir. path (). join ( "input.txt" ), "changed" ). unwrap (); 601wait_for_log ( dir. path (), "start\nterm\nstart\nterm\nstart\n" ); 602stop_watch ( & mut child); 603} 604 605# [ cfg ( unix )] 606# [ test ] 607fn watch_passes_help_after_the_name_through_to_the_task () { 608let dir = 609project ( r#"tasks: { build: {command: "echo ran >> runs.log", files: ["input.txt"]} }"# ); 610 std:: fs:: write ( dir. path (). join ( "input.txt" ), "one" ). unwrap (); 611let mut child =spawn_watch_args ( dir. path (), & [ "task" , "--watch" , "build" , "--help" ]); 612wait_for_log ( dir. path (), "ran --help\n" ); 613stop_watch ( & mut child); 614} 615 616# [ cfg ( unix )] 617# [ test ] 618fn watch_stays_put_when_a_reload_drops_all_files () { 619let dir = 620project ( r#"tasks: { build: {command: "echo run >> runs.log", files: ["input.txt"]} }"# ); 621 std:: fs:: write ( dir. path (). join ( "input.txt" ), "one" ). unwrap (); 622let mut child =spawn_watch ( dir. path (), "build" ); 623wait_for_log ( dir. path (), "run\n" ); 624 std:: fs:: write ( 625 dir. path (). join ( "avril.conf" ), 626r#"tasks: { build: "echo replaced >> runs.log" }"# , 627) 628. unwrap (); 629 std:: thread:: sleep ( Duration :: from_millis ( 500 )); 630assert_eq! ( read_log ( dir. path ()), "run\n" ); 631assert! ( child. try_wait (). unwrap (). is_none ()); 632 std:: fs:: write ( 633 dir. path (). join ( "avril.conf" ), 634r#"tasks: { build: {command: "echo restored >> runs.log", files: ["input.txt"]} }"# , 635) 636. unwrap (); 637wait_for_log ( dir. path (), "run\nrestored\n" ); 638stop_watch ( & mut child); 639} 640 641# [ cfg ( unix )] 642# [ test ] 643fn watch_notices_a_populated_directory_moving_in () { 644let dir =project ( 645r#"tasks: { build: {command: "cat src/a.txt src/sub/b.txt >> runs.log", files: ["src/**/*.txt"]} }"# , 646); 647 std:: fs:: create_dir ( dir. path (). join ( "src" )). unwrap (); 648 std:: fs:: write ( dir. path (). join ( "src/a.txt" ), "one\n" ). unwrap (); 649let mut child =spawn_watch ( dir. path (), "build" ); 650wait_for_log ( dir. path (), "one\n" ); 651 std:: fs:: create_dir ( dir. path (). join ( "stage" )). unwrap (); 652 std:: fs:: write ( dir. path (). join ( "stage/b.txt" ), "two\n" ). unwrap (); 653 std:: fs:: rename ( dir. path (). join ( "stage" ), dir. path (). join ( "src/sub" )). unwrap (); 654wait_for_log ( dir. path (), "one\none\ntwo\n" ); 655stop_watch ( & mut child); 656} 657 658# [ cfg ( unix )] 659# [ test ] 660fn watch_reloads_an_env_file_outside_the_package_root () { 661let outer = tempfile:: tempdir (). unwrap (); 662let dir = outer. path (). join ( "pkg" ); 663 std:: fs:: create_dir ( & dir). unwrap (); 664 std:: fs:: write ( dir. join ( "avril.conf" ), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "../shared.env"} }"# ). unwrap (); 665 std:: fs:: write ( dir. join ( "input.txt" ), "one\n" ). unwrap (); 666 std:: fs:: write ( outer. path (). join ( "shared.env" ), "FLAVOR=one\n" ). unwrap (); 667let mut child =spawn_watch ( & dir, "build" ); 668wait_for_log ( & dir, "one\n" ); 669 std:: fs:: write ( outer. path (). join ( "shared.env" ), "FLAVOR=two\n" ). unwrap (); 670wait_for_log ( & dir, "one\none\n" ); 671stop_watch ( & mut child); 672} 673 674# [ cfg ( unix )] 675# [ test ] 676fn watch_cleans_up_background_processes_from_a_completed_run () { 677let dir =project ( r#"tasks: { serve: {command: "sh bg.sh &", files: ["input.txt"]} }"# ); 678 std:: fs:: write ( 679 dir. path (). join ( "bg.sh" ), 680"echo $$ > bg.pid\necho start >> runs.log\ntrap '' INT TERM\nwhile :; do sleep 1; done\n" , 681) 682. unwrap (); 683 std:: fs:: write ( dir. path (). join ( "input.txt" ), "one" ). unwrap (); 684let mut child =spawn_watch ( dir. path (), "serve" ); 685wait_for_log ( dir. path (), "start\n" ); 686 std:: thread:: sleep ( Duration :: from_millis ( 300 )); 687stop_watch ( & mut child); 688assert! ( child. wait (). unwrap (). success ()); 689let pid = std:: fs:: read_to_string ( dir. path (). join ( "bg.pid" )). unwrap (); 690assert! ( 691 !Command :: new ( "kill" ) 692. args ([ "-0" , pid. trim ()]) 693. status () 694. unwrap () 695. success (), 696"background process {} survived shutdown" , 697 pid. trim () 698); 699} 700 701# [ cfg ( unix )] 702# [ test ] 703fn watch_carries_a_forced_refresh_through_a_cancelled_run () { 704let dir =project ( 705r#"tasks: { build: {command: "echo started >> starts.log; sleep 1; echo work >> runs.log", files: ["input.txt"], envFile: ".env"} }"# , 706); 707 std:: fs:: write ( dir. path (). join ( ".env" ), "FLAVOR=one\n" ). unwrap (); 708 std:: fs:: write ( dir. path (). join ( "input.txt" ), "A" ). unwrap (); 709let output =avril_cached ( dir. path (), & [ "task" , "build" ], & []); 710assert! ( output. status . success (), "{}" , stderr ( & output)); 711 std:: fs:: write ( dir. path (). join ( "input.txt" ), "B" ). unwrap (); 712let output =avril_cached ( dir. path (), & [ "task" , "build" ], & []); 713assert! ( output. status . success (), "{}" , stderr ( & output)); 714 std:: fs:: write ( dir. path (). join ( "input.txt" ), "A" ). unwrap (); 715 std:: fs:: remove_file ( dir. path (). join ( "runs.log" )). unwrap (); 716 std:: fs:: remove_file ( dir. path (). join ( "starts.log" )). unwrap (); 717let mut child =spawn_watch ( dir. path (), "build" ); 718 std:: thread:: sleep ( Duration :: from_millis ( 400 )); 719 std:: fs:: write ( dir. path (). join ( ".env" ), "FLAVOR=two\n" ). unwrap (); 720let deadline =Instant :: now () +Duration :: from_secs ( 10 ); 721while !std:: fs:: read_to_string ( dir. path (). join ( "starts.log" )). is_ok_and ( |text| !text. is_empty ()) 722{ 723assert! ( Instant :: now () < deadline, "forced run never started" ); 724 std:: thread:: sleep ( Duration :: from_millis ( 10 )); 725} 726 std:: fs:: write ( dir. path (). join ( "input.txt" ), "B" ). unwrap (); 727wait_for_log ( dir. path (), "work\n" ); 728stop_watch ( & mut child); 729} 730 731# [ cfg ( unix )] 732# [ test ] 733fn watch_unrelated_writes_do_not_starve_the_debounce () { 734let dir =project ( 735r#"tasks: { build: {command: "cat src/input.txt >> runs.log", files: ["src/*.txt"]} }"# , 736); 737 std:: fs:: create_dir ( dir. path (). join ( "src" )). unwrap (); 738 std:: fs:: write ( dir. path (). join ( "src/input.txt" ), "one\n" ). unwrap (); 739let mut child =spawn_watch ( dir. path (), "build" ); 740wait_for_log ( dir. path (), "one\n" ); 741let stop = std:: sync:: Arc :: new ( std:: sync:: atomic:: AtomicBool :: new ( false )); 742let writer_stop = stop. clone (); 743let writer_dir = dir. path (). join ( "src/unrelated.log" ); 744let writer = std:: thread:: spawn ( move ||{ 745let staging = writer_dir. with_extension ( "tmp" ); 746while !writer_stop. load ( std:: sync:: atomic:: Ordering :: SeqCst ) { 747let _ = std:: fs:: write ( & staging, "noise" ); 748let _ = std:: fs:: rename ( & staging, & writer_dir); 749 std:: thread:: sleep ( Duration :: from_millis ( 10 )); 750} 751}); 752 std:: fs:: write ( dir. path (). join ( "src/input.txt" ), "two\n" ). unwrap (); 753wait_for_log ( dir. path (), "one\ntwo\n" ); 754 stop. store ( true , std:: sync:: atomic:: Ordering :: SeqCst ); 755 writer. join (). unwrap (); 756stop_watch ( & mut child); 757} 758 759# [ cfg ( unix )] 760# [ test ] 761fn watch_follows_an_env_file_through_a_symlink_parent () { 762let outer = tempfile:: tempdir (). unwrap (); 763let dir = outer. path (). join ( "pkg" ); 764 std:: fs:: create_dir ( & dir). unwrap (); 765 std:: fs:: create_dir ( outer. path (). join ( "target" )). unwrap (); 766 std:: os:: unix:: fs:: symlink ( outer. path (). join ( "target" ), dir. join ( "link" )). unwrap (); 767 std:: fs:: write ( dir. join ( "avril.conf" ), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "link/../shared.env"} }"# ). unwrap (); 768 std:: fs:: write ( dir. join ( "input.txt" ), "one\n" ). unwrap (); 769 std:: fs:: write ( outer. path (). join ( "shared.env" ), "FLAVOR=one\n" ). unwrap (); 770let mut child =spawn_watch ( & dir, "build" ); 771wait_for_log ( & dir, "one\n" ); 772 std:: fs:: write ( outer. path (). join ( "shared.env" ), "FLAVOR=two\n" ). unwrap (); 773wait_for_log ( & dir, "one\none\n" ); 774stop_watch ( & mut child); 775} 776 777# [ cfg ( unix )] 778# [ test ] 779fn watch_recovers_when_an_external_env_directory_appears () { 780let outer = tempfile:: tempdir (). unwrap (); 781let dir = outer. path (). join ( "pkg" ); 782 std:: fs:: create_dir ( & dir). unwrap (); 783 std:: fs:: write ( dir. join ( "avril.conf" ), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "../ext/shared.env"} }"# ). unwrap (); 784 std:: fs:: write ( dir. join ( "input.txt" ), "one\n" ). unwrap (); 785let mut child =spawn_watch ( & dir, "build" ); 786 std:: thread:: sleep ( Duration :: from_millis ( 400 )); 787 std:: fs:: create_dir ( outer. path (). join ( "ext" )). unwrap (); 788 std:: fs:: write ( outer. path (). join ( "ext/shared.env" ), "FLAVOR=one\n" ). unwrap (); 789wait_for_log ( & dir, "one\n" ); 790 std:: fs:: write ( outer. path (). join ( "ext/shared.env" ), "FLAVOR=two\n" ). unwrap (); 791wait_for_log ( & dir, "one\none\n" ); 792stop_watch ( & mut child); 793} 794 795# [ cfg ( unix )] 796# [ test ] 797fn watch_follows_an_env_symlink_being_retargeted () { 798let dir =project ( 799r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: ".env"} }"# , 800); 801 std:: fs:: write ( dir. path (). join ( "input.txt" ), "one\n" ). unwrap (); 802 std:: fs:: write ( dir. path (). join ( "a.env" ), "FLAVOR=a\n" ). unwrap (); 803 std:: fs:: write ( dir. path (). join ( "b.env" ), "FLAVOR=b\n" ). unwrap (); 804 std:: os:: unix:: fs:: symlink ( "a.env" , dir. path (). join ( ".env" )). unwrap (); 805let mut child =spawn_watch ( dir. path (), "build" ); 806wait_for_log ( dir. path (), "one\n" ); 807 std:: fs:: remove_file ( dir. path (). join ( ".env" )). unwrap (); 808 std:: os:: unix:: fs:: symlink ( "b.env" , dir. path (). join ( ".env" )). unwrap (); 809wait_for_log ( dir. path (), "one\none\n" ); 810stop_watch ( & mut child); 811} 812 813# [ cfg ( unix )] 814# [ test ] 815fn watch_notices_a_nested_directory_moving_out () { 816let dir =project ( 817r#"tasks: { build: {command: "cat src/a.txt src/sub/b.txt >> runs.log", files: ["src/**/*.txt"]} }"# , 818); 819 std:: fs:: create_dir_all ( dir. path (). join ( "src/sub" )). unwrap (); 820 std:: fs:: write ( dir. path (). join ( "src/a.txt" ), "A\n" ). unwrap (); 821 std:: fs:: write ( dir. path (). join ( "src/sub/b.txt" ), "one\n" ). unwrap (); 822let mut child =spawn_watch ( dir. path (), "build" ); 823wait_for_log ( dir. path (), "A\none\n" ); 824let outside = tempfile:: tempdir (). unwrap (); 825 std:: fs:: rename ( dir. path (). join ( "src/sub" ), outside. path (). join ( "sub" )). unwrap (); 826wait_for_log ( dir. path (), "A\none\nA\n" ); 827stop_watch ( & mut child); 828} 829 830# [ cfg ( unix )] 831# [ test ] 832fn watch_recovers_when_a_dangling_env_symlinks_target_appears () { 833let outer = tempfile:: tempdir (). unwrap (); 834let dir = outer. path (). join ( "pkg" ); 835 std:: fs:: create_dir ( & dir). unwrap (); 836 std:: fs:: write ( dir. join ( "avril.conf" ), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: ".env"} }"# ). unwrap (); 837 std:: fs:: write ( dir. join ( "input.txt" ), "one\n" ). unwrap (); 838 std:: os:: unix:: fs:: symlink ( "../target.env" , dir. join ( ".env" )). unwrap (); 839let mut child =spawn_watch ( & dir, "build" ); 840 std:: thread:: sleep ( Duration :: from_millis ( 400 )); 841 std:: fs:: write ( outer. path (). join ( "target.env" ), "FLAVOR=one\n" ). unwrap (); 842wait_for_log ( & dir, "one\n" ); 843 std:: fs:: write ( outer. path (). join ( "target.env" ), "FLAVOR=two\n" ). unwrap (); 844wait_for_log ( & dir, "one\none\n" ); 845stop_watch ( & mut child); 846} 847 848# [ cfg ( unix )] 849# [ test ] 850fn watch_follows_an_intermediate_env_symlink_being_retargeted () { 851let outer = tempfile:: tempdir (). unwrap (); 852let dir = outer. path (). join ( "pkg" ); 853 std:: fs:: create_dir ( & dir). unwrap (); 854for sidein [ "a" , "c" ] { 855 std:: fs:: create_dir_all ( outer. path (). join ( side). join ( "b" )). unwrap (); 856 std:: fs:: write ( 857 outer. path (). join ( side). join ( "shared.env" ), 858format! ( "FLAVOR={side}\n" ), 859) 860. unwrap (); 861} 862 std:: fs:: write ( dir. join ( "avril.conf" ), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "link/../shared.env"} }"# ). unwrap (); 863 std:: fs:: write ( dir. join ( "input.txt" ), "one\n" ). unwrap (); 864 std:: os:: unix:: fs:: symlink ( "../a/b" , dir. join ( "link" )). unwrap (); 865let mut child =spawn_watch ( & dir, "build" ); 866wait_for_log ( & dir, "one\n" ); 867 std:: fs:: remove_file ( dir. join ( "link" )). unwrap (); 868 std:: os:: unix:: fs:: symlink ( "../c/b" , dir. join ( "link" )). unwrap (); 869wait_for_log ( & dir, "one\none\n" ); 870stop_watch ( & mut child); 871} 872 873# [ cfg ( unix )] 874# [ test ] 875fn watch_follows_the_middle_of_an_env_symlink_chain () { 876let outer = tempfile:: tempdir (). unwrap (); 877let dir = outer. path (). join ( "pkg" ); 878 std:: fs:: create_dir ( & dir). unwrap (); 879 std:: fs:: create_dir ( outer. path (). join ( "links" )). unwrap (); 880 std:: fs:: create_dir ( outer. path (). join ( "targets" )). unwrap (); 881 std:: fs:: write ( outer. path (). join ( "targets/a.env" ), "FLAVOR=a\n" ). unwrap (); 882 std:: fs:: write ( outer. path (). join ( "targets/b.env" ), "FLAVOR=b\n" ). unwrap (); 883 std:: os:: unix:: fs:: symlink ( "../targets/a.env" , outer. path (). join ( "links/middle.env" )). unwrap (); 884 std:: fs:: write ( dir. join ( "avril.conf" ), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: ".env"} }"# ). unwrap (); 885 std:: fs:: write ( dir. join ( "input.txt" ), "one\n" ). unwrap (); 886 std:: os:: unix:: fs:: symlink ( "../links/middle.env" , dir. join ( ".env" )). unwrap (); 887let mut child =spawn_watch ( & dir, "build" ); 888wait_for_log ( & dir, "one\n" ); 889 std:: fs:: remove_file ( outer. path (). join ( "links/middle.env" )). unwrap (); 890 std:: os:: unix:: fs:: symlink ( "../targets/b.env" , outer. path (). join ( "links/middle.env" )). unwrap (); 891wait_for_log ( & dir, "one\none\n" ); 892stop_watch ( & mut child); 893} 894 895# [ cfg ( unix )] 896# [ test ] 897fn watch_recovers_when_an_env_targets_directory_is_created () { 898let outer = tempfile:: tempdir (). unwrap (); 899let dir = outer. path (). join ( "pkg" ); 900 std:: fs:: create_dir ( & dir). unwrap (); 901 std:: fs:: write ( dir. join ( "avril.conf" ), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: ".env"} }"# ). unwrap (); 902 std:: fs:: write ( dir. join ( "input.txt" ), "one\n" ). unwrap (); 903 std:: os:: unix:: fs:: symlink ( "../missing/shared.env" , dir. join ( ".env" )). unwrap (); 904let mut child =spawn_watch ( & dir, "build" ); 905 std:: thread:: sleep ( Duration :: from_millis ( 400 )); 906 std:: fs:: create_dir ( outer. path (). join ( "missing" )). unwrap (); 907 std:: fs:: write ( outer. path (). join ( "missing/shared.env" ), "FLAVOR=one\n" ). unwrap (); 908wait_for_log ( & dir, "one\n" ); 909stop_watch ( & mut child); 910} 911 912# [ cfg ( unix )] 913# [ test ] 914fn watch_survives_an_external_env_ancestor_being_replaced () { 915let outer = tempfile:: tempdir (). unwrap (); 916let dir = outer. path (). join ( "pkg" ); 917 std:: fs:: create_dir ( & dir). unwrap (); 918 std:: fs:: create_dir_all ( outer. path (). join ( "secrets/version" )). unwrap (); 919 std:: fs:: write ( dir. join ( "avril.conf" ), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "../secrets/version/shared.env"} }"# ). unwrap (); 920 std:: fs:: write ( dir. join ( "input.txt" ), "one\n" ). unwrap (); 921 std:: fs:: write ( 922 outer. path (). join ( "secrets/version/shared.env" ), 923"FLAVOR=one\n" , 924) 925. unwrap (); 926let mut child =spawn_watch ( & dir, "build" ); 927wait_for_log ( & dir, "one\n" ); 928 std:: fs:: rename ( 929 outer. path (). join ( "secrets" ), 930 outer. path (). join ( "secrets-old" ), 931) 932. unwrap (); 933 std:: fs:: create_dir_all ( outer. path (). join ( "secrets/version" )). unwrap (); 934 std:: fs:: write ( 935 outer. path (). join ( "secrets/version/shared.env" ), 936"FLAVOR=two\n" , 937) 938. unwrap (); 939wait_for_log ( & dir, "one\none\n" ); 940 std:: fs:: write ( 941 outer. path (). join ( "secrets/version/shared.env" ), 942"FLAVOR=three\n" , 943) 944. unwrap (); 945wait_for_log ( & dir, "one\none\none\n" ); 946stop_watch ( & mut child); 947} 948 949# [ cfg ( unix )] 950# [ test ] 951fn watch_recovers_through_a_dangling_intermediate_env_symlink () { 952let outer = tempfile:: tempdir (). unwrap (); 953let dir = outer. path (). join ( "pkg" ); 954 std:: fs:: create_dir ( & dir). unwrap (); 955 std:: fs:: write ( dir. join ( "avril.conf" ), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "link/shared.env"} }"# ). unwrap (); 956 std:: fs:: write ( dir. join ( "input.txt" ), "one\n" ). unwrap (); 957 std:: os:: unix:: fs:: symlink ( "../missing" , dir. join ( "link" )). unwrap (); 958let mut child =spawn_watch ( & dir, "build" ); 959 std:: thread:: sleep ( Duration :: from_millis ( 400 )); 960 std:: fs:: create_dir ( outer. path (). join ( "missing" )). unwrap (); 961 std:: fs:: write ( outer. path (). join ( "missing/shared.env" ), "FLAVOR=one\n" ). unwrap (); 962wait_for_log ( & dir, "one\n" ); 963stop_watch ( & mut child); 964} 965 966# [ cfg ( unix )] 967# [ test ] 968fn watch_follows_a_symlinked_config_and_input () { 969let outer = tempfile:: tempdir (). unwrap (); 970let dir = outer. path (). join ( "pkg" ); 971 std:: fs:: create_dir ( & dir). unwrap (); 972 std:: fs:: write ( 973 outer. path (). join ( "shared.conf" ), 974r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"]} }"# , 975) 976. unwrap (); 977 std:: os:: unix:: fs:: symlink ( "../shared.conf" , dir. join ( "avril.conf" )). unwrap (); 978 std:: fs:: write ( outer. path (). join ( "shared-input.txt" ), "one\n" ). unwrap (); 979 std:: os:: unix:: fs:: symlink ( "../shared-input.txt" , dir. join ( "input.txt" )). unwrap (); 980let mut child =spawn_watch ( & dir, "build" ); 981wait_for_log ( & dir, "one\n" ); 982 std:: fs:: write ( outer. path (). join ( "shared-input.txt" ), "two\n" ). unwrap (); 983wait_for_log ( & dir, "one\ntwo\n" ); 984 std:: fs:: write ( 985 outer. path (). join ( "shared.conf" ), 986r#"tasks: { build: {command: "echo changed >> runs.log", files: ["input.txt"]} }"# , 987) 988. unwrap (); 989wait_for_log ( & dir, "one\ntwo\nchanged\n" ); 990stop_watch ( & mut child); 991} 992 993# [ cfg ( unix )] 994# [ test ] 995fn watch_keeps_root_coverage_when_an_env_alias_points_at_the_package () { 996let outer = tempfile:: tempdir (). unwrap (); 997let dir = outer. path (). join ( "pkg" ); 998 std:: fs:: create_dir ( & dir). unwrap (); 999 std:: fs:: write ( dir. join ( "avril.conf" ), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "../alias/.env"} }"# ). unwrap (); 1000 std:: fs:: write ( dir. join ( "input.txt" ), "one\n" ). unwrap (); 1001 std:: fs:: write ( dir. join ( ".env" ), "FLAVOR=one\n" ). unwrap (); 1002 std:: os:: unix:: fs:: symlink ( "pkg" , outer. path (). join ( "alias" )). unwrap (); 1003let mut child =spawn_watch ( & dir, "build" ); 1004wait_for_log ( & dir, "one\n" ); 1005 std:: fs:: write ( dir. join ( "input.txt" ), "two\n" ). unwrap (); 1006wait_for_log ( & dir, "one\ntwo\n" ); 1007stop_watch ( & mut child); 1008} 1009 1010# [ cfg ( unix )] 1011# [ test ] 1012fn watch_recovers_when_a_config_symlink_is_fixed_after_retargeting () { 1013let outer = tempfile:: tempdir (). unwrap (); 1014let dir = outer. path (). join ( "pkg" ); 1015 std:: fs:: create_dir ( & dir). unwrap (); 1016 std:: fs:: write ( 1017 outer. path (). join ( "a.conf" ), 1018r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"]} }"# , 1019) 1020. unwrap (); 1021 std:: os:: unix:: fs:: symlink ( "../a.conf" , dir. join ( "avril.conf" )). unwrap (); 1022 std:: fs:: write ( dir. join ( "input.txt" ), "one\n" ). unwrap (); 1023let mut child =spawn_watch ( & dir, "build" ); 1024wait_for_log ( & dir, "one\n" ); 1025 std:: fs:: remove_file ( dir. join ( "avril.conf" )). unwrap (); 1026 std:: os:: unix:: fs:: symlink ( "../b.conf" , dir. join ( "avril.conf" )). unwrap (); 1027 std:: thread:: sleep ( Duration :: from_millis ( 500 )); 1028assert_eq! ( read_log ( & dir), "one\n" ); 1029 std:: fs:: write ( 1030 outer. path (). join ( "b.conf" ), 1031r#"tasks: { build: {command: "echo changed >> runs.log", files: ["input.txt"]} }"# , 1032) 1033. unwrap (); 1034wait_for_log ( & dir, "one\nchanged\n" ); 1035stop_watch ( & mut child); 1036} 1037 1038# [ cfg ( unix )] 1039# [ test ] 1040fn watch_recovers_when_a_missing_env_traversal_directory_appears () { 1041let outer = tempfile:: tempdir (). unwrap (); 1042let dir = outer. path (). join ( "pkg" ); 1043 std:: fs:: create_dir ( & dir). unwrap (); 1044 std:: fs:: write ( dir. join ( "avril.conf" ), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "link/../shared.env"} }"# ). unwrap (); 1045 std:: fs:: write ( dir. join ( "input.txt" ), "one\n" ). unwrap (); 1046 std:: fs:: write ( outer. path (). join ( "shared.env" ), "FLAVOR=one\n" ). unwrap (); 1047 std:: os:: unix:: fs:: symlink ( "../missing" , dir. join ( "link" )). unwrap (); 1048let mut child =spawn_watch ( & dir, "build" ); 1049 std:: thread:: sleep ( Duration :: from_millis ( 400 )); 1050// A regular file where the directory belongs blocks the spelling 1051// without ending recovery: replacing it with the directory must be 1052// noticed. 1053 std:: fs:: write ( outer. path (). join ( "missing" ), "not a directory" ). unwrap (); 1054 std:: thread:: sleep ( Duration :: from_millis ( 400 )); 1055assert_eq! ( read_log ( & dir), "" ); 1056 std:: fs:: remove_file ( outer. path (). join ( "missing" )). unwrap (); 1057 std:: fs:: create_dir ( outer. path (). join ( "missing" )). unwrap (); 1058wait_for_log ( & dir, "one\n" ); 1059stop_watch ( & mut child); 1060} 1061 1062# [ cfg ( unix )] 1063# [ test ] 1064fn watch_repairs_coverage_when_an_input_ancestor_is_replaced () { 1065let outer = tempfile:: tempdir (). unwrap (); 1066let dir = outer. path (). join ( "pkg" ); 1067 std:: fs:: create_dir ( & dir). unwrap (); 1068 std:: fs:: create_dir_all ( outer. path (). join ( "external/sub" )). unwrap (); 1069 std:: fs:: write ( 1070 dir. join ( "avril.conf" ), 1071r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"]} }"# , 1072) 1073. unwrap (); 1074 std:: fs:: write ( outer. path (). join ( "external/sub/input.txt" ), "one\n" ). unwrap (); 1075 std:: os:: unix:: fs:: symlink ( "../external/sub/input.txt" , dir. join ( "input.txt" )). unwrap (); 1076let mut child =spawn_watch ( & dir, "build" ); 1077wait_for_log ( & dir, "one\n" ); 1078 std:: fs:: rename ( 1079 outer. path (). join ( "external" ), 1080 outer. path (). join ( "external-old" ), 1081) 1082. unwrap (); 1083 std:: fs:: create_dir_all ( outer. path (). join ( "external/sub" )). unwrap (); 1084 std:: fs:: write ( outer. path (). join ( "external/sub/input.txt" ), "two\n" ). unwrap (); 1085wait_for_log ( & dir, "one\ntwo\n" ); 1086 std:: fs:: write ( outer. path (). join ( "external/sub/input.txt" ), "three\n" ). unwrap (); 1087wait_for_log ( & dir, "one\ntwo\nthree\n" ); 1088stop_watch ( & mut child); 1089} 1090 1091# [ cfg ( unix )] 1092# [ test ] 1093fn watch_recovers_an_input_through_a_missing_directory () { 1094let outer = tempfile:: tempdir (). unwrap (); 1095let dir = outer. path (). join ( "pkg" ); 1096 std:: fs:: create_dir ( & dir). unwrap (); 1097 std:: fs:: write ( 1098 dir. join ( "avril.conf" ), 1099r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"]} }"# , 1100) 1101. unwrap (); 1102 std:: fs:: write ( outer. path (). join ( "shared-input.txt" ), "one\n" ). unwrap (); 1103 std:: os:: unix:: fs:: symlink ( "../missing/../shared-input.txt" , dir. join ( "input.txt" )). unwrap (); 1104let mut child =spawn_watch ( & dir, "build" ); 1105 std:: thread:: sleep ( Duration :: from_millis ( 400 )); 1106 std:: fs:: create_dir ( outer. path (). join ( "missing" )). unwrap (); 1107wait_for_log ( & dir, "one\n" ); 1108stop_watch ( & mut child); 1109} 1110 1111# [ cfg ( unix )] 1112# [ test ] 1113fn watch_recovers_when_a_replaced_config_directory_is_fixed () { 1114let outer = tempfile:: tempdir (). unwrap (); 1115let dir = outer. path (). join ( "pkg" ); 1116 std:: fs:: create_dir ( & dir). unwrap (); 1117 std:: fs:: create_dir ( outer. path (). join ( "shared" )). unwrap (); 1118 std:: fs:: write ( 1119 outer. path (). join ( "shared/config.conf" ), 1120r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"]} }"# , 1121) 1122. unwrap (); 1123 std:: os:: unix:: fs:: symlink ( "../shared/config.conf" , dir. join ( "avril.conf" )). unwrap (); 1124 std:: fs:: write ( dir. join ( "input.txt" ), "one\n" ). unwrap (); 1125let mut child =spawn_watch ( & dir, "build" ); 1126wait_for_log ( & dir, "one\n" ); 1127 std:: fs:: rename ( outer. path (). join ( "shared" ), outer. path (). join ( "shared-old" )). unwrap (); 1128 std:: fs:: create_dir ( outer. path (). join ( "shared" )). unwrap (); 1129 std:: fs:: write ( 1130 outer. path (). join ( "shared/config.conf" ), 1131"tasks: { build: 42 }" , 1132) 1133. unwrap (); 1134 std:: thread:: sleep ( Duration :: from_millis ( 500 )); 1135assert_eq! ( read_log ( & dir), "one\n" ); 1136 std:: fs:: write ( 1137 outer. path (). join ( "shared/config.conf" ), 1138r#"tasks: { build: {command: "echo changed >> runs.log", files: ["input.txt"]} }"# , 1139) 1140. unwrap (); 1141wait_for_log ( & dir, "one\nchanged\n" ); 1142stop_watch ( & mut child); 1143} 1144 1145# [ cfg ( unix )] 1146# [ test ] 1147fn watch_anchor_takes_the_group_down_when_the_watcher_dies_abruptly () { 1148let dir =project ( r#"tasks: { serve: {command: "sh bg.sh &", files: ["input.txt"]} }"# ); 1149 std:: fs:: write ( 1150 dir. path (). join ( "bg.sh" ), 1151"echo $$ > bg.pid\necho start >> runs.log\ntrap '' INT TERM\nwhile :; do sleep 1; done\n" , 1152) 1153. unwrap (); 1154 std:: fs:: write ( dir. path (). join ( "input.txt" ), "one" ). unwrap (); 1155let mut child =spawn_watch ( dir. path (), "serve" ); 1156wait_for_log ( dir. path (), "start\n" ); 1157 std:: thread:: sleep ( Duration :: from_millis ( 300 )); 1158 child. kill (). unwrap (); 1159let _ = child. wait (); 1160let pid = std:: fs:: read_to_string ( dir. path (). join ( "bg.pid" )). unwrap (); 1161let deadline =Instant :: now () +Duration :: from_secs ( 10 ); 1162while Command :: new ( "kill" ) 1163. args ([ "-0" , pid. trim ()]) 1164. status () 1165. unwrap () 1166. success () 1167{ 1168assert! ( 1169Instant :: now () < deadline, 1170"background process {} survived the watcher's abrupt death" , 1171 pid. trim () 1172); 1173 std:: thread:: sleep ( Duration :: from_millis ( 10 )); 1174} 1175} 1176 1177# [ cfg ( unix )] 1178# [ test ] 1179fn watch_shutdown_during_a_restart_does_not_start_another_run () { 1180let dir =project ( r#"tasks: { serve: {command: "sh serve.sh", files: ["input.txt"]} }"# ); 1181// The task acknowledges the restart's SIGINT (and survives it), so 1182// the test knows the watcher is inside restart termination before 1183// it sends its own. 1184 std:: fs:: write ( 1185 dir. path (). join ( "serve.sh" ), 1186"echo start >> runs.log\ntrap 'echo int >> runs.log' INT\nwhile :; do sleep 1; done\n" , 1187) 1188. unwrap (); 1189 std:: fs:: write ( dir. path (). join ( "input.txt" ), "one" ). unwrap (); 1190let mut child =spawn_watch ( dir. path (), "serve" ); 1191wait_for_log ( dir. path (), "start\n" ); 1192 std:: fs:: write ( dir. path (). join ( "input.txt" ), "two" ). unwrap (); 1193let deadline =Instant :: now () +Duration :: from_secs ( 10 ); 1194while !read_log ( dir. path ()). contains ( "int" ) { 1195assert! ( 1196Instant :: now () < deadline, 1197"restart never interrupted the task" 1198); 1199 std:: thread:: sleep ( Duration :: from_millis ( 10 )); 1200} 1201let status =Command :: new ( "kill" ) 1202. args ([ "-INT" , & child. id (). to_string ()]) 1203. status () 1204. unwrap (); 1205assert! ( status. success ()); 1206while child. try_wait (). unwrap (). is_none () { 1207assert! ( Instant :: now () < deadline, "watcher did not shut down" ); 1208 std:: thread:: sleep ( Duration :: from_millis ( 10 )); 1209} 1210assert! ( child. wait (). unwrap (). success ()); 1211assert_eq! ( 1212read_log ( dir. path ()) 1213. lines () 1214. filter ( |line|* line =="start" ) 1215. count (), 12161 , 1217"a second run started during shutdown: {:?}" , 1218read_log ( dir. path ()) 1219); 1220} 1221 1222# [ cfg ( unix )] 1223# [ test ] 1224fn watch_follows_a_traversed_directory_being_replaced_by_a_symlink () { 1225let outer = tempfile:: tempdir (). unwrap (); 1226let dir = outer. path (). join ( "pkg" ); 1227 std:: fs:: create_dir ( & dir). unwrap (); 1228 std:: fs:: create_dir ( outer. path (). join ( "route" )). unwrap (); 1229 std:: fs:: create_dir_all ( outer. path (). join ( "new/base" )). unwrap (); 1230 std:: fs:: write ( dir. join ( "avril.conf" ), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "link/../shared.env"} }"# ). unwrap (); 1231 std:: fs:: write ( dir. join ( "input.txt" ), "one\n" ). unwrap (); 1232 std:: fs:: write ( outer. path (). join ( "shared.env" ), "FLAVOR=orig\n" ). unwrap (); 1233 std:: fs:: write ( outer. path (). join ( "new/shared.env" ), "FLAVOR=new\n" ). unwrap (); 1234 std:: os:: unix:: fs:: symlink ( "../route" , dir. join ( "link" )). unwrap (); 1235let mut child =spawn_watch ( & dir, "build" ); 1236wait_for_log ( & dir, "one\n" ); 1237 std:: fs:: remove_dir ( outer. path (). join ( "route" )). unwrap (); 1238 std:: os:: unix:: fs:: symlink ( "new/base" , outer. path (). join ( "route" )). unwrap (); 1239wait_for_log ( & dir, "one\none\n" ); 1240 std:: fs:: write ( outer. path (). join ( "new/shared.env" ), "FLAVOR=newer\n" ). unwrap (); 1241wait_for_log ( & dir, "one\none\none\n" ); 1242stop_watch ( & mut child); 1243} 1244 1245# [ cfg ( unix )] 1246# [ test ] 1247fn watch_reloads_env_files_and_the_config () { 1248let dir =project ( 1249"tasks: { build: {command: \"cat input.txt >> runs.log\", files: [\"input.txt\"], envFile: \".env\"} }" , 1250); 1251 std:: fs:: write ( dir. path (). join ( "input.txt" ), "one\n" ). unwrap (); 1252 std:: fs:: write ( dir. path (). join ( ".env" ), "FLAVOR=one\n" ). unwrap (); 1253let mut child =spawn_watch ( dir. path (), "build" ); 1254wait_for_log ( dir. path (), "one\n" ); 1255// A same-content rewrite of the config changes nothing. 1256let config = std:: fs:: read_to_string ( dir. path (). join ( "avril.conf" )). unwrap (); 1257 std:: fs:: write ( dir. path (). join ( "avril.conf" ), config). unwrap (); 1258 std:: thread:: sleep ( Duration :: from_millis ( 400 )); 1259assert_eq! ( read_log ( dir. path ()), "one\n" ); 1260 std:: fs:: write ( dir. path (). join ( ".env" ), "FLAVOR=two\n" ). unwrap (); 1261wait_for_log ( dir. path (), "one\none\n" ); 1262 std:: fs:: write ( dir. path (). join ( "avril.conf" ), "tasks: { build: {command: \"echo changed >> runs.log\", files: [\"input.txt\"], envFile: \".env\"} }" ). unwrap (); 1263wait_for_log ( dir. path (), "one\none\nchanged\n" ); 1264stop_watch ( & mut child); 1265} 1266 1267fn avril_cached ( dir : & Path , args : & [ & str ], env : & [( & str , & str )]) ->Output { 1268let mut command =Command :: new ( env! ( "CARGO_BIN_EXE_avril" )); 1269 command 1270. current_dir ( dir) 1271. args ( args) 1272. env ( "AVRIL_CACHE_DIR" , dir. join ( "cache" )) 1273. env_remove ( "SITE_TEST_FLAVOR" ) 1274. env_remove ( "FLAVOR" ) 1275. env_remove ( "UNRELATED" ); 1276for ( name, value) in env{ 1277 command. env ( name, value); 1278} 1279 command. output (). unwrap () 1280} 1281 1282# [ test ] 1283fn cached_tasks_skip_while_outputs_are_intact_and_rerun_when_anything_changes () { 1284let dir =project ( & format! ( 1285"tasks: {{ site: {{ command: \"{} run ./generate.js\", files: ['src/**'], output: ['dist/'], env: ['SITE_TEST_FLAVOR'] }} }}" , 1286env! ( "CARGO_BIN_EXE_avril" ). replace ( '\\' , "/" ) 1287)); 1288 std:: fs:: create_dir ( dir. path (). join ( "src" )). unwrap (); 1289 std:: fs:: write ( dir. path (). join ( "src/content.txt" ), "one" ). unwrap (); 1290 std:: fs:: write ( 1291 dir. path (). join ( "generate.js" ), 1292r#" 1293import { mkdirs, readTextFile, writeTextFile } from "avril:fs"; 1294const content = await readTextFile("src/content.txt"); 1295await mkdirs("dist"); 1296await writeTextFile("dist/index.html", `site:${content}`); 1297console.log("generated"); 1298"# , 1299) 1300. unwrap (); 1301let index = dir. path (). join ( "dist/index.html" ); 1302 1303let output =avril_cached ( dir. path (), & [ "task" , "site" ], & []); 1304assert! ( output. status . success (), "{}" , stderr ( & output)); 1305assert_eq! ( stdout ( & output), "generated\n" ); 1306assert_eq! ( std:: fs:: read_to_string ( & index). unwrap (), "site:one" ); 1307 1308// Nothing changed: the task is skipped and its outputs are left alone. 1309let output =avril_cached ( dir. path (), & [ "task" , "site" ], & []); 1310assert! ( output. status . success (), "{}" , stderr ( & output)); 1311assert_eq! ( stdout ( & output), "" ); 1312assert! ( 1313stderr ( & output). contains ( "Task site (cached)" ), 1314"{}" , 1315stderr ( & output) 1316); 1317assert_eq! ( std:: fs:: read_to_string ( & index). unwrap (), "site:one" ); 1318 1319// Outputs are verified, not restored: tampering with one reruns the task. 1320 std:: fs:: write ( & index, "tampered" ). unwrap (); 1321let output =avril_cached ( dir. path (), & [ "task" , "site" ], & []); 1322assert! ( output. status . success (), "{}" , stderr ( & output)); 1323assert_eq! ( stdout ( & output), "generated\n" ); 1324assert_eq! ( std:: fs:: read_to_string ( & index). unwrap (), "site:one" ); 1325 1326// Deleting the outputs likewise reruns the task, which recreates them. 1327 std:: fs:: remove_dir_all ( dir. path (). join ( "dist" )). unwrap (); 1328let output =avril_cached ( dir. path (), & [ "task" , "site" ], & []); 1329assert! ( output. status . success (), "{}" , stderr ( & output)); 1330assert_eq! ( stdout ( & output), "generated\n" ); 1331assert_eq! ( std:: fs:: read_to_string ( & index). unwrap (), "site:one" ); 1332 1333 std:: fs:: write ( dir. path (). join ( "src/content.txt" ), "two" ). unwrap (); 1334let output =avril_cached ( dir. path (), & [ "task" , "site" ], & []); 1335assert! ( output. status . success (), "{}" , stderr ( & output)); 1336assert_eq! ( stdout ( & output), "generated\n" ); 1337assert_eq! ( std:: fs:: read_to_string ( & index). unwrap (), "site:two" ); 1338 1339// A listed env var is part of the key even though the command ignores it. 1340let output =avril_cached ( 1341 dir. path (), 1342& [ "task" , "site" ], 1343& [( "SITE_TEST_FLAVOR" , "blue" )], 1344); 1345assert_eq! ( stdout ( & output), "generated\n" ); 1346let output =avril_cached ( 1347 dir. path (), 1348& [ "task" , "site" ], 1349& [( "SITE_TEST_FLAVOR" , "blue" )], 1350); 1351assert_eq! ( stdout ( & output), "" ); 1352let output =avril_cached ( 1353 dir. path (), 1354& [ "task" , "site" ], 1355& [( "SITE_TEST_FLAVOR" , "green" )], 1356); 1357assert_eq! ( stdout ( & output), "generated\n" ); 1358} 1359 1360# [ test ] 1361fn cache_keys_include_extra_args_and_dependency_fingerprints () { 1362let dir =project ( 1363r#" 1364tasks: { 1365work: {command: "echo work", files: ["input.txt"]}, 1366} 1367"# , 1368); 1369 std:: fs:: write ( dir. path (). join ( "input.txt" ), "input" ). unwrap (); 1370let output =avril_cached ( dir. path (), & [ "task" , "work" , "a" ], & []); 1371assert_eq! ( stdout ( & output), "work a\n" ); 1372let output =avril_cached ( dir. path (), & [ "task" , "work" , "a" ], & []); 1373assert_eq! ( stdout ( & output), "" ); 1374assert! ( stderr ( & output). contains ( "(cached)" ), "{}" , stderr ( & output)); 1375let output =avril_cached ( dir. path (), & [ "task" , "work" , "b" ], & []); 1376assert_eq! ( stdout ( & output), "work b\n" ); 1377 1378let dir =project ( 1379r#" 1380tasks: { 1381child: "echo child1", 1382parent: {command: "echo parent", dependencies: ["child"], files: ["input.txt"]}, 1383} 1384"# , 1385); 1386 std:: fs:: write ( dir. path (). join ( "input.txt" ), "input" ). unwrap (); 1387let output =avril_cached ( dir. path (), & [ "task" , "parent" ], & []); 1388assert_eq! ( stdout ( & output), "child1\nparent\n" ); 1389// The uncached child always runs, but the parent is served from cache. 1390let output =avril_cached ( dir. path (), & [ "task" , "parent" ], & []); 1391assert_eq! ( stdout ( & output), "child1\n" ); 1392 std:: fs:: write ( 1393 dir. path (). join ( "avril.conf" ), 1394r#" 1395tasks: { 1396child: "echo child2", 1397parent: {command: "echo parent", dependencies: ["child"], files: ["input.txt"]}, 1398} 1399"# , 1400) 1401. unwrap (); 1402let output =avril_cached ( dir. path (), & [ "task" , "parent" ], & []); 1403assert_eq! ( stdout ( & output), "child2\nparent\n" ); 1404} 1405 1406# [ test ] 1407fn a_files_pattern_that_matches_nothing_is_an_error_against_the_config () { 1408let dir =project ( 1409r#" 1410tasks: { 1411build: {command: "echo build", files: ["missing/**/*.ts"]}, 1412} 1413"# , 1414); 1415let output =avril_cached ( dir. path (), & [ "task" , "build" ], & []); 1416assert! ( !output. status . success ()); 1417let error =stderr ( & output); 1418assert! ( error. contains ( "matched no files" ), "{error}" ); 1419assert! ( error. contains ( "avril.conf:3:" ), "{error}" ); 1420assert! ( !stdout ( & output). contains ( "build" )); 1421} 1422 1423# [ test ] 1424fn without_a_config_there_is_a_clear_error () { 1425let dir = tempfile:: tempdir (). unwrap (); 1426let output =avril ( dir. path (), & [ "task" , "anything" ]); 1427assert! ( !output. status . success ()); 1428assert! ( 1429stderr ( & output). contains ( "No avril.conf found" ), 1430"{}" , 1431stderr ( & output) 1432); 1433}