use std::path::Path; use std::process::{Child, Command, Output, Stdio}; use std::time::{Duration, Instant}; #[test] fn task_module_exports_only_run_tasks_with_stable_function_metadata() { let dir = project(&[ ("avril.conf", "name: \"fixture\"\n"), ( "module.ts", r#" import * as task from "avril:task"; const exports = Object.keys(task); if (exports.length !== 1 || exports[0] !== "runTasks") { throw new Error(`unexpected task exports: ${exports.join(",")}`); } if (task.runTasks.name !== "runTasks" || task.runTasks.length !== 1) { throw new Error(`unexpected runTasks metadata: ${task.runTasks.name}/${task.runTasks.length}`); } "#, ), ]); let output = avril(dir.path(), &["run", "module.ts"]); assert!(output.status.success(), "{}", stderr(&output)); } fn project(files: &[(&str, &str)]) -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); for (name, contents) in files { let path = dir.path().join(name); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, contents).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() } const BUILD: &str = r#" import { runTasks } from "avril:task"; import { readTextFile, writeTextFile, mkdirs } from "avril:fs"; const report = await runTasks({ page: { files: ["input.txt"], output: ["dist/page.html"], run: async () => { const count = await readTextFile("count.txt").catch(() => ""); await writeTextFile("count.txt", count + "x"); await mkdirs("dist"); await writeTextFile("dist/page.html", "page\n"); }, }, shout: { dependencies: ["page"], command: ["cat", "dist/page.html"], }, fail: { command: ["false"] }, maybe: { dependencies: ["page"], run: (ctx) => (ctx.args.includes("fail") ? 7 : 0), }, all: { dependencies: ["shout"] }, }); console.log(`code=${report.code} completed=${report.completed.join(",")} cached=${report.cached.join(",")}`); if (report.code !== 0) throw new Error("build failed"); "#; #[test] fn function_and_argv_tasks_run_and_cache() { let dir = project(&[ ("build.ts", BUILD), ("input.txt", "seed\n"), ("avril.conf", "name: \"fixture\"\n"), ]); let first = avril(dir.path(), &["run", "build.ts"]); assert!(first.status.success(), "{}", stderr(&first)); assert!(stdout(&first).contains("code=0"), "{}", stdout(&first)); assert_eq!( std::fs::read_to_string(dir.path().join("dist/page.html")).unwrap(), "page\n" ); let second = avril(dir.path(), &["run", "build.ts"]); assert!(second.status.success(), "{}", stderr(&second)); assert!( stdout(&second).contains("cached=page"), "{}", stdout(&second) ); // The function task's body ran exactly once across both builds. assert_eq!( std::fs::read_to_string(dir.path().join("count.txt")).unwrap(), "x" ); } #[test] fn failed_tasks_are_reported_and_throw_if_failed_exits_nonzero() { let dir = project(&[ ("avril.conf", "name: \"fixture\"\n"), ( "fail.ts", r#" import { runTasks } from "avril:task"; const report = await runTasks({ bad: { command: ["false"] }, ok: { command: ["true"] }, all: { dependencies: ["bad", "ok"], parallel: true }, }); console.log(`code=${report.code} failed=${report.failed.map((f) => f.name).join(",")}`); report.throwIfFailed(); "#, ), ]); let output = avril(dir.path(), &["run", "fail.ts"]); assert!(!output.status.success(), "{}", stdout(&output)); assert!( stdout(&output).contains("code=1 failed=bad"), "{}", stdout(&output) ); } struct Guard(Child); impl Drop for Guard { fn drop(&mut self) { let _ = self.0.kill(); let _ = self.0.wait(); } } fn wait_for(path: &Path, deadline: Duration) -> bool { let start = Instant::now(); while start.elapsed() < deadline { if path.exists() { return true; } std::thread::sleep(Duration::from_millis(100)); } false } #[test] fn watch_reevaluates_the_graph_and_renders_new_files() { let dir = project(&[ ("avril.conf", "name: \"fixture\"\n"), ("posts/one.md", "# One\n"), ( "watch.ts", r#" import { runTasks } from "avril:task"; import { readDir } from "avril:std"; import { writeTextFile, mkdirs } from "avril:fs"; await runTasks(async () => { const names = []; for await (const entry of readDir("posts")) { if (entry.isFile && entry.name.endsWith(".md")) names.push(entry.name); } names.sort(); await mkdirs("dist"); return { ...Object.fromEntries(names.map((name) => [`post:${name}`, { files: [`posts/${name}`], output: [`dist/${name}.html`], run: async () => { await writeTextFile(`dist/${name}.html`, `rendered ${name}\n`); }, }])), all: { dependencies: names.map((name) => `post:${name}`) }, }; }, { target: "all", watch: true }); "#, ), ]); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(dir.path()) .args(["run", "watch.ts"]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let _guard = Guard(child); assert!( wait_for( &dir.path().join("dist/one.md.html"), Duration::from_secs(20) ), "initial render did not happen" ); std::fs::write(dir.path().join("posts/two.md"), "# Two\n").unwrap(); assert!( wait_for( &dir.path().join("dist/two.md.html"), Duration::from_secs(20) ), "watch did not render the new post" ); let mut guard = _guard; let _ = guard.0.kill(); let _ = guard.0.wait(); } #[test] fn graph_errors_throw() { let dir = project(&[ ("avril.conf", "name: \"fixture\"\n"), ( "cycle.ts", r#" import { runTasks } from "avril:task"; await runTasks({ a: { dependencies: ["b"], command: "true" }, b: { dependencies: ["a"], command: "true" }, all: { dependencies: ["a"] }, }); "#, ), ]); let output = avril(dir.path(), &["run", "cycle.ts"]); assert!(!output.status.success()); let text = stderr(&output); assert!(text.contains("Task cycle detected"), "{text}"); }