use std::path::{Path, PathBuf}; use std::process::{Child, Command, Output}; use std::time::{Duration, Instant}; fn project(config: &str) -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("avril.conf"), config).unwrap(); dir } fn avril(dir: &Path, args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(dir) .args(args) .output() .unwrap() } fn stdout(output: &Output) -> String { String::from_utf8(output.stdout.clone()).unwrap() } fn stderr(output: &Output) -> String { String::from_utf8(output.stderr.clone()).unwrap() } #[test] fn listing_shows_descriptions_commands_and_groupings() { let dir = project( r#" tasks: { build: "echo building", serve: {command: "echo serving", description: "Run the server", dependencies: ["build"]}, all: {dependencies: ["serve"]}, } "#, ); let output = avril(dir.path(), &["task"]); assert!(output.status.success(), "{}", stderr(&output)); assert_eq!( stdout(&output), "Available tasks:\n- all\n dependencies: serve\n- build\n echo building\n- serve\n Run the server\n" ); let output = avril(dir.path(), &["task", "nope"]); assert!(!output.status.success()); assert!( stderr(&output).contains("Unknown task \"nope\""), "{}", stderr(&output) ); assert!(stderr(&output).contains("- build"), "{}", stderr(&output)); } #[test] fn argv_command_tasks_run_without_a_shell() { let dir = project( r#" tasks: { build: {command: ["printf", "%s", "a b >> $(echo nope)"]}, } "#, ); let output = avril(dir.path(), &["task", "build"]); assert!(output.status.success(), "{}", stderr(&output)); // Shell metacharacters arrive literally, and the argument containing // spaces is a single argument. assert_eq!(stdout(&output), "a b >> $(echo nope)"); } #[test] fn dependencies_run_once_in_order_and_extra_args_are_forwarded() { let dir = project( r#" tasks: { shared: "echo shared", a: {command: "echo a", dependencies: ["shared"]}, b: {command: "echo b", dependencies: ["shared"]}, greet: {command: "echo hello", dependencies: ["a", "b"]}, } "#, ); let output = avril(dir.path(), &["task", "greet", "wide", "world"]); assert!(output.status.success(), "{}", stderr(&output)); assert_eq!(stdout(&output), "shared\na\nb\nhello wide world\n"); // Each command header goes to stderr, in execution order. let errors = stderr(&output); let headers: Vec<_> = errors .lines() .filter(|line| line.starts_with("Task ")) .collect(); assert_eq!( headers, [ "Task shared echo shared", "Task a echo a", "Task b echo b", "Task greet echo hello wide world" ] ); } #[test] fn commands_run_in_the_package_root_from_a_subdirectory() { // marker.js only exists at the package root, so `avril run ./marker.js` // only succeeds if the command's working directory is the package root. let dir = project(&format!( "tasks: {{ marker: \"{} run ./marker.js\" }}", env!("CARGO_BIN_EXE_avril").replace('\\', "/") )); std::fs::write(dir.path().join("marker.js"), "console.log('ran-at-root');").unwrap(); std::fs::create_dir_all(dir.path().join("src/nested")).unwrap(); let nested: PathBuf = dir.path().join("src/nested"); let output = avril(&nested, &["task", "marker"]); assert!(output.status.success(), "{}", stderr(&output)); assert_eq!(stdout(&output), "ran-at-root\n"); } #[test] fn parallel_dependencies_actually_run_at_the_same_time() { // Each script announces itself, then waits for all three announcements: // run sequentially, the first would time out instead of finishing. let exe = env!("CARGO_BIN_EXE_avril").replace('\\', "/"); let dir = project(&format!( "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}} }}" )); for name in ["a", "b", "c"] { std::fs::write( dir.path().join(format!("{name}.js")), format!( r#" import {{ exists, writeTextFile }} from "avril:fs"; await writeTextFile("{name}.started", ""); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); let ready = false; for (let attempt = 0; attempt < 500 && !ready; attempt++) {{ ready = true; for (const other of ["a", "b", "c"]) {{ if (!(await exists(other + ".started"))) {{ ready = false; break; }} }} if (!ready) await sleep(10); }} if (!ready) throw new Error("dependencies did not run in parallel"); console.log("{name}"); "# ), ) .unwrap(); } let output = avril(dir.path(), &["task", "all"]); assert!(output.status.success(), "{}", stderr(&output)); let text = stdout(&output); let mut lines: Vec<_> = text.lines().collect(); lines.sort_unstable(); assert_eq!(lines, ["a", "all-done", "b", "c"]); } #[test] fn parallel_branches_share_dependencies_and_any_failure_is_status_one() { let dir = project( r#" tasks: { shared: "echo shared", a: {command: "echo a", dependencies: ["shared"]}, b: {command: "echo b", dependencies: ["shared"]}, all: {dependencies: ["a", "b"], parallel: true}, } "#, ); let output = avril(dir.path(), &["task", "all"]); assert!(output.status.success(), "{}", stderr(&output)); let text = stdout(&output); let mut lines: Vec<_> = text.lines().collect(); lines.sort_unstable(); assert_eq!(lines, ["a", "b", "shared"]); let dir = project( r#" tasks: { one: "exit 3", two: "echo two-ran", three: "exit 5", all: {command: "echo parent", dependencies: ["one", "two", "three"], parallel: true}, } "#, ); let output = avril(dir.path(), &["task", "all"]); // Not three or five: once failures race, the run is simply status 1. assert_eq!(output.status.code(), Some(1), "{}", stderr(&output)); assert!(stdout(&output).contains("two-ran"), "{}", stdout(&output)); assert!(!stdout(&output).contains("parent")); } #[test] fn a_failing_dependency_stops_the_run_and_propagates_its_exit_code() { let dir = project( r#" tasks: { fail: "exit 3", after: {command: "echo should-not-run", dependencies: ["fail"]}, } "#, ); let output = avril(dir.path(), &["task", "after"]); assert_eq!(output.status.code(), Some(3), "{}", stderr(&output)); assert!(!stdout(&output).contains("should-not-run")); } #[test] fn unknown_dependencies_and_cycles_are_reported_against_the_config() { let dir = project( r#" tasks: { a: {command: "echo a", dependencies: ["missing"]}, } "#, ); let output = avril(dir.path(), &["task", "a"]); assert!(!output.status.success()); let error = stderr(&output); assert!(error.contains("Unknown task \"missing\""), "{error}"); assert!(error.contains("avril.conf:3:"), "{error}"); let dir = project( r#" tasks: { a: {command: "echo a", dependencies: ["b"]}, b: {command: "echo b", dependencies: ["a"]}, self: {command: "echo self", dependencies: ["self"]}, } "#, ); for watch in [false, true] { for (name, cycle, line) in [("a", "a -> b -> a", 4), ("self", "self -> self", 5)] { let args = if watch { vec!["task", "--watch", name] } else { vec!["task", name] }; let output = avril(dir.path(), &args); assert!(!output.status.success()); let error = stderr(&output); assert!( error.contains(&format!("Task cycle detected: {cycle}")), "{error}" ); assert!(error.contains(&format!("avril.conf:{line}:")), "{error}"); } } } #[cfg(unix)] #[test] fn task_preserves_native_environment_and_search_paths() { use std::os::unix::{ffi::OsStrExt, fs::symlink}; let dir = project(r#"tasks: { run: 'printf "%s" "$RAW"' }"#); let binary = dir.path().join(std::ffi::OsStr::from_bytes(b"bin-\xff")); std::fs::create_dir(&binary).unwrap(); symlink("/bin/sh", binary.join("sh")).unwrap(); for path in [Some(binary.as_os_str()), None] { let mut command = Command::new(env!("CARGO_BIN_EXE_avril")); command .current_dir(dir.path()) .args(["task", "run"]) .env("RAW", std::ffi::OsStr::from_bytes(b"\xfe\xff")) .env_remove("PATH"); if let Some(path) = path { command.env("PATH", path); } let output = command.output().unwrap(); assert!(output.status.success(), "{}", stderr(&output)); assert_eq!(output.stdout, b"\xfe\xff"); } } #[test] fn env_files_apply_to_a_task_and_its_dependencies_recursively() { let bin = env!("CARGO_BIN_EXE_avril").replace('\\', "/"); let dir = project(&format!( r#" tasks: {{ root: {{command: "{bin} run ./show.js", envFile: ".env.root", dependencies: ["child"]}}, child: {{command: "{bin} run ./show.js", envFile: ".env.child", dependencies: ["leaf"]}}, leaf: "{bin} run ./show.js", }} "# )); std::fs::write( dir.path().join(".env.root"), "GREETING=root-file\nLEVEL=root\nROOT_ONLY=yes\n", ) .unwrap(); std::fs::write( dir.path().join(".env.child"), "LEVEL=child\nCHILD_ONLY=yes\n", ) .unwrap(); std::fs::write( dir.path().join("show.js"), "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", ) .unwrap(); let output = Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(dir.path()) .args(["task", "root"]) .env("GREETING", "process") .env_remove("LEVEL") .env_remove("ROOT_ONLY") .env_remove("CHILD_ONLY") .output() .unwrap(); assert!(output.status.success(), "{}", stderr(&output)); // Dependencies run first: leaf and child see the child's file overriding // the root's, while the root itself only sees its own file. The process // environment wins over every file. assert_eq!( stdout(&output), "process/child/yes/yes\nprocess/child/yes/yes\nprocess/root/yes/-\n" ); } #[test] fn a_missing_task_env_file_is_reported_against_the_config() { let dir = project( "tasks: { root: { command: 'echo hi', envFile: '.env.missing', dependencies: ['leaf'] }, leaf: 'echo leaf' }", ); let output = avril(dir.path(), &["task", "root"]); assert!(!output.status.success()); let errors = stderr(&output); assert!(errors.contains("Cannot load env file"), "{errors}"); assert!(errors.contains("avril.conf:"), "{errors}"); } #[cfg(unix)] #[test] fn an_env_file_path_that_does_not_open_is_not_rerouted() { let dir = project("tasks: { build: { command: 'echo hi', envFile: 'missing/../.env' } }"); std::fs::write(dir.path().join(".env"), "FLAVOR=one\n").unwrap(); let output = avril(dir.path(), &["task", "build"]); assert!(!output.status.success()); assert!( stderr(&output).contains("Cannot load env file"), "{}", stderr(&output) ); } #[test] fn only_env_listed_env_file_values_are_part_of_the_cache_key() { let dir = project( "tasks: { site: { command: 'echo work', files: ['input.txt'], envFile: '.env', env: ['FLAVOR'] } }", ); std::fs::write(dir.path().join("input.txt"), "input").unwrap(); std::fs::write(dir.path().join(".env"), "FLAVOR=one\nUNRELATED=one\n").unwrap(); let output = avril_cached(dir.path(), &["task", "site"], &[]); assert!(output.status.success(), "{}", stderr(&output)); assert_eq!(stdout(&output), "work\n"); let output = avril_cached(dir.path(), &["task", "site"], &[]); assert!(output.status.success(), "{}", stderr(&output)); assert_eq!(stdout(&output), ""); assert!(stderr(&output).contains("Task site (cached)")); // A value the task did not declare in `env` does not invalidate it. std::fs::write(dir.path().join(".env"), "FLAVOR=one\nUNRELATED=two\n").unwrap(); let output = avril_cached(dir.path(), &["task", "site"], &[]); assert!(output.status.success(), "{}", stderr(&output)); assert_eq!(stdout(&output), ""); assert!(stderr(&output).contains("Task site (cached)")); std::fs::write(dir.path().join(".env"), "FLAVOR=two\nUNRELATED=two\n").unwrap(); let output = avril_cached(dir.path(), &["task", "site"], &[]); assert!(output.status.success(), "{}", stderr(&output)); assert_eq!(stdout(&output), "work\n"); } /// A spawned watcher that cannot leak past a failed assertion. struct WatchGuard(Child); impl Drop for WatchGuard { fn drop(&mut self) { let _ = self.0.kill(); let _ = self.0.wait(); } } impl std::ops::Deref for WatchGuard { type Target = Child; fn deref(&self) -> &Child { &self.0 } } impl std::ops::DerefMut for WatchGuard { fn deref_mut(&mut self) -> &mut Child { &mut self.0 } } fn spawn_watch(dir: &Path, name: &str) -> WatchGuard { spawn_watch_args(dir, &["task", "--watch", name]) } fn spawn_watch_args(dir: &Path, args: &[&str]) -> WatchGuard { WatchGuard( Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(dir) .args(args) .env("AVRIL_CACHE_DIR", dir.join("cache")) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() .unwrap(), ) } fn read_log(dir: &Path) -> String { std::fs::read_to_string(dir.join("runs.log")).unwrap_or_default() } fn wait_for_log(dir: &Path, expected: &str) { let deadline = Instant::now() + Duration::from_secs(10); while Instant::now() < deadline { if read_log(dir) == expected { return; } std::thread::sleep(Duration::from_millis(10)); } panic!( "timed out waiting for runs.log to become {expected:?}; it is {:?}", read_log(dir) ); } fn stop_watch(child: &mut WatchGuard) { #[cfg(unix)] { let status = Command::new("kill") .args(["-INT", &child.id().to_string()]) .status() .unwrap(); assert!(status.success()); let deadline = Instant::now() + Duration::from_secs(10); while Instant::now() < deadline { if child.try_wait().unwrap().is_some() { return; } std::thread::sleep(Duration::from_millis(10)); } } let _ = child.kill(); let _ = child.wait(); } #[test] fn watch_requires_files_somewhere_in_the_task_graph() { let dir = project(r#"tasks: { plain: "echo plain" }"#); let output = avril(dir.path(), &["task", "--watch", "plain"]); assert!(!output.status.success()); assert!( stderr(&output).contains("cannot be watched"), "{}", stderr(&output) ); assert!(stderr(&output).contains("'files'"), "{}", stderr(&output)); let output = avril(dir.path(), &["task", "--watch"]); assert!(!output.status.success()); assert!(stderr(&output).contains("Usage: avril task")); } #[cfg(unix)] #[test] fn watch_reruns_when_inputs_of_a_dependency_change() { let dir = project( "tasks: { build: {command: \"cat input.txt >> runs.log\", files: [\"input.txt\"]}, all: {dependencies: [\"build\"]} }", ); std::fs::write(dir.path().join("input.txt"), "one\n").unwrap(); let mut child = spawn_watch(dir.path(), "all"); wait_for_log(dir.path(), "one\n"); // Files outside the task's patterns never rerun it. std::fs::write(dir.path().join("unrelated.txt"), "x").unwrap(); std::thread::sleep(Duration::from_millis(400)); assert_eq!(read_log(dir.path()), "one\n"); // Inputs are compared by size and modification time, so rewriting the // same contents still reruns the task. std::fs::write(dir.path().join("input.txt"), "one\n").unwrap(); wait_for_log(dir.path(), "one\none\n"); std::fs::write(dir.path().join("input.txt"), "two\n").unwrap(); wait_for_log(dir.path(), "one\none\ntwo\n"); stop_watch(&mut child); assert!(child.wait().unwrap().success()); } #[cfg(unix)] #[test] fn watch_debounces_a_burst_of_changes_into_one_rerun() { let dir = project("tasks: { build: {command: \"echo run >> runs.log\", files: [\"input.txt\"]} }"); std::fs::write(dir.path().join("input.txt"), "start").unwrap(); let mut child = spawn_watch(dir.path(), "build"); wait_for_log(dir.path(), "run\n"); for index in 0..10 { std::fs::write(dir.path().join("input.txt"), format!("edit-{index}")).unwrap(); } wait_for_log(dir.path(), "run\nrun\n"); std::thread::sleep(Duration::from_millis(400)); assert_eq!(read_log(dir.path()), "run\nrun\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_keeps_watching_after_the_task_fails() { let dir = project( "tasks: { build: {command: \"echo ran >> runs.log; exit 3\", files: [\"input.txt\"]} }", ); std::fs::write(dir.path().join("input.txt"), "one").unwrap(); let mut child = spawn_watch(dir.path(), "build"); wait_for_log(dir.path(), "ran\n"); assert!(child.try_wait().unwrap().is_none()); std::fs::write(dir.path().join("input.txt"), "two").unwrap(); wait_for_log(dir.path(), "ran\nran\n"); assert!(child.try_wait().unwrap().is_none()); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_kills_and_restarts_a_running_task() { let dir = project("tasks: { serve: {command: \"sh serve.sh\", files: [\"input.txt\"]} }"); 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(); std::fs::write(dir.path().join("input.txt"), "one").unwrap(); let mut child = spawn_watch(dir.path(), "serve"); wait_for_log(dir.path(), "start\n"); std::fs::write(dir.path().join("input.txt"), "two").unwrap(); wait_for_log(dir.path(), "start\nterm\nstart\n"); stop_watch(&mut child); assert!(child.wait().unwrap().success()); wait_for_log(dir.path(), "start\nterm\nstart\nterm\n"); let pids = std::fs::read_to_string(dir.path().join("serve.pid")).unwrap(); for pid in pids.lines() { let alive = Command::new("kill") .args(["-0", pid]) .status() .unwrap() .success(); assert!(!alive, "watched process {pid} outlived the watcher"); } } #[cfg(unix)] #[test] fn watch_restart_reaches_descendants_after_the_shell_exits() { let dir = project(r#"tasks: { serve: {command: "sh launcher.sh", files: ["input.txt"]} }"#); std::fs::write( dir.path().join("bg.sh"), "echo $$ >> bg.pid\necho start >> runs.log\ntrap '' INT TERM\nwhile :; do sleep 1; done\n", ) .unwrap(); std::fs::write(dir.path().join("launcher.sh"), "sh bg.sh & wait\n").unwrap(); std::fs::write(dir.path().join("input.txt"), "one").unwrap(); let mut child = spawn_watch(dir.path(), "serve"); wait_for_log(dir.path(), "start\n"); std::fs::write(dir.path().join("input.txt"), "two").unwrap(); wait_for_log(dir.path(), "start\nstart\n"); let pids = std::fs::read_to_string(dir.path().join("bg.pid")).unwrap(); let first = pids.lines().next().unwrap(); assert!( !Command::new("kill") .args(["-0", first]) .status() .unwrap() .success(), "first descendant {first} survived the restart" ); stop_watch(&mut child); assert!(child.wait().unwrap().success()); let pids = std::fs::read_to_string(dir.path().join("bg.pid")).unwrap(); for pid in pids.lines() { assert!( !Command::new("kill") .args(["-0", pid]) .status() .unwrap() .success(), "descendant {pid} survived shutdown" ); } } #[cfg(unix)] #[test] fn watch_restarts_a_running_task_on_a_same_content_rewrite() { let dir = project(r#"tasks: { serve: {command: "sh serve.sh", files: ["input.txt"]} }"#); 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(); std::fs::write(dir.path().join("input.txt"), "same").unwrap(); let mut child = spawn_watch(dir.path(), "serve"); wait_for_log(dir.path(), "start\n"); // Inputs are compared by size and modification time: the same bytes // with a new mtime still restart the task. std::fs::write(dir.path().join("input.txt"), "same").unwrap(); wait_for_log(dir.path(), "start\nterm\nstart\n"); std::fs::write(dir.path().join("input.txt"), "changed").unwrap(); wait_for_log(dir.path(), "start\nterm\nstart\nterm\nstart\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_passes_help_after_the_name_through_to_the_task() { let dir = project(r#"tasks: { build: {command: "echo ran >> runs.log", files: ["input.txt"]} }"#); std::fs::write(dir.path().join("input.txt"), "one").unwrap(); let mut child = spawn_watch_args(dir.path(), &["task", "--watch", "build", "--help"]); wait_for_log(dir.path(), "ran --help\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_stays_put_when_a_reload_drops_all_files() { let dir = project(r#"tasks: { build: {command: "echo run >> runs.log", files: ["input.txt"]} }"#); std::fs::write(dir.path().join("input.txt"), "one").unwrap(); let mut child = spawn_watch(dir.path(), "build"); wait_for_log(dir.path(), "run\n"); std::fs::write( dir.path().join("avril.conf"), r#"tasks: { build: "echo replaced >> runs.log" }"#, ) .unwrap(); std::thread::sleep(Duration::from_millis(500)); assert_eq!(read_log(dir.path()), "run\n"); assert!(child.try_wait().unwrap().is_none()); std::fs::write( dir.path().join("avril.conf"), r#"tasks: { build: {command: "echo restored >> runs.log", files: ["input.txt"]} }"#, ) .unwrap(); wait_for_log(dir.path(), "run\nrestored\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_notices_a_populated_directory_moving_in() { let dir = project( r#"tasks: { build: {command: "cat src/a.txt src/sub/b.txt >> runs.log", files: ["src/**/*.txt"]} }"#, ); std::fs::create_dir(dir.path().join("src")).unwrap(); std::fs::write(dir.path().join("src/a.txt"), "one\n").unwrap(); let mut child = spawn_watch(dir.path(), "build"); wait_for_log(dir.path(), "one\n"); std::fs::create_dir(dir.path().join("stage")).unwrap(); std::fs::write(dir.path().join("stage/b.txt"), "two\n").unwrap(); std::fs::rename(dir.path().join("stage"), dir.path().join("src/sub")).unwrap(); wait_for_log(dir.path(), "one\none\ntwo\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_reloads_an_env_file_outside_the_package_root() { let outer = tempfile::tempdir().unwrap(); let dir = outer.path().join("pkg"); std::fs::create_dir(&dir).unwrap(); std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "../shared.env"} }"#).unwrap(); std::fs::write(dir.join("input.txt"), "one\n").unwrap(); std::fs::write(outer.path().join("shared.env"), "FLAVOR=one\n").unwrap(); let mut child = spawn_watch(&dir, "build"); wait_for_log(&dir, "one\n"); std::fs::write(outer.path().join("shared.env"), "FLAVOR=two\n").unwrap(); wait_for_log(&dir, "one\none\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_cleans_up_background_processes_from_a_completed_run() { let dir = project(r#"tasks: { serve: {command: "sh bg.sh &", files: ["input.txt"]} }"#); std::fs::write( dir.path().join("bg.sh"), "echo $$ > bg.pid\necho start >> runs.log\ntrap '' INT TERM\nwhile :; do sleep 1; done\n", ) .unwrap(); std::fs::write(dir.path().join("input.txt"), "one").unwrap(); let mut child = spawn_watch(dir.path(), "serve"); wait_for_log(dir.path(), "start\n"); std::thread::sleep(Duration::from_millis(300)); stop_watch(&mut child); assert!(child.wait().unwrap().success()); let pid = std::fs::read_to_string(dir.path().join("bg.pid")).unwrap(); assert!( !Command::new("kill") .args(["-0", pid.trim()]) .status() .unwrap() .success(), "background process {} survived shutdown", pid.trim() ); } #[cfg(unix)] #[test] fn watch_carries_a_forced_refresh_through_a_cancelled_run() { let dir = project( r#"tasks: { build: {command: "echo started >> starts.log; sleep 1; echo work >> runs.log", files: ["input.txt"], envFile: ".env"} }"#, ); std::fs::write(dir.path().join(".env"), "FLAVOR=one\n").unwrap(); std::fs::write(dir.path().join("input.txt"), "A").unwrap(); let output = avril_cached(dir.path(), &["task", "build"], &[]); assert!(output.status.success(), "{}", stderr(&output)); std::fs::write(dir.path().join("input.txt"), "B").unwrap(); let output = avril_cached(dir.path(), &["task", "build"], &[]); assert!(output.status.success(), "{}", stderr(&output)); std::fs::write(dir.path().join("input.txt"), "A").unwrap(); std::fs::remove_file(dir.path().join("runs.log")).unwrap(); std::fs::remove_file(dir.path().join("starts.log")).unwrap(); let mut child = spawn_watch(dir.path(), "build"); std::thread::sleep(Duration::from_millis(400)); std::fs::write(dir.path().join(".env"), "FLAVOR=two\n").unwrap(); let deadline = Instant::now() + Duration::from_secs(10); while !std::fs::read_to_string(dir.path().join("starts.log")).is_ok_and(|text| !text.is_empty()) { assert!(Instant::now() < deadline, "forced run never started"); std::thread::sleep(Duration::from_millis(10)); } std::fs::write(dir.path().join("input.txt"), "B").unwrap(); wait_for_log(dir.path(), "work\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_unrelated_writes_do_not_starve_the_debounce() { let dir = project( r#"tasks: { build: {command: "cat src/input.txt >> runs.log", files: ["src/*.txt"]} }"#, ); std::fs::create_dir(dir.path().join("src")).unwrap(); std::fs::write(dir.path().join("src/input.txt"), "one\n").unwrap(); let mut child = spawn_watch(dir.path(), "build"); wait_for_log(dir.path(), "one\n"); let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let writer_stop = stop.clone(); let writer_dir = dir.path().join("src/unrelated.log"); let writer = std::thread::spawn(move || { let staging = writer_dir.with_extension("tmp"); while !writer_stop.load(std::sync::atomic::Ordering::SeqCst) { let _ = std::fs::write(&staging, "noise"); let _ = std::fs::rename(&staging, &writer_dir); std::thread::sleep(Duration::from_millis(10)); } }); std::fs::write(dir.path().join("src/input.txt"), "two\n").unwrap(); wait_for_log(dir.path(), "one\ntwo\n"); stop.store(true, std::sync::atomic::Ordering::SeqCst); writer.join().unwrap(); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_follows_an_env_file_through_a_symlink_parent() { let outer = tempfile::tempdir().unwrap(); let dir = outer.path().join("pkg"); std::fs::create_dir(&dir).unwrap(); std::fs::create_dir(outer.path().join("target")).unwrap(); std::os::unix::fs::symlink(outer.path().join("target"), dir.join("link")).unwrap(); std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "link/../shared.env"} }"#).unwrap(); std::fs::write(dir.join("input.txt"), "one\n").unwrap(); std::fs::write(outer.path().join("shared.env"), "FLAVOR=one\n").unwrap(); let mut child = spawn_watch(&dir, "build"); wait_for_log(&dir, "one\n"); std::fs::write(outer.path().join("shared.env"), "FLAVOR=two\n").unwrap(); wait_for_log(&dir, "one\none\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_recovers_when_an_external_env_directory_appears() { let outer = tempfile::tempdir().unwrap(); let dir = outer.path().join("pkg"); std::fs::create_dir(&dir).unwrap(); std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "../ext/shared.env"} }"#).unwrap(); std::fs::write(dir.join("input.txt"), "one\n").unwrap(); let mut child = spawn_watch(&dir, "build"); std::thread::sleep(Duration::from_millis(400)); std::fs::create_dir(outer.path().join("ext")).unwrap(); std::fs::write(outer.path().join("ext/shared.env"), "FLAVOR=one\n").unwrap(); wait_for_log(&dir, "one\n"); std::fs::write(outer.path().join("ext/shared.env"), "FLAVOR=two\n").unwrap(); wait_for_log(&dir, "one\none\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_follows_an_env_symlink_being_retargeted() { let dir = project( r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: ".env"} }"#, ); std::fs::write(dir.path().join("input.txt"), "one\n").unwrap(); std::fs::write(dir.path().join("a.env"), "FLAVOR=a\n").unwrap(); std::fs::write(dir.path().join("b.env"), "FLAVOR=b\n").unwrap(); std::os::unix::fs::symlink("a.env", dir.path().join(".env")).unwrap(); let mut child = spawn_watch(dir.path(), "build"); wait_for_log(dir.path(), "one\n"); std::fs::remove_file(dir.path().join(".env")).unwrap(); std::os::unix::fs::symlink("b.env", dir.path().join(".env")).unwrap(); wait_for_log(dir.path(), "one\none\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_notices_a_nested_directory_moving_out() { let dir = project( r#"tasks: { build: {command: "cat src/a.txt src/sub/b.txt >> runs.log", files: ["src/**/*.txt"]} }"#, ); std::fs::create_dir_all(dir.path().join("src/sub")).unwrap(); std::fs::write(dir.path().join("src/a.txt"), "A\n").unwrap(); std::fs::write(dir.path().join("src/sub/b.txt"), "one\n").unwrap(); let mut child = spawn_watch(dir.path(), "build"); wait_for_log(dir.path(), "A\none\n"); let outside = tempfile::tempdir().unwrap(); std::fs::rename(dir.path().join("src/sub"), outside.path().join("sub")).unwrap(); wait_for_log(dir.path(), "A\none\nA\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_recovers_when_a_dangling_env_symlinks_target_appears() { let outer = tempfile::tempdir().unwrap(); let dir = outer.path().join("pkg"); std::fs::create_dir(&dir).unwrap(); std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: ".env"} }"#).unwrap(); std::fs::write(dir.join("input.txt"), "one\n").unwrap(); std::os::unix::fs::symlink("../target.env", dir.join(".env")).unwrap(); let mut child = spawn_watch(&dir, "build"); std::thread::sleep(Duration::from_millis(400)); std::fs::write(outer.path().join("target.env"), "FLAVOR=one\n").unwrap(); wait_for_log(&dir, "one\n"); std::fs::write(outer.path().join("target.env"), "FLAVOR=two\n").unwrap(); wait_for_log(&dir, "one\none\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_follows_an_intermediate_env_symlink_being_retargeted() { let outer = tempfile::tempdir().unwrap(); let dir = outer.path().join("pkg"); std::fs::create_dir(&dir).unwrap(); for side in ["a", "c"] { std::fs::create_dir_all(outer.path().join(side).join("b")).unwrap(); std::fs::write( outer.path().join(side).join("shared.env"), format!("FLAVOR={side}\n"), ) .unwrap(); } std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "link/../shared.env"} }"#).unwrap(); std::fs::write(dir.join("input.txt"), "one\n").unwrap(); std::os::unix::fs::symlink("../a/b", dir.join("link")).unwrap(); let mut child = spawn_watch(&dir, "build"); wait_for_log(&dir, "one\n"); std::fs::remove_file(dir.join("link")).unwrap(); std::os::unix::fs::symlink("../c/b", dir.join("link")).unwrap(); wait_for_log(&dir, "one\none\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_follows_the_middle_of_an_env_symlink_chain() { let outer = tempfile::tempdir().unwrap(); let dir = outer.path().join("pkg"); std::fs::create_dir(&dir).unwrap(); std::fs::create_dir(outer.path().join("links")).unwrap(); std::fs::create_dir(outer.path().join("targets")).unwrap(); std::fs::write(outer.path().join("targets/a.env"), "FLAVOR=a\n").unwrap(); std::fs::write(outer.path().join("targets/b.env"), "FLAVOR=b\n").unwrap(); std::os::unix::fs::symlink("../targets/a.env", outer.path().join("links/middle.env")).unwrap(); std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: ".env"} }"#).unwrap(); std::fs::write(dir.join("input.txt"), "one\n").unwrap(); std::os::unix::fs::symlink("../links/middle.env", dir.join(".env")).unwrap(); let mut child = spawn_watch(&dir, "build"); wait_for_log(&dir, "one\n"); std::fs::remove_file(outer.path().join("links/middle.env")).unwrap(); std::os::unix::fs::symlink("../targets/b.env", outer.path().join("links/middle.env")).unwrap(); wait_for_log(&dir, "one\none\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_recovers_when_an_env_targets_directory_is_created() { let outer = tempfile::tempdir().unwrap(); let dir = outer.path().join("pkg"); std::fs::create_dir(&dir).unwrap(); std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: ".env"} }"#).unwrap(); std::fs::write(dir.join("input.txt"), "one\n").unwrap(); std::os::unix::fs::symlink("../missing/shared.env", dir.join(".env")).unwrap(); let mut child = spawn_watch(&dir, "build"); std::thread::sleep(Duration::from_millis(400)); std::fs::create_dir(outer.path().join("missing")).unwrap(); std::fs::write(outer.path().join("missing/shared.env"), "FLAVOR=one\n").unwrap(); wait_for_log(&dir, "one\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_survives_an_external_env_ancestor_being_replaced() { let outer = tempfile::tempdir().unwrap(); let dir = outer.path().join("pkg"); std::fs::create_dir(&dir).unwrap(); std::fs::create_dir_all(outer.path().join("secrets/version")).unwrap(); 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(); std::fs::write(dir.join("input.txt"), "one\n").unwrap(); std::fs::write( outer.path().join("secrets/version/shared.env"), "FLAVOR=one\n", ) .unwrap(); let mut child = spawn_watch(&dir, "build"); wait_for_log(&dir, "one\n"); std::fs::rename( outer.path().join("secrets"), outer.path().join("secrets-old"), ) .unwrap(); std::fs::create_dir_all(outer.path().join("secrets/version")).unwrap(); std::fs::write( outer.path().join("secrets/version/shared.env"), "FLAVOR=two\n", ) .unwrap(); wait_for_log(&dir, "one\none\n"); std::fs::write( outer.path().join("secrets/version/shared.env"), "FLAVOR=three\n", ) .unwrap(); wait_for_log(&dir, "one\none\none\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_recovers_through_a_dangling_intermediate_env_symlink() { let outer = tempfile::tempdir().unwrap(); let dir = outer.path().join("pkg"); std::fs::create_dir(&dir).unwrap(); std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "link/shared.env"} }"#).unwrap(); std::fs::write(dir.join("input.txt"), "one\n").unwrap(); std::os::unix::fs::symlink("../missing", dir.join("link")).unwrap(); let mut child = spawn_watch(&dir, "build"); std::thread::sleep(Duration::from_millis(400)); std::fs::create_dir(outer.path().join("missing")).unwrap(); std::fs::write(outer.path().join("missing/shared.env"), "FLAVOR=one\n").unwrap(); wait_for_log(&dir, "one\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_follows_a_symlinked_config_and_input() { let outer = tempfile::tempdir().unwrap(); let dir = outer.path().join("pkg"); std::fs::create_dir(&dir).unwrap(); std::fs::write( outer.path().join("shared.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"]} }"#, ) .unwrap(); std::os::unix::fs::symlink("../shared.conf", dir.join("avril.conf")).unwrap(); std::fs::write(outer.path().join("shared-input.txt"), "one\n").unwrap(); std::os::unix::fs::symlink("../shared-input.txt", dir.join("input.txt")).unwrap(); let mut child = spawn_watch(&dir, "build"); wait_for_log(&dir, "one\n"); std::fs::write(outer.path().join("shared-input.txt"), "two\n").unwrap(); wait_for_log(&dir, "one\ntwo\n"); std::fs::write( outer.path().join("shared.conf"), r#"tasks: { build: {command: "echo changed >> runs.log", files: ["input.txt"]} }"#, ) .unwrap(); wait_for_log(&dir, "one\ntwo\nchanged\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_keeps_root_coverage_when_an_env_alias_points_at_the_package() { let outer = tempfile::tempdir().unwrap(); let dir = outer.path().join("pkg"); std::fs::create_dir(&dir).unwrap(); std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "../alias/.env"} }"#).unwrap(); std::fs::write(dir.join("input.txt"), "one\n").unwrap(); std::fs::write(dir.join(".env"), "FLAVOR=one\n").unwrap(); std::os::unix::fs::symlink("pkg", outer.path().join("alias")).unwrap(); let mut child = spawn_watch(&dir, "build"); wait_for_log(&dir, "one\n"); std::fs::write(dir.join("input.txt"), "two\n").unwrap(); wait_for_log(&dir, "one\ntwo\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_recovers_when_a_config_symlink_is_fixed_after_retargeting() { let outer = tempfile::tempdir().unwrap(); let dir = outer.path().join("pkg"); std::fs::create_dir(&dir).unwrap(); std::fs::write( outer.path().join("a.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"]} }"#, ) .unwrap(); std::os::unix::fs::symlink("../a.conf", dir.join("avril.conf")).unwrap(); std::fs::write(dir.join("input.txt"), "one\n").unwrap(); let mut child = spawn_watch(&dir, "build"); wait_for_log(&dir, "one\n"); std::fs::remove_file(dir.join("avril.conf")).unwrap(); std::os::unix::fs::symlink("../b.conf", dir.join("avril.conf")).unwrap(); std::thread::sleep(Duration::from_millis(500)); assert_eq!(read_log(&dir), "one\n"); std::fs::write( outer.path().join("b.conf"), r#"tasks: { build: {command: "echo changed >> runs.log", files: ["input.txt"]} }"#, ) .unwrap(); wait_for_log(&dir, "one\nchanged\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_recovers_when_a_missing_env_traversal_directory_appears() { let outer = tempfile::tempdir().unwrap(); let dir = outer.path().join("pkg"); std::fs::create_dir(&dir).unwrap(); std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "link/../shared.env"} }"#).unwrap(); std::fs::write(dir.join("input.txt"), "one\n").unwrap(); std::fs::write(outer.path().join("shared.env"), "FLAVOR=one\n").unwrap(); std::os::unix::fs::symlink("../missing", dir.join("link")).unwrap(); let mut child = spawn_watch(&dir, "build"); std::thread::sleep(Duration::from_millis(400)); // A regular file where the directory belongs blocks the spelling // without ending recovery: replacing it with the directory must be // noticed. std::fs::write(outer.path().join("missing"), "not a directory").unwrap(); std::thread::sleep(Duration::from_millis(400)); assert_eq!(read_log(&dir), ""); std::fs::remove_file(outer.path().join("missing")).unwrap(); std::fs::create_dir(outer.path().join("missing")).unwrap(); wait_for_log(&dir, "one\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_repairs_coverage_when_an_input_ancestor_is_replaced() { let outer = tempfile::tempdir().unwrap(); let dir = outer.path().join("pkg"); std::fs::create_dir(&dir).unwrap(); std::fs::create_dir_all(outer.path().join("external/sub")).unwrap(); std::fs::write( dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"]} }"#, ) .unwrap(); std::fs::write(outer.path().join("external/sub/input.txt"), "one\n").unwrap(); std::os::unix::fs::symlink("../external/sub/input.txt", dir.join("input.txt")).unwrap(); let mut child = spawn_watch(&dir, "build"); wait_for_log(&dir, "one\n"); std::fs::rename( outer.path().join("external"), outer.path().join("external-old"), ) .unwrap(); std::fs::create_dir_all(outer.path().join("external/sub")).unwrap(); std::fs::write(outer.path().join("external/sub/input.txt"), "two\n").unwrap(); wait_for_log(&dir, "one\ntwo\n"); std::fs::write(outer.path().join("external/sub/input.txt"), "three\n").unwrap(); wait_for_log(&dir, "one\ntwo\nthree\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_recovers_an_input_through_a_missing_directory() { let outer = tempfile::tempdir().unwrap(); let dir = outer.path().join("pkg"); std::fs::create_dir(&dir).unwrap(); std::fs::write( dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"]} }"#, ) .unwrap(); std::fs::write(outer.path().join("shared-input.txt"), "one\n").unwrap(); std::os::unix::fs::symlink("../missing/../shared-input.txt", dir.join("input.txt")).unwrap(); let mut child = spawn_watch(&dir, "build"); std::thread::sleep(Duration::from_millis(400)); std::fs::create_dir(outer.path().join("missing")).unwrap(); wait_for_log(&dir, "one\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_recovers_when_a_replaced_config_directory_is_fixed() { let outer = tempfile::tempdir().unwrap(); let dir = outer.path().join("pkg"); std::fs::create_dir(&dir).unwrap(); std::fs::create_dir(outer.path().join("shared")).unwrap(); std::fs::write( outer.path().join("shared/config.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"]} }"#, ) .unwrap(); std::os::unix::fs::symlink("../shared/config.conf", dir.join("avril.conf")).unwrap(); std::fs::write(dir.join("input.txt"), "one\n").unwrap(); let mut child = spawn_watch(&dir, "build"); wait_for_log(&dir, "one\n"); std::fs::rename(outer.path().join("shared"), outer.path().join("shared-old")).unwrap(); std::fs::create_dir(outer.path().join("shared")).unwrap(); std::fs::write( outer.path().join("shared/config.conf"), "tasks: { build: 42 }", ) .unwrap(); std::thread::sleep(Duration::from_millis(500)); assert_eq!(read_log(&dir), "one\n"); std::fs::write( outer.path().join("shared/config.conf"), r#"tasks: { build: {command: "echo changed >> runs.log", files: ["input.txt"]} }"#, ) .unwrap(); wait_for_log(&dir, "one\nchanged\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_anchor_takes_the_group_down_when_the_watcher_dies_abruptly() { let dir = project(r#"tasks: { serve: {command: "sh bg.sh &", files: ["input.txt"]} }"#); std::fs::write( dir.path().join("bg.sh"), "echo $$ > bg.pid\necho start >> runs.log\ntrap '' INT TERM\nwhile :; do sleep 1; done\n", ) .unwrap(); std::fs::write(dir.path().join("input.txt"), "one").unwrap(); let mut child = spawn_watch(dir.path(), "serve"); wait_for_log(dir.path(), "start\n"); std::thread::sleep(Duration::from_millis(300)); child.kill().unwrap(); let _ = child.wait(); let pid = std::fs::read_to_string(dir.path().join("bg.pid")).unwrap(); let deadline = Instant::now() + Duration::from_secs(10); while Command::new("kill") .args(["-0", pid.trim()]) .status() .unwrap() .success() { assert!( Instant::now() < deadline, "background process {} survived the watcher's abrupt death", pid.trim() ); std::thread::sleep(Duration::from_millis(10)); } } #[cfg(unix)] #[test] fn watch_shutdown_during_a_restart_does_not_start_another_run() { let dir = project(r#"tasks: { serve: {command: "sh serve.sh", files: ["input.txt"]} }"#); // The task acknowledges the restart's SIGINT (and survives it), so // the test knows the watcher is inside restart termination before // it sends its own. std::fs::write( dir.path().join("serve.sh"), "echo start >> runs.log\ntrap 'echo int >> runs.log' INT\nwhile :; do sleep 1; done\n", ) .unwrap(); std::fs::write(dir.path().join("input.txt"), "one").unwrap(); let mut child = spawn_watch(dir.path(), "serve"); wait_for_log(dir.path(), "start\n"); std::fs::write(dir.path().join("input.txt"), "two").unwrap(); let deadline = Instant::now() + Duration::from_secs(10); while !read_log(dir.path()).contains("int") { assert!( Instant::now() < deadline, "restart never interrupted the task" ); std::thread::sleep(Duration::from_millis(10)); } let status = Command::new("kill") .args(["-INT", &child.id().to_string()]) .status() .unwrap(); assert!(status.success()); while child.try_wait().unwrap().is_none() { assert!(Instant::now() < deadline, "watcher did not shut down"); std::thread::sleep(Duration::from_millis(10)); } assert!(child.wait().unwrap().success()); assert_eq!( read_log(dir.path()) .lines() .filter(|line| *line == "start") .count(), 1, "a second run started during shutdown: {:?}", read_log(dir.path()) ); } #[cfg(unix)] #[test] fn watch_follows_a_traversed_directory_being_replaced_by_a_symlink() { let outer = tempfile::tempdir().unwrap(); let dir = outer.path().join("pkg"); std::fs::create_dir(&dir).unwrap(); std::fs::create_dir(outer.path().join("route")).unwrap(); std::fs::create_dir_all(outer.path().join("new/base")).unwrap(); std::fs::write(dir.join("avril.conf"), r#"tasks: { build: {command: "cat input.txt >> runs.log", files: ["input.txt"], envFile: "link/../shared.env"} }"#).unwrap(); std::fs::write(dir.join("input.txt"), "one\n").unwrap(); std::fs::write(outer.path().join("shared.env"), "FLAVOR=orig\n").unwrap(); std::fs::write(outer.path().join("new/shared.env"), "FLAVOR=new\n").unwrap(); std::os::unix::fs::symlink("../route", dir.join("link")).unwrap(); let mut child = spawn_watch(&dir, "build"); wait_for_log(&dir, "one\n"); std::fs::remove_dir(outer.path().join("route")).unwrap(); std::os::unix::fs::symlink("new/base", outer.path().join("route")).unwrap(); wait_for_log(&dir, "one\none\n"); std::fs::write(outer.path().join("new/shared.env"), "FLAVOR=newer\n").unwrap(); wait_for_log(&dir, "one\none\none\n"); stop_watch(&mut child); } #[cfg(unix)] #[test] fn watch_reloads_env_files_and_the_config() { let dir = project( "tasks: { build: {command: \"cat input.txt >> runs.log\", files: [\"input.txt\"], envFile: \".env\"} }", ); std::fs::write(dir.path().join("input.txt"), "one\n").unwrap(); std::fs::write(dir.path().join(".env"), "FLAVOR=one\n").unwrap(); let mut child = spawn_watch(dir.path(), "build"); wait_for_log(dir.path(), "one\n"); // A same-content rewrite of the config changes nothing. let config = std::fs::read_to_string(dir.path().join("avril.conf")).unwrap(); std::fs::write(dir.path().join("avril.conf"), config).unwrap(); std::thread::sleep(Duration::from_millis(400)); assert_eq!(read_log(dir.path()), "one\n"); std::fs::write(dir.path().join(".env"), "FLAVOR=two\n").unwrap(); wait_for_log(dir.path(), "one\none\n"); std::fs::write(dir.path().join("avril.conf"), "tasks: { build: {command: \"echo changed >> runs.log\", files: [\"input.txt\"], envFile: \".env\"} }").unwrap(); wait_for_log(dir.path(), "one\none\nchanged\n"); stop_watch(&mut child); } fn avril_cached(dir: &Path, args: &[&str], env: &[(&str, &str)]) -> Output { let mut command = Command::new(env!("CARGO_BIN_EXE_avril")); command .current_dir(dir) .args(args) .env("AVRIL_CACHE_DIR", dir.join("cache")) .env_remove("SITE_TEST_FLAVOR") .env_remove("FLAVOR") .env_remove("UNRELATED"); for (name, value) in env { command.env(name, value); } command.output().unwrap() } #[test] fn cached_tasks_skip_while_outputs_are_intact_and_rerun_when_anything_changes() { let dir = project(&format!( "tasks: {{ site: {{ command: \"{} run ./generate.js\", files: ['src/**'], output: ['dist/'], env: ['SITE_TEST_FLAVOR'] }} }}", env!("CARGO_BIN_EXE_avril").replace('\\', "/") )); std::fs::create_dir(dir.path().join("src")).unwrap(); std::fs::write(dir.path().join("src/content.txt"), "one").unwrap(); std::fs::write( dir.path().join("generate.js"), r#" import { mkdirs, readTextFile, writeTextFile } from "avril:fs"; const content = await readTextFile("src/content.txt"); await mkdirs("dist"); await writeTextFile("dist/index.html", `site:${content}`); console.log("generated"); "#, ) .unwrap(); let index = dir.path().join("dist/index.html"); let output = avril_cached(dir.path(), &["task", "site"], &[]); assert!(output.status.success(), "{}", stderr(&output)); assert_eq!(stdout(&output), "generated\n"); assert_eq!(std::fs::read_to_string(&index).unwrap(), "site:one"); // Nothing changed: the task is skipped and its outputs are left alone. let output = avril_cached(dir.path(), &["task", "site"], &[]); assert!(output.status.success(), "{}", stderr(&output)); assert_eq!(stdout(&output), ""); assert!( stderr(&output).contains("Task site (cached)"), "{}", stderr(&output) ); assert_eq!(std::fs::read_to_string(&index).unwrap(), "site:one"); // Outputs are verified, not restored: tampering with one reruns the task. std::fs::write(&index, "tampered").unwrap(); let output = avril_cached(dir.path(), &["task", "site"], &[]); assert!(output.status.success(), "{}", stderr(&output)); assert_eq!(stdout(&output), "generated\n"); assert_eq!(std::fs::read_to_string(&index).unwrap(), "site:one"); // Deleting the outputs likewise reruns the task, which recreates them. std::fs::remove_dir_all(dir.path().join("dist")).unwrap(); let output = avril_cached(dir.path(), &["task", "site"], &[]); assert!(output.status.success(), "{}", stderr(&output)); assert_eq!(stdout(&output), "generated\n"); assert_eq!(std::fs::read_to_string(&index).unwrap(), "site:one"); std::fs::write(dir.path().join("src/content.txt"), "two").unwrap(); let output = avril_cached(dir.path(), &["task", "site"], &[]); assert!(output.status.success(), "{}", stderr(&output)); assert_eq!(stdout(&output), "generated\n"); assert_eq!(std::fs::read_to_string(&index).unwrap(), "site:two"); // A listed env var is part of the key even though the command ignores it. let output = avril_cached( dir.path(), &["task", "site"], &[("SITE_TEST_FLAVOR", "blue")], ); assert_eq!(stdout(&output), "generated\n"); let output = avril_cached( dir.path(), &["task", "site"], &[("SITE_TEST_FLAVOR", "blue")], ); assert_eq!(stdout(&output), ""); let output = avril_cached( dir.path(), &["task", "site"], &[("SITE_TEST_FLAVOR", "green")], ); assert_eq!(stdout(&output), "generated\n"); } #[test] fn cache_keys_include_extra_args_and_dependency_fingerprints() { let dir = project( r#" tasks: { work: {command: "echo work", files: ["input.txt"]}, } "#, ); std::fs::write(dir.path().join("input.txt"), "input").unwrap(); let output = avril_cached(dir.path(), &["task", "work", "a"], &[]); assert_eq!(stdout(&output), "work a\n"); let output = avril_cached(dir.path(), &["task", "work", "a"], &[]); assert_eq!(stdout(&output), ""); assert!(stderr(&output).contains("(cached)"), "{}", stderr(&output)); let output = avril_cached(dir.path(), &["task", "work", "b"], &[]); assert_eq!(stdout(&output), "work b\n"); let dir = project( r#" tasks: { child: "echo child1", parent: {command: "echo parent", dependencies: ["child"], files: ["input.txt"]}, } "#, ); std::fs::write(dir.path().join("input.txt"), "input").unwrap(); let output = avril_cached(dir.path(), &["task", "parent"], &[]); assert_eq!(stdout(&output), "child1\nparent\n"); // The uncached child always runs, but the parent is served from cache. let output = avril_cached(dir.path(), &["task", "parent"], &[]); assert_eq!(stdout(&output), "child1\n"); std::fs::write( dir.path().join("avril.conf"), r#" tasks: { child: "echo child2", parent: {command: "echo parent", dependencies: ["child"], files: ["input.txt"]}, } "#, ) .unwrap(); let output = avril_cached(dir.path(), &["task", "parent"], &[]); assert_eq!(stdout(&output), "child2\nparent\n"); } #[test] fn a_files_pattern_that_matches_nothing_is_an_error_against_the_config() { let dir = project( r#" tasks: { build: {command: "echo build", files: ["missing/**/*.ts"]}, } "#, ); let output = avril_cached(dir.path(), &["task", "build"], &[]); assert!(!output.status.success()); let error = stderr(&output); assert!(error.contains("matched no files"), "{error}"); assert!(error.contains("avril.conf:3:"), "{error}"); assert!(!stdout(&output).contains("build")); } #[test] fn without_a_config_there_is_a_clear_error() { let dir = tempfile::tempdir().unwrap(); let output = avril(dir.path(), &["task", "anything"]); assert!(!output.status.success()); assert!( stderr(&output).contains("No avril.conf found"), "{}", stderr(&output) ); }