use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use veloxc::commands::dev::{
ChangeDebouncer, ChangeKind, DevCmd, DirWatcher, WatchReaction, classify_change,
react_to_watch_error, watch_roots,
};
static SEQ: AtomicU32 = AtomicU32::new(0);
struct TempTree(PathBuf);
impl TempTree {
fn new() -> Self {
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let p =
std::env::temp_dir().join(format!("velox-watch-tests-{}-{}", std::process::id(), n));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).expect("create temp tree");
Self(p)
}
fn write(&self, rel: &str, contents: &str) {
let p = self.0.join(rel);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).expect("create parent");
}
std::fs::write(&p, contents).expect("write file");
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempTree {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn sfc(template: &str, script: &str, style: &str) -> String {
format!(
"<template>\n{template}\n</template>\n<script setup>\n{script}\n</script>\n<style>\n{style}\n</style>\n"
)
}
#[test]
fn a_save_is_reported_in_well_under_100ms() {
let tree = TempTree::new();
let (tx, rx) = mpsc::channel();
let _watcher = DirWatcher::start(tree.path(), tx);
let start = Instant::now();
tree.write("App.vx", "<template><p>hi</p></template>");
let event = rx
.recv_timeout(Duration::from_millis(500))
.expect("watcher must report the save");
let elapsed = start.elapsed();
let DevCmd::FileChanged { path, .. } = event else {
panic!("expected FileChanged, got {event:?}");
};
assert_eq!(
path,
PathBuf::from("App.vx"),
"event carries the changed path"
);
assert!(
elapsed < Duration::from_millis(100),
"save reported after {elapsed:?}; the 400 ms poll must be gone, this must be \
an inotify notification (target: under 100 ms)"
);
}
#[test]
fn modifying_an_existing_file_is_reported() {
let tree = TempTree::new();
tree.write("App.vx", "<template><p>v1</p></template>");
let (tx, rx) = mpsc::channel();
let _watcher = DirWatcher::start(tree.path(), tx);
tree.write("App.vx", "<template><p>v2</p></template>");
let event = rx
.recv_timeout(Duration::from_millis(500))
.expect("watcher must report a modification");
let DevCmd::FileChanged { path, .. } = event else {
panic!("expected FileChanged, got {event:?}");
};
assert_eq!(path, PathBuf::from("App.vx"));
}
#[test]
fn a_change_under_target_is_not_reported() {
let tree = TempTree::new();
tree.write("target/debug/deps/placeholder", "");
tree.write("keep/placeholder", "");
let _ = std::fs::remove_file(tree.path().join("target/debug/deps/placeholder"));
let _ = std::fs::remove_file(tree.path().join("keep/placeholder"));
let (tx, rx) = mpsc::channel();
let _watcher = DirWatcher::start(tree.path(), tx);
tree.write("target/debug/deps/libfoo-9f8e7d.rlib", "junk");
tree.write("target/debug/.fingerprint/bar/bin-counter", "junk");
let leaked = rx.recv_timeout(Duration::from_millis(250));
assert!(
leaked.is_err(),
"target/ must be excluded, but got {leaked:?}"
);
tree.write("keep/observed.txt", "x");
let event = rx
.recv_timeout(Duration::from_millis(500))
.expect("control: a write into a watched non-ignored dir must be observed");
assert!(
matches!(event, DevCmd::FileChanged { .. }),
"control expected a FileChanged, got {event:?}"
);
}
#[test]
fn a_nested_target_directory_is_not_reported() {
let tree = TempTree::new();
tree.write("sub/project/target/debug/placeholder", "");
let _ = std::fs::remove_file(tree.path().join("sub/project/target/debug/placeholder"));
let (tx, rx) = mpsc::channel();
let _watcher = DirWatcher::start(tree.path(), tx);
tree.write("sub/project/target/debug/artifact.txt", "junk");
let leaked = rx.recv_timeout(Duration::from_millis(250));
assert!(
leaked.is_err(),
"a nested target/ must be excluded, but got {leaked:?}"
);
tree.write("sub/project/observed.txt", "x");
let event = rx
.recv_timeout(Duration::from_millis(500))
.expect("control: a write beside the excluded tree must be observed");
assert!(
matches!(event, DevCmd::FileChanged { .. }),
"control expected a FileChanged, got {event:?}"
);
}
#[test]
fn ignored_paths_are_excluded_but_source_files_are_not() {
let tree = TempTree::new();
tree.write(".git/placeholder", "");
tree.write(".vscode/placeholder", "");
tree.write(".cache/placeholder", "");
let _ = std::fs::remove_file(tree.path().join(".git/placeholder"));
let _ = std::fs::remove_file(tree.path().join(".vscode/placeholder"));
let _ = std::fs::remove_file(tree.path().join(".cache/placeholder"));
let (tx, rx) = mpsc::channel();
let _watcher = DirWatcher::start(tree.path(), tx);
tree.write(".git/HEAD", "ref: refs/heads/main");
tree.write(".vscode/settings.json", "{}");
tree.write(".cache/build.bin", "junk");
let leaked = rx.recv_timeout(Duration::from_millis(250));
assert!(
leaked.is_err(),
"dot-directories must be excluded, got {leaked:?}"
);
tree.write("App.vx", "<template><p>hi</p></template>");
let event = rx
.recv_timeout(Duration::from_millis(500))
.expect("control: a real source edit must still be reported");
let DevCmd::FileChanged { path, .. } = event else {
panic!("expected FileChanged, got {event:?}");
};
assert_eq!(path, PathBuf::from("App.vx"));
}
#[test]
fn a_burst_collapses_to_one_rebuild() {
let window = Duration::from_millis(50);
let t0 = Instant::now();
let mut d = ChangeDebouncer::new();
d.record(t0, window);
d.record(t0 + Duration::from_millis(5), window);
d.record(t0 + Duration::from_millis(10), window);
assert!(
!d.take_if_due(t0 + Duration::from_millis(20)),
"must not fire while events are still arriving"
);
assert!(
d.take_if_due(t0 + Duration::from_millis(61)),
"the burst must collapse to one rebuild once quiet for the window"
);
assert!(
!d.take_if_due(t0 + Duration::from_millis(62)),
"one burst must produce exactly one rebuild, not a repeat"
);
}
#[test]
fn a_second_burst_after_the_first_rebuilds_again() {
let window = Duration::from_millis(50);
let t0 = Instant::now();
let mut d = ChangeDebouncer::new();
d.record(t0, window);
assert!(d.take_if_due(t0 + Duration::from_millis(51)));
d.record(t0 + Duration::from_millis(500), window);
assert!(d.take_if_due(t0 + Duration::from_millis(551)));
}
#[test]
fn wait_returns_only_the_remaining_window() {
let window = Duration::from_millis(50);
let t0 = Instant::now();
let mut d = ChangeDebouncer::new();
d.record(t0, window);
let remaining = d
.wait(t0 + Duration::from_millis(30))
.expect("armed debouncer must report a remaining wait");
assert_eq!(remaining, Duration::from_millis(20));
let past = d
.wait(t0 + Duration::from_millis(60))
.expect("a pending burst must always bound the wait");
assert!(
past <= Duration::from_millis(1),
"a pending burst must never yield a long wait, got {past:?}"
);
assert!(d.take_if_due(t0 + Duration::from_millis(60)));
assert_eq!(d.wait(t0 + Duration::from_millis(60)), None);
}
#[test]
fn a_watch_error_is_reported_and_never_stops_the_server() {
let message = "Failed to watch /home/u/app/src: No space left on device \
(inotify instance limit reached)";
match react_to_watch_error(message) {
WatchReaction::Report {
message: reported,
fatal: false,
} => {
assert!(
reported.contains("No space left on device"),
"the OS error must reach the user verbatim, got {reported:?}"
);
assert!(
reported.contains("fs.inotify.max_user_watches"),
"the report must name the remedy, got {reported:?}"
);
assert!(
reported.contains("fs.inotify.max_user_instances"),
"the report must name the remedy, got {reported:?}"
);
}
other => panic!("a watch error must be non-fatal and must carry the remedy; got {other:?}"),
}
}
#[test]
fn the_watcher_keeps_delivering_after_an_error_is_reported() {
let tree = TempTree::new();
let (tx, rx) = mpsc::channel();
let _watcher = DirWatcher::start(tree.path(), tx.clone());
tx.send(DevCmd::WatchError {
message: "synthetic".into(),
})
.expect("the error must be sendable on the loop's channel");
tree.write("App.vx", "<template><p>after</p></template>");
let deadline = Instant::now() + Duration::from_millis(500);
let mut saw_change = false;
while Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(DevCmd::FileChanged { .. }) => {
saw_change = true;
break;
}
Ok(DevCmd::WatchError { .. }) => continue,
Ok(other) => panic!("unexpected command before the change: {other:?}"),
Err(_) => continue,
}
}
assert!(
saw_change,
"a change after a watch error must still be reported"
);
}
#[test]
fn a_style_only_edit_is_classified_as_style_only() {
let before = sfc("<p>hi</p>", "let n = 0;", "p { color: red; }");
let after = sfc("<p>hi</p>", "let n = 0;", "p { color: blue; }");
assert_eq!(classify_change(&before, &after), ChangeKind::StyleOnly);
}
#[test]
fn a_template_only_edit_is_classified_as_template_only() {
let before = sfc("<p>hi</p>", "let n = 0;", "p { color: red; }");
let after = sfc("<p>bye</p>", "let n = 0;", "p { color: red; }");
assert_eq!(classify_change(&before, &after), ChangeKind::TemplateOnly);
}
#[test]
fn a_script_only_edit_is_classified_as_script() {
let before = sfc("<p>hi</p>", "let n = 0;", "p { color: red; }");
let after = sfc("<p>hi</p>", "let n = 1;", "p { color: red; }");
assert_eq!(classify_change(&before, &after), ChangeKind::Script);
}
#[test]
fn a_multi_block_edit_falls_back_to_script() {
let before = sfc("<p>hi</p>", "let n = 0;", "p { color: red; }");
let after = sfc("<p>bye</p>", "let n = 1;", "p { color: blue; }");
assert_eq!(classify_change(&before, &after), ChangeKind::Script);
let after = sfc("<p>bye</p>", "let n = 0;", "p { color: blue; }");
assert_eq!(classify_change(&before, &after), ChangeKind::Script);
}
#[test]
fn an_unparseable_file_falls_back_to_script() {
let before = sfc("<p>hi</p>", "let n = 0;", "p { color: red; }");
let after = "<template><p>hi</p></templ";
assert_eq!(classify_change(before.as_str(), after), ChangeKind::Script);
let before_broken = "<template><p>hi</p></templ";
assert_eq!(
classify_change(before_broken, before.as_str()),
ChangeKind::Script
);
}
#[test]
fn a_non_sfc_file_falls_back_to_script() {
assert_eq!(
classify_change("fn main() {}", "fn main() { /* x */ }"),
ChangeKind::Script
);
assert_eq!(
classify_change("[package]\nname=\"a\"\n", "[package]\nname=\"b\"\n"),
ChangeKind::Script
);
}
#[test]
fn reindented_style_content_is_still_style_only() {
let before = sfc("<p>hi</p>", "let n = 0;", "p { color: red; }");
let after = sfc("<p>hi</p>", "let n = 0;", " p { color: red; }");
assert_eq!(classify_change(&before, &after), ChangeKind::StyleOnly);
}
#[test]
fn a_scaffolded_project_watches_src_and_assets() {
let tree = TempTree::new();
tree.write("src/App.vx", "<template><p>hi</p></template>");
tree.write("assets/velox-logo.svg", "<svg></svg>");
let roots = watch_roots(tree.path());
assert_eq!(
roots,
vec![tree.path().join("src"), tree.path().join("assets")],
"the project root is deliberately NOT watched — see watch_roots"
);
}
#[test]
fn the_root_list_does_not_filter_out_a_missing_assets_dir() {
let tree = TempTree::new();
tree.write("src/App.vx", "<template><p>hi</p></template>");
let roots = watch_roots(tree.path());
assert!(
roots.iter().any(|r| r.ends_with("assets")),
"assets/ was dropped from the root list because it does not exist yet; \
that filtering belongs in DirWatcher::start_roots, not here: {:?}",
roots
);
let (tx, rx) = mpsc::channel();
let _w = DirWatcher::start_roots(&roots, tx);
tree.write("src/App.vx", "<template><p>changed</p></template>");
let got = rx
.recv_timeout(Duration::from_secs(3))
.expect("a src/ change is still reported when assets/ is absent");
match got {
DevCmd::FileChanged { path, .. } => assert_eq!(path, PathBuf::from("App.vx")),
other => panic!("expected a FileChanged, got {other:?}"),
}
}
#[test]
fn a_change_under_assets_is_reported_against_the_assets_root() {
let tree = TempTree::new();
tree.write("src/App.vx", "<template><p>hi</p></template>");
tree.write("assets/velox-logo.svg", "<svg version=\"1.1\"></svg>");
let (tx, rx) = mpsc::channel();
let _w = DirWatcher::start_roots(&watch_roots(tree.path()), tx);
let png: &[u8] = &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff];
std::fs::write(tree.path().join("assets/velox-logo.png"), png).expect("write png");
let got = rx
.recv_timeout(Duration::from_secs(3))
.expect("a new file under assets/ is reported");
match got {
DevCmd::FileChanged { path, kind } => {
assert_eq!(
path,
PathBuf::from("velox-logo.png"),
"path is relative to assets/"
);
assert_eq!(
kind,
ChangeKind::Script,
"an unreadable binary is not a template"
);
}
other => panic!("expected a FileChanged, got {other:?}"),
}
}
#[test]
fn a_same_named_file_under_each_root_classifies_independently() {
let tree = TempTree::new();
tree.write(
"src/App.vx",
&sfc("<p>a</p>", "let n = 0;", "p { color: red; }"),
);
tree.write(
"assets/App.vx",
&sfc("<p>b</p>", "let n = 0;", "p { color: blue; }"),
);
let (tx, rx) = mpsc::channel();
let _w = DirWatcher::start_roots(&watch_roots(tree.path()), tx);
std::fs::write(
tree.path().join("assets/App.vx"),
sfc("<p>b changed</p>", "let n = 0;", "p { color: blue; }"),
)
.expect("write");
let got = rx
.recv_timeout(Duration::from_secs(3))
.expect("the assets/ change is reported");
match got {
DevCmd::FileChanged { path, kind } => {
assert_eq!(path, PathBuf::from("App.vx"));
assert_eq!(
kind,
ChangeKind::Script,
"a template change must classify as Script even though a same-named file \
exists under the other root"
);
}
other => panic!("expected a FileChanged, got {other:?}"),
}
}