char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somgenerate task typedefs from typescript impl1069809

main
6.6 KiB239 linesraw
1use std::path::Path;
2use std::process::{Child, Command, Output, Stdio};
3use std::time::{Duration, Instant};
4
5#[test]
6fn task_module_exports_only_run_tasks_with_stable_function_metadata() {
7    let dir = project(&[
8        ("avril.conf", "name: \"fixture\"\n"),
9        (
10            "module.ts",
11            r#"
12import * as task from "avril:task";
13const exports = Object.keys(task);
14if (exports.length !== 1 || exports[0] !== "runTasks") {
15  throw new Error(`unexpected task exports: ${exports.join(",")}`);
16}
17if (task.runTasks.name !== "runTasks" || task.runTasks.length !== 1) {
18  throw new Error(`unexpected runTasks metadata: ${task.runTasks.name}/${task.runTasks.length}`);
19}
20"#,
21        ),
22    ]);
23    let output = avril(dir.path(), &["run", "module.ts"]);
24    assert!(output.status.success(), "{}", stderr(&output));
25}
26
27fn project(files: &[(&str, &str)]) -> tempfile::TempDir {
28    let dir = tempfile::tempdir().unwrap();
29    for (name, contents) in files {
30        let path = dir.path().join(name);
31        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
32        std::fs::write(path, contents).unwrap();
33    }
34    dir
35}
36
37fn avril(dir: &Path, args: &[&str]) -> Output {
38    Command::new(env!("CARGO_BIN_EXE_avril"))
39        .current_dir(dir)
40        .args(args)
41        .output()
42        .unwrap()
43}
44
45fn stdout(output: &Output) -> String {
46    String::from_utf8(output.stdout.clone()).unwrap()
47}
48
49fn stderr(output: &Output) -> String {
50    String::from_utf8(output.stderr.clone()).unwrap()
51}
52
53const BUILD: &str = r#"
54import { runTasks } from "avril:task";
55import { readTextFile, writeTextFile, mkdirs } from "avril:fs";
56
57const report = await runTasks({
58  page: {
59    files: ["input.txt"],
60    output: ["dist/page.html"],
61    run: async () => {
62      const count = await readTextFile("count.txt").catch(() => "");
63      await writeTextFile("count.txt", count + "x");
64      await mkdirs("dist");
65      await writeTextFile("dist/page.html", "page\n");
66    },
67  },
68  shout: {
69    dependencies: ["page"],
70    command: ["cat", "dist/page.html"],
71  },
72  fail: { command: ["false"] },
73  maybe: {
74    dependencies: ["page"],
75    run: (ctx) => (ctx.args.includes("fail") ? 7 : 0),
76  },
77  all: { dependencies: ["shout"] },
78});
79console.log(`code=${report.code} completed=${report.completed.join(",")} cached=${report.cached.join(",")}`);
80if (report.code !== 0) throw new Error("build failed");
81"#;
82
83#[test]
84fn function_and_argv_tasks_run_and_cache() {
85    let dir = project(&[
86        ("build.ts", BUILD),
87        ("input.txt", "seed\n"),
88        ("avril.conf", "name: \"fixture\"\n"),
89    ]);
90    let first = avril(dir.path(), &["run", "build.ts"]);
91    assert!(first.status.success(), "{}", stderr(&first));
92    assert!(stdout(&first).contains("code=0"), "{}", stdout(&first));
93    assert_eq!(
94        std::fs::read_to_string(dir.path().join("dist/page.html")).unwrap(),
95        "page\n"
96    );
97
98    let second = avril(dir.path(), &["run", "build.ts"]);
99    assert!(second.status.success(), "{}", stderr(&second));
100    assert!(
101        stdout(&second).contains("cached=page"),
102        "{}",
103        stdout(&second)
104    );
105    // The function task's body ran exactly once across both builds.
106    assert_eq!(
107        std::fs::read_to_string(dir.path().join("count.txt")).unwrap(),
108        "x"
109    );
110}
111
112#[test]
113fn failed_tasks_are_reported_and_throw_if_failed_exits_nonzero() {
114    let dir = project(&[
115        ("avril.conf", "name: \"fixture\"\n"),
116        (
117            "fail.ts",
118            r#"
119import { runTasks } from "avril:task";
120const report = await runTasks({
121  bad: { command: ["false"] },
122  ok: { command: ["true"] },
123  all: { dependencies: ["bad", "ok"], parallel: true },
124});
125console.log(`code=${report.code} failed=${report.failed.map((f) => f.name).join(",")}`);
126report.throwIfFailed();
127"#,
128        ),
129    ]);
130    let output = avril(dir.path(), &["run", "fail.ts"]);
131    assert!(!output.status.success(), "{}", stdout(&output));
132    assert!(
133        stdout(&output).contains("code=1 failed=bad"),
134        "{}",
135        stdout(&output)
136    );
137}
138
139struct Guard(Child);
140
141impl Drop for Guard {
142    fn drop(&mut self) {
143        let _ = self.0.kill();
144        let _ = self.0.wait();
145    }
146}
147
148fn wait_for(path: &Path, deadline: Duration) -> bool {
149    let start = Instant::now();
150    while start.elapsed() < deadline {
151        if path.exists() {
152            return true;
153        }
154        std::thread::sleep(Duration::from_millis(100));
155    }
156    false
157}
158
159#[test]
160fn watch_reevaluates_the_graph_and_renders_new_files() {
161    let dir = project(&[
162        ("avril.conf", "name: \"fixture\"\n"),
163        ("posts/one.md", "# One\n"),
164        (
165            "watch.ts",
166            r#"
167import { runTasks } from "avril:task";
168import { readDir } from "avril:std";
169import { writeTextFile, mkdirs } from "avril:fs";
170
171await runTasks(async () => {
172  const names = [];
173  for await (const entry of readDir("posts")) {
174    if (entry.isFile && entry.name.endsWith(".md")) names.push(entry.name);
175  }
176  names.sort();
177  await mkdirs("dist");
178  return {
179    ...Object.fromEntries(names.map((name) => [`post:${name}`, {
180      files: [`posts/${name}`],
181      output: [`dist/${name}.html`],
182      run: async () => { await writeTextFile(`dist/${name}.html`, `rendered ${name}\n`); },
183    }])),
184    all: { dependencies: names.map((name) => `post:${name}`) },
185  };
186}, { target: "all", watch: true });
187"#,
188        ),
189    ]);
190    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
191        .current_dir(dir.path())
192        .args(["run", "watch.ts"])
193        .stdout(Stdio::piped())
194        .stderr(Stdio::piped())
195        .spawn()
196        .unwrap();
197    let _guard = Guard(child);
198
199    assert!(
200        wait_for(
201            &dir.path().join("dist/one.md.html"),
202            Duration::from_secs(20)
203        ),
204        "initial render did not happen"
205    );
206    std::fs::write(dir.path().join("posts/two.md"), "# Two\n").unwrap();
207    assert!(
208        wait_for(
209            &dir.path().join("dist/two.md.html"),
210            Duration::from_secs(20)
211        ),
212        "watch did not render the new post"
213    );
214    let mut guard = _guard;
215    let _ = guard.0.kill();
216    let _ = guard.0.wait();
217}
218
219#[test]
220fn graph_errors_throw() {
221    let dir = project(&[
222        ("avril.conf", "name: \"fixture\"\n"),
223        (
224            "cycle.ts",
225            r#"
226import { runTasks } from "avril:task";
227await runTasks({
228  a: { dependencies: ["b"], command: "true" },
229  b: { dependencies: ["a"], command: "true" },
230  all: { dependencies: ["a"] },
231});
232"#,
233        ),
234    ]);
235    let output = avril(dir.path(), &["run", "cycle.ts"]);
236    assert!(!output.status.success());
237    let text = stderr(&output);
238    assert!(text.contains("Task cycle detected"), "{text}");
239}