krz/orgo

Lightning fast org-mode static site generator. fast go org-mode static-site-generator

tests/watch.rs

245 lines · 10078 bytes

  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::sync::Arc;
 10use std::time::{Duration, Instant};
 11
 12use camino::{Utf8Path, Utf8PathBuf};
 13
 14use orgo::site::{build_site, BuildOptions};
 15use orgo::watch::ChangeFilter;
 16
 17fn tmpdir(tag: &str) -> Utf8PathBuf {
 18    static N: AtomicU32 = AtomicU32::new(0);
 19    let n = N.fetch_add(1, Ordering::Relaxed);
 20    let base = Utf8PathBuf::from_path_buf(std::env::temp_dir())
 21        .expect("utf-8 temp dir")
 22        .join(format!("orgo-watch-{}-{tag}-{n}", std::process::id()));
 23    let _ = std::fs::remove_dir_all(&base);
 24    std::fs::create_dir_all(&base).unwrap();
 25    base
 26}
 27
 28// ---------------------------------------------------------------------------
 29// The change filter
 30// ---------------------------------------------------------------------------
 31
 32/// The one that matters: without it, `watch . -o _site` rebuilds forever.
 33#[test]
 34fn changes_under_the_output_directory_are_ignored() {
 35    let root = tmpdir("filterout");
 36    let src = root.join("src");
 37    let out = src.join("_site");
 38    std::fs::create_dir_all(&out).unwrap();
 39
 40    let filter = ChangeFilter::new(&src, &out);
 41    assert!(!filter.is_relevant(Utf8Path::new("_site/index.html")));
 42    assert!(!filter.is_relevant(Utf8Path::new("_site/blog/post.html")));
 43    assert!(!filter.is_relevant(Utf8Path::new("_site/.orgo-cache.json")));
 44    assert!(filter.is_relevant(Utf8Path::new("index.org")), "real sources still count");
 45}
 46
 47/// An output directory outside the source cannot cause a loop, and must not accidentally
 48/// suppress a similarly-named source directory.
 49#[test]
 50fn an_external_output_directory_suppresses_nothing() {
 51    let root = tmpdir("filterext");
 52    let src = root.join("src");
 53    let out = root.join("out");
 54    std::fs::create_dir_all(&src).unwrap();
 55    std::fs::create_dir_all(&out).unwrap();
 56
 57    let filter = ChangeFilter::new(&src, &out);
 58    assert!(filter.is_relevant(Utf8Path::new("index.org")));
 59    assert!(filter.is_relevant(Utf8Path::new("out/notes.org")), "a source dir named `out`");
 60}
 61
 62/// A change to the config or a template changes the output, so both must rebuild — even
 63/// though build-time *discovery* skips them as non-content. The watch rule is "would this
 64/// change the site?", not "is this a page?".
 65#[test]
 66fn build_inputs_trigger_a_rebuild_even_though_discovery_skips_them() {
 67    let root = tmpdir("filterinputs");
 68    let src = root.join("src");
 69    std::fs::create_dir_all(&src).unwrap();
 70    let filter = ChangeFilter::new(&src, &root.join("out"));
 71
 72    assert!(filter.is_relevant(Utf8Path::new("orgo.toml")));
 73    assert!(filter.is_relevant(Utf8Path::new("templates/base.html")));
 74    assert!(filter.is_relevant(Utf8Path::new("templates/feed.xml")));
 75    assert!(filter.is_relevant(Utf8Path::new("style.css")), "assets are copied through");
 76}
 77
 78/// `.git` churns on every command, and rebuilding the site because git wrote an index
 79/// lock would make watch useless in any repository.
 80#[test]
 81fn dot_directories_and_editor_scratch_files_are_ignored() {
 82    let root = tmpdir("filterdots");
 83    let src = root.join("src");
 84    std::fs::create_dir_all(&src).unwrap();
 85    let filter = ChangeFilter::new(&src, &root.join("out"));
 86
 87    for ignored in [
 88        ".git/index",
 89        ".git/objects/ab/cdef",
 90        ".DS_Store",
 91        "blog/.#post.org",  // Emacs lock
 92        "post.org~",        // Emacs backup
 93        ".post.org.swp",    // vim
 94        "#post.org#",       // Emacs auto-save
 95        "build.tmp",
 96    ] {
 97        assert!(
 98            !filter.is_relevant(Utf8Path::new(ignored)),
 99            "{ignored} should not trigger a rebuild"
100        );
101    }
102    for relevant in ["post.org", "blog/post.org", "a-file~with-tilde.org"] {
103        assert!(
104            filter.is_relevant(Utf8Path::new(relevant)),
105            "{relevant} should trigger a rebuild"
106        );
107    }
108}
109
110/// Events arrive as absolute paths and in bursts, often naming one file several times.
111#[test]
112fn absolute_event_paths_are_reduced_to_a_sorted_unique_set() {
113    let root = tmpdir("filterrel");
114    let src = root.join("src");
115    let out = src.join("_site");
116    std::fs::create_dir_all(&out).unwrap();
117
118    let filter = ChangeFilter::new(&src, &out);
119    let events = vec![
120        src.join("b.org"),
121        src.join("a.org"),
122        src.join("b.org"),
123        src.join("_site/a.html"),
124        src.join("a.org~"),
125    ];
126    assert_eq!(
127        filter.relevant(events),
128        vec![Utf8PathBuf::from("a.org"), Utf8PathBuf::from("b.org")]
129    );
130}
131
132// ---------------------------------------------------------------------------
133// Event kinds
134// ---------------------------------------------------------------------------
135
136/// A read is not a change. inotify reports opens, and a build reads every source file, so
137/// an access forwarded as a change makes each rebuild trigger the next.
138#[test]
139fn access_events_do_not_count_as_changes() {
140    use notify::event::{AccessKind, AccessMode, CreateKind, DataChange, ModifyKind, RemoveKind};
141    use notify::EventKind;
142    use orgo::watch::is_change;
143
144    for read in [
145        AccessKind::Any,
146        AccessKind::Read,
147        AccessKind::Open(AccessMode::Any),
148        AccessKind::Open(AccessMode::Read),
149        AccessKind::Close(AccessMode::Read),
150    ] {
151        assert!(
152            !is_change(&EventKind::Access(read)),
153            "{read:?} is a read, not a change"
154        );
155    }
156
157    assert!(
158        is_change(&EventKind::Access(AccessKind::Close(AccessMode::Write))),
159        "closing a file that was written to is a change"
160    );
161    assert!(is_change(&EventKind::Create(CreateKind::File)));
162    assert!(is_change(&EventKind::Modify(ModifyKind::Data(DataChange::Any))));
163    assert!(is_change(&EventKind::Remove(RemoveKind::File)));
164    assert!(
165        is_change(&EventKind::Any),
166        "FSEvents and the Windows backend report unclassified changes this way"
167    );
168}
169
170// ---------------------------------------------------------------------------
171// End to end
172// ---------------------------------------------------------------------------
173
174/// Drive the real watcher against a real edit, then hold still. Timing-dependent by
175/// nature, so it polls for the expected result with a generous ceiling rather than
176/// sleeping a fixed amount.
177#[test]
178fn watching_rebuilds_the_site_when_a_source_file_changes() {
179    let root = tmpdir("watchrun");
180    let src = root.join("src");
181    std::fs::create_dir_all(&src).unwrap();
182    std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nFirst version.\n").unwrap();
183    let out = src.join("_site"); // deliberately inside the source: the loop case
184
185    build_site(&src, &out, &BuildOptions::default()).unwrap();
186    assert!(std::fs::read_to_string(out.join("index.html"))
187        .unwrap()
188        .contains("First version."));
189
190    // Count rebuilds rather than reading an output file's mtime: a quiet watcher is
191    // "no more rebuilds", and that is what the counter says directly.
192    let rebuilds = Arc::new(AtomicU32::new(0));
193    let (src_t, out_t, counter) = (src.clone(), out.clone(), Arc::clone(&rebuilds));
194    let handle = std::thread::spawn(move || {
195        let _ = orgo::watch::run_with(&src_t, &out_t, &BuildOptions::default(), |_| {
196            counter.fetch_add(1, Ordering::Relaxed);
197        });
198    });
199
200    // The edit is repeated rather than made once after a fixed head start. The watcher
201    // registers its OS watches *after* an initial build, and that build loads syntect's
202    // syntax set — on a cold CI runner, comfortably longer than any head start worth
203    // hard-coding. An edit made before anything is listening produces no event at all,
204    // which looks exactly like a watcher that does not work, and only one of those is a
205    // defect worth failing a build over. (Found by the first CI run on Linux, where a
206    // 300ms head start was not enough and macOS had never noticed.)
207    let deadline = Instant::now() + Duration::from_secs(30);
208    let mut wrote_at: Option<Instant> = None;
209    while rebuilds.load(Ordering::Relaxed) == 0 && Instant::now() < deadline {
210        if wrote_at.is_none_or(|t| t.elapsed() >= Duration::from_millis(400)) {
211            std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nSecond version.\n").unwrap();
212            wrote_at = Some(Instant::now());
213        }
214        std::thread::sleep(Duration::from_millis(50));
215    }
216    assert!(
217        rebuilds.load(Ordering::Relaxed) > 0,
218        "an edit should trigger a rebuild within 30s"
219    );
220    assert!(std::fs::read_to_string(out.join("index.html"))
221        .unwrap()
222        .contains("Second version."));
223
224    // Nothing is editing the tree now, so nothing more should be built. Two of the
225    // rebuild's own side effects would otherwise feed back in as changes:
226    //
227    // 1. Its writes. The output lives inside the source, so they raise events —
228    //    [`ChangeFilter`] drops them.
229    // 2. Its reads. A build reads every source file, and inotify reports opens, so a
230    //    watcher that treats an access as a change rebuilds at full speed. Reported
231    //    against `orgo serve`, where every rebuild reloads the browser.
232    //
233    // The first sleep lets the rebuild from the last repeated write land, or this
234    // measures that instead of a feedback loop.
235    std::thread::sleep(Duration::from_millis(1500));
236    let settled = rebuilds.load(Ordering::Relaxed);
237    std::thread::sleep(Duration::from_millis(1500));
238    assert_eq!(
239        settled,
240        rebuilds.load(Ordering::Relaxed),
241        "a quiet tree must produce no rebuilds — watch is rebuilding in a loop"
242    );
243
244    drop(handle); // the watcher thread ends with the process
245}