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}