krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

v0.19.1: tests/watch.rs · raw

  1//! `watch`: the change filter, and one end-to-end run against real filesystem events.
  2//!
  3//! The filter carries the weight here. `orgo watch . -o _site` puts the output inside
  4//! the source, so a rebuild writes files, writing files raises events, and events trigger
  5//! a rebuild — a loop that never stops. That it is a pure function is what makes the
  6//! guarantee testable without waiting on a filesystem.
  7
  8use std::sync::atomic::{AtomicU32, Ordering};
  9use std::time::{Duration, Instant};
 10
 11use camino::{Utf8Path, Utf8PathBuf};
 12
 13use orgo::site::{build_site, BuildOptions};
 14use orgo::watch::ChangeFilter;
 15
 16fn tmpdir(tag: &str) -> Utf8PathBuf {
 17    static N: AtomicU32 = AtomicU32::new(0);
 18    let n = N.fetch_add(1, Ordering::Relaxed);
 19    let base = Utf8PathBuf::from_path_buf(std::env::temp_dir())
 20        .expect("utf-8 temp dir")
 21        .join(format!("orgo-watch-{}-{tag}-{n}", std::process::id()));
 22    let _ = std::fs::remove_dir_all(&base);
 23    std::fs::create_dir_all(&base).unwrap();
 24    base
 25}
 26
 27// ---------------------------------------------------------------------------
 28// The change filter
 29// ---------------------------------------------------------------------------
 30
 31/// The one that matters: without it, `watch . -o _site` rebuilds forever.
 32#[test]
 33fn changes_under_the_output_directory_are_ignored() {
 34    let root = tmpdir("filterout");
 35    let src = root.join("src");
 36    let out = src.join("_site");
 37    std::fs::create_dir_all(&out).unwrap();
 38
 39    let filter = ChangeFilter::new(&src, &out);
 40    assert!(!filter.is_relevant(Utf8Path::new("_site/index.html")));
 41    assert!(!filter.is_relevant(Utf8Path::new("_site/blog/post.html")));
 42    assert!(!filter.is_relevant(Utf8Path::new("_site/.orgo-cache.json")));
 43    assert!(filter.is_relevant(Utf8Path::new("index.org")), "real sources still count");
 44}
 45
 46/// An output directory outside the source cannot cause a loop, and must not accidentally
 47/// suppress a similarly-named source directory.
 48#[test]
 49fn an_external_output_directory_suppresses_nothing() {
 50    let root = tmpdir("filterext");
 51    let src = root.join("src");
 52    let out = root.join("out");
 53    std::fs::create_dir_all(&src).unwrap();
 54    std::fs::create_dir_all(&out).unwrap();
 55
 56    let filter = ChangeFilter::new(&src, &out);
 57    assert!(filter.is_relevant(Utf8Path::new("index.org")));
 58    assert!(filter.is_relevant(Utf8Path::new("out/notes.org")), "a source dir named `out`");
 59}
 60
 61/// A change to the config or a template changes the output, so both must rebuild — even
 62/// though build-time *discovery* skips them as non-content. The watch rule is "would this
 63/// change the site?", not "is this a page?".
 64#[test]
 65fn build_inputs_trigger_a_rebuild_even_though_discovery_skips_them() {
 66    let root = tmpdir("filterinputs");
 67    let src = root.join("src");
 68    std::fs::create_dir_all(&src).unwrap();
 69    let filter = ChangeFilter::new(&src, &root.join("out"));
 70
 71    assert!(filter.is_relevant(Utf8Path::new("orgo.toml")));
 72    assert!(filter.is_relevant(Utf8Path::new("templates/base.html")));
 73    assert!(filter.is_relevant(Utf8Path::new("templates/feed.xml")));
 74    assert!(filter.is_relevant(Utf8Path::new("style.css")), "assets are copied through");
 75}
 76
 77/// `.git` churns on every command, and rebuilding the site because git wrote an index
 78/// lock would make watch useless in any repository.
 79#[test]
 80fn dot_directories_and_editor_scratch_files_are_ignored() {
 81    let root = tmpdir("filterdots");
 82    let src = root.join("src");
 83    std::fs::create_dir_all(&src).unwrap();
 84    let filter = ChangeFilter::new(&src, &root.join("out"));
 85
 86    for ignored in [
 87        ".git/index",
 88        ".git/objects/ab/cdef",
 89        ".DS_Store",
 90        "blog/.#post.org",  // Emacs lock
 91        "post.org~",        // Emacs backup
 92        ".post.org.swp",    // vim
 93        "#post.org#",       // Emacs auto-save
 94        "build.tmp",
 95    ] {
 96        assert!(
 97            !filter.is_relevant(Utf8Path::new(ignored)),
 98            "{ignored} should not trigger a rebuild"
 99        );
100    }
101    for relevant in ["post.org", "blog/post.org", "a-file~with-tilde.org"] {
102        assert!(
103            filter.is_relevant(Utf8Path::new(relevant)),
104            "{relevant} should trigger a rebuild"
105        );
106    }
107}
108
109/// Events arrive as absolute paths and in bursts, often naming one file several times.
110#[test]
111fn absolute_event_paths_are_reduced_to_a_sorted_unique_set() {
112    let root = tmpdir("filterrel");
113    let src = root.join("src");
114    let out = src.join("_site");
115    std::fs::create_dir_all(&out).unwrap();
116
117    let filter = ChangeFilter::new(&src, &out);
118    let events = vec![
119        src.join("b.org"),
120        src.join("a.org"),
121        src.join("b.org"),
122        src.join("_site/a.html"),
123        src.join("a.org~"),
124    ];
125    assert_eq!(
126        filter.relevant(events),
127        vec![Utf8PathBuf::from("a.org"), Utf8PathBuf::from("b.org")]
128    );
129}
130
131// ---------------------------------------------------------------------------
132// End to end
133// ---------------------------------------------------------------------------
134
135/// Drive the real watcher against a real edit. Timing-dependent by nature, so it polls
136/// for the expected result with a generous ceiling rather than sleeping a fixed amount.
137#[test]
138fn watching_rebuilds_the_site_when_a_source_file_changes() {
139    let root = tmpdir("watchrun");
140    let src = root.join("src");
141    std::fs::create_dir_all(&src).unwrap();
142    std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nFirst version.\n").unwrap();
143    let out = src.join("_site"); // deliberately inside the source: the loop case
144
145    build_site(&src, &out, &BuildOptions::default()).unwrap();
146    assert!(std::fs::read_to_string(out.join("index.html"))
147        .unwrap()
148        .contains("First version."));
149
150    let (src_t, out_t) = (src.clone(), out.clone());
151    let handle = std::thread::spawn(move || {
152        let _ = orgo::watch::run(&src_t, &out_t, &BuildOptions::default());
153    });
154
155    // The edit is repeated rather than made once after a fixed head start. The watcher
156    // registers its OS watches *after* an initial build, and that build loads syntect's
157    // syntax set — on a cold CI runner, comfortably longer than any head start worth
158    // hard-coding. An edit made before anything is listening produces no event at all,
159    // which looks exactly like a watcher that does not work, and only one of those is a
160    // defect worth failing a build over. (Found by the first CI run on Linux, where a
161    // 300ms head start was not enough and macOS had never noticed.)
162    let deadline = Instant::now() + Duration::from_secs(30);
163    let mut rebuilt = false;
164    let mut wrote_at: Option<Instant> = None;
165    while Instant::now() < deadline {
166        if wrote_at.is_none_or(|t| t.elapsed() >= Duration::from_millis(400)) {
167            std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nSecond version.\n").unwrap();
168            wrote_at = Some(Instant::now());
169        }
170        if std::fs::read_to_string(out.join("index.html"))
171            .map(|h| h.contains("Second version."))
172            .unwrap_or(false)
173        {
174            rebuilt = true;
175            break;
176        }
177        std::thread::sleep(Duration::from_millis(50));
178    }
179    assert!(rebuilt, "an edit should trigger a rebuild within 30s");
180
181    // The output lives inside the source, so the rebuild's own writes raised events. If
182    // those are not filtered out, watch spins forever.
183    //
184    // The file to watch for that is `syntax.css`, not `index.html`. The incremental
185    // build leaves an unchanged page alone, so `index.html` holds still even *during* a
186    // runaway loop — an assertion on it passes whether or not the filter works, which is
187    // exactly what it did before this comment existed. `syntax.css` is rewritten on
188    // every build, so its mtime is a direct record of how many builds have run.
189    // Long enough for the rebuild from the last repeated write to have landed before the
190    // first reading, or this measures that instead of a feedback loop.
191    let stylesheet = out.join("syntax.css");
192    std::thread::sleep(Duration::from_millis(1500));
193    let first = std::fs::metadata(&stylesheet).unwrap().modified().unwrap();
194    std::thread::sleep(Duration::from_millis(1200));
195    let second = std::fs::metadata(&stylesheet).unwrap().modified().unwrap();
196    assert_eq!(
197        first, second,
198        "the build's own writes must not feed back in as changes — watch is rebuilding \
199         in a loop"
200    );
201
202    drop(handle); // the watcher thread ends with the process
203}